Visual LabINTERACTIVE LEARNING
COMPLETE RENDERING EXAMPLE数据 · 纹理 · 管线 · 绘制

WebGPU 完整 3D 渲染

一个不依赖外部资源的纹理立方体:24 个顶点记录、36 个索引、1 张程序生成纹理和 1 个 MVP Uniform,完整走通从 CPU 数据到屏幕像素的流程。

VERTEX BUFFERTEXTUREUNIFORMBIND GROUPDEPTH
01

实时结果

下面的立方体正由本页完整示例实时渲染;纹理、矩阵和顶点数据均在本地创建。

正在初始化 WebGPU…
02

整个渲染流程

先创建长期资源,再在每一帧更新矩阵、编码 RenderPass,最后提交到 GPU。

1模型数据position + uv + index
2GPU 资源Buffer + Texture
3Bind GroupMVP + view + sampler
4PipelineWGSL + 顶点布局
5RenderPass颜色 + 深度附件
6drawIndexed36 个索引 → 像素
VERTEX顶点与索引

六个面各用 4 个顶点记录和独立 UV,IndexBuffer 将它们组织成 12 个三角形。

UNIFORM每帧数据传递

CPU 计算 MVP 矩阵,通过 queue.writeBuffer 写入 64 字节 UniformBuffer。

TEXTURE纹理与采样器

像素上传到 GPUTexture,TextureView 与 Sampler 分别占用一个 binding。

DEPTH正确的 3D 遮挡

DepthTexture 保存最近表面深度,确保立方体背后的三角形不会覆盖前面。

03

完整代码

三个代码块按顺序拼接即可运行;没有省略资源创建、绑定或绘制步骤。

STEP 1

创建数据与 GPU 资源

配置 Canvas,上传顶点、索引与 4×4 棋盘纹理,并创建 UniformBuffer。

JavaScript · 数据与资源
// HTML: <canvas id="webgpu-canvas"></canvas>
const canvas = document.querySelector('#webgpu-canvas');
const adapter = await navigator.gpu?.requestAdapter();
if (!canvas || !adapter) throw new Error('WebGPU 不可用');

const device = await adapter.requestDevice();
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: 'opaque' });

// 每个顶点:position.xyz + uv.xy,共 20 字节
// 六个面各用 4 个顶点,让每个面都拥有独立的 0~1 UV
const vertices = new Float32Array([
  -1,-1, 1, 0,1,   1,-1, 1, 1,1,   1, 1, 1, 1,0,  -1, 1, 1, 0,0, // front
   1,-1,-1, 0,1,  -1,-1,-1, 1,1,  -1, 1,-1, 1,0,   1, 1,-1, 0,0, // back
   1,-1, 1, 0,1,   1,-1,-1, 1,1,   1, 1,-1, 1,0,   1, 1, 1, 0,0, // right
  -1,-1,-1, 0,1,  -1,-1, 1, 1,1,  -1, 1, 1, 1,0,  -1, 1,-1, 0,0, // left
  -1, 1, 1, 0,1,   1, 1, 1, 1,1,   1, 1,-1, 1,0,  -1, 1,-1, 0,0, // top
  -1,-1,-1, 0,1,   1,-1,-1, 1,1,   1,-1, 1, 1,0,  -1,-1, 1, 0,0, // bottom
]);
const indices = new Uint16Array([
   0, 1, 2,  0, 2, 3,   4, 5, 6,  4, 6, 7,
   8, 9,10,  8,10,11,  12,13,14, 12,14,15,
  16,17,18, 16,18,19,  20,21,22, 20,22,23,
]);

function createBuffer(data, usage) {
  const buffer = device.createBuffer({
    size: data.byteLength, usage, mappedAtCreation: true,
  });
  new data.constructor(buffer.getMappedRange()).set(data);
  buffer.unmap();
  return buffer;
}

