WebGPU Rotation:基于单位圆的 2D 旋转

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

本文是 WebGPU 3D 数学系列的第二篇,承接上一篇“平移(Translation)”,介绍如何在顶点着色器中实现 2D 旋转。核心思路是利用单位圆上的点 (cosθ, sinθ) 作为旋转向量,通过简单的乘加运算将顶点绕原点旋转任意角度。下文将编译并解读原文关键内容,保留核心 WGSL 与 JavaScript 代码,并提炼出三个实现层面的优化要点。


WebGPU Rotation

一、单位圆与旋转的数学原理

单位圆(unit circle) 是半径为 1 的圆。圆上任意一点的坐标 (x, y) 满足 x² + y² = 1。对于任意角度 θ,该点的坐标为:

x = cosθ
y = sinθ

旋转公式可以写成:

rotatedX = originalX * cosθ - originalY * sinθ
rotatedY = originalX * sinθ + originalY * cosθ

这正是把顶点绕原点旋转 θ 角度的二维旋转公式。在着色器中,我们只需要将 (cosθ, sinθ) 作为 uniform 传入,即可在 GPU 端完成旋转。这种方法避免了在 JavaScript 中逐顶点计算旋转,也无需维护旋转矩阵。


二、着色器代码更新(WGSL)

原文在上一篇平移示例的基础上,为 Uniforms 结构体增加了一个 rotation: vec2f 字段,并在顶点着色器中先旋转、再平移。核心修改如下:

struct Uniforms {
  color: vec4f,
  resolution: vec2f,
  translation: vec2f,
  rotation: vec2f,        // 新增:旋转向量 (cosθ, sinθ)
};

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 rotatedPosition = vec2f(
    vert.position.x * uni.rotation.x - vert.position.y * uni.rotation.y,
    vert.position.x * uni.rotation.y + vert.position.y * uni.rotation.x
  );

  // 再加上平移
  let position = rotatedPosition + 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 轴(因为像素空间 Y 向下,裁剪空间 Y 向上)
  let clipSpace = flippedClipSpace * vec2f(1, -1);

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

要点:旋转和平移的顺序是先旋转后平移,与原文一致。注意旋转向量 (rotation.x, rotation.y) 实际存储的是 (cosθ, sinθ)。


三、JavaScript 端 uniform 缓冲区调整

新增旋转字段后,uniform 缓冲区的大小及偏移量需要重新计算。原文中 color 占 4 个 float,resolution 占 2 个,translation 占 2 个,rotation 占 2 个,总共 10 个 float(40 字节)。但 WebGPU 的 uniform 数据需要满足对齐要求:每个字段应按其对齐大小(通常为 16 字节)进行填充。因此原文在末尾添加了 8 字节的 padding,使总大小为 48 字节。相关代码:

// 颜色(4) + 分辨率(2) + 平移(2) + 旋转(2) + 填充(2?实际8字节)
const uniformBufferSize = (4 + 2 + 2 + 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 kTranslationOffset = 6;
const kRotationOffset = 8;

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 degToRad = d => d * Math.PI / 180;

const settings = {
  translation: [150, 100],
  rotation: degToRad(30),          // 初始旋转 30°
};

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);   // UI 显示角度,内部使用弧度

function render() {
  // ...
  resolutionValue.set([canvas.width, canvas.height]);
  translationValue.set(settings.translation);
  rotationValue.set([
      Math.cos(settings.rotation),
      Math.sin(settings.rotation),
  ]);
  // ...
}

四、弧度(radian)说明

原文特别强调使用弧度而非角度进行数学计算。弧度使三角函数和旋转计算更简洁:

  • 一整圈 = 2π 弧度(≈6.283)
  • 半圈 = π 弧度
  • 90° = π/2 弧度
  • 30° = π/6 弧度

在 JavaScript 中,Math.cos 和 Math.sin 接受弧度值,因此需要将用户输入的角度转换为弧度(deg * Math.PI / 180)。仅在 UI 层显示角度,内部始终使用弧度。


五、核心优化要点提炼

  1. 利用单位圆旋转参数避免矩阵运算
    在着色器中使用 (cosθ, sinθ) 直接计算旋转,比构造 2×2 旋转矩阵并作矩阵乘法更简单、更高效。尤其对于仅需 2D 旋转的场景,可减少 uniform 数据量并降低着色器指令数。

  2. 手动管理 uniform 缓冲区对齐与填充
    WebGPU 的 uniform 数据要求按 16 字节边界对齐,混合不同类型字段时必须添加 padding。本示例在末尾增加 8 字节填充,确保后续 uniform 数据(如将来添加更多字段)不会因内存错位而出错。正确计算偏移量也是避免性能损失(如隐式 padding 导致的数据上传错误)的关键。

  3. 在 UI 层完成角度/弧度转换,着色器直接使用弧度
    将所有角度计算放在 JavaScript 端,着色器只接收最终的正弦/余弦值。这样 GPU 不必为每个顶点计算三角函数,也避免了在着色器中进行重复的角度转换。同时,使用弧度作为内部单位减少了代码中可能出现的单位混用错误。


六、GLSL/WebGL 专业术语检查

原文术语(WebGPU/WGSL)中文译名GLSL/WebGL 对应概念备注
@group(0) @binding(0) var<uniform>绑定组/绑定点(uniform 变量)uniform 变量或 UBO(Uniform Buffer Object)WebGPU 使用绑定组模型替代 GLSL 的 uniform 块。
@builtin(position)内置变量(顶点位置)gl_Position同为裁剪空间坐标输出。
@location(0)顶点属性位置layout(location = 0) in vec2 position;WebGPU 使用显式 location 标记。
clip space裁剪空间裁剪空间两者概念相同,但 WebGPU 的 Y 轴方向与 WebGL 相同(向上),而像素/屏幕空间 Y 向下。
uniform buffer统一缓冲区UBO(Uniform Buffer Object)WebGPU 中通过 createBuffer 创建,使用 GPUBufferUsage.UNIFORM 标志。
GPUBufferUsage.COPY_DST缓冲区复制目标用途glBufferData 写数据表示该缓冲区可作为拷贝目标,用于写入数据。
vec2f / vec4f2/4 分量浮点向量vec2 / vec4(GLSL)WGSL 显式指定浮点类型为 f。
degToRad角度转弧度无对应(通常使用 radians())GLSL 内置 radians() 函数,WebGPU 着色器不提供内置转换,需在 JS 端完成。

注意事项:

  • 原文使用的是 WGSL(WebGPU Shader Language),而非 GLSL。两者语法相似但关键字和内置函数有差异(如 WGSL 的 let 替代 GLSL 的 const/局部变量,var<uniform> 声明 uniform 等)。
  • WebGPU 的裁剪空间 Y 轴向上,与 WebGL 一致;但像素坐标 Y 轴向下,因此在着色器中需要翻转 Y(vec2f(1, -1))。
  • WebGPU 采用绑定组(bind group)管理资源绑定,而非 GLSL 的直接 uniform 查找,这是 API 层面的重要区别。

七、总结

本文通过引入单位圆与正弦/余弦,演示了如何在不使用矩阵的情况下实现顶点旋转。核心是将旋转角转换为 (cosθ, sinθ) 传入着色器,在 GPU 上完成旋转与平移。实现过程中需注意 uniform 缓冲区对齐、弧度使用以及 Y 轴翻转等细节。下一篇将进入更简单的“缩放(Scale)”主题。

希望本篇解读能帮助你理解 WebGPU 中旋转的底层实现与优化思路。