收口字体渲染路径以降低 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/设备上的人工目视文字显示效果
This commit is contained in:
SepComet 2026-06-09 08:20:38 +08:00
parent 5ac61dca0e
commit 23a5b50aec
10 changed files with 379 additions and 1451 deletions

View File

@ -165,7 +165,12 @@ Framebuffer 对照版本可能需要 root 权限访问 `/dev/fb0`。SDL2 版本
## 资源转换工具 ## 资源转换工具
项目运行时不依赖 PNG/TTF 解码库。图片和字体资源在离线阶段转换成 C++ 头文件,运行时直接以 `uint32_t` 数组访问,像素格式统一为: 项目运行时不依赖 PNG/TTF 解码库。图片和字体资源在离线阶段转换成 C++ 头文件,但运行时格式按资源类型区分:
- sprite / atlas`uint16_t` `RGBA5551`
- bitmap font`uint8_t` 1-bit mask8 个像素打包为 1 个字节)
sprite 相关像素格式约定为:
```text ```text
(R << 24) | (G << 16) | (B << 8) | A (R << 24) | (G << 16) | (B << 8) | A
@ -232,7 +237,13 @@ assets/font/font_atlas.png
assets/font/font_atlas.h assets/font/font_atlas.h
``` ```
字体范围为 ASCII 32~126按 16 列排列。生成端会把字体 alpha 阈值化为 0/255以匹配当前 `DrawContext::draw_text` 的像素风路径;运行时只把非透明像素替换成调用方指定颜色。 字体范围为 ASCII 32~126按 16 列排列。生成端会先把字体 alpha 阈值化为 0/255再按整张 atlas 逐行打包成 **row-major、MSB-first** 的 1-bit mask
- `font_atlas_mask[]` 中每个 `uint8_t` 表示连续 8 个像素
- bit=1 表示该像素需要绘制
- bit=0 表示跳过
运行时 `DrawContext::draw_text` 不再读取字体 atlas 的 RGB/A 颜色,而是直接把 mask 中 bit=1 的像素写成调用方传入文字颜色对应的 `RGB565`
## 显示后端架构 ## 显示后端架构

File diff suppressed because it is too large Load Diff

View File

@ -305,9 +305,9 @@ poll input -> update current app -> render current app -> present framebuffer
- 运行时资源优先使用离线转换后的简单数组,不在 IMX6U 运行时解码 PNG/TTF。 - 运行时资源优先使用离线转换后的简单数组,不在 IMX6U 运行时解码 PNG/TTF。
- `tools/png_to_header.py` 将 PNG 转为 `uint32_t` RGBA 数组,适用于 sprite、小图标、测试纹理等。 - `tools/png_to_header.py` 将 PNG 转为 `uint32_t` RGBA 数组,适用于 sprite、小图标、测试纹理等。
- `tools/gen_font_atlas.py` 将共享像素字体转为 ASCII bitmap atlas并输出同名 PNG 预览和 C++ 头文件。 - `tools/gen_font_atlas.py` 将共享像素字体转为 ASCII bitmap atlas并输出同名 PNG 预览和 C++ 头文件;运行时字体数据使用 `uint8_t` 1-bit maskrow-major、MSB-first
- 生成头文件、源 PNG/TTF 和转换脚本应一起纳入仓库,保证资源可追溯、可再生成。 - 生成头文件、源 PNG/TTF 和转换脚本应一起纳入仓库,保证资源可追溯、可再生成。
- 生成数据目前面向简单直接的调试/小型游戏资源;后续如果资源体积增长,应再评估 1-bit/8-bit mask、RLE 或自定义资源包格式。 - 生成数据目前面向简单直接的调试/小型游戏资源;后续如果资源体积增长,应继续评估 1-bit mask、RLE 或自定义资源包格式。
## 12. SpriteRegion 与 Tilemap 约定 ## 12. SpriteRegion 与 Tilemap 约定

View File

