如何使用 WebGPU 从零开始渲染动漫角色
简介
如果你用过 three.js、babylon.js 这些框架,知道怎么加载模型、设置相机、加光照,但搞不清楚底层到底发生了什么;或者你看了 WebGPU 的 "Hello Triangle" 示例,还是不知道怎么从零开始渲染一个真正的 3D 角色——那这个教程就是为你准备的。
我们会用五个步骤,从最简单的三角形开始,一步步做到一个完整贴图、能动的动漫角色。在这个过程中,你会学到完整的渲染管线:几何缓冲区、相机变换、材质纹理、骨骼动画,还有把它们串起来的渲染循环。
重点不在数学或着色器代码(这些可以交给 AI),而在理解 WebGPU 的思维模型:有哪些组件(缓冲区、绑定组、管线、渲染通道),它们怎么连起来(数据怎么流转),为什么要这样设计(什么时候用 uniform 缓冲区,什么时候用 storage 缓冲区)。最后你会得到一个能跑的渲染器,也能看懂 Reze Engine 这种引擎是怎么做的。完整代码在这里。
Engine v0: 你的第一个三角形
先从经典的 Hello Triangle 开始。虽然很基础,但它展示了 WebGPU 管线的所有基本组件。理解了这些组件怎么连起来,后面做复杂模型就只是加数据,不需要学新概念了。
理解 GPU 作为独立的计算单元
高级框架把底层细节都藏起来了,但 WebGPU 需要你直接跟 GPU 打交道。可以把 GPU 想象成另一台电脑,有自己的内存和指令集。不像 JavaScript 里直接传数据给函数,用 GPU 得跨边界通信,你得明确告诉它:
- 要处理什么:顶点数据
- 数据在哪:缓冲区(buffer),就是 GPU 内存里的一块区域,类似 ArrayBuffer
- 怎么处理:着色器和管线,告诉 GPU 怎么转换和渲染
- 从哪开始:渲染通道,执行渲染的命令队列
简单说,高级框架你操作的是 Mesh、Material、Scene 这些对象,WebGPU 里你操作的是 Buffer、Texture、Pipeline 这些底层资源。
WebGPU 初始化模式
第一个 Engine 类 engines/v0.ts 按标准的 WebGPU 初始化流程来:
- 请求 GPU 设备,在画布上创建渲染上下文
- 创建 GPU 缓冲区,把 3 个顶点的位置数据写进去(用
writeBuffer) - 写着色器:
- 顶点着色器:处理每个顶点,把 3D 坐标转成屏幕坐标
- 片段着色器:决定每个像素的颜色
- 创建渲染管线:把着色器和缓冲区布局打包在一起
- 创建渲染通道:执行管线,在屏幕上画出三角形
顶点缓冲区
顶点缓冲区存的是顶点位置数据。创建步骤:
- 用
device.createBuffer()创建缓冲区 - 指定
GPUBufferUsage.VERTEX标志 - 用
device.queue.writeBuffer()把数据从 CPU 传到 GPU
着色器
着色器用 WGSL 写,在 GPU 上跑:
- 顶点着色器:每个顶点跑一次,输出屏幕位置
- 片段着色器:每个像素跑一次,输出颜色
渲染管线
渲染管线把着色器、顶点布局、渲染状态打包在一起,定义:
- 用哪些着色器
- 顶点数据格式(位置、法线、UV 等)
- 片段怎么混合和测试(深度测试、混合模式等)
Engine v1: 添加相机并使其成为 3D
第一个例子只画了一帧静态画面。要让它变成 3D,需要两样东西:相机,还有能连续生成帧的渲染循环。
什么是相机?
在 WebGPU 里,相机不是 3D 对象,是两个变换矩阵(view 和 projection),用来把 3D 世界坐标转成 2D 屏幕坐标,产生深度感。不像高级框架有现成的相机对象,WebGPU 得自己管这些矩阵。
简单说:
- View 矩阵:相机在哪,朝哪看
- Projection 矩阵:怎么把 3D 空间投影到 2D 屏幕(透视投影)
矩阵乘法的细节不用管(交给 AI),只要知道这两个矩阵组合起来,就能把 3D 顶点坐标转成屏幕上的 2D 像素位置。
相机类
相机类在 lib/camera.ts。实现细节不用管(交给 AI),只要知道它会算 view 和 projection 矩阵,并且会根据鼠标操作(拖拽、缩放、平移)更新。
关键概念:Uniform 缓冲区
要把相机矩阵从 JavaScript 传到着色器,用 uniform 缓冲区——一块 GPU 内存,所有着色器都能访问,像全局变量一样。
先把相机数据写进缓冲区:
this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
然后创建绑定组,告诉 GPU 这个缓冲区在哪,把它绑到渲染通道:
this.bindGroup = this.device.createBindGroup({
label: "bind group layout",
layout: this.pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
})
在渲染通道里设置绑定组:
pass.setBindGroup(0, this.bindGroup);
最后在着色器里,定义个结构体,内存布局要跟缓冲区匹配:
struct CameraUniforms {
view: mat4x4f,
projection: mat4x4f,
viewPos: vec3f,
_padding: f32,
};
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
现在着色器就能直接访问 camera.view 和 camera.projection 了。在顶点着色器里,把每个顶点位置乘上这些矩阵:
@vertex
fn vs(@location(0) position: vec2<f32>) -> @builtin(position) vec4<f32> {
return camera.projection * camera.view * vec4f(position, 0.0, 1.0);
}
渲染循环
要做出动画效果,得有个渲染循环,每帧都调用一次渲染函数。用 requestAnimationFrame 就行:
const render = () => {
// 更新相机(响应鼠标输入)
this.camera.update()
// 更新 uniform 缓冲区
this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
// 渲染
// ...
requestAnimationFrame(render)
}
为什么用 Uniform 缓冲区?
uniform 缓冲区是 WebGPU 的基础模式,用来从 CPU 往 GPU 传数据,比如光照参数、材质属性、变换矩阵。它是只读的,适合每帧更新一次的数据(像相机矩阵)。storage 缓冲区是可读写的,适合需要 GPU 计算的数据。
Engine v2: 渲染角色几何体
现在从硬编码的三角形转到真正的模型几何体。我们用预解析好的 PMX 模型数据,这是 MMD(MikuMikuDance)动漫角色的标准格式。MMD 在动漫风格角色建模里很常用,很多社区都在做《原神》《深空之眼》这些游戏的模型。解析器这里不讲了(什么格式都行,需要的话让 AI 生成解析器)。重点是理解两个数据结构:顶点和索引。
顶点数据结构
每个顶点包含三种数据,按顺序存在内存里(这叫交错顶点数据):
- 位置:3D 空间里的
[x, y, z]坐标 - 法线:垂直于表面的
[nx, ny, nz]方向(用来算光照) - UV 坐标:
[u, v]纹理映射坐标(告诉纹理图片的哪部分要显示)
索引缓冲区
索引缓冲区指定哪些顶点组成三角形。不用复制顶点数据,用索引引用就行,能省很多内存。
比如一个正方形要 4 个顶点,但可以用 2 个三角形(6 个索引)表示,不用存 6 个重复顶点。
实现细节
在 engines/v2.ts 里,从模型数据创建顶点和索引缓冲区。看 initVertexBuffers 方法:
private initVertexBuffers() {
const vertices = Float32Array.from(this.model.vertices)
this.vertexBuffer = this.device.createBuffer({
label: "model vertex buffer",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
})
this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices.buffer)
// 创建索引缓冲区
const indices = Uint32Array.from(this.model.indices)
this.indexBuffer = this.device.createBuffer({
label: "model index buffer",
size: indices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
})
this.device.queue.writeBuffer(this.indexBuffer, 0, indices.buffer)
}
关键变化是用索引绘制,不用直接绘制。渲染通道调用 drawIndexed,指定索引缓冲区:
pass.setVertexBuffer(0, this.vertexBuffer)
pass.setIndexBuffer(this.indexBuffer, "uint32")
pass.drawIndexed(this.model.indices.length) // 使用索引绘制所有三角形
顶点布局定义
创建渲染管线时,要定义顶点布局,告诉 GPU 怎么解释缓冲区里的数据:
vertex: {
module: shaderModule,
entryPoint: "vs",
buffers: [{
arrayStride: 32, // 每个顶点 32 字节(3 个 float32 位置 + 3 个 float32 法线 + 2 个 float32 UV)
attributes: [
{ shaderLocation: 0, offset: 0, format: "float32x3" }, // 位置
{ shaderLocation: 1, offset: 12, format: "float32x3" }, // 法线
{ shaderLocation: 2, offset: 24, format: "float32x2" }, // UV
],
}],
}
结果就是角色的红色轮廓。没纹理(后面会加),只能看到原始几何体。但这是个重要里程碑——从 3 个硬编码顶点,到能渲染几千个三角形的复杂模型。
Engine v3: 材质和纹理
现在加纹理,给角色上色和细节。这里有两个概念:材质和纹理。
材质
材质把一组顶点(通过索引)连起来,指定画这些三角形时用哪些纹理和参数。在角色模型里,材质可以是脸、头发、衣服这些部分。每个材质包含:
- 纹理索引(用哪个纹理)
- 渲染参数(透明度、混合模式等)
- 顶点范围(哪些三角形属于这个材质)
复杂角色模型里,不同部分(脸、头发、衣服)要用不同材质和纹理,所以得按材质分别渲染。
纹理
纹理就是存颜色数据的图片文件。在 WebGPU 里,得手动把图片数据上传到 GPU。每个顶点都有 UV 坐标(类似纹理的 x、y 坐标),用来映射到纹理的某个位置。片段着色器用这些坐标采样纹理,决定每个像素的颜色。
UV 坐标:想象一张地图,UV 坐标就像经纬度,告诉着色器"这个顶点对应纹理图片的哪个位置"。UV 范围通常是 0.0 到 1.0,(0,0) 是左上角,(1,1) 是右下角。
加载和创建纹理
在 engines/v3.ts 里,先加载纹理图片,创建 GPU 纹理。看 initTexture 方法:获取图片文件,创建 ImageBitmap,然后创建 GPUTexture 并上传图片数据:
const imageBitmap = await createImageBitmap(await response.blob())
const texture = this.device.createTexture({
size: [imageBitmap.width, imageBitmap.height],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
})
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
imageBitmap.width,
imageBitmap.height,
])
采样器
然后创建采样器,定义怎么采样纹理(过滤、包装等):
this.sampler = this.device.createSampler({
magFilter: "linear", // 放大时的过滤方式
minFilter: "linear", // 缩小时的过滤方式
addressModeU: "repeat", // U 方向的包装模式
addressModeV: "repeat", // V 方向的包装模式
})
- 过滤模式:
linear平滑插值,nearest像素化 - 包装模式:
repeat重复纹理,clamp-to-edge夹到边缘
在着色器里传 UV 坐标
要把 UV 坐标从顶点着色器传到片段着色器,定义个 VertexOutput 结构,把位置和 UV 打包:
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs(
@location(0) position: vec3<f32>,
@location(2) uv: vec2<f32>
) -> VertexOutput {
var output: VertexOutput;
output.position = camera.projection * camera.view * vec4f(position, 1.0);
output.uv = uv;
return output;
}
片段着色器接收 UV 坐标,用 textureSample 采样纹理:
@fragment
fn fs(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(textureSample(texture, textureSampler, input.uv).rgb, 1.0);
}
绑定纹理到着色器
要把纹理绑到着色器,给每个材质创建个绑定组,包含纹理和采样器。作为第二个绑定组,放在相机 uniform 旁边:
for (const material of this.model.materials) {
const textureIndex = material.diffuseTextureIndex
const materialBindGroup = this.device.createBindGroup({
layout: this.pipeline.getBindGroupLayout(1),
entries: [
{ binding: 0, resource: this.textures[textureIndex].createView() },
{ binding: 1, resource: this.sampler },
],
})
this.materialBindGroups.push(materialBindGroup)
}
按材质渲染
最后按材质分别渲染。别对整个模型调一次 drawIndexed,要遍历材质,设置每个材质的绑定组,画它的三角形:
let firstIndex = 0
for (let i = 0; i < this.model.materials.length; i++) {
const material = this.model.materials[i]
if (material.vertexCount === 0) continue
pass.setBindGroup(1, this.materialBindGroups[i])
pass.drawIndexed(material.vertexCount, 1, firstIndex)
firstIndex += material.vertexCount
}
结果就是完全贴图的角色了。
深度测试
你可能会发现角色看起来透明,或者能看到背面。这是因为没开深度测试,GPU 按提交顺序画三角形——远处的可能画在近处的上面。
修复很简单,三步:创建深度纹理,加到渲染通道,配置管线。不用改着色器:
// 创建深度纹理
this.depthTexture = this.device.createTexture({
size: [width, height],
format: "depth24plus",
usage: GPUTextureUsage.RENDER_ATTACHMENT,
})
// 添加到渲染通道
depthStencilAttachment: {
view: this.depthTexture.createView(),
depthClearValue: 1.0, // 1.0 表示最远
depthLoadOp: "clear", // 每帧清除深度缓冲区
depthStoreOp: "store", // 存储深度值用于下一帧
}
// 添加到管线
depthStencil: {
depthWriteEnabled: true, // 允许写入深度值
depthCompare: "less", // 只渲染更近的片段
format: "depth24plus",
}
完整实现在 engines/v3_2.ts。有了材质、纹理和深度测试,静态渲染管线就完整了。角色完全贴图,从任何角度看都是实心的。
Engine v4: 骨骼和蒙皮
在 WebGPU 里,得理解骨骼系统怎么工作。高级框架会自动处理骨骼动画,但这里得手动实现。
骨骼和层次结构
骨骼是层次结构里的变换。想象个木偶:关节(肩膀、肘部、手腕)就是骨骼,用线(层次关系)连起来。每个骨骼都有个父骨骼(除了根骨骼),移动父骨骼,所有子骨骼都会跟着动。MMD 模型里,典型的臂链是这样的:
センター (center) → 上半身 (upper_body) → 右肩 (shoulder_R) → 右腕 (arm_R) → 右ひじ (elbow_R) → 右手首 (wrist_R) → 手指关节
旋转上半身时,整个上半身——肩膀、手臂、肘部、手腕、手指——都会跟着转。这种级联效果是因为每个骨骼的变换是相对于父骨骼的。
就像 DOM 树,父元素动了,子元素也跟着动。骨骼系统就是 3D 空间里的"DOM 树"。
骨骼变换的数学原理
每个骨骼都有个局部变换矩阵,表示相对父骨骼的旋转、缩放、平移。要算骨骼在世界空间的最终位置,从根骨骼往下遍历,把每个骨骼的局部变换乘上父骨骼的世界变换:
worldMatrix[bone] = worldMatrix[parent] × localMatrix[bone]
这样子骨骼就会跟着父骨骼移动。
蒙皮:将骨骼连接到顶点
蒙皮就是骨骼怎么变形网格。这是骨骼动画的核心概念。
简单说:想象个木偶,皮肤(网格)要跟着关节(骨骼)动。但皮肤上的每个点(顶点)可能同时受多个关节影响。比如肩膀附近的顶点主要受肩膀骨骼影响,但也稍微受上臂骨骼影响,这样动起来过渡更自然。
每个顶点存最多 4 个骨骼索引和 4 个权重,权重总和是 1.0。骨骼移动时,顶点的最终位置是加权混合:
// 顶点数据
joints: [15, 16, 0, 0] // 骨骼索引:这个顶点受骨骼 15 和 16 影响
weights: [0.7, 0.3, 0, 0] // 权重:70% 来自骨骼 15,30% 来自骨骼 16
// 最终位置 = 每个骨骼变换的加权和
finalPosition = (skinMatrix[15] * position) * 0.7
+ (skinMatrix[16] * position) * 0.3
就像 CSS 的 transform-origin,但更复杂。一个顶点可以同时有多个"原点"(骨骼),最终位置是这些原点的加权平均。
每个骨骼的 skinMatrix 结合了当前姿态和绑定姿态(bind pose),让骨骼旋转时能平滑变形。绑定姿态是模型的初始姿态(通常是 T-pose 或 A-pose),用来算顶点相对骨骼的原始位置。
绑定姿态(Bind Pose)和蒙皮矩阵
绑定姿态是模型在 T-pose 或 A-pose 时的原始姿态。每个骨骼都有个逆绑定矩阵(inverse bind matrix),用来把顶点从世界空间转回骨骼的局部空间。蒙皮矩阵这么算:
skinMatrix = worldMatrix × inverseBindMatrix
这样顶点会跟着骨骼移动,同时保持相对骨骼的正确位置。
CPU 端的骨骼控制
骨骼在 CPU 上。动画、物理、用户输入都在这里更新骨骼旋转。旋转骨骼时,引擎重新算层次结构(父到子变换),把结果上传到 GPU:
// 你的游戏代码:旋转颈部骨骼
engine.rotateBone("首", rotation)
// 内部,这触发:
// 1. evaluatePose() - 从层次结构重新计算所有世界矩阵
// 2. 上传世界矩阵到 GPU
// 3. 计算通道 - 计算蒙皮矩阵
// 4. 下一次渲染使用更新的蒙皮
计算着色器:并行矩阵计算
几百个骨骼、几千个顶点,在 CPU 上算蒙皮矩阵太慢了。这就是计算着色器的用武之地——这也是 WebGPU 相对 WebGL 的关键优势。计算着色器在 GPU 上做大规模并行计算,很适合矩阵运算。
把骨骼矩阵上传到 storage 缓冲区,然后调度计算着色器并行算所有蒙皮矩阵。471 个骨骼的模型,就是 471 个矩阵乘法在 GPU 上同时跑:
@group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
@group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
@group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
let boneIndex = globalId.x;
if (boneIndex >= boneCount.count) { return; }
skinMatrices[boneIndex] = worldMatrices[boneIndex] * inverseBindMatrices[boneIndex];
}
Storage 缓冲区 vs Uniform 缓冲区
- Uniform 缓冲区:只读,大小有限(通常 64KB),适合每帧更新一次的小数据(像相机矩阵)
- Storage 缓冲区:可读写,大小几乎无限制,适合需要 GPU 计算的大数据(像骨骼矩阵数组)
完整的渲染流程
每帧的完整流程(完整实现在 engines/v4.ts):
- CPU:动画或用户输入更新骨骼旋转
- CPU:
evaluatePose()遍历层次结构,计算世界矩阵 - CPU → GPU:上传世界矩阵到 storage 缓冲区
- GPU 计算通道:并行计算所有骨骼的
skinMatrix = world × inverseBind - GPU 渲染通道:顶点着色器读蒙皮矩阵,按每个顶点的骨骼权重混合顶点
顶点着色器做最终的蒙皮计算:
@group(0) @binding(1) var<storage, read> skinMats: array<mat4x4f>;
@vertex
fn vs(
@location(0) position: vec3<f32>,
@location(3) joints: vec4<u32>,
@location(4) weights: vec4<f32>
) -> VertexOutput {
// 根据骨骼影响混合位置
var skinnedPos = vec4f(0.0);
for (var i = 0u; i < 4u; i++) {
skinnedPos += (skinMats[joints[i]] * vec4f(position, 1.0)) * weights[i];
}
output.position = camera.projection * camera.view * skinnedPos;
}
计算通道设置
要用计算着色器,创建个计算管线并调度它:
// 创建计算管线
const computePipeline = this.device.createComputePipeline({
layout: "auto",
compute: {
module: computeShaderModule,
entryPoint: "main",
},
})
// 在计算通道中调度
const computePass = encoder.beginComputePass()
computePass.setPipeline(computePipeline)
computePass.setBindGroup(0, computeBindGroup)
computePass.dispatchWorkgroups(Math.ceil(boneCount / 64)) // 64 是工作组大小
computePass.end()
总结
现在你已经搭好了一个完整的 WebGPU 渲染管线——从简单三角形到完全贴图、骨骼动画的角色。你理解了核心组件(缓冲区、绑定组、管线、渲染通道),它们怎么连起来(CPU 到 GPU 的数据流、着色器接口),以及为什么这样设计(uniform vs storage 缓冲区、计算着色器做并行计算)。
关键点
- GPU 是独立的计算单元:需要明确的数据传输和指令
- 缓冲区是数据传输的基础:vertex、index、uniform、storage 缓冲区各有各的用途
- 着色器定义处理逻辑:vertex、fragment、compute 着色器在管线的不同阶段跑
- 绑定组连接资源:把缓冲区、纹理、采样器组织在一起
- 计算着色器做并行:利用 GPU 的并行计算能力
下一步
这个教程主要讲 WebGPU 的基础。物理模拟、逆运动学、动态光照、后处理这些高级功能都建立在这些概念上——它们是应用级功能,不是新的 WebGPU 原语。可以在 Reze Engine 源码里探索这些,它把这里学的东西扩展成了一个完整的动漫角色渲染器。
本教程是 Reze Engine 项目的一部分。完整源代码可在 GitHub 上找到。