如何从零用WebGPU渲染二次元MMD人物模型

1 阅读16分钟

如何使用 WebGPU 从零开始渲染动漫角色

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_it9bc1oir7p31cuzkqn0.webp

简介

原文: reze.one/tutorial

如果你用过 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 初始化流程来:

  1. 请求 GPU 设备,在画布上创建渲染上下文
  2. 创建 GPU 缓冲区,把 3 个顶点的位置数据写进去(用 writeBuffer
  3. 写着色器:
    • 顶点着色器:处理每个顶点,把 3D 坐标转成屏幕坐标
    • 片段着色器:决定每个像素的颜色
  4. 创建渲染管线:把着色器和缓冲区布局打包在一起
  5. 创建渲染通道:执行管线,在屏幕上画出三角形

顶点缓冲区

顶点缓冲区存的是顶点位置数据。创建步骤:

  • device.createBuffer() 创建缓冲区
  • 指定 GPUBufferUsage.VERTEX 标志
  • device.queue.writeBuffer() 把数据从 CPU 传到 GPU

着色器

着色器用 WGSL 写,在 GPU 上跑:

  • 顶点着色器:每个顶点跑一次,输出屏幕位置
  • 片段着色器:每个像素跑一次,输出颜色

渲染管线

渲染管线把着色器、顶点布局、渲染状态打包在一起,定义:

  • 用哪些着色器
  • 顶点数据格式(位置、法线、UV 等)
  • 片段怎么混合和测试(深度测试、混合模式等)

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_9k2kqp1kxwrcewfqc77w.webp


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.viewcamera.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 计算的数据。

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_7v85ohemb42ikeyb8wl4.webp


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 个硬编码顶点,到能渲染几千个三角形的复杂模型。

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_41o3h5pvjemwfor3p5u5.webp


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
}

结果就是完全贴图的角色了。

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_vttc9ecp2u7w122yj6md.webp

深度测试

你可能会发现角色看起来透明,或者能看到背面。这是因为没开深度测试,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。有了材质、纹理和深度测试,静态渲染管线就完整了。角色完全贴图,从任何角度看都是实心的。

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_zw1fja133f3s5w340393.webp


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

  1. CPU:动画或用户输入更新骨骼旋转
  2. CPUevaluatePose() 遍历层次结构,计算世界矩阵
  3. CPU → GPU:上传世界矩阵到 storage 缓冲区
  4. GPU 计算通道:并行计算所有骨骼的 skinMatrix = world × inverseBind
  5. 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()

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_shb5xum7i07w1jicru4t.webp


总结

现在你已经搭好了一个完整的 WebGPU 渲染管线——从简单三角形到完全贴图、骨骼动画的角色。你理解了核心组件(缓冲区、绑定组、管线、渲染通道),它们怎么连起来(CPU 到 GPU 的数据流、着色器接口),以及为什么这样设计(uniform vs storage 缓冲区、计算着色器做并行计算)。

关键点

  1. GPU 是独立的计算单元:需要明确的数据传输和指令
  2. 缓冲区是数据传输的基础:vertex、index、uniform、storage 缓冲区各有各的用途
  3. 着色器定义处理逻辑:vertex、fragment、compute 着色器在管线的不同阶段跑
  4. 绑定组连接资源:把缓冲区、纹理、采样器组织在一起
  5. 计算着色器做并行:利用 GPU 的并行计算能力

下一步

这个教程主要讲 WebGPU 的基础。物理模拟、逆运动学、动态光照、后处理这些高级功能都建立在这些概念上——它们是应用级功能,不是新的 WebGPU 原语。可以在 Reze Engine 源码里探索这些,它把这里学的东西扩展成了一个完整的动漫角色渲染器。


本教程是 Reze Engine 项目的一部分。完整源代码可在 GitHub 上找到。