加入离线 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:
@@ -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()
|
||||
Reference in New Issue
Block a user