WebGPU 平移变换(WebGPU Translation)

原作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-translation.html


本文假设你已经阅读了 WebGPU 基础、Uniforms 以及顶点缓冲区 这几篇文章。如果还没有,建议先阅读后再回来。

本文是 3D 数学系列文章的第一篇,后续文章依次为:

  1. 平移(Translation) ← 你在这里
  2. 旋转(Rotation)
  3. 缩放(Scale)
  4. 矩阵数学(Matrix Math)
  5. 正交投影(Orthographic Projection)
  6. 透视投影(Perspective Projection)
  7. 相机(Cameras)
  8. 矩阵栈(Matrix Stacks)
  9. 场景图(Scene Graphs)

原文配图

从顶点缓冲区示例出发

本文的代码与顶点缓冲区一文中的示例相似,但不再绘制一堆圆形,而是绘制一个 F 字母。为了减少数据量,我们会使用索引缓冲区(index buffer)来共享顶点。

我们将像 Canvas 2D API 那样工作在像素空间而不是裁剪空间。F 字母由 6 个三角形构成:

F 字母由 6 个三角形组成

下面是 F 的顶点数据:

function createFVertices() {
  const vertexData = new Float32Array([
    // 左侧列
    0, 0,
    30, 0,
    0, 150,
    30, 150,

    // 顶部横杠
    30, 0,
    100, 0,
    30, 30,
    100, 30,

    // 中间横杠
    30, 60,
    70, 60,
    30, 90,
    70, 90,
  ]);

  const indexData = new Uint32Array([
    0,  1,  2,    2,  1,  3,  // 左侧列
    4,  5,  6,    6,  5,  7,  // 顶部横杠
    8,  9, 10,   10,  9, 11,  // 中间横杠
  ]);

  return {
    vertexData,
    indexData,
    numVertices: indexData.length,
  };
}

顶点数据在像素空间中,我们需要把它转换到裁剪空间。做法是将画布分辨率作为 uniform 传入着色器,并在着色器中进行数学变换。下面一步一步拆解:

struct Uniforms {
  color: vec4f,
  resolution: vec2f,
};

struct Vertex {
  @location(0) position: vec2f,
};

struct VSOutput {
  @builtin(position) position: vec4f,
};

@group(0) @binding(0) `var<uniform>` uni: Uniforms;

@vertex fn vs(vert: Vertex) -> VSOutput {
  var vsOut: VSOutput;
  
  let position = vert.position;

  // 将像素坐标转换为 0.0 到 1.0 的归一化值
  let zeroToOne = position / uni.resolution;

  // 从 0~1 转换到 0~2
  let zeroToTwo = zeroToOne * 2.0;

  // 从 0~2 转换到 -1~+1(裁剪空间)
  let flippedClipSpace = zeroToTwo - 1.0;

  // 翻转 Y 轴
  let clipSpace = flippedClipSpace * vec2f(1, -1);

  vsOut.position = vec4f(clipSpace, 0.0, 1.0);
  return vsOut;
}

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
  return uni.color;
}

可以看到:顶点位置除以分辨率得到 01 的归一化值,乘以 2 后得到 02,再减去 1 得到 -1~+1 的裁剪空间值。但裁剪空间的 Y 轴向上,而 Canvas 2D 的 Y 轴向下,所以最后将 Y 乘以 -1。

我们只有位置这一个顶点属性,渲染管线配置如下:

const pipeline = device.createRenderPipeline({
  label: 'just 2d position',
  layout: 'auto',
  vertex: {
    module,
    buffers: [
      {
        arrayStride: (2) * 4, // (2) 个 float,每个 4 字节
        attributes: [
          {shaderLocation: 0, offset: 0, format: 'float32x2'},  // position
        ],
      },
    ],
  },
  fragment: {
    module,
    targets: [{ format: presentationFormat }],
  },
});

接下来为 uniform 数据创建缓冲区:

// color, resolution, padding
const uniformBufferSize = (4 + 2) * 4 + 8;
const uniformBuffer = device.createBuffer({
  label: 'uniforms',
  size: uniformBufferSize,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

const uniformValues = new Float32Array(uniformBufferSize / 4);

// 各个 uniform 值在 float32 数组中的偏移
const kColorOffset = 0;
const kResolutionOffset = 4;

const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4);
const resolutionValue = uniformValues.subarray(kResolutionOffset, kResolutionOffset + 2);

// 颜色不会变化,所以初始化时设置一次即可
colorValue.set([Math.random(), Math.random(), Math.random(), 1]);

渲染时更新分辨率:

function render() {
  // ...

  // 在 JavaScript 端的 Float32Array 中设置 uniform 值
  resolutionValue.set([canvas.width, canvas.height]);

  // 将 uniform 值上传到 uniform 缓冲区
  device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
}

透明画布与网格背景

为了让背景看起来像方格纸,我们需要让画布透明,并绘制一个 CSS 网格背景。方格纸每个小格为 10×10 像素,每 100×100 像素绘制一条更粗的线。

:root {
  --bg-color: #fff;
  --line-color-1: #AAA;
  --line-color-2: #DDD;
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: #000;
    --line-color-1: #666;
    --line-color-2: #333;
  }
}
canvas {
  display: block;
  width: 100%;
  height: 100%;
  background-color: var(--bg-color);
  background-image: linear-gradient(var(--line-color-1) 1.5px, transparent 1.5px),
      linear-gradient(90deg, var(--line-color-1) 1.5px, transparent 1.5px),
      linear-gradient(var(--line-color-2) 1px, transparent 1px),
      linear-gradient(90deg, var(--line-color-2) 1px, transparent 1px);
  background-position: -1.5px -1.5px, -1.5px -1.5px, -1px -1px, -1px -1px;
  background-size: 100px 100px, 100px 100px, 10px 10px, 10px 10px;  
}

