# 渲染流程梳理 这份文档拆解当前项目里的渲染链路,包括: - 压力数据如何从 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_instance_buffer: wgpu::Buffer, glyph_vertex_buffer: wgpu::Buffer, glyph_instance_buffer: wgpu::Buffer, glyph_instances: Vec, } ``` 字段含义: | 字段 | 作用 | | --- | --- | | `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 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(-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 ``` 但现在归一化仍在 `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, pub dot: bool, } ``` 改成: ```rust pub enum MarkerMode { Dot, Number, } pub struct FingerMode { pub rows: u32, pub cols: u32, pub range: Range, pub marker_mode: MarkerMode, } ``` 这样比 `dot: bool` 更清楚。 现在的: ```rust dot: true ``` 需要脑内翻译成“点模式”。 改成: ```rust marker_mode: MarkerMode::Dot ``` 代码语义会直接很多。