YSU
YSU 内核

渲染器

把显示列表翻译成 wgpu 的 draw call。两条管线,一次实例化提交,一张字形图集。16 条单元测试。

把显示列表翻译成 wgpu 的 draw call。两条管线,一次实例化提交,一张字形图集。16 条单元测试。

谁创建设备

// crates/ysu/src/render/mod.rs
//! 渲染器不创建 GPU 设备,`Device` 与 `Queue` 由外壳传进来,这样内核可以
//! 脱离窗口单独测试,设备的生命周期也只归外壳管。

Renderer 确实不创建设备——它接受外部传入的 DeviceQueue

在当前的 C ABI 架构里,创建设备的是 crates/ysu-capi,不是 C++ 外壳。C++ 那侧完全碰不到 wgpu,它只拿到一块 RGBA 像素。所以上面那段注释里的「外壳」是旧的叫法(那时外壳还是 Rust 写的)。

从 C 调用方的角度看:设备在 ysu_engine_new 里建好,在 ysu_engine_free 里释放,中间不需要它操心。

两条管线

管线顶点 / 片元着色器绑定组
矩形管线vs_rect / fs_rectgroup0 globals
文字管线vs_glyph / fs_glyphgroup0 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::Load

render 先清成白色再画。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

On this page