const vertexBuffer = createBuffer(
  vertices, GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
);
const indexBuffer = createBuffer(indices, GPUBufferUsage.INDEX);
const uniformBuffer = device.createBuffer({
  size: 64,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

// 生成 4×4 棋盘纹理并上传到 GPU
const pixels = new Uint8Array(4 * 4 * 4);
for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) {
  const color = (x + y) % 2 ? [40, 200, 180, 255] : [70, 110, 255, 255];
  pixels.set(color, (y * 4 + x) * 4);
}
const texture = device.createTexture({
  size: [4, 4], format: 'rgba8unorm',
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
device.queue.writeTexture(
  { texture }, pixels, { bytesPerRow: 16 }, { width: 4, height: 4 },
);
const sampler = device.createSampler({
  magFilter: 'nearest', minFilter: 'nearest',
});
STEP 2

WGSL:矩阵变换与纹理采样

顶点阶段读取 Uniform 和 VertexBuffer,片元阶段通过 Texture + Sampler 取色。

WGSL · texture-cube.wgsl
struct Uniforms {
  mvp: mat4x4<f32>,
};

@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var colorTexture: texture_2d<f32>;
@group(0) @binding(2) var colorSampler: sampler;

struct VertexInput {
  @location(0) position: vec3<f32>,
  @location(1) uv: vec2<f32>,
};

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) uv: vec2<f32>,
  @location(1) objectPosition: vec3<f32>,
};

@vertex
fn vsMain(input: VertexInput) -> VertexOutput {
  var output: VertexOutput;
  output.position = uniforms.mvp * vec4<f32>(input.position, 1.0);
  output.uv = input.uv;
  output.objectPosition = input.position;
  return output;
}

@fragment
fn fsMain(input: VertexOutput) -> @location(0) vec4<f32> {
  let normal = normalize(cross(dpdx(input.objectPosition), dpdy(input.objectPosition)));
  let light = 0.35 + 0.65 * abs(dot(normal, normalize(vec3<f32>(0.4, 0.7, 1.0))));
  let texel = textureSample(colorTexture, colorSampler, input.uv);
  return vec4<f32>(texel.rgb * light, texel.a);
}
@binding(0)UniformBufferMVP 矩阵 · Vertex @binding(1)TextureView颜色纹理 · Fragment @binding(2)Sampler采样规则 · Fragment
STEP 3

创建管线并逐帧绘制

Pipeline 固定顶点布局和深度状态;每帧只更新矩阵、编码命令并提交。

JavaScript · Pipeline 与 RenderPass
const module = device.createShaderModule({ code: shader });
const pipeline = device.createRenderPipeline({
  layout: 'auto',
  vertex: {
    module, entryPoint: 'vsMain',
    buffers: [{
      arrayStride: 20,
      attributes: [
        { shaderLocation: 0, offset: 0,  format: 'float32x3' },
        { shaderLocation: 1, offset: 12, format: 'float32x2' },
      ],
    }],
  },
  fragment: { module, entryPoint: 'fsMain', targets: [{ format }] },
  primitive: { cullMode: 'back' },
  depthStencil: {
    format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less',
  },
});

const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [
    { binding: 0, resource: { buffer: uniformBuffer } },
    { binding: 1, resource: texture.createView() },
    { binding: 2, resource: sampler },
  ],
});

// 下面四个小函数只负责生成 MVP 矩阵
const multiply = (a, b) => {
  const out = new Float32Array(16);
  for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++)
    for (let k = 0; k < 4; k++) out[c*4+r] += a[k*4+r] * b[c*4+k];
  return out;
};
const perspective = (fov, aspect, near, far) => {
  const f = 1 / Math.tan(fov / 2), nf = 1 / (near - far);
  return new Float32Array([
    f/aspect,0,0,0, 0,f,0,0, 0,0,far*nf,-1, 0,0,near*far*nf,0,
  ]);
};
const translation = z => new Float32Array([
  1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,z,1,
]);
const rotation = t => {
  const y = 0.65 + t * 0.55, x = 0.45 + t * 0.32;
  return multiply(
    new Float32Array([Math.cos(y),0,-Math.sin(y),0, 0,1,0,0, Math.sin(y),0,Math.cos(y),0, 0,0,0,1]),
    new Float32Array([1,0,0,0, 0,Math.cos(x),Math.sin(x),0, 0,-Math.sin(x),Math.cos(x),0, 0,0,0,1]),
  );
};

const depthTexture = device.createTexture({
  size: [canvas.width, canvas.height], format: 'depth24plus',
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
});

function frame(ms) {
  const projection = perspective(Math.PI / 3, canvas.width / canvas.height, .1, 100);
  const mvp = multiply(projection, multiply(translation(-5), rotation(ms / 1000)));
  device.queue.writeBuffer(uniformBuffer, 0, mvp);

  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      clearValue: { r: .025, g: .04, b: .08, a: 1 },
      loadOp: 'clear', storeOp: 'store',
    }],
    depthStencilAttachment: {
      view: depthTexture.createView(),
      depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'store',
    },
  });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.setVertexBuffer(0, vertexBuffer);
  pass.setIndexBuffer(indexBuffer, 'uint16');
  pass.drawIndexed(indices.length);
  pass.end();
  device.queue.submit([encoder.finish()]);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
COMPLETE FLOW模型数据 → GPUBuffer / GPUTexture → BindGroup → Pipeline → RenderPass → drawIndexed
继续学习纹理上传