一个基于 WebGPU 和 Rust (WASM) 构建的高性能 glTF 2.0 查看器。 在线地址:https://gltf-wgpu-viewer.scorp.fun/ Github
选择Rust来编译 WebAssembly (WASM) 是一个在现代高性能 Web 开发中非常流行的选择,特别是在像gltf-wgpu-viewer这样的图形和3D项目中。 导入模型和相关资源后的效果: 
为什么选择Rust来编译 WASM
- 接近原生的执行速度 :Rust 编译生成的WASM代码运行速度非常快,接近C/C++的原生性能。
- 无垃圾回收 (No GC) :与JavaScript、Go或C#不同,Rust没有运行时垃圾回收器。这意味着它不会因为GC暂停而导致帧率波动(Jank),这对3D渲染和游戏至关重要。
- 适合计算密集型任务 :在项目中,Rust被用于glTF解析 ( src/lib.rs )。解析大型3D模型文件涉及大量的二进制数据读取、结构体转换和内存操作,Rust在这方面比JavaScript快得多且更节省内存 。
- 编译期保障 :Rust的所有权(Ownership)和借用(Borrowing)机制在编译阶段就消除了空指针、数据竞争和内存泄漏等常见错误。
- 稳定性 :对于复杂的解析逻辑(如处理glTF的各种嵌套结构和二进制buffer),Rust能够保证代码在运行时不会因为内存问题莫名崩溃,提供了比C++更高的安全性。
- wasm-bindgen:你的项目使用了这个库,它能自动生成JavaScript和Rust之间的“胶水代码”,让你可以像调用普通JS函数一样调用Rust函数,并且能高效地在两者之间传递复杂数据(如字符串、数组、对象)。
- wasm-pack:你使用的构建工具,一键集成编译、打包、发布流程,体验非常顺滑。
功能特性
- WebGPU 后端:利用现代 WebGPU API 进行高效渲染。
- Rust WASM 解析器:使用 Rust 和 wasm-bindgen 实现高性能 glTF 解析。
- Draco 压缩支持:通过 Google Draco Decoder 支持 KHR_draco_mesh_compression 扩展。
- 交互式控制:支持轨道相机的旋转、平移和缩放。
- 拖拽上传:支持直接将 .gltf/.glb 文件或文件夹拖入窗口。
- PBR 渲染:基础 PBR 光照模型(环境光、方向光、镜面光)。
主要目录结构
.
├── src/lib.rs # Rust 核心逻辑 (glTF 解析)
├── main.js # WebGPU 渲染主循环
├── camera.js # 相机控制逻辑
├── index.html # UI 入口
└── Cargo.toml # Rust 依赖配置
关键依赖 (Cargo.toml)
- wasm-bindgen: 用于 Rust 与 JS 交互。
- js-sys: 操作 JS 的原生类型(如 Float32Array)。
- gltf: Rust 生态中最成熟的 glTF 解析库。
- glam / nalgebra: (可选) 用于数学计算,本项目主要使用原生数组以减少依赖。
Rust 侧:构建 glTF 解析器
文件位置:src/lib.rs
定义跨语言数据结构 (MeshData)
为了让 JS 能高效获取解析后的数据,我们需要定义一个 wasm_bindgen 导出的结构体。
关键点:零拷贝 (Zero-Copy) 访问 我们不将数据 clone 给 JS,而是通过 unsafe 块返回 Rust 内存的视图 (View)。
#[wasm_bindgen]
pub struct MeshData {
positions: Vec<f32>,
indices: Vec<u32>,
// … 其他属性
}
#[wasm_bindgen]
impl MeshData {
// JS 调用 mesh.positions 时,直接返回指向 WASM 内存的 Float32Array
#[wasm_bindgen(getter)]
pub fn positions(&self) -> Float32Array {
unsafe { Float32Array::view(&self.positions) }
}
}
处理多文件加载 (fetch_file_from_js)
glTF 文件通常引用外部的 .bin 或纹理文件。由于 WASM 无法直接访问文件系统,我们需要通过 JS 传入的文件映射来获取数据。
实现逻辑:
JS 将所有拖入的文件读取为 文件名 -> Uint8Array 的 Map。
Rust 解析 glTF 时遇到外部引用(URI),调用辅助函数在 Map 中查找。
智能路径匹配:为了兼容不同系统的路径分隔符和 URL 编码,实现了多种查找策略(如解码 URL、去除前缀 ./、仅匹配文件名等)。
// src/lib.rs
fn fetch_file_from_js(file_map: &JsValue, uri: &str) -> Option<Vec<u8>> {
// 1. 尝试直接使用 URI 获取
if let Some(v) = get_js_file(file_map, uri) { return Some(v); }
// 2. 尝试 URL 解码
let decoded = urlencoding::decode(uri).ok()?;
if let Some(v) = get_js_file(file_map, &decoded) { return Some(v); }
// 3. 尝试去除路径前缀 (处理相对路径 ./texture.png)
let uri_trim = uri.strip_prefix("./").unwrap_or(uri);
if let Some(v) = get_js_file(file_map, uri_trim) { return Some(v); }
// 4. 回退到仅文件名匹配 (解决目录结构不匹配问题)
let uri_base = uri_trim.rsplit(|c| c == '/' || c == '\\\\').next()?;
if let Some(v) = get_js_file(file_map, uri_base) { return Some(v); }
None
}
解析主逻辑 (parse_multifile_gltf)
- 遍历每个 Mesh 的 Primitive。
- 读取 POSITION (位置), NORMAL (法线), TEXCOORD_0 (UV) 等访问器 (Accessor)。
- 将二进制数据转换为扁平的 Vec<f32> 或 Vec<u32>。
// src/lib.rs
for mesh in gltf.meshes() {
for primitive in mesh.primitives() {
let reader = primitive.reader(|buffer| {
// 回调:根据 buffer 索引查找对应的二进制数据
// files 是预先加载好的 HashMap<String, Vec<u8>>
// 注意:这里简化了逻辑,实际需处理 source 映射
Some(&files.get(&buffer.index().to_string())?[..])
});
if let Some(iter) = reader.read_positions() {
for vertex in iter {
// 扁平化存储 positions
positions.push(vertex[0]);
positions.push(vertex[1]);
positions.push(vertex[2]);
}
}
// … 同理读取 normals, uvs, indices
}
}
JS 侧:WebGPU 渲染管线搭建
文件位置:main.js
初始化 WebGPU
WebGPU 的初始化是异步的:
编写 WGSL 着色器
顶点着色器 (Vertex Shader):
- 接收:位置、法线、UV。
- 计算:应用 MVP 矩阵 (uniforms.mvp * vec4(pos, 1.0))。
- 输出:裁剪空间位置 + 世界空间属性(用于光照)。
片段着色器 (Fragment Shader):
- 实现光照模型:
- 环境光:基础亮度。
- 漫反射 (Diffuse):dot(normal, light_dir)。
- 镜面光 (Specular):Blinn-Phong 模型。
- 纹理采样:使用 textureSample 读取颜色。// main.js (WGSL)
@fragment
fn fs_main(in: vOut) -> @location(0) vec4<f32> {
// 采样基础颜色
let base = textureSample(t_diffuse, s_diffuse, in.uv).rgb;// 计算法线与视线
let N = normalize(in.normal);
let V = normalize(uniforms.camera_pos – in.world_pos);// 1. 头灯漫反射 (光线方向 = 视线方向)
let diffuse = max(dot(N, V), 0.0) * 0.8;// 2. 镜面高光 (Blinn-Phong)
let H = normalize(V + V); // 半向量 (因为光线方向=视线方向)
let specular = pow(max(dot(N, H), 0.0), 32.0) * 0.2;return vec4<f32>(base * (0.3 + diffuse) + vec3(specular), 1.0);
}
创建渲染管线 (RenderPipeline)
管线对象定义了渲染的所有状态,创建后不可修改(Immutable):
- Layout: auto (自动推断 BindGroup 布局)。
- Vertex State: 关联 Shader 模块,定义 Buffer 布局(Stride, Offset, Format)。
- Fragment State: 关联 Shader 模块,定义输出颜色格式。
- DepthStencil: 开启深度测试 (depthCompare: 'less'),防止模型前后遮挡错误。
// main.js
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module: device.createShaderModule({ code: shaderCode }),
entryPoint: 'vs_main',
buffers: [
// 顶点缓冲布局:Pos(3) + Normal(3) + Color(4) + UV(2)
// Stride = 12 bytes (3 * 4 bytes)
{ arrayStride: 12, attributes: [{ shaderLocation: 0, format: 'float32x3', offset: 0 }] },
// … 其他属性配置
]
},
primitive: {
topology: 'triangle-list',
cullMode: 'none' // 禁用剔除以支持双面材质
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus'
}
});
核心逻辑:数据桥接与资源上传
这是连接 Rust 与 WebGPU 的桥梁。
数据流向
const positionsView = meshData.positions;
// 创建 GPU Buffer
const vBuffer = device.createBuffer({
size: positionsView.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
});
// 写入数据
device.queue.writeBuffer(vBuffer, 0, positionsView);
注意:这里直接把 Rust 内存写入了 GPU,非常高效。
渲染循环 (Render Loop)
- setBindGroup(0): 绑定 Uniforms (MVP 矩阵)。
- setBindGroup(1): 绑定材质 (纹理 + 采样器)。
- setVertexBuffer: 绑定顶点数据。
- setIndexBuffer: 绑定索引数据。
- drawIndexed: 根据 Rust 返回的 index_count 执行绘制。
高级特性:Draco 压缩支持
对于大型 3D 模型,Google Draco 压缩可以显著减小文件体积(通常减少 10-20 倍),从而加快网络加载速度。虽然解压需要时间,但对于 Web 应用,带宽往往是更大的瓶颈。
混合解析策略
本项目实现了“双路解析”机制:
- 标准 glTF:优先使用 Rust (gltf crate) 解析,速度最快。
- Draco glTF:当检测到 KHR_draco_mesh_compression 扩展时,自动切换到 JS 端的 Draco 解码器。
注意:虽然 Rust 也有 Draco 绑定,但在 WASM 环境下编译 C++ 依赖较为复杂,因此直接复用 Google 官方提供的 draco_decoder.wasm 是更轻量、更稳健的方案。
实现细节 (JS 端)
动态加载解码器: 使用 draco_wasm_wrapper.js 动态加载 Google CDN 上的 draco_decoder.wasm,避免增加主包体积。
// main.js
const dracoBaseUrl = 'https://www.gstatic.com/draco/versioned/decoders/1.5.7/';
async function getDracoModule() {
if (dracoModulePromise) return dracoModulePromise;
// 动态插入 script 标签加载解码器
const script = document.createElement('script');
script.src = `${dracoBaseUrl}draco_wasm_wrapper.js`;
// … Promise 封装加载逻辑
}
场景图扁平化 (Flattening): Draco 解码器解出的是局部坐标系的几何体。为了适配我们简单的渲染管线,必须在 CPU 端手动应用场景图变换:
- 遍历节点树:递归计算每个节点的世界矩阵 (WorldMatrix = ParentMatrix * LocalMatrix)。
- 预变换顶点:v_world = WorldMatrix * v_local。
- 预变换法线:n_world = InverseTranspose(WorldMatrix) * n_local (使用逆转置矩阵以维持法线垂直性)。
// 递归遍历场景图
function traverse(nodeIndex, parentMatrix) {
const node = json.nodes[nodeIndex];
const localMatrix = mat4.create();
getNodeMatrix(node, localMatrix); // 获取 T/R/S 变换
// 计算世界矩阵
const worldMatrix = mat4.create();
mat4.multiply(worldMatrix, parentMatrix, localMatrix);
if (node.mesh !== undefined) {
// 解码网格并应用世界矩阵变换
const primitives = getDecodedMesh(node.mesh);
for (const prim of primitives) {
// … 变换位置 positions
// 计算法线矩阵 (逆转置)
const normalMatrix = mat4.create();
mat4.invert(normalMatrix, worldMatrix);
mat4.transpose(normalMatrix, normalMatrix);
// … 变换法线 normals
}
}
// 递归子节点
if (node.children) {
node.children.forEach(child => traverse(child, worldMatrix));
}
}
数据统一: Draco 解码后的数据被重组为与 Rust 解析器完全一致的 MeshData 格式(Positions, Indices, DrawCalls),从而复用后续的 GPU 上传和渲染逻辑。
交互系统:轨道相机实现
文件位置:camera.js
数学基础
使用 gl-matrix 库进行矩阵运算。
- View Matrix (视图矩阵):使用 mat4.lookAt(eye, target, up) 生成。
- Projection Matrix (投影矩阵):使用 mat4.perspective 生成。
- MVP: Projection * View * Model。
事件处理
- 旋转:监听 pointermove。计算 dx, dy 差值,更新球坐标系角度 (Yaw, Pitch)。
- 平移:监听 Shift + 拖拽。计算视图矩阵的 Right 和 Up 向量,在相机平面上移动 target 点。
- 缩放:监听 wheel。修改相机与目标的距离 distance。
数据更新
每帧或交互发生时:
// main.js
function updateMVP(device, buffer, canvas, camera, bounds) {
// 1. 计算投影矩阵 (自动适配长宽比)
const projection = mat4.create();
mat4.perspective(projection, Math.PI / 4, canvas.width / canvas.height, near, far);
// 2. WebGPU Z轴校正 (OpenGL [-1, 1] -> WebGPU [0, 1])
const correction = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,0.5,0, 0,0,0.5,1]);
mat4.multiply(projection, correction, projection);
// 3. 计算视图矩阵 (LookAt)
const view = camera.getViewMatrix();
// 4. 合成 MVP 并上传
const mvp = mat4.create();
mat4.multiply(mvp, projection, view);
// 写入 Uniform Buffer (offset 0 = MVP, offset 64 = CameraPos)
device.queue.writeBuffer(buffer, 0, mvp);
device.queue.writeBuffer(buffer, 64, new Float32Array(camera.getEyePosition()));
}

