WebGPU Scale(缩放)

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

本文是 WebGPU 3D 数学系列教程的第三篇,基于前两篇《平移》和《旋转》的内容继续展开。缩放与平移一样简单:只需将顶点位置乘以期望的缩放系数即可。下面我们来看对着色器所做的改动。


3 个核心优化要点

在编译原文的基础上,提炼出以下 3 个与 Uniform 缓冲区、变换顺序及内存布局相关的核心要点:

  1. 精确计算 Uniform 缓冲区大小,移除 padding 冗余
    原代码从 (4 + 2 + 2 + 2) * 4 + 8(包含手动 padding 字节)改为 (4 + 2 + 2 + 2 + 2) * 4,利用 Float32Array 的 subarray 视图直接映射每个 uniform 的偏移量,避免因手动字节对齐错误而浪费空间。

  2. 在着色器内保持变换顺序:先缩放、再旋转、最后平移
    WGSL 代码中先计算 scaledPosition,再计算 rotatedPosition,最后加上 translation。这种固定顺序能保证几何体先围绕自身缩放/旋转,再整体平移到目标位置,是后续矩阵组合的基础。

  3. 通过偏移量常量管理 Uniform 布局,提升可维护性
    使用 kColorOffset、kResolutionOffset、kTranslationOffset、kRotationOffset、kScaleOffset 等常量在 JavaScript 侧管理 Float32Array 索引,避免在多个地方硬编码数字,降低更新 Uniform 时出错的风险。


着色器改动

首先在 Uniform 结构体中添加 scale: vec2f,并在顶点着色器中先缩放、再旋转、最后平移:

struct Uniforms {
  color: vec4f,
  resolution: vec2f,
  translation: vec2f,
  rotation: vec2f,
  scale: 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;

  // Scale the position
  let scaledPosition = vert.position * uni.scale;

  // Rotate the position
  let rotatedPosition = vec2f(
    scaledPosition.x * uni.rotation.y - scaledPosition.y * uni.rotation.x,
    scaledPosition.x * uni.rotation.x + scaledPosition.y * uni.rotation.y
  );

  // Add in the translation
  let position = rotatedPosition + uni.translation;

  // convert the position from pixels to a 0.0 to 1.0 value
  let zeroToOne = position / uni.resolution;

  // convert from 0 <-> 1 to 0 <-> 2
  let zeroToTwo = zeroToOne * 2.0;

  // covert from 0 <-> 2 to -1 <-> +1 (clip space)
  let flippedClipSpace = zeroToTwo - 1.0;

  // flip Y
  let clipSpace = flippedClipSpace * vec2f(1, -1);

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

Uniform 缓冲区更新

接下来调整 Uniform 缓冲区的大小,移除原先的 padding 字节,并添加 scale 的偏移量:

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

  const uniformValues = new Float32Array(uniformBufferSize / 4);

  // offsets to the various uniform values in float32 indices
  const kColorOffset = 0;
  const kResolutionOffset = 4;
  const kTranslationOffset = 6;
  const kRotationOffset = 8;
  const kScaleOffset = 10;

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

渲染时,需要将缩放值写入 Uniform 缓冲区:

  const settings = {
    translation: [150, 100],
    rotation: degToRad(30),
    scale: [1, 1],
  };

  const radToDegOptions = { min: -360, max: 360, step: 1, converters: GUI.converters.radToDeg };

  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');
  gui.add(settings, 'rotation', radToDegOptions);
  gui.add(settings.scale, '0', -5, 5).name('scale.x');
  gui.add(settings.scale, '1', -5, 5).name('scale.y');

  function render() {
    ...

    // Set the uniform values in our JavaScript side Float32Array
    resolutionValue.set([canvas.width, canvas.height]);
    translationValue.set(settings.translation);
    rotationValue.set([
        Math.cos(settings.rotation),
        Math.sin(settings.rotation),
    ]);
    scaleValue.set(settings.scale);

    // upload the uniform values to the uniform buffer
    device.queue.writeBuffer(uniformBuffer, 0, uniformValues);

现在可以拖动滑块来观察缩放效果。注意两个现象:

  • 使用负值缩放会翻转几何体。
  • 缩放是相对于原点 (0, 0) 进行的,对于示例中的字母 F,原点位于左上角。因为顶点位置乘以缩放系数后会远离原点,这符合预期。如果想围绕其他点缩放,可以在缩放前添加一个“预缩放平移”,或者直接修改 F 的顶点数据。稍后我们会介绍另一种更通用的方法。

GLSL / WebGL 专业术语检查

原文使用 WebGPU + WGSL,并非 GLSL / WebGL,但两者在着色器编程中有许多对应概念。以下列出关键术语及其准确翻译,避免混淆:

原文术语中文翻译备注
Uniform buffer统一缓冲区WebGPU 中通过 GPUBufferUsage.UNIFORM 创建,对应 WebGL 的 uniform 变量集合
Vertex shader顶点着色器WGSL 中用 @vertex 标记
Clip space裁剪空间顶点着色器输出的坐标系,范围 -1 到 +1
@builtin(position)内建位置输出对应 GLSL 的 gl_Position
@group(0) @binding(0)绑定组 0,绑定点 0对应 WebGL 中 uniform 的绑定位置,但基于绑定组模型
vec2f / vec4f2 分量 / 4 分量浮点向量WGSL 类型,类比 GLSL 的 vec2 / vec4
Float32Array32 位浮点类型数组JavaScript 侧存储 uniform 数据
device.queue.writeBuffer命令队列写入缓冲区将数据上传到 GPU 缓冲区

需要特别注意的是,WebGPU 的 Uniform 缓冲区布局遵循 std140 对齐规则,但本示例通过 Float32Array 的紧凑排列避开了对齐填充,这在 WebGL 中可能需要手动计算 UNPACK_ALIGNMENT 或使用 gl.uniform* 逐个设置。WebGPU 的显式缓冲管理让内存布局更可控,但也要求开发者精确计算偏移量。