WebGPU 数据内存布局
英文原作者:webgpufundamentals Contributors(WebGPU Fundamentals 贡献者)
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-memory-layout.html
在 WebGPU 中,几乎所有传入 GPU 的数据都需要按照着色器中定义的结构精确地在内存中排布。这与 JavaScript 或 TypeScript 的开发体验截然不同——在那些语言里,内存布局问题很少出现在日常编码中。
在 WGSL 着色器代码里,我们通常会定义 struct(结构体)。结构体类似于 JavaScript 对象,你可以声明成员(类似属性),但除了命名之外,每个成员还必须指定类型。同时,当你在 CPU 端提供数据时,你需要自行计算出结构体中每个成员在缓冲区中的确切字节偏移量。
WGSL v1 提供了四种基础数据类型:
f32:32 位浮点数i32:32 位有符号整数u32:32 位无符号整数f16:16 位浮点数(可选特性)
一个字节是 8 位,因此 32 位值占 4 字节,16 位值占 2 字节。
结构体内存布局示例
下面定义一个简单的结构体:
struct OurStruct {
velocity: f32,
acceleration: f32,
frameCount: u32,
};
该结构在内存中可以直观地表示为 12 个连续字节:velocity 占前 4 字节,acceleration 占接下来的 4 字节,frameCount 占最后 4 字节。
为了向着色器传递数据,我们需要准备一个与 OurStruct 内存布局一致的 ArrayBuffer,然后创建正确类型的 TypedArray 视图来填充数据:
const kOurStructSizeBytes =
4 + // velocity
4 + // acceleration
4 ; // frameCount
const ourStructData = new ArrayBuffer(kOurStructSizeBytes);
const ourStructValuesAsF32 = new Float32Array(ourStructData);
const ourStructValuesAsU32 = new Uint32Array(ourStructData);
这里 ourStructData 是一块原始内存,ourStructValuesAsF32 和 ourStructValuesAsU32 是同一块内存的不同视图,分别按照 32 位浮点数和 32 位无符号整数解释字节。
设置数据:
const kVelocityOffset = 0;
const kAccelerationOffset = 1;
const kFrameCountOffset = 2;
ourStructValuesAsF32[kVelocityOffset] = 1.2;
ourStructValuesAsF32[kAccelerationOffset] = 3.4;
ourStructValuesAsU32[kFrameCountOffset] = 56; // 整数
TypedArray 视图的多种构造方式
TypedArray 构造函数有多种重载,理解它们有助于更高效地管理内存:
new Float32Array(12):创建新的ArrayBuffer(12×4 字节),并创建视图。new Float32Array([4, 5, 6]):创建新的ArrayBuffer(3×4 字节),并初始化为 4、5、6。new Float32Array(someArrayBuffer):在已存在的ArrayBuffer上创建视图。new Float32Array(someArrayBuffer, byteOffset):从指定偏移开始创建视图。new Float32Array(someArrayBuffer, byteOffset, length):从偏移开始,长度为length个元素(单位)的视图。
注意:当通过 new Uint32Array(f32s) 从另一个 TypedArray 创建新数组时,值是按数值转换的,而不是按二进制复制。例如:
const f32s = new Float32Array([0.8, 0.9, 1.0, 1.1, 1.2]);
const u32s = new Uint32Array(f32s);
console.log(u32s); // 输出 0, 0, 1, 1, 1
因为 0.8、1.2 等无法放入 Uint32Array,会被转换为无符号整数。
利用最后一个构造函数,我们可以为结构体的每个成员创建独立的视图:
const kOurStructSizeBytes =
4 + // velocity
4 + // acceleration
4 ; // frameCount
const ourStructData = new ArrayBuffer(kOurStructSizeBytes);
const velocityView = new Float32Array(ourStructData, 0, 1);
const accelerationView = new Float32Array(ourStructData, 4, 1);
const frameCountView = new Uint32Array(ourStructData, 8, 1);
velocityView[0] = 1.2;
accelerationView[0] = 3.4;
frameCountView[0] = 56;
或者使用 subarray 方法,它返回同一缓冲区上的新视图:
const kOurStructSizeFloat32Units =
1 + // velocity
1 + // acceleration
1 ; // frameCount
const ourStructDataAsF32 = new Float32Array(kOurStructSizeFloat32Units);
const ourStructDataAsU32 = new Uint32Array(ourStructDataAsF32.buffer);
const velocityView = ourStructDataAsF32.subarray(0, 1);
const accelerationView = ourStructDataAsF32.subarray(1, 2);
const frameCountView = ourStructDataAsU32.subarray(2, 3);
velocityView[0] = 1.2;
accelerationView[0] = 3.4;
frameCountView[0] = 56;
共享同一块 ArrayBuffer 的多个视图
多个 TypedArray 视图可以指向同一个 ArrayBuffer,修改一个视图的内容会影响其他视图看到的字节。例如:
const v1 = new Float32Array(5);
const v2 = v1.subarray(3, 5); // 视图查看 v1 的最后两个浮点数
v2[0] = 123;
v2[1] = 456;
console.log(v1); // 输出 0, 0, 0, 123, 456
不同类型视图也能共享内存,比如用 Uint32Array 查看 Float32Array 的底层位表示:
const f32 = new Float32Array([1, 1000, -1000])
const u32 = new Uint32Array(f32.buffer);
console.log(Array.from(u32).map(v => v.toString(16).padStart(8, '0')));
// 输出 '3f800000', '447a0000', 'c47a0000'
vec 和 mat 类型
WGSL 基于四种基本类型定义了向量和矩阵类型,例如:
| 类型 | 描述 | 缩写 |
|---|---|---|
vec2<f32> | 2 个 f32 | vec2f |
vec3<f32> | 3 个 f32 | vec3f |
vec4<f32> | 4 个 f32 | vec4f |
mat4x4<f32> | 4×4 f32 矩阵 | mat4x4f |
完整表格请参考原文。
这些类型在内存中同样有对齐要求,而且向量和矩阵的对齐规则往往比基础类型更严格。例如 vec3f 需要 16 字节对齐(即使它只有 12 字节大小),这会导致额外的填充字节。
对齐规则:填充字节的来源
考虑以下结构体:
struct Ex2 {
scale: f32,
offset: vec3f,
projection: mat4x4f,
};
实际内存布局会包含填充字节,因为 offset(vec3f)需要对齐到 16 字节边界。scale 占 4 字节,因此 offset 必须从偏移 16 开始(跳过 12 字节填充),之后 projection 也需要对齐。这导致结构体总大小远大于成员大小之和。
更复杂的是,数组和结构体自身也有特殊的对齐规则。例如:
struct Ex3 {
transform: mat3x3f,
directions: `array<vec3f, 4>`,
};
mat3x3f 中的每个 vec3f 列都需要 16 字节对齐,array<vec3f, 4> 中的每个元素同样如此。因此数组元素之间会存在填充。
再看一个例子:
struct Ex4a {
velocity: vec3f,
};
struct Ex4 {
orientation: vec3f,
size: f32,
direction: `array<vec3f, 1>`,
scale: f32,
info: Ex4a,
friction: f32,
};
为什么 size 紧跟在 orientation 后面(偏移 12),而 scale 和 friction 却被推到偏移 32 和 64?原因在于:
array<E, N>的对齐要求是AlignOf(E),其步长(stride)为roundUp(AlignOf(E), SizeOf(E))。struct的对齐要求是成员对齐的最大值,并且结构体大小会被向上取整为其对齐的倍数。
即使数组只有一个 vec3f 元素,数组本身仍然需要按照 vec3f 的 16 字节对齐。结构体 Ex4a 虽然只有一个 vec3f 成员,但结构体本身的对齐也是 16 字节。因此,scale 和 friction 等后续成员被迫对齐到下一个 16 字节边界。
具体计算公式:
struct S with members M1...MN:
align = max(AlignOfMember(S,1), ..., AlignOfMember(S,N))
size = roundUp(align, justPastLastMember)
`array<E, N>`:
align = AlignOf(E)
size = N × roundUp(AlignOf(E), SizeOf(E))
完整的规则可参考 WGSL 规范。
手动计算偏移和大小是痛点
在 WebGPU 开发中,手动计算结构体偏移和大小无疑是最令人头疼的部分。如果你在着色器结构体中间添加一个成员,就必须回到 JavaScript 中手动更新所有偏移量。任何一个字节错误都可能导致着色器读取到错误的数据,且不会报错——模型无法绘制或计算产生错误结果。
幸运的是,有现成的库可以自动处理这些计算,例如 webgpu-utils。你只需将 WGSL 代码交给它,它会自动生成数据布局信息,并提供一个便捷的 API 来读写对应的缓冲区。
示例:
import {
makeShaderDataDefinitions,
makeStructuredView,
} from 'https://greggman.github.io/webgpu-utils/dist/0.x/webgpu-utils-1.x.module.js';
const code = `
struct Ex4a {
velocity: vec3f,
};
struct Ex4 {
orientation: vec3f,
size: f32,
direction: `array<vec3f, 1>`,
scale: f32,
info: Ex4a,
friction: f32,
};
@group(0) @binding(0) `var<uniform>` myUniforms: Ex4;
...
`;
const defs = makeShaderDataDefinitions(code);
const myUniformValues = makeStructuredView(defs.uniforms.myUniforms);
// 通过 set 方法一次性设置所有成员,内部自动处理偏移
myUniformValues.set({
orientation: [1, 0, -1],
size: 2,
direction: [0, 1, 0],
scale: 1.5,
info: {
velocity: [2, 3, 4],
},
friction: 0.1,
});
// 将 myUniformValues.arrayBuffer 传递给 WebGPU 即可
使用此类库可以大幅减少手动计算带来的调试时间。当然,如果你希望手动计算,也可以使用官方提供的 WGSL Offset Computer 工具来辅助。
核心优化要点
1. 复用单个 ArrayBuffer 的多视图访问
利用 TypedArray 的不同构造函数(尤其是 new Float32Array(buffer, byteOffset, length) 和 subarray),在同一块内存上创建多个视图,分别读写不同区域或不同数据类型。这样避免了多次数据复制,也保证了 CPU 端与 GPU 端内存布局的一致性。
2. 采用库自动计算着色器内存布局
使用 webgpu-utils 等工具,将 WGSL 代码传入 makeShaderDataDefinitions 与 makeStructuredView,自动生成结构体偏移和大小,彻底摆脱手动更新偏移量的繁琐和易错性。这是提升 WebGPU 开发效率的关键实践。
3. 理解对齐规则并优化结构体成员顺序
WGSL 中 vec3f、矩阵以及数组和结构体都有特殊的对齐要求(通常为 16 字节)。掌握这些规则后,可以通过调整成员顺序(例如将需要对齐的类型前置)来减少填充字节,节省缓冲区内存并可能提升访问效率。例如,将 vec3f 放在 f32 之前,避免因对齐而插入过大的填充。
GLSL / WebGL 专业术语检查
| 英文术语 | 中文翻译 / 说明 |
|---|---|
| memory layout | 内存布局 |
| struct | 结构体 |
| f32 / i32 / u32 / f16 | 32位浮点 / 32位有符号整数 / 32位无符号整数 / 16位半精度浮点(可保留缩写) |
| TypedArray | 类型化数组(JavaScript 内置对象) |
| ArrayBuffer | 数组缓冲区(原始二进制数据缓冲区) |
| offset | 偏移量(字节偏移) |
| alignment | 对齐(要求) |
| size | 大小(字节数) |
| stride | 步长(数组中相邻元素起始字节距离) |
| vec3f | 三维浮点向量(WGSL 类型) |
| mat4x4f | 4×4 浮点矩阵(WGSL 类型) |
| uniform | uniform 变量(统一变量) / uniform 缓冲区 |
| binding | 绑定(资源绑定索引) |
| padding | 填充字节(为对齐而插入的无用字节) |
| buffer | 缓冲区(GPU 缓冲区) |
| WGSL | WebGPU Shading Language,WebGPU 着色语言 |
| WebGPU | 新一代 Web 图形 API,不叫 WebGL,但与 WebGL 同属 Web 图形技术 |
以上术语在 WebGPU / WGSL 语境下是标准用法,与 WebGL / GLSL 术语有部分重叠,但需注意 WGSL 使用 struct 和 var<uniform> 等不同于 GLSL 的语法。在中文技术文档中通常保留英文缩写或使用上述中文表达。
