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 个 f32vec2f
vec3<f32>3 个 f32vec3f
vec4<f32>4 个 f32vec4f
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 / f1632位浮点 / 32位有符号整数 / 32位无符号整数 / 16位半精度浮点(可保留缩写)
TypedArray类型化数组(JavaScript 内置对象)
ArrayBuffer数组缓冲区(原始二进制数据缓冲区)
offset偏移量(字节偏移)
alignment对齐(要求)
size大小(字节数)
stride步长(数组中相邻元素起始字节距离)
vec3f三维浮点向量(WGSL 类型)
mat4x4f4×4 浮点矩阵(WGSL 类型)
uniformuniform 变量(统一变量) / uniform 缓冲区
binding绑定(资源绑定索引)
padding填充字节(为对齐而插入的无用字节)
buffer缓冲区(GPU 缓冲区)
WGSLWebGPU Shading Language,WebGPU 着色语言
WebGPU新一代 Web 图形 API,不叫 WebGL,但与 WebGL 同属 Web 图形技术

以上术语在 WebGPU / WGSL 语境下是标准用法,与 WebGL / GLSL 术语有部分重叠,但需注意 WGSL 使用 struct 和 var<uniform> 等不同于 GLSL 的语法。在中文技术文档中通常保留英文缩写或使用上述中文表达。