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

18 KiB
Raw Blame History

渲染流程梳理

这份文档拆解当前项目里的渲染链路,包括:

  • 压力数据如何从 CPU 进入 GPU
  • egui_wgpu::CallbackTraitprepare / 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

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

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_matrixConnectionManager 取最新数据:

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 会把它转成:

pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT];

每个 cell 有两个 f32

[normalized_pressure, display_value]

含义:

作用
normalized_pressure 0.0..=1.0,用于颜色、大小、亮度。
display_value 原始压力值的显示版本,用于数字模式。

当前归一化范围写死在 app.rs

const RANGE_MIN: f32 = 0.0;
const RANGE_MAX: f32 = 7000.0;

这意味着虽然 FingerMode / HandGatewayMode 里已经有 range 字段,但实际颜色归一化还没有用到它。

2. App 创建 WGPU Callback

draw_wgpu_background 根据当前 viewport 创建 callback

WgpuBackgroundCallback {
    width,
    height,
    pressure: self.pressure_matrix,
    active_mode: self.active_mode.clone(),
}

WgpuBackgroundCallback 保存的是“每帧变化的数据”:

  • 当前视口宽度
  • 当前视口高度
  • 当前压力帧
  • 当前渲染模式

它不保存长期 GPU 资源。长期资源在 BackgroundRenderResources 里。

3. Callback Prepare 阶段

WgpuBackgroundCallback 实现了:

impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback

prepare 阶段代码:

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 阶段代码:

let resources: &BackgroundRenderResources = resources.get().unwrap();
resources.paint(render_pass, &self.active_mode);

这个阶段才是真正向 render_pass 发 draw call。

active_mode 在这里传进去,决定后面画点阵、数字,还是画模型。

GPU 资源管理

BackgroundRenderResourcesEskinDesktopApp::new 里创建一次:

renderer
    .callback_resources
    .insert(BackgroundRenderResources::new(
        &wgpu_state.device,
        &wgpu_state.queue,
        &wgpu_state.target_format,
        MATRIX_ROWS,
        MATRIX_COLS,
    ));

它负责保存长期存在的 GPU 资源:

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 数组。

模式切换

当前运行时渲染模式:

pub enum ActiveMode {
    Finger(FingerMode),
    Hand(HandGatewayMode),
}

app.rs 持有:

active_mode: ActiveMode,

UI 层的 SerialMode 是用户选择,真正渲染用的是 ActiveMode

切换逻辑在 switch_mode

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 先画背景,再按模式分支:

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 字段选择点模式或数字模式:

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

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 模式当前只画模型:

render_pass.set_pipeline(&self.model_pipeline);
render_pass.set_vertex_buffer(1, self.model_instance_buffer.slice(..));

然后遍历 mesh

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 世界里。

固定默认尺寸:

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) 映射到世界坐标:

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;

返回:

[
    x,
    MATRIX_OFFSET_Y + layout.label_float_offset,
    MATRIX_OFFSET_Z + z,
    1.0,
]

所以 marker 是铺在 X/Z 平面上Y 方向稍微浮起来。

build_view_projection 根据矩阵尺寸创建相机:

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 侧:

#[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 侧:

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

queue.write_buffer(
    &self.uniform_buffer,
    0,
    bytemuck::cast_slice(&[self.uniform]),
);

Marker / Glyph 几何

代码里叫 glyph,但它不是字体系统里的 glyph。它更像一个“压力 marker”。

基础几何是一个 quad

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

struct GlyphInstance {
    world_position: [f32; 4],
    style: [f32; 4],
}

WGSL 接收:

struct GlyphInstanceInput {
    @location(1) world_position: vec4f,
    @location(2) style: vec4f,
}

style 的当前含义:

分量 含义
style.x 归一化压力值。
style.y 显示用压力值。
style.z 预留。
style.w 预留。

Shader 总览

所有 pipeline 当前共用一个 WGSL 文件:

include_str!("../static/wgsl/shader.wgsl")

不同 pipeline 选择不同入口函数。

Background Shader

Rust 侧创建:

create_background_pipeline(...)

WGSL entry point

@vertex
fn vs_background(...)

@fragment
fn fs_background(...)

