WebGPU Rotation:基于单位圆的 2D 旋转
原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-rotation.html
本文是 WebGPU 3D 数学系列的第二篇,承接上一篇“平移(Translation)”,介绍如何在顶点着色器中实现 2D 旋转。核心思路是利用单位圆上的点 (cosθ, sinθ) 作为旋转向量,通过简单的乘加运算将顶点绕原点旋转任意角度。下文将编译并解读原文关键内容,保留核心 WGSL 与 JavaScript 代码,并提炼出三个实现层面的优化要点。
一、单位圆与旋转的数学原理
单位圆(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 层显示角度,内部始终使用弧度。
五、核心优化要点提炼
-
利用单位圆旋转参数避免矩阵运算
在着色器中使用(cosθ, sinθ)直接计算旋转,比构造 2×2 旋转矩阵并作矩阵乘法更简单、更高效。尤其对于仅需 2D 旋转的场景,可减少 uniform 数据量并降低着色器指令数。 -
手动管理 uniform 缓冲区对齐与填充
WebGPU 的 uniform 数据要求按 16 字节边界对齐,混合不同类型字段时必须添加 padding。本示例在末尾增加 8 字节填充,确保后续 uniform 数据(如将来添加更多字段)不会因内存错位而出错。正确计算偏移量也是避免性能损失(如隐式 padding 导致的数据上传错误)的关键。 -
在 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 / vec4f | 2/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 中旋转的底层实现与优化思路。
