加入离线 sprite 与位图字体转换流程

Demo 现在通过生成的 C++ 头文件使用测试 sprite 和像素风 bitmap font,避免在运行时解码 PNG/TTF。同步补充资源转换文档,明确源资源、生成脚本和生成头文件需要一起提交,保证后续可追溯和可重新生成。
Constraint: IMX6U 运行时路径应尽量轻量,避免图片/字体解码开销
Constraint: 生成像素沿用当前 framebuffer 的 uint32_t RGBA 格式
Rejected: 运行时加载 PNG/TTF | 会增加解码依赖和运行时成本
Confidence: high
Scope-risk: moderate
Directive: 修改 sprite 或字体时,必须同步提交源资源、转换脚本和生成头文件
Tested: cmake -B build-win .; cmake --build build-win --config Release
Not-tested: 尚未在 IMX6U 真机上验证 ARM framebuffer/SDL 后端
This commit is contained in:
SepComet
2026-06-06 23:55:27 +08:00
parent 3e735e27b0
commit 213fa7e961
17 changed files with 1968 additions and 16 deletions
+142
View File
@@ -0,0 +1,142 @@
"""
用 PIL 渲染 ASCII 像素风位图字体图集。
用法:
python tools/gen_font_atlas.py [output_dir]
默认输出目录: assets/font/
生成: font_atlas.png, font_atlas.h
字符范围: ASCII 32~126
字体: 优先使用 assets/font/ndrtyyl-prqyys-undertale-hebrew-uppercase.ttf,
不存在时回退到系统等宽字体(Courier New / Consolas / monospace)
像素格式: (R << 24) | (G << 16) | (B << 8) | A
生成端会把 alpha 阈值化为 0/255,匹配当前 DrawContext::draw_text 的像素风绘制路径。
"""
import os
import sys
from PIL import Image, ImageDraw, ImageFont
FIRST_CHAR = 32
LAST_CHAR = 126
NUM_CHARS = LAST_CHAR - FIRST_CHAR + 1 # 95
COLUMNS = 16
ROWS = (NUM_CHARS + COLUMNS - 1) // COLUMNS # 6
def find_mono_font(size: int) -> ImageFont.FreeTypeFont:
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
candidates = [
os.path.join(repo_root, "assets/font", "ndrtyyl-prqyys-undertale-hebrew-uppercase.ttf"),
"C:/Windows/Fonts/consola.ttf", # Consolas (Windows)
"C:/Windows/Fonts/cour.ttf", # Courier New (Windows)
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", # Linux
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
]
for path in candidates:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def generate(output_dir: str):
font = find_mono_font(14)
# 测量完整 ASCII 字符集的尺寸。
# 不能只用 "M":它没有下伸部,按它算高度会裁掉 g/j/p/q/y/_ 等字符。
tmp_img = Image.new("RGBA", (1, 1))
tmp_draw = ImageDraw.Draw(tmp_img)
chars = [chr(FIRST_CHAR + i) for i in range(NUM_CHARS)]
bboxes = [tmp_draw.textbbox((0, 0), ch, font=font) for ch in chars]
pad = 1
min_left = min(b[0] for b in bboxes)
min_top = min(b[1] for b in bboxes)
max_right = max(b[2] for b in bboxes)
max_bottom = max(b[3] for b in bboxes)
# 等宽字体仍使用统一 cell。宽度用字体 advance,避免窄字符影响字距;
# 高度用全字符 bbox,保证下伸字符不被裁切。
max_advance = max(tmp_draw.textlength(ch, font=font) for ch in chars)
char_w = int(max_advance + 0.9999) + pad * 2
char_h = (max_bottom - min_top) + pad * 2
atlas_w = COLUMNS * char_w
atlas_h = ROWS * char_h
img = Image.new("RGBA", (atlas_w, atlas_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
for i in range(NUM_CHARS):
ch = chr(FIRST_CHAR + i)
col = i % COLUMNS
row = i // COLUMNS
x = col * char_w + pad - min_left
y = row * char_h + pad - min_top
draw.text((x, y), ch, fill=(255, 255, 255, 255), font=font)
# 像素风:把 TrueType 抗锯齿 alpha 二值化。
# 渲染端目前只判断 alpha 是否非 0;如果保留半透明边缘,会被画成实心像素导致发胖/毛边。
threshold = 128
for py in range(atlas_h):
for px in range(atlas_w):
_, _, _, a = img.getpixel((px, py))
img.putpixel((px, py), (255, 255, 255, 255) if a >= threshold else (0, 0, 0, 0))
png_path = os.path.join(output_dir, "font_atlas.png")
img.save(png_path)
# 生成 C 头文件:将每行像素打包成 bit 行(每像素 1 bit,白色=1,透明=0)
# 这样每个 glyph 的一行只需要 char_w/8 个字节
# 但为了简单,这里直接存储为 uint32_t RGBA 像素数组
# 使用时白色像素用指定颜色替换,透明像素跳过
pixels = []
for y in range(atlas_h):
for x in range(atlas_w):
r, g, b, a = img.getpixel((x, y))
pixels.append((r << 24) | (g << 16) | (b << 8) | a)
h_path = os.path.join(output_dir, "font_atlas.h")
with open(h_path, "w", encoding="utf-8") as f:
f.write("// Auto-generated by tools/gen_font_atlas.py\n")
f.write("#pragma once\n#include <cstdint>\n\n")
f.write(f"static const int32_t font_atlas_width = {atlas_w};\n")
f.write(f"static const int32_t font_atlas_height = {atlas_h};\n")
f.write(f"static const int32_t font_char_w = {char_w};\n")
f.write(f"static const int32_t font_char_h = {char_h};\n")
f.write(f"static const int32_t font_first_char = {FIRST_CHAR};\n")
f.write(f"static const int32_t font_columns = {COLUMNS};\n\n")
f.write(f"static const uint32_t font_atlas_pixels[] = {{\n")
for i in range(0, len(pixels), 16):
chunk = pixels[i:i + 16]
line = ", ".join(f"0x{p:08X}" for p in chunk)
f.write(f" {line},\n")
f.write("};\n")
print(f"Atlas: {atlas_w}x{atlas_h}, char: {char_w}x{char_h}")
print(f"Generated: {png_path}, {h_path}")
def main():
if len(sys.argv) >= 2:
out_dir = sys.argv[1]
else:
out_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"assets", "font"
)
os.makedirs(out_dir, exist_ok=True)
generate(out_dir)
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
"""
将 PNG 图片转换为 C 头文件(uint32_t 像素数组)。
用法:
python tools/png_to_header.py input.png output.h [var_name]
像素格式: (R << 24) | (G << 16) | (B << 8) | A
与 FrameBuffer 的 uint32_t 格式一致。
如果图片没有 alpha 通道,A 默认为 0xFF。
"""
import sys
import os
from PIL import Image
def convert(input_path: str, output_path: str, var_name: str):
img = Image.open(input_path).convert("RGBA")
w, h = img.size
pixels = list(img.getdata()) if not hasattr(img, "get_flattened_data") else list(img.get_flattened_data())
packed = []
for r, g, b, a in pixels:
packed.append((r << 24) | (g << 16) | (b << 8) | a)
with open(output_path, "w", encoding="utf-8") as f:
f.write("// Auto-generated by tools/png_to_header.py\n")
f.write(f"#pragma once\n#include <cstdint>\n\n")
f.write(f"static const int32_t {var_name}_width = {w};\n")
f.write(f"static const int32_t {var_name}_height = {h};\n\n")
f.write(f"static const uint32_t {var_name}_pixels[] = {{\n")
for i in range(0, len(packed), 16):
chunk = packed[i : i + 16]
line = ", ".join(f"0x{p:08X}" for p in chunk)
f.write(f" {line},\n")
f.write("};\n")
def main():
if len(sys.argv) < 3:
print(f"用法: {sys.argv[0]} input.png output.h [var_name]")
sys.exit(1)
input_path = sys.argv[1]
output_path = sys.argv[2]
if len(sys.argv) >= 4:
var_name = sys.argv[3]
else:
# 从文件名生成变量名: player_idle.png -> player_idle
var_name = os.path.splitext(os.path.basename(input_path))[0]
convert(input_path, output_path, var_name)
print(f"已生成: {output_path} (var: {var_name}, 像素数: {var_name}_width * {var_name}_height)")
if __name__ == "__main__":
main()