@ -141,12 +141,12 @@ Framebuffer 后端是 IMX6U 上最容易误判性能的路径。`FBDisplay::pres
- 图片、字体等外部资源应在离线阶段转换为运行时直接可用的数据格式,避免在 IMX6U 热路径或启动关键路径中引入 PNG/TTF 解码成本。 - 图片、字体等外部资源应在离线阶段转换为运行时直接可用的数据格式,避免在 IMX6U 热路径或启动关键路径中引入 PNG/TTF 解码成本。
- 当前工具约定: - 当前工具约定:
- `tools/png_to_header.py`PNG -> `uint16_t` `RGBA5551` 头文件sprite 主路径)。 - `tools/png_to_header.py`PNG -> `uint16_t` `RGBA5551` 头文件sprite 主路径)。
- `tools/gen_font_atlas.py`:像素字体 TTF -> ASCII bitmap font atlas/header。 - `tools/gen_font_atlas.py`:像素字体 TTF -> ASCII bitmap font atlas/header,其中 atlas header 输出为 `uint8_t` 1-bit mask
- sprite 运行时输入规范统一为 `RGBA5551`,内部 backbuffer 统一为 `RGB565`;字体 atlas 目前仍允许局部兼容 `RGBA8888` 输入,但只使用 1-bit alpha 语义,不支持 alpha blending。 - sprite 运行时输入规范统一为 `RGBA5551`,内部 backbuffer 统一为 `RGB565`;字体 atlas 运行时输入统一为 row-major、MSB-first 的 1-bit mask不再保留 `RGBA8888` 兼容路径,也不支持 alpha blending。
- 源资源、生成脚本和生成头文件应同时提交,保证资源可追溯、可复现。 - 源资源、生成脚本和生成头文件应同时提交,保证资源可追溯、可复现。
- 运行时绘制 sprite/font/tilemap 时只做裁剪、透明判断、颜色替换、tile id 查表或必要的像素拷贝;不要在绘制函数内做文件 IO、图片解码、字体栅格化或动态分配。 - 运行时绘制 sprite/font/tilemap 时只做裁剪、透明判断、颜色替换、tile id 查表或必要的像素拷贝;不要在绘制函数内做文件 IO、图片解码、字体栅格化或动态分配。
- Tilemap 绘制应按视口可见范围遍历 tile不能每帧无条件扫描整张地图视口边缘允许通过 sprite 像素裁剪显示半个 tile。 - Tilemap 绘制应按视口可见范围遍历 tile不能每帧无条件扫描整张地图视口边缘允许通过 sprite 像素裁剪显示半个 tile。
- 字体资源当前走像素风路径,生成端会把 alpha 阈值化为 0/255如果未来要恢复抗锯齿字体必须同步设计 framebuffer alpha blending而不能只在绘制端把所有非 0 alpha 当作实心像素 - 字体资源当前走像素风路径,生成端会把 alpha 阈值化为 0/255 后再压成 1-bit mask;如果未来要恢复抗锯齿字体,必须同步设计 framebuffer alpha blending而不能只把字体资源重新扩回多 bit alpha
## 9. 新代码提交前检查清单 ## 9. 新代码提交前检查清单

View File

@ -133,11 +133,9 @@ int main(int argc, char* argv[])
Platform::SteadyTimeSource time_source; Platform::SteadyTimeSource time_source;
RenderData::BitmapFont font; RenderData::BitmapFont font;
font.atlas = RenderData::Image( font.mask_bits = font_atlas_mask;
font_atlas_pixels, font.atlas_width = font_atlas_width;
font_atlas_width, font.atlas_height = font_atlas_height;
font_atlas_height,
RenderData::PixelFormat::RGBA8888);
font.char_w = font_char_w; font.char_w = font_char_w;
font.char_h = font_char_h; font.char_h = font_char_h;
font.columns = font_columns; font.columns = font_columns;

View File

@ -325,31 +325,68 @@ namespace Core
} }
} }
void DrawContext::draw_font_mask_region(const RenderData::BitmapFont& font,
int32_t src_x, int32_t src_y,
int32_t src_w, int32_t src_h,
int32_t dst_x, int32_t dst_y,
uint16_t pixel)
{
Core::FramePixel* dst = static_cast<Core::FramePixel*>(frameBuffer->get_buffer());
const int32_t fb_w = frameBuffer->get_width();
for (int32_t row = 0; row < src_h; ++row)
{
const int32_t atlas_row = src_y + row;
const int32_t dst_y_abs = dst_y + row;
Core::FramePixel* dst_row = dst + dst_y_abs * fb_w + dst_x;
for (int32_t col = 0; col < src_w; ++col)
{
const int32_t atlas_x = src_x + col;
const int32_t bit_index = atlas_row * font.atlas_width + atlas_x;
const uint8_t packed = font.mask_bits[bit_index >> 3];
const uint8_t bit_mask = static_cast<uint8_t>(0x80u >> (bit_index & 7));
if ((packed & bit_mask) == 0u) continue;
dst_row[col] = pixel;
}
}
}
void DrawContext::draw_text(const RenderData::BitmapFont& font, int32_t x, int32_t y, void DrawContext::draw_text(const RenderData::BitmapFont& font, int32_t x, int32_t y,
const RenderData::Color& color, const char* text) const RenderData::Color& color, const char* text)
{ {
if (!font.atlas.pixels || !text) return; if (!font.mask_bits || !text) return;
const uint32_t rgba = color.to_rgba();
const int32_t cw = font.char_w; const int32_t cw = font.char_w;
const int32_t ch = font.char_h; const int32_t ch = font.char_h;
const int32_t atlas_w = font.atlas.width; const int32_t atlas_w = font.atlas_width;
const uint32_t* src = static_cast<const uint32_t*>(font.atlas.pixels); const int32_t atlas_h = font.atlas_height;
const int32_t screen_w = frameBuffer->get_width(); const int32_t screen_w = frameBuffer->get_width();
const int32_t screen_h = frameBuffer->get_height(); const int32_t screen_h = frameBuffer->get_height();
if (cw <= 0 || ch <= 0 || font.columns <= 0 || atlas_w <= 0 || atlas_h <= 0) return;
const Core::FramePixel glyph_pixel = Core::rgba_to_frame_pixel(color.to_rgba());
int32_t cursor_x = x; int32_t cursor_x = x;
for (const char* p = text; *p; ++p) for (const char* p = text; *p; ++p)
{ {
const int32_t index = static_cast<int32_t>(*p) - font.first_char; const int32_t index = static_cast<int32_t>(*p) - font.first_char;
if (index < 0) { cursor_x += cw; continue; } if (index < 0)
{
cursor_x += cw;
continue;
}
const int32_t col = index % font.columns; const int32_t col = index % font.columns;
const int32_t row = index / font.columns; const int32_t row = index / font.columns;
const int32_t src_x = col * cw; const int32_t src_x = col * cw;
const int32_t src_y = row * ch; const int32_t src_y = row * ch;
if (src_x < 0 || src_y < 0 || src_x + cw > atlas_w || src_y + ch > atlas_h)
{
cursor_x += cw;
continue;
}
// 整字符在屏幕外则跳过
if (cursor_x + cw <= 0 || cursor_x >= screen_w || y + ch <= 0 || y >= screen_h) if (cursor_x + cw <= 0 || cursor_x >= screen_w || y + ch <= 0 || y >= screen_h)
{ {
cursor_x += cw; cursor_x += cw;
@ -362,18 +399,14 @@ namespace Core
if (y + ch > screen_h) sy_end = screen_h - y; if (y + ch > screen_h) sy_end = screen_h - y;
if (cursor_x < 0) sx_start = -cursor_x; if (cursor_x < 0) sx_start = -cursor_x;
if (cursor_x + cw > screen_w) sx_end = screen_w - cursor_x; if (cursor_x + cw > screen_w) sx_end = screen_w - cursor_x;
draw_font_mask_region(font,
for (int32_t sy = sy_start; sy < sy_end; ++sy) src_x + sx_start,
{ src_y + sy_start,
const uint32_t* atlas_row = src + (src_y + sy) * atlas_w; sx_end - sx_start,
const int32_t dst_y_abs = y + sy; sy_end - sy_start,
for (int32_t sx = sx_start; sx < sx_end; ++sx) cursor_x + sx_start,
{ y + sy_start,
const uint32_t pixel = atlas_row[src_x + sx]; glyph_pixel);
if ((pixel & 0xFF) == 0) continue;
frameBuffer->set_pixel_unsafe(cursor_x + sx, dst_y_abs, rgba);
}
}
cursor_x += cw; cursor_x += cw;
} }
} }

View File

@ -35,6 +35,12 @@ namespace Core
Rasterizer::Rasterizer* rasterizer; Rasterizer::Rasterizer* rasterizer;
Rasterizer::TriangleRasterizer* triangleRasterizer; Rasterizer::TriangleRasterizer* triangleRasterizer;
void draw_font_mask_region(const RenderData::BitmapFont& font,
int32_t src_x, int32_t src_y,
int32_t src_w, int32_t src_h,
int32_t dst_x, int32_t dst_y,
uint16_t pixel);
public: public:
DrawContext(int32_t width, int32_t height); DrawContext(int32_t width, int32_t height);
~DrawContext(); ~DrawContext();

View File

@ -1,15 +1,27 @@
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include "Image.h"
namespace RenderData namespace RenderData
{ {
struct BitmapFont struct BitmapFont
{ {
Image atlas; const uint8_t* mask_bits;
int32_t atlas_width;
int32_t atlas_height;
int32_t char_w; int32_t char_w;
int32_t char_h; int32_t char_h;
int32_t columns; int32_t columns;
int32_t first_char; int32_t first_char;
BitmapFont()
: mask_bits(nullptr),
atlas_width(0),
atlas_height(0),
char_w(0),
char_h(0),
columns(0),
first_char(0)
{
}
}; };
} }

View File

@ -28,6 +28,13 @@ namespace
void shutdown() override {} void shutdown() override {}
}; };
const Core::FramePixel* PresentPixels(Core::DrawContext& ctx, CaptureDisplay& display)
{
ctx.present(&display);
assert(display.framebuffer != nullptr);
return static_cast<const Core::FramePixel*>(display.framebuffer->get_buffer());
}
void TestFramePixelIsRgb565() void TestFramePixelIsRgb565()
{ {
static_assert(sizeof(Core::FramePixel) == sizeof(uint16_t), "FramePixel must stay RGB565"); static_assert(sizeof(Core::FramePixel) == sizeof(uint16_t), "FramePixel must stay RGB565");
@ -105,6 +112,87 @@ namespace
assert(loaded_pixels[i] == original_pixels[i]); assert(loaded_pixels[i] == original_pixels[i]);
} }
} }
void TestDrawTextUsesBitMaskAndColor()
{
const uint8_t glyph_mask[] = { 0x90u };
RenderData::BitmapFont font;
font.mask_bits = glyph_mask;
font.atlas_width = 2;
font.atlas_height = 2;
font.char_w = 2;
font.char_h = 2;
font.columns = 1;
font.first_char = 'A';
Core::DrawContext ctx(2, 2);
ctx.clear_color(RenderData::Color::Black());
ctx.draw_text(font, 0, 0, RenderData::Color::Red(), "A");
CaptureDisplay display;
const Core::FramePixel* pixels = PresentPixels(ctx, display);
assert(pixels[0] == 0xF800u);
assert(pixels[1] == 0x0000u);
assert(pixels[2] == 0x0000u);
assert(pixels[3] == 0xF800u);
}
void TestDrawTextColorChangesPerCallAndBackgroundFill()
{
const uint8_t glyph_mask[] = { 0x90u };
RenderData::BitmapFont font;
font.mask_bits = glyph_mask;
font.atlas_width = 2;
font.atlas_height = 2;
font.char_w = 2;
font.char_h = 2;
font.columns = 1;
font.first_char = 'A';
Core::DrawContext ctx(4, 2);
ctx.clear_color(RenderData::Color::Black());
ctx.draw_text(font, 0, 0, RenderData::Color::White(), RenderData::Color::Blue(), "A");
ctx.draw_text(font, 2, 0, RenderData::Color::Green(), "A");
CaptureDisplay display;
const Core::FramePixel* pixels = PresentPixels(ctx, display);
assert(pixels[0] == 0xFFFFu);
assert(pixels[1] == 0x001Fu);
assert(pixels[2] == 0x07E0u);
assert(pixels[3] == 0x0000u);
assert(pixels[4] == 0x001Fu);
assert(pixels[5] == 0xFFFFu);
assert(pixels[6] == 0x0000u);
assert(pixels[7] == 0x07E0u);
}
void TestDrawTextClipsBitMaskToScreen()
{
const uint8_t glyph_mask[] = { 0x90u };
RenderData::BitmapFont font;
font.mask_bits = glyph_mask;
font.atlas_width = 2;
font.atlas_height = 2;
font.char_w = 2;
font.char_h = 2;
font.columns = 1;
font.first_char = 'A';
Core::DrawContext ctx(2, 2);
ctx.clear_color(RenderData::Color::Black());
ctx.draw_text(font, -1, -1, RenderData::Color::Green(), "A");
ctx.draw_text(font, 3, 3, RenderData::Color::Red(), "A");
CaptureDisplay display;
const Core::FramePixel* pixels = PresentPixels(ctx, display);
assert(pixels[0] == 0x07E0u);
assert(pixels[1] == 0x0000u);
assert(pixels[2] == 0x0000u);
assert(pixels[3] == 0x0000u);
}
} }
int main() int main()
@ -113,6 +201,9 @@ int main()
TestFrameBufferStoresRgb565(); TestFrameBufferStoresRgb565();
TestDrawSpriteUsesOneBitAlpha(); TestDrawSpriteUsesOneBitAlpha();
TestSpriteAssetLoaderRoundTripsRgba5551(); TestSpriteAssetLoaderRoundTripsRgba5551();
TestDrawTextUsesBitMaskAndColor();
TestDrawTextColorChangesPerCallAndBackgroundFill();
TestDrawTextClipsBitMaskToScreen();
std::cout << "render_pipeline_tests: PASS\n"; std::cout << "render_pipeline_tests: PASS\n";
return 0; return 0;
} }

View File

@ -11,8 +11,8 @@
字体: 优先使用 assets/font/ndrtyyl-prqyys-undertale-hebrew-uppercase.ttf 字体: 优先使用 assets/font/ndrtyyl-prqyys-undertale-hebrew-uppercase.ttf
不存在时回退到系统等宽字体Courier New / Consolas / monospace 不存在时回退到系统等宽字体Courier New / Consolas / monospace
像素格式: (R << 24) | (G << 16) | (B << 8) | A 输出格式: 1-bit row-major bit-packed maskMSB-first
生成端会把 alpha 阈值化为 0/255匹配当前 DrawContext::draw_text 的像素风绘制路径 生成端会把 alpha 阈值化为 0/255再将整张 atlas 逐行打包成遮罩位图
""" """
import os import os
@ -91,16 +91,21 @@ def generate(output_dir: str):
png_path = os.path.join(output_dir, "font_atlas.png") png_path = os.path.join(output_dir, "font_atlas.png")
img.save(png_path) img.save(png_path)
# 生成 C 头文件:将每行像素打包成 bit 行(每像素 1 bit白色=1透明=0 mask_bits = []
# 这样每个 glyph 的一行只需要 char_w/8 个字节 packed = 0
# 但为了简单,这里直接存储为 uint32_t RGBA 像素数组 bit_count = 0
# 使用时白色像素用指定颜色替换,透明像素跳过
pixels = []
for y in range(atlas_h): for y in range(atlas_h):
for x in range(atlas_w): for x in range(atlas_w):
r, g, b, a = img.getpixel((x, y)) _, _, _, a = img.getpixel((x, y))
pixels.append((r << 24) | (g << 16) | (b << 8) | a) 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") h_path = os.path.join(output_dir, "font_atlas.h")
with open(h_path, "w", encoding="utf-8") as f: with open(h_path, "w", encoding="utf-8") as f:
@ -112,11 +117,11 @@ def generate(output_dir: str):
f.write(f"static const int32_t font_char_h = {char_h};\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_first_char = {FIRST_CHAR};\n")
f.write(f"static const int32_t font_columns = {COLUMNS};\n\n") f.write(f"static const int32_t font_columns = {COLUMNS};\n\n")
f.write(f"static const uint32_t font_atlas_pixels[] = {{\n") f.write(f"static const uint8_t font_atlas_mask[] = {{\n")
for i in range(0, len(pixels), 16): for i in range(0, len(mask_bits), 16):
chunk = pixels[i:i + 16] chunk = mask_bits[i:i + 16]
line = ", ".join(f"0x{p:08X}" for p in chunk) line = ", ".join(f"0x{value:02X}" for value in chunk)
f.write(f" {line},\n") f.write(f" {line},\n")
f.write("};\n") f.write("};\n")