渲染器
把显示列表翻译成 wgpu 的 draw call。两条管线,一次实例化提交,一张字形图集。16 条单元测试。
把显示列表翻译成 wgpu 的 draw call。两条管线,一次实例化提交,一张字形图集。16 条单元测试。
谁创建设备
// crates/ysu/src/render/mod.rs
//! 渲染器不创建 GPU 设备,`Device` 与 `Queue` 由外壳传进来,这样内核可以
//! 脱离窗口单独测试,设备的生命周期也只归外壳管。Renderer 确实不创建设备——它接受外部传入的 Device 与 Queue。
在当前的 C ABI 架构里,创建设备的是 crates/ysu-capi,不是 C++ 外壳。C++ 那侧完全碰不到 wgpu,它只拿到一块 RGBA 像素。所以上面那段注释里的「外壳」是旧的叫法(那时外壳还是 Rust 写的)。
从 C 调用方的角度看:设备在 ysu_engine_new 里建好,在 ysu_engine_free 里释放,中间不需要它操心。
两条管线
| 管线 | 顶点 / 片元着色器 | 绑定组 |
|---|---|---|
| 矩形管线 | vs_rect / fs_rect | group0 globals |
| 文字管线 | vs_glyph / fs_glyph | group0 globals + group1 atlas |
统一的配置:三角形列表、不做背面剔除、没有深度模板、alpha 混合、目标格式跟随纹理。
没有顶点缓冲。 每个矩形的六个顶点由 vertex_index 在顶点着色器里生成:
// 用顶点序号拼出两个三角形所有几何信息走实例缓冲。一个实例就是一个矩形或一个字形。
实例缓冲
步长 48 字节(12 个 f32),按 VertexStepMode::Instance 提交。
缓冲按需重建,容量取 next_power_of_two,初始 1024。一次提交的量超过容量时重建一次更大的,之后就不再频繁分配。
全局 uniform
const GLOBALS_SIZE: usize = 32;八个 f32:视口尺寸、滚动位置、缩放,加三个填充位。
struct Globals {
viewport: vec2<f32>,
scroll: vec2<f32>,
zoom: f32,
_padding0: f32,
_padding1: f32,
_padding2: f32,
}这里必须用三个独立的 f32 填充,不能用 vec3<f32>。 WGSL 里 vec3<f32> 的对齐是 16 字节,会把结构体撑到 48 字节,与 Rust 侧按 32 字节写进去的数据对不上——wgpu 会报缓冲区大小校验错误。着色器文件里专门写了这条注释。
坐标换算
顶点变换:
let screen = (page - globals.scroll) * globals.zoom;
let clip = screen / globals.viewport * 2.0 - 1.0;剪刀矩形走同一套换算:
fn scissor_rect(/* ... */) -> /* ... */;先减滚动,再乘缩放与像素比。两步都不能少。
漏掉减滚动这一步会出什么现象:裁剪范围在屏幕坐标里跟着滚动量下移,于是页面往上走的那些内容全被裁没,屏幕上出现一大片空白。
这个错曾经藏了很久,因为旧的 Rust 外壳先把显示列表整体平移再渲染,set_view 收到的滚动量恒为零,漏掉的那一步正好不起作用。改成把滚动量交给着色器之后,它立刻现形。
修法是把换算抽成自由函数。 它依赖设备吗?不依赖。留在 Renderer 的方法里就测不到;抽出来之后单元测试能覆盖住。
剪刀矩形与批次
// 用 set_scissor_rect 设置裁剪范围裁剪范围改变、或者矩形与文字之间切换时,批次断开,提交一次 draw call。
也就是说,一次渲染的 draw call 数量大致等于「裁剪范围变化的次数 + 管线切换的次数」,而不是命令条数。一个没有裁剪切、只有背景和文字的页面通常只有两次提交。
字形图集
pub const DEFAULT_ATLAS_SIZE: u32 = 2048;2048×2048,格式 R8Unorm(单通道灰度),用途是纹理绑定加拷贝目标。
分配用货架算法。 维护一个当前货架的 y、高度、以及游标 x。放不下一个字形时游标换行、行满时开新货架。字形成对之间留 1 像素间隙防止采样串色。
没有逐项淘汰,也没有 LRU。 整张图集排满时整体清空(reset()),字形随后按需重新栅格化。代价是偶发的重排,收益是不用维护淘汰策略。
比整张图还大的字形返回 None,调用方跳过。
上传时每行按 256 字节对齐。 这是 Queue::write_texture 的硬要求。填充只存在于内部的暂存缓冲里,不进入图集本身。
缓存键是 cosmic_text::CacheKey——同样的字形、字号、字重、斜体才会命中同一个条目。
彩色字形(emoji)被跳过。 图集是单通道的,装不下彩色位图,判定为 SwashContent::Mask 之外的一律略过。后果是 emoji 显示成空白。
字形的栅格化尺寸
let raster_scale = (scale * zoom).max(1.0);按物理像素栅格化。缩放 200% 时字形是按两倍大小重新栅格化的,不是把 100% 的位图拉大——所以放大之后文字仍然清晰。
. max(1.0) 是下限:缩小时不按缩小后的尺寸栅格化(那会让小字更糊),而是保持原尺寸让采样去缩。
颜色
交换链是 sRGB 格式,所以颜色要做 sRGB 到线性的转换:
fn srgb_to_linear(/* ... */) -> /* ... */;手工换算,手写的公式。交给硬件自动做也可以,但那要求纹理格式与混合状态的组合正好配套;手写更可控,代价是每次渲染多做几次幂运算。
两种渲染入口
pub fn render(&mut self, /* ... */) -> /* ... */; // LoadOp::Clear(WHITE)
pub fn render_over(&mut self, /* ... */) -> /* ... */; // LoadOp::Loadrender 先清成白色再画。render_over 保留已有内容往上叠——旧的 Rust 外壳用它把浏览器界面(标签栏、工具栏)叠在页面上。现在外壳是 Qt 画的,所以 C ABI 只暴露 render 这一条。
边框线型
四种线型不在着色器里,而是在命令展开阶段手工算成矩形:
| 线型 | 画法 |
|---|---|
Solid | 一条实心矩形 |
Dashed | 按三倍线宽分段,实一段空一段 |
Dotted | 一串正方形 |
Double | 两条细线 |
border_rects 先把边框盒拆成四条边,每条边按自己的宽度和样式展开。
测试
16 条。它们不建 GPU 设备——那样测试在没显卡的机器上就挂了。测的是纯函数部分:坐标换算、sRGB 转换、剪刀矩形的计算、边框分拆、图集的货架分配。
这些恰好是渲染里最容易出错的地方,也恰好是不依赖设备的部分。真正的成像验靠示例程序出图人眼看:
cargo run -p ysu-capi --example dump_frame -- /tmp/frame.ppm 1.0 home 200