上面的 CSS 同时考虑了亮色和暗色模式。

之前所有示例都使用不透明画布。为了看到背景,我们需要做两处修改:

  1. 在 context.configure 时将 alphaMode 设置为 'premultiplied'(默认是 'opaque'):
context.configure({
  device,
  format: presentationFormat,
  alphaMode: 'premultiplied',
});
  1. 在 GPURenderPassDescriptor 中将清除值设为透明,即 [0, 0, 0, 0]。因为默认 clearValue 就是 0, 0, 0, 0,所以只需删除之前设置其他值的行即可:
const renderPassDescriptor = {
  label: 'our basic canvas renderPass',
  colorAttachments: [
    {
      // view: <- 渲染时填充
      // clearValue: [0.3, 0.3, 0.3, 1],  // 删除这行,默认就是透明
      loadOp: 'clear',
      storeOp: 'store',
    },
  ],
};

运行后可以看到 F 的大小与网格背景吻合:F 的顶点数据定义出宽 100 像素、高 150 像素的形状,起点在 (0,0),向右延伸到 (100,0),向下延伸到 (0,150)。


添加平移(Translation)

平移就是移动物体。为此,我们在 uniform 中增加一个 translation 字段,并在着色器中将它与顶点位置相加。

修改 WGSL:

struct Uniforms {
  color: vec4f,
  resolution: vec2f,
  translation: vec2f,
};

struct Vertex {
  @location(0) position: vec2f,
};

struct VSOutput {
  @builtin(position) position: vec4f,
};

@group(0) @binding(0) `var<uniform>` uni: Uniforms;

@vertex fn vs(vert: Vertex) -> VSOutput {
  var vsOut: VSOutput;
  
  // 加上平移量
  let position = vert.position + uni.translation;

  // 将像素坐标转换为 0.0 到 1.0 的归一化值
  let zeroToOne = position / uni.resolution;

  // 从 0~1 转换到 0~2
  let zeroToTwo = zeroToOne * 2.0;

  // 从 0~2 转换到 -1~+1(裁剪空间)
  let flippedClipSpace = zeroToTwo - 1.0;

  // 翻转 Y 轴
  let clipSpace = flippedClipSpace * vec2f(1, -1);

  vsOut.position = vec4f(clipSpace, 0.0, 1.0);
  return vsOut;
}

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
  return uni.color;
}

接下来调整 uniform 缓冲区大小与偏移:

// color, resolution, translation
const uniformBufferSize = (4 + 2 + 2) * 4;
const uniformBuffer = device.createBuffer({
  label: 'uniforms',
  size: uniformBufferSize,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

const uniformValues = new Float32Array(uniformBufferSize / 4);

// 各个 uniform 值在 float32 数组中的偏移
const kColorOffset = 0;
const kResolutionOffset = 4;
const kTranslationOffset = 6;

const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4);
const resolutionValue = uniformValues.subarray(kResolutionOffset, kResolutionOffset + 2);
const translationValue = uniformValues.subarray(kTranslationOffset, kTranslationOffset + 2);

然后在渲染函数中设置平移量:

const settings = {
  translation: [0, 0],
};

function render() {
  // ...

  // 在 JavaScript 端的 Float32Array 中设置 uniform 值
  resolutionValue.set([canvas.width, canvas.height]);
  translationValue.set(settings.translation);

  // 将 uniform 值上传到 uniform 缓冲区
  device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
}

最后添加一个简单的 GUI 来调节平移:

import GUI from '../3rdparty/muigui-0.x.module.js';

// ...
const settings = {
  translation: [0, 0],
};

const gui = new GUI();
gui.onChange(render);
gui.add(settings.translation, '0', 0, 1000).name('translation.x');
gui.add(settings.translation, '1', 0, 1000).name('translation.y');

现在平移已经生效。如果将平移设置为 (200, 300),F 的左上角顶点就会出现在 (200, 300) 处。


核心优化要点提炼

  1. 使用索引缓冲区共享顶点
    本文通过 indexData 将 6 个三角形所需的顶点从 18 个减少到 12 个。在更复杂的网格中,索引缓冲区可以大幅降低显存占用和顶点着色器的调用次数。

  2. 在着色器中完成坐标变换,避免每帧重传顶点数据
    通过将 resolution 和 translation 作为 uniform 传入,像素坐标到裁剪空间的转换完全在 GPU 端完成。当平移量变化时,只需更新 uniform 缓冲区,无需重新创建或上传顶点缓冲区,显著减少 CPU-GPU 数据传输和内存带宽。

  3. 精确计算 uniform 缓冲区大小与偏移,遵循 WGSL 对齐规则
    color 是 vec4f(16 字节),resolution 与 translation 各是 vec2f(8 字节),总计 32 字节。代码使用 Float32Array 子视图(subarray)管理偏移,并通过一次 device.queue.writeBuffer 批量上传所有 uniform 值,避免多次小数据传输带来的开销。这种布局同时满足 WGSL 对 vec4f 的 16 字节对齐要求。


术语检查:本文使用的是 WebGPU / WGSL 技术栈,而非 GLSL/WebGL。相关术语如 uniform buffer(统一缓冲区)、vertex buffer(顶点缓冲区)、index buffer(索引缓冲区)、clip space(裁剪空间)、alphaMode(透明混合模式)等均为 WebGPU 标准术语,与 GLSL/WebGL 中的对应概念有区别(例如 WGSL 使用 @group/@binding 而非 GLSL 的 layout 限定符)。文中均保持准确用法。


本文基于 WebGPU 官方教程翻译整理,仅供学习参考。