收口字体渲染路径以降低 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:
parent
5ac61dca0e
commit
23a5b50aec
15
README.md
15
README.md
|
|
@ -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 mask(8 个像素打包为 1 个字节)
|
||||
|
||||
sprite 相关像素格式约定为:
|
||||
|
||||
```text
|
||||
(R << 24) | (G << 16) | (B << 8) | A
|
||||
|
|
@ -232,7 +237,13 @@ assets/font/font_atlas.png
|
|||
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
|
|
@ -305,9 +305,9 @@ poll input -> update current app -> render current app -> present framebuffer
|
|||
|
||||
- 运行时资源优先使用离线转换后的简单数组,不在 IMX6U 运行时解码 PNG/TTF。
|
||||
- `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 mask(row-major、MSB-first)。
|
||||
- 生成头文件、源 PNG/TTF 和转换脚本应一起纳入仓库,保证资源可追溯、可再生成。
|
||||
- 生成数据目前面向简单直接的调试/小型游戏资源;后续如果资源体积增长,应再评估 1-bit/8-bit mask、RLE 或自定义资源包格式。
|
||||
- 生成数据目前面向简单直接的调试/小型游戏资源;后续如果资源体积增长,应继续评估 1-bit mask、RLE 或自定义资源包格式。
|
||||
|
||||
## 12. SpriteRegion 与 Tilemap 约定
|
||||
|
||||
|
|
|
|||
|
|
@ -141,12 +141,12 @@ Framebuffer 后端是 IMX6U 上最容易误判性能的路径。`FBDisplay::pres
|
|||
- 图片、字体等外部资源应在离线阶段转换为运行时直接可用的数据格式,避免在 IMX6U 热路径或启动关键路径中引入 PNG/TTF 解码成本。
|
||||
- 当前工具约定:
|
||||
- `tools/png_to_header.py`:PNG -> `uint16_t` `RGBA5551` 头文件(sprite 主路径)。
|
||||
- `tools/gen_font_atlas.py`:像素字体 TTF -> ASCII bitmap font atlas/header。
|
||||
- sprite 运行时输入规范统一为 `RGBA5551`,内部 backbuffer 统一为 `RGB565`;字体 atlas 目前仍允许局部兼容 `RGBA8888` 输入,但只使用 1-bit alpha 语义,不支持 alpha blending。
|
||||
- `tools/gen_font_atlas.py`:像素字体 TTF -> ASCII bitmap font atlas/header,其中 atlas header 输出为 `uint8_t` 1-bit mask。
|
||||
- sprite 运行时输入规范统一为 `RGBA5551`,内部 backbuffer 统一为 `RGB565`;字体 atlas 运行时输入统一为 row-major、MSB-first 的 1-bit mask,不再保留 `RGBA8888` 兼容路径,也不支持 alpha blending。
|
||||
- 源资源、生成脚本和生成头文件应同时提交,保证资源可追溯、可复现。
|
||||
- 运行时绘制 sprite/font/tilemap 时只做裁剪、透明判断、颜色替换、tile id 查表或必要的像素拷贝;不要在绘制函数内做文件 IO、图片解码、字体栅格化或动态分配。
|
||||
- Tilemap 绘制应按视口可见范围遍历 tile,不能每帧无条件扫描整张地图;视口边缘允许通过 sprite 像素裁剪显示半个 tile。
|
||||
- 字体资源当前走像素风路径,生成端会把 alpha 阈值化为 0/255;如果未来要恢复抗锯齿字体,必须同步设计 framebuffer alpha blending,而不能只在绘制端把所有非 0 alpha 当作实心像素。
|
||||
- 字体资源当前走像素风路径,生成端会把 alpha 阈值化为 0/255 后再压成 1-bit mask;如果未来要恢复抗锯齿字体,必须同步设计 framebuffer alpha blending,而不能只把字体资源重新扩回多 bit alpha。
|
||||
|
||||
## 9. 新代码提交前检查清单
|
||||
|
||||
|
|
|
|||
|
|
@ -133,11 +133,9 @@ int main(int argc, char* argv[])
|
|||
Platform::SteadyTimeSource time_source;
|
||||
|
||||
RenderData::BitmapFont font;
|
||||
font.atlas = RenderData::Image(
|
||||
font_atlas_pixels,
|
||||
font_atlas_width,
|
||||
font_atlas_height,
|
||||
RenderData::PixelFormat::RGBA8888);
|
||||
font.mask_bits = font_atlas_mask;
|
||||
font.atlas_width = font_atlas_width;
|
||||
font.atlas_height = font_atlas_height;
|
||||
font.char_w = font_char_w;
|
||||
font.char_h = font_char_h;
|
||||
font.columns = font_columns;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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 ch = font.char_h;
|
||||
const int32_t atlas_w = font.atlas.width;
|
||||
const uint32_t* src = static_cast<const uint32_t*>(font.atlas.pixels);
|
||||
const int32_t atlas_w = font.atlas_width;
|
||||
const int32_t atlas_h = font.atlas_height;
|
||||
const int32_t screen_w = frameBuffer->get_width();
|
||||
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;
|
||||
for (const char* p = text; *p; ++p)
|
||||
{
|
||||
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 row = index / font.columns;
|
||||
const int32_t src_x = col * cw;
|
||||
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)
|
||||
{
|
||||
cursor_x += cw;
|
||||
|
|
@ -362,18 +399,14 @@ namespace Core
|
|||
if (y + ch > screen_h) sy_end = screen_h - y;
|
||||
if (cursor_x < 0) sx_start = -cursor_x;
|
||||
if (cursor_x + cw > screen_w) sx_end = screen_w - cursor_x;
|
||||
|
||||
for (int32_t sy = sy_start; sy < sy_end; ++sy)
|
||||
{
|
||||
const uint32_t* atlas_row = src + (src_y + sy) * atlas_w;
|
||||
const int32_t dst_y_abs = y + sy;
|
||||
for (int32_t sx = sx_start; sx < sx_end; ++sx)
|
||||
{
|
||||
const uint32_t pixel = atlas_row[src_x + sx];
|
||||
if ((pixel & 0xFF) == 0) continue;
|
||||
frameBuffer->set_pixel_unsafe(cursor_x + sx, dst_y_abs, rgba);
|
||||
}
|
||||
}
|
||||
draw_font_mask_region(font,
|
||||
src_x + sx_start,
|
||||
src_y + sy_start,
|
||||
sx_end - sx_start,
|
||||
sy_end - sy_start,
|
||||
cursor_x + sx_start,
|
||||
y + sy_start,
|
||||
glyph_pixel);
|
||||
cursor_x += cw;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ namespace Core
|
|||
Rasterizer::Rasterizer* rasterizer;
|
||||
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:
|
||||
DrawContext(int32_t width, int32_t height);
|
||||
~DrawContext();
|
||||
|
|
|
|||
|
|
@ -1,15 +1,27 @@
|
|||
#pragma once
|
||||
#include <cstdint>
|
||||
#include "Image.h"
|
||||
|
||||
namespace RenderData
|
||||
{
|
||||
struct BitmapFont
|
||||
{
|
||||
Image atlas;
|
||||
const uint8_t* mask_bits;
|
||||
int32_t atlas_width;
|
||||
int32_t atlas_height;
|
||||
int32_t char_w;
|
||||
int32_t char_h;
|
||||
int32_t columns;
|
||||
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)
|
||||
{
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ namespace
|
|||
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()
|
||||
{
|
||||
static_assert(sizeof(Core::FramePixel) == sizeof(uint16_t), "FramePixel must stay RGB565");
|
||||
|
|
@ -105,6 +112,87 @@ namespace
|
|||
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()
|
||||
|
|
@ -113,6 +201,9 @@ int main()
|
|||
TestFrameBufferStoresRgb565();
|
||||
TestDrawSpriteUsesOneBitAlpha();
|
||||
TestSpriteAssetLoaderRoundTripsRgba5551();
|
||||
TestDrawTextUsesBitMaskAndColor();
|
||||
TestDrawTextColorChangesPerCallAndBackgroundFill();
|
||||
TestDrawTextClipsBitMaskToScreen();
|
||||
std::cout << "render_pipeline_tests: PASS\n";
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
字体: 优先使用 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 的像素风绘制路径。
|
||||
输出格式: 1-bit row-major bit-packed mask(MSB-first)
|
||||
生成端会把 alpha 阈值化为 0/255,再将整张 atlas 逐行打包成遮罩位图。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -91,16 +91,21 @@ def generate(output_dir: str):
|
|||
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 = []
|
||||
mask_bits = []
|
||||
packed = 0
|
||||
bit_count = 0
|
||||
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)
|
||||
_, _, _, 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:
|
||||
|
|
@ -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_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")
|
||||
f.write(f"static const uint8_t font_atlas_mask[] = {{\n")
|
||||
|
||||
for i in range(0, len(pixels), 16):
|
||||
chunk = pixels[i:i + 16]
|
||||
line = ", ".join(f"0x{p:08X}" for p in chunk)
|
||||
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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue