Files
IMX6U-Game/tools/gen_font_atlas.py
T
basil 23a5b50aec 收口字体渲染路径以降低 atlas 体积并直接输出 RGB565
将位图字体从 RGBA8888 atlas 改为 1-bit mask,避免继续为字体存储无效颜色信息。
生成端现在按 row-major、MSB-first 将 8 个像素打包进 1 个 uint8_t;运行时由
draw_text 提供目标颜色,并在 DrawContext 中直接把 bit=1 的像素写入 RGB565 framebuffer。同步补充了回归测试与文档,确保颜色替换、背景填充和裁剪语义保持稳定。

Constraint: 本次只收口字体路径,不改 sprite/tilemap 资源格式
Constraint: 运行时保持纯二值绘制语义,不引入 alpha blending 或抗锯齿
Rejected: 继续兼容 RGBA8888 字体输入 | 会保留双路径复杂度且无实际收益
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: 后续字体资源必须继续使用 row-major + MSB-first 的 1-bit mask;修改位序前先同步生成脚本、运行时读取和测试
Tested: cmake --build build-win --config Release
Tested: build-win/Release/render_pipeline_tests.exe
Tested: ctest -C Release --output-on-failure
Tested: font_atlas_mask 字节数校验(2808 == ceil(208*108/8))
Not-tested: Demo/设备上的人工目视文字显示效果
2026-06-09 08:20:38 +08:00

148 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
用 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)
输出格式: 1-bit row-major bit-packed mask(MSB-first)
生成端会把 alpha 阈值化为 0/255,再将整张 atlas 逐行打包成遮罩位图。
"""
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)
mask_bits = []
packed = 0
bit_count = 0
for y in range(atlas_h):
for x in range(atlas_w):
_, _, _, a = img.getpixel((x, y))
packed = (packed << 1) | (1 if a != 0 else 0)
bit_count += 1
if bit_count == 8:
mask_bits.append(packed)
packed = 0
bit_count = 0
if bit_count != 0:
mask_bits.append(packed << (8 - bit_count))
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 uint8_t font_atlas_mask[] = {{\n")
for i in range(0, len(mask_bits), 16):
chunk = mask_bits[i:i + 16]
line = ", ".join(f"0x{value:02X}" for value 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()