vs_background 画一个全屏三角形:

let positions = array<vec2f, 3>(
    vec2f(-1.0, -3.0),
    vec2f(3.0, 1.0),
    vec2f(-1.0, 1.0),
);

一个大三角形覆盖整屏,避免两个三角形拼接时可能出现的边界问题。

fs_background 负责画:

  • 深色垂直渐变
  • vignette 暗角
  • 中央氛围带
  • 底部压力色条
  • 色条刻度线

颜色映射来自:

fn sample_range_color(value: f32) -> vec3f

色阶大概是:

  1. 蓝青
  2. 绿色
  3. 橙色
  4. 红色

Glyph / 数字 Shader

Rust 侧创建:

create_glyph_pipeline(...)

WGSL entry point

@vertex
fn vs_glyph(...)

@fragment
fn fs_glyph(...)

这是数字模式。

vs_glyph 做几件事:

  1. 把 instance 的 world_positionu.view_proj,得到 clip space 中心点。
  2. instance.style.x 读取压力强度。
  3. 根据强度计算 marker 的像素大小。
  4. viewport 把像素大小转换成 NDC 偏移。

关键代码:

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 做数字绘制:

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 侧创建:

create_dot_pipeline(...)

WGSL entry point

@vertex
fn vs_dot(...)

@fragment
fn fs_dot(...)

这是圆点模式。

vs_dotvs_glyph 类似,也是把世界坐标点展开成屏幕像素大小的 quad。

点模式的尺寸曲线更小:

let pixel_size = u.glyph.x * mix(0.72, 1.85, shaped);

fs_dot 画:

  • 圆形核心
  • 外发光
  • 小高光
  • 压力映射颜色

圆形 alpha 来自:

fn circle_alpha(local: vec2f, radius: f32, softness: f32) -> f32

核心片段:

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 侧创建:

create_model_pipeline(...)

WGSL entry point

@vertex
fn vs_model(...)

@fragment
fn fs_model(...)

输入 layout

@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. 输出不透明颜色。
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 里加载一次:

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 是写死的:

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 里有:

rows: u32,
cols: u32,

BackgroundRenderResourceslayout 和 instance buffer 是创建时确定的。

现在 paint_finger 只用 rows/cols 限制 draw count

let draw_count = self.visible_instance_count(mode.rows, mode.cols);

所以它能少画一些点,但不会重新计算新尺寸下的空间布局。

如果后面要支持动态矩阵尺寸,需要:

  1. 尺寸变化时重建 MatrixLayout 和 instance buffer。
  2. 或者一开始分配最大尺寸 buffer然后每帧按 active rows/cols 更新坐标。

2. range 字段还没有真正用起来

FingerModeHandGatewayMode 都有:

range: Range<u32>

但现在归一化仍在 normalize_pressure_sample 里硬编码为 0..7000

所以如果要让不同模式有不同色域范围,需要把 range 传入归一化过程,或者把归一化移动到 render/update instance 阶段。

3. glyph 命名有点误导

现在的 glyph 实际更像 marker。

推荐未来改名:

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 模式还没有压力叠加

现在:

ActiveMode::Hand(mode) => self.paint_hand(render_pass, mode),

只画模型,不画压力点。

如果手掌模式要显示压力映射,需要额外设计“压力矩阵 -> 手模型表面/区域”的映射。

可能方向:

ActiveMode::Hand(mode) => {
    self.paint_hand(render_pass, mode);
    self.paint_hand_pressure_overlay(render_pass, mode);
}

但 overlay 的坐标不应该复用现在的矩阵 X/Z 网格,需要新的手部映射数据。

建议下一步

把:

pub struct FingerMode {
    pub rows: u32,
    pub cols: u32,
    pub range: Range<u32>,
    pub dot: bool,
}

改成:

pub enum MarkerMode {
    Dot,
    Number,
}

pub struct FingerMode {
    pub rows: u32,
    pub cols: u32,
    pub range: Range<u32>,
    pub marker_mode: MarkerMode,
}

这样比 dot: bool 更清楚。

现在的:

dot: true

需要脑内翻译成“点模式”。

改成:

marker_mode: MarkerMode::Dot

代码语义会直接很多。