Files
eskin-player/render.md
2026-06-26 02:03:07 +08:00

867 lines
18 KiB
Markdown
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.
# 渲染流程梳理
这份文档拆解当前项目里的渲染链路,包括:
- 压力数据如何从 CPU 进入 GPU
- `egui_wgpu::CallbackTrait``prepare / paint` 两阶段
- `BackgroundRenderResources` 负责管理哪些 GPU 资源
- `shader.wgsl` 里的各个 shader entry point 分别做什么
- `Finger / Hand` 两种模式如何切换渲染路径
主要相关文件:
- `src/app.rs`
- `src/render.rs`
- `src/matrix.rs`
- `src/model.rs`
- `static/wgsl/shader.wgsl`
## 总览
渲染入口在 `EskinDesktopApp::ui`
```rust
self.draw_wgpu_background(ui);
self.draw_panel_context_menu(ui);
self.draw_title_bar(ui, frame);
self.draw_floating_panels(&ctx);
```
顺序很重要:
1. `draw_wgpu_background` 先绘制整块 WGPU 背景和 3D 内容。
2. 右键菜单、标题栏、floating panel 后画,所以会盖在 WGPU 内容上。
3. `ctx.request_repaint()` 保证背景持续刷新。
真正的 WGPU 绘制不是直接在 `app.rs` 里执行,而是通过 egui 的 paint callback
```rust
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect,
WgpuBackgroundCallback {
width,
height,
pressure: self.pressure_matrix,
active_mode: self.active_mode.clone(),
},
));
```
可以理解成:`app.rs` 每帧把“当前要画什么”打包成 `WgpuBackgroundCallback`,然后交给 `egui_wgpu` 在合适的 GPU 阶段执行。
## 数据流
### 1. 串口数据进入 App
`update_pressure_matrix``ConnectionManager` 取最新数据:
```rust
if let Some(sample) = self.connection.take_latest_sample() {
normalize_pressure_sample(
&sample.matrix,
sample.rows,
sample.cols,
&mut self.pressure_matrix,
);
}
```
串口原始数据是一个扁平的一维矩阵,元素是整数压力值。
`normalize_pressure_sample` 会把它转成:
```rust
pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT];
```
每个 cell 有两个 `f32`
```text
[normalized_pressure, display_value]
```
含义:
| 值 | 作用 |
| --- | --- |
| `normalized_pressure` | `0.0..=1.0`,用于颜色、大小、亮度。 |
| `display_value` | 原始压力值的显示版本,用于数字模式。 |
当前归一化范围写死在 `app.rs`
```rust
const RANGE_MIN: f32 = 0.0;
const RANGE_MAX: f32 = 7000.0;
```
这意味着虽然 `FingerMode / HandGatewayMode` 里已经有 `range` 字段,但实际颜色归一化还没有用到它。
### 2. App 创建 WGPU Callback
`draw_wgpu_background` 根据当前 viewport 创建 callback
```rust
WgpuBackgroundCallback {
width,
height,
pressure: self.pressure_matrix,
active_mode: self.active_mode.clone(),
}
```
`WgpuBackgroundCallback` 保存的是“每帧变化的数据”:
- 当前视口宽度
- 当前视口高度
- 当前压力帧
- 当前渲染模式
它不保存长期 GPU 资源。长期资源在 `BackgroundRenderResources` 里。
### 3. Callback Prepare 阶段
`WgpuBackgroundCallback` 实现了:
```rust
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback
```
`prepare` 阶段代码:
```rust
let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap();
resources.prepare(queue, self.width, self.height, &self.pressure);
```
这个阶段主要做 CPU -> GPU 的数据更新:
1. 根据 viewport aspect ratio 重新计算相机矩阵。
2.`MatrixUniform` 写入 `uniform_buffer`
3. 根据当前 `PressureFrame` 更新所有 marker instance。
4.`glyph_instances` 写入 `glyph_instance_buffer`
这里不会真正 draw只是准备 GPU buffer。
### 4. Callback Paint 阶段
`paint` 阶段代码:
```rust
let resources: &BackgroundRenderResources = resources.get().unwrap();
resources.paint(render_pass, &self.active_mode);
```
这个阶段才是真正向 `render_pass` 发 draw call。
`active_mode` 在这里传进去,决定后面画点阵、数字,还是画模型。
## GPU 资源管理
`BackgroundRenderResources``EskinDesktopApp::new` 里创建一次:
```rust
renderer
.callback_resources
.insert(BackgroundRenderResources::new(
&wgpu_state.device,
&wgpu_state.queue,
&wgpu_state.target_format,
MATRIX_ROWS,
MATRIX_COLS,
));
```
它负责保存长期存在的 GPU 资源:
```rust
pub struct BackgroundRenderResources {
layout: MatrixLayout,
rows: u32,
cols: u32,
uniform: MatrixUniform,
uniform_buffer: wgpu::Buffer,
uniform_bind_group: wgpu::BindGroup,
background_pipeline: wgpu::RenderPipeline,
glyph_pipeline: wgpu::RenderPipeline,
dot_pipeline: wgpu::RenderPipeline,
model_pipeline: wgpu::RenderPipeline,
model: Option<Model>,
model_instance_buffer: wgpu::Buffer,
glyph_vertex_buffer: wgpu::Buffer,
glyph_instance_buffer: wgpu::Buffer,
glyph_instances: Vec<GlyphInstance>,
}
```
字段含义:
| 字段 | 作用 |
| --- | --- |
| `layout` | 压力矩阵的世界空间布局。 |
| `rows`, `cols` | 当前 GPU 资源按多少行列创建。 |
| `uniform` | CPU 侧 uniform 缓存。 |
| `uniform_buffer` | GPU uniform buffer。 |
| `uniform_bind_group` | WGSL 里的 `@group(0)`。 |
| `background_pipeline` | 全屏背景、底部色条。 |
| `glyph_pipeline` | 数字/数值 marker。 |
| `dot_pipeline` | 圆点 marker。 |
| `model_pipeline` | OBJ 模型渲染。 |
| `model` | 加载好的模型和材质。 |
| `model_instance_buffer` | 模型实例矩阵。 |
| `glyph_vertex_buffer` | 一个 marker quad 的 6 个顶点。 |
| `glyph_instance_buffer` | 每个压力点一个 instance。 |
| `glyph_instances` | CPU 侧 instance 数组。 |
## 模式切换
当前运行时渲染模式:
```rust
pub enum ActiveMode {
Finger(FingerMode),
Hand(HandGatewayMode),
}
```
`app.rs` 持有:
```rust
active_mode: ActiveMode,
```
UI 层的 `SerialMode` 是用户选择,真正渲染用的是 `ActiveMode`
切换逻辑在 `switch_mode`
```rust
fn switch_mode(&mut self, next: SerialMode) {
self.connect_state.mode = next;
self.config_state.mode = next;
self.connection.disconnect();
self.pressure_matrix.fill([0.0, 0.0]);
self.data_log_frame = 0;
self.active_mode = match next {
SerialMode::Finger => ActiveMode::Finger(FingerMode {
rows: self.matrix_config.rows,
cols: self.matrix_config.cols,
range: 0..7000,
dot: true,
}),
SerialMode::Hand => ActiveMode::Hand(HandGatewayMode { range: 0..7000 }),
}
}
```
这里做了几件事:
1. 同步 UI 状态。
2. 断开当前连接。
3. 清空压力帧。
4. 重置日志计数。
5. 设置新的 `active_mode`
这比只改一个 enum 更像真正的互斥生命周期切换。
## 绘制分发
`BackgroundRenderResources::paint` 先画背景,再按模式分支:
```rust
render_pass.set_pipeline(&self.background_pipeline);
render_pass.draw(0..3, 0..1);
match active_mode {
ActiveMode::Finger(mode) => self.paint_finger(render_pass, mode),
ActiveMode::Hand(mode) => self.paint_hand(render_pass, mode),
}
```
当前模式行为:
| 模式 | 绘制内容 |
| --- | --- |
| `Finger` | 背景 + 压力 marker。 |
| `Hand` | 背景 + 模型。 |
## Finger 模式
Finger 模式根据 `dot` 字段选择点模式或数字模式:
```rust
let marker_pipeline = if mode.dot {
&self.dot_pipeline
} else {
&self.glyph_pipeline
};
```
含义:
| `mode.dot` | pipeline | 效果 |
| --- | --- | --- |
| `true` | `dot_pipeline` | 画圆点。 |
| `false` | `glyph_pipeline` | 画数字。 |
两个 pipeline 共用同一套 vertex buffer
```rust
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
render_pass.set_vertex_buffer(1, self.glyph_instance_buffer.slice(..));
render_pass.draw(0..6, 0..draw_count);
```
解释:
- vertex buffer 0一个 quad 的 6 个顶点。
- vertex buffer 1每个压力点一个 instance。
- `draw(0..6, 0..draw_count)`:每个 instance 都复用这 6 个顶点。
## Hand 模式
Hand 模式当前只画模型:
```rust
render_pass.set_pipeline(&self.model_pipeline);
render_pass.set_vertex_buffer(1, self.model_instance_buffer.slice(..));
```
然后遍历 mesh
```rust
for mesh in &model.meshes {
if let Some(material) = model.materials.get(mesh.material) {
render_pass.set_bind_group(1, &material.bind_group, &[]);
}
render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
render_pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
render_pass.draw_indexed(0..mesh.num_elements, 0, 0..1);
}
```
绑定关系:
| Slot / Group | 内容 |
| --- | --- |
| bind group 0 | `MatrixUniform` |
| bind group 1 | 模型纹理和 sampler |
| vertex buffer 0 | 模型顶点 |
| vertex buffer 1 | 模型实例矩阵 |
## Matrix 布局
`src/matrix.rs` 定义压力矩阵如何放到 3D 世界里。
固定默认尺寸:
```rust
pub const MATRIX_ROWS: u32 = 12;
pub const MATRIX_COLS: u32 = 7;
```
`MatrixLayout::new(rows, cols)` 计算:
- cell 间距
- board 宽度
- board 深度
- board padding
- marker 垂直浮起高度
`glyph_world_position``(row, col)` 映射到世界坐标:
```rust
let x = (col as f32 - cols as f32 / 2.0 + 0.5) * layout.cell_spacing;
let z = (row as f32 - rows as f32 / 2.0 + 0.5) * layout.cell_spacing;
```
返回:
```rust
[
x,
MATRIX_OFFSET_Y + layout.label_float_offset,
MATRIX_OFFSET_Z + z,
1.0,
]
```
所以 marker 是铺在 X/Z 平面上Y 方向稍微浮起来。
`build_view_projection` 根据矩阵尺寸创建相机:
```rust
let view = glam::Mat4::look_at_rh(eye, target, glam::Vec3::Y);
let projection = glam::Mat4::perspective_rh(...);
let open_gl_to_wgpu = ...
```
最后的 `open_gl_to_wgpu` 是为了把 OpenGL 风格深度范围转换成 WGPU 需要的 clip space。
## Uniform
Rust 侧:
```rust
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct MatrixUniform {
view_proj: [[f32; 4]; 4],
viewport: [f32; 4],
glyph: [f32; 4],
color: [f32; 4],
}
```
WGSL 侧:
```wgsl
struct MatrixUniform {
view_proj: mat4x4f,
viewport: vec4f,
glyph: vec4f,
color: vec4f,
}
@group(0) @binding(0)
var<uniform> u: MatrixUniform;
```
字段含义:
| 字段 | 含义 |
| --- | --- |
| `view_proj` | 世界坐标到 clip space 的矩阵。 |
| `viewport.xy` | 当前 viewport 像素尺寸。 |
| `glyph.x` | marker 基础像素大小。 |
| `color` | 目前基本没用上,预留字段。 |
每帧在 `prepare` 里写入 GPU
```rust
queue.write_buffer(
&self.uniform_buffer,
0,
bytemuck::cast_slice(&[self.uniform]),
);
```
## Marker / Glyph 几何
代码里叫 `glyph`,但它不是字体系统里的 glyph。它更像一个“压力 marker”。
基础几何是一个 quad
```rust
let glyph_vertices = [
[-1.0, -1.0],
[ 1.0, -1.0],
[-1.0, 1.0],
[-1.0, 1.0],
[ 1.0, -1.0],
[ 1.0, 1.0],
];
```
这 6 个点组成两个三角形。
每个压力点对应一个 `GlyphInstance`
```rust
struct GlyphInstance {
world_position: [f32; 4],
style: [f32; 4],
}
```
WGSL 接收:
```wgsl
struct GlyphInstanceInput {
@location(1) world_position: vec4f,
@location(2) style: vec4f,
}
```
`style` 的当前含义:
| 分量 | 含义 |
| --- | --- |
| `style.x` | 归一化压力值。 |
| `style.y` | 显示用压力值。 |
| `style.z` | 预留。 |
| `style.w` | 预留。 |
## Shader 总览
所有 pipeline 当前共用一个 WGSL 文件:
```rust
include_str!("../static/wgsl/shader.wgsl")
```
不同 pipeline 选择不同入口函数。
## Background Shader
Rust 侧创建:
```rust
create_background_pipeline(...)
```
WGSL entry point
```wgsl
@vertex
fn vs_background(...)
@fragment
fn fs_background(...)
```
`vs_background` 画一个全屏三角形:
```wgsl
let positions = array<vec2f, 3>(
vec2f(-1.0, -3.0),
vec2f(3.0, 1.0),
vec2f(-1.0, 1.0),
);
```
一个大三角形覆盖整屏,避免两个三角形拼接时可能出现的边界问题。
`fs_background` 负责画:
- 深色垂直渐变
- vignette 暗角
- 中央氛围带
- 底部压力色条
- 色条刻度线
颜色映射来自:
```wgsl
fn sample_range_color(value: f32) -> vec3f
```
色阶大概是:
1. 蓝青
2. 绿色
3. 橙色
4. 红色
## Glyph / 数字 Shader
Rust 侧创建:
```rust
create_glyph_pipeline(...)
```
WGSL entry point
```wgsl
@vertex
fn vs_glyph(...)
@fragment
fn fs_glyph(...)
```
这是数字模式。
`vs_glyph` 做几件事:
1. 把 instance 的 `world_position``u.view_proj`,得到 clip space 中心点。
2.`instance.style.x` 读取压力强度。
3. 根据强度计算 marker 的像素大小。
4.`viewport` 把像素大小转换成 NDC 偏移。
关键代码:
```wgsl
let shaped = pow(saturate(instance.style.x), 0.9);
let pixel_size = u.glyph.x * mix(1.08, 2.20, shaped);
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
```
这意味着数字不是固定世界尺寸,而是接近固定屏幕像素尺寸,更适合读数。
`fs_glyph` 做数字绘制:
```wgsl
let alpha = number_alpha(in.local, in.display_value);
let color = sample_range_color(in.intensity) * mix(0.82, 1.16, saturate(in.intensity));
return vec4f(color, alpha);
```
数字不是字体贴图,而是在 shader 里用七段数码管方式画出来。
相关函数:
- `digit_segment_on`
- `seven_segment_digit_alpha`
- `digit_count`
- `digit_at`
- `number_alpha`
## Dot / 点 Shader
Rust 侧创建:
```rust
create_dot_pipeline(...)
```
WGSL entry point
```wgsl
@vertex
fn vs_dot(...)
@fragment
fn fs_dot(...)
```
这是圆点模式。
`vs_dot``vs_glyph` 类似,也是把世界坐标点展开成屏幕像素大小的 quad。
点模式的尺寸曲线更小:
```wgsl
let pixel_size = u.glyph.x * mix(0.72, 1.85, shaped);
```
`fs_dot` 画:
- 圆形核心
- 外发光
- 小高光
- 压力映射颜色
圆形 alpha 来自:
```wgsl
fn circle_alpha(local: vec2f, radius: f32, softness: f32) -> f32
```
核心片段:
```wgsl
let core = circle_alpha(in.local, 0.42, 0.055);
let glow = circle_alpha(in.local, 0.78, 0.18) * intensity * 0.45;
let alpha = max(core, glow);
```
## Model Shader
Rust 侧创建:
```rust
create_model_pipeline(...)
```
WGSL entry point
```wgsl
@vertex
fn vs_model(...)
@fragment
fn fs_model(...)
```
输入 layout
```wgsl
@location(0) position: vec3f,
@location(1) tex_coords: vec2f,
@location(2) normal: vec3f,
@location(5) model_0: vec4f,
@location(6) model_1: vec4f,
@location(7) model_2: vec4f,
@location(8) model_3: vec4f,
```
`position / tex_coords / normal` 来自模型 vertex buffer。
`model_0..model_3` 来自 instance buffer也就是每个模型实例的 transform matrix。
`vs_model`
1. 重建 model matrix。
2. 计算 world position。
3.`u.view_proj` 转到 clip space。
4. 变换 normal。
5. 翻转纹理 Y 坐标。
`fs_model`
1. 采样纹理。
2. 计算简单漫反射光照。
3. 加一点 rim light。
4. 输出不透明颜色。
```wgsl
let light_dir = normalize(vec3f(-0.35, 0.85, 0.45));
let diffuse = max(dot(normal, light_dir), 0.0);
let rim = pow(1.0 - saturate(abs(normal.y)), 2.0) * 0.16;
let base = textureSample(model_texture, model_sampler, in.tex_coords).rgb;
let color = base * (0.28 + diffuse * 0.72) + vec3f(0.14, 0.24, 0.32) * rim;
```
## 模型加载
模型在 `BackgroundRenderResources::new` 里加载一次:
```rust
let model =
match resources::load_model("cube.obj", device, _queue, &texture_bind_group_layout) {
Ok(model) => Some(model),
Err(err) => {
log::warn!("failed to load cube.obj: {err:#}");
None
}
};
```
当前模型实例 transform 是写死的:
```rust
let model_instance = Instance::new(
glam::Vec3::new(0.0, -2.0, 12.0),
glam::Quat::from_rotation_y(0.65) * glam::Quat::from_rotation_x(-0.35),
)
.to_raw();
```
它把模型放到和压力矩阵相同的相机空间里。
## 当前限制
### 1. `rows / cols` 还没有真正重建布局
`FingerMode` 里有:
```rust
rows: u32,
cols: u32,
```
`BackgroundRenderResources``layout` 和 instance buffer 是创建时确定的。
现在 `paint_finger` 只用 rows/cols 限制 draw count
```rust
let draw_count = self.visible_instance_count(mode.rows, mode.cols);
```
所以它能少画一些点,但不会重新计算新尺寸下的空间布局。
如果后面要支持动态矩阵尺寸,需要:
1. 尺寸变化时重建 `MatrixLayout` 和 instance buffer。
2. 或者一开始分配最大尺寸 buffer然后每帧按 active rows/cols 更新坐标。
### 2. `range` 字段还没有真正用起来
`FingerMode``HandGatewayMode` 都有:
```rust
range: Range<u32>
```
但现在归一化仍在 `normalize_pressure_sample` 里硬编码为 `0..7000`
所以如果要让不同模式有不同色域范围,需要把 range 传入归一化过程,或者把归一化移动到 render/update instance 阶段。
### 3. `glyph` 命名有点误导
现在的 `glyph` 实际更像 marker。
推荐未来改名:
```text
GlyphVertex -> MarkerVertex
GlyphInstance -> MarkerInstance
glyph_pipeline -> number_marker_pipeline
dot_pipeline -> dot_marker_pipeline
glyph_vertex_buffer -> marker_vertex_buffer
glyph_instance_buffer -> marker_instance_buffer
glyph_instances -> marker_instances
```
### 4. Hand 模式还没有压力叠加
现在:
```rust
ActiveMode::Hand(mode) => self.paint_hand(render_pass, mode),
```
只画模型,不画压力点。
如果手掌模式要显示压力映射,需要额外设计“压力矩阵 -> 手模型表面/区域”的映射。
可能方向:
```rust
ActiveMode::Hand(mode) => {
self.paint_hand(render_pass, mode);
self.paint_hand_pressure_overlay(render_pass, mode);
}
```
但 overlay 的坐标不应该复用现在的矩阵 X/Z 网格,需要新的手部映射数据。
## 建议下一步
把:
```rust
pub struct FingerMode {
pub rows: u32,
pub cols: u32,
pub range: Range<u32>,
pub dot: bool,
}
```
改成:
```rust
pub enum MarkerMode {
Dot,
Number,
}
pub struct FingerMode {
pub rows: u32,
pub cols: u32,
pub range: Range<u32>,
pub marker_mode: MarkerMode,
}
```
这样比 `dot: bool` 更清楚。
现在的:
```rust
dot: true
```
需要脑内翻译成“点模式”。
改成:
```rust
marker_mode: MarkerMode::Dot
```
代码语义会直接很多。