《游戏引擎实现原理》4.3 对齐、padding 与 TypedArray 上的真实布局

本节把顶点结构 [pos.xyz][pad][uv.uv][color.rgba] 手工布局到一块 ArrayBuffer 上,讲清 std140 的 16 字节对齐规则、padding 从哪来、以及「CPU 紧打包」为什么会让偏移错位;并用可运行代码算出结构体大小与步长,再补上 WebGPU 的 bytesPerRow 必须是 256 倍数这一约束。

4.3 对齐、padding 与 TypedArray 上的真实布局

4.2 的 arena 给了我们一块连续的 ArrayBuffer,但「连续」只是必要条件。真正决定这份内存能不能被 CPU 与 GPU 同时正确解读的,是布局:每个字段从第几个字节开始、占几个字节、字段之间有没有被塞进看不见的 padding。

这一节就拿最典型的一个结构开刀——顶点:

[pos.x, pos.y, pos.z, pad][uv.u, uv.v][color.rgba]

它看起来只有 6 个数值,但手工布局到 ArrayBuffer 上时,偏移量并不是「一个接一个」那么简单。我们会写一个布局计算器,把它算清楚,并断言出每个字段的真实偏移。

本节产出一个完整可跑的 src/core/vertex-layout.ts 与 vertex-layout.test.ts,在 Node 26.5.0 上跑过 tsc --noEmit 与 vitest。文中所有偏移量与步长都来自真实运行结果。

std140 的对齐规则,与「紧打包」的差异

GPU 上有一份被广泛采用的对齐约定,叫 std140。它的核心规则有两条:

  • 基础类型按自身大小对齐:float 4 字节、vec2 8 字节、vec4 16 字节。
  • vec3 是个例外:它只占 12 字节,却要求按 16 字节对齐。

第二条是绝大多数人第一次踩的坑。你写一个 vec3,编译器会在它后面悄悄塞 4 字节 padding,好让下一个 16 字节对齐的字段落在正确的位置。如果你在 CPU 侧按「12 字节紧挨着下一个」来算,偏移就会整体错位,GPU 读出来的数据会像乱码。

用我们的顶点结构对照一下两种口径:

字段类型std140 偏移紧打包偏移
positionvec3f(12 B,对齐 16)00
padf3212—
uvvec2f(8 B,对齐 8)1612
colorunorm8x4(4 B)2420
结构体大小28 B24 B
arrayStride32 B(28 → 16 对齐)24 B

注意「紧打包」那一列:把 vec3 当成 12 字节直接拼下一个字段,uv 就落到了偏移 12——不是 8 的倍数。某些后端会直接拒绝这种布局,另一些会静默读错。这正是 std140 要在 vec3 后面补 4 字节的原因。

可运行产物:src/core/vertex-layout.ts

下面是完整的 src/core/vertex-layout.ts。它把「按类型对齐」这件事写成了数据驱动的查表,layoutStruct 负责算出每个字段的偏移与整个结构体的步长,layoutStructTight 则刻意按「紧打包」算一遍,方便你把两者摆在一起看差异。

// file: src/core/vertex-layout.ts
// tier: L1
// verify: npx vitest run src/core/vertex-layout.test.ts
//
// helio 内核:在 ArrayBuffer 上手工布局顶点结构。
// 关键点:GPU 的对齐规则(std140)与「CPU 紧打包」不一致,
// vec3 占 12 字节但要按 16 字节对齐;行距(bytesPerRow)必须是 256 的倍数。

export type FieldType = 'f32' | 'vec2f' | 'vec3f' | 'vec4f' | 'u32' | 'unorm8x4';

export type FieldSpec = { readonly name: string; readonly type: FieldType };

export type FieldLayout = {
  readonly name: string;
  readonly type: FieldType;
  readonly offset: number;
  readonly size: number;
};

export type StructLayout = {
  readonly fields: readonly FieldLayout[];
  /** 最后一个字段结束的位置(未向上取整)。 */
  readonly size: number;
  /** 顶点缓冲区的 arrayStride:结构体大小按最大对齐向上取整。 */
  readonly stride: number;
  /** 结构体的最大成员对齐。 */
  readonly align: number;
};

/** 各类型的字节数与分量数。 */
const TYPE_SIZE: Readonly<Record<FieldType, number>> = {
  f32: 4, vec2f: 8, vec3f: 12, vec4f: 16, u32: 4, unorm8x4: 4,
};
const COMPONENTS: Readonly<Record<FieldType, number>> = {
  f32: 1, vec2f: 2, vec3f: 3, vec4f: 4, u32: 1, unorm8x4: 1,
};

/**
 * 各类型的基础对齐。注意 vec3f 占 12 字节却要求 16 字节对齐——
 * 这是 std140 的规则,也是「紧打包」最容易踩的坑。
 */
const TYPE_ALIGN: Readonly<Record<FieldType, number>> = {
  f32: 4, vec2f: 8, vec3f: 16, vec4f: 16, u32: 4, unorm8x4: 4,
};

/** 向上对齐到 alignment(必须是 2 的幂)。 */
export function alignUp(value: number, alignment: number): number {
  if (alignment <= 0 || (alignment & (alignment - 1)) !== 0) {
    throw new RangeError(`alignment must be a power of two, got ${alignment}`);
  }
  return (value + alignment - 1) & ~(alignment - 1);
}

/** 按 std140 规则计算结构体布局。 */
export function layoutStruct(fields: readonly FieldSpec[]): StructLayout {
  const laid: FieldLayout[] = [];
  let offset = 0;
  let maxAlign = 1;
  for (const f of fields) {
    const align = TYPE_ALIGN[f.type];
    offset = alignUp(offset, align);
    const size = TYPE_SIZE[f.type];
    laid.push({ name: f.name, type: f.type, offset, size });
    offset += size;
    if (align > maxAlign) maxAlign = align;
  }
  return { fields: laid, size: offset, stride: alignUp(offset, maxAlign), align: maxAlign };
}

/**
 * 「CPU 紧打包」布局:把顶点当成一串连续的 4 字节标量,所有字段只按 4 字节对齐,
 * 不做 std140 的 16 字节补位。用它和 layoutStruct 对比,就能看出补位带来的偏移差异。
 */
export function layoutStructTight(fields: readonly FieldSpec[]): StructLayout {
  const laid: FieldLayout[] = [];
  let offset = 0;
  for (const f of fields) {
    offset = alignUp(offset, 4);
    const size = TYPE_SIZE[f.type];
    laid.push({ name: f.name, type: f.type, offset, size });
    offset += size;
  }
  return { fields: laid, size: offset, stride: alignUp(offset, 4), align: 4 };
}

/** WebGPU:顶点缓冲区的 arrayStride 必须是 4 的倍数。 */
export function assertVertexStride(stride: number): void {
  if (stride <= 0 || stride % 4 !== 0) {
    throw new RangeError(`vertex arrayStride ${stride} must be a positive multiple of 4`);
  }
}

/** WebGPU:copyBufferToTexture 等操作里 bytesPerRow 必须是 256 的倍数。 */
export function alignRowPitch(widthBytes: number): number {
  return alignUp(widthBytes, 256);
}

/** 按布局把一个顶点写进 buffer(用 DataView 逐字段写,便于观察偏移)。 */
export function writeVertex(
  layout: StructLayout,
  buffer: ArrayBuffer,
  base: number,
  values: Readonly<Record<string, number | readonly number[]>>,
): void {
  const dv = new DataView(buffer);
  for (const f of layout.fields) {
    const at = base + f.offset;
    const v = values[f.name];
    if (f.type === 'u32' || f.type === 'unorm8x4') {
      dv.setUint32(at, v as number, true);
    } else if (f.type === 'f32') {
      dv.setFloat32(at, v as number, true);
    } else {
      const a = v as readonly number[];
      for (let i = 0; i < COMPONENTS[f.type]; i++) dv.setFloat32(at + i * 4, a[i]!, true);
    }
  }
}

/** 打包成 0xRRGGBBAA 的 unorm8x4。 */
export function packRGBA(r: number, g: number, b: number, a: number): number {
  return (((r & 0xff) << 24) | ((g & 0xff) << 16) | ((b & 0xff) << 8) | (a & 0xff)) >>> 0;
}

writeVertex 用 DataView 而不是 Float32Array,是因为颜色字段是 unorm8x4(4 字节打包成 u32),必须按整型写。写顶点时逐个字段按算好的偏移写,这样布局一旦算错,字节落点会立刻暴露。

用例:断言偏移与步长

布局代码的价值全在「偏移算对没有」。下面这组用例把关键偏移、步长、以及紧打包的错位都钉死。

// file: src/core/vertex-layout.test.ts
// tier: L1
// verify: npx vitest run src/core/vertex-layout.test.ts

import { describe, expect, it } from 'vitest';
import {
  alignRowPitch,
  alignUp,
  assertVertexStride,
  layoutStruct,
  layoutStructTight,
  packRGBA,
  writeVertex,
  type FieldSpec,
} from './vertex-layout.ts';

// 顶点:[pos.x, pos.y, pos.z, pad][uv.u, uv.v][color.rgba]
const VERTEX: FieldSpec[] = [
  { name: 'position', type: 'vec3f' },
  { name: 'pad', type: 'f32' },
  { name: 'uv', type: 'vec2f' },
  { name: 'color', type: 'unorm8x4' },
];

describe('alignUp', () => {
  it('向上对齐到 2 的幂', () => {
    expect(alignUp(0, 16)).toBe(0);
    expect(alignUp(12, 16)).toBe(16);
    expect(alignUp(16, 16)).toBe(16);
    expect(alignUp(17, 16)).toBe(32);
    expect(alignUp(1000, 256)).toBe(1024);
  });

  it('拒绝非 2 的幂', () => {
    expect(() => alignUp(1, 3)).toThrow(RangeError);
  });
});

describe('layoutStruct(std140)', () => {
  it('算出顶点结构的偏移与步长', () => {
    const l = layoutStruct(VERTEX);
    const byName = Object.fromEntries(l.fields.map((f) => [f.name, f.offset]));
    expect(byName).toEqual({ position: 0, pad: 12, uv: 16, color: 24 });
    expect(l.size).toBe(28);
    expect(l.align).toBe(16);
    expect(l.stride).toBe(32); // 28 向上对齐到 16
  });

  it('紧打包会把 uv 挤到未对齐的偏移上', () => {
    const tight = layoutStructTight([
      { name: 'position', type: 'vec3f' },
      { name: 'uv', type: 'vec2f' },
      { name: 'color', type: 'unorm8x4' },
    ]);
    const byName = Object.fromEntries(tight.fields.map((f) => [f.name, f.offset]));
    expect(byName).toEqual({ position: 0, uv: 12, color: 20 });
    expect(tight.size).toBe(24);
    // 紧打包省了 8 字节,但 uv 落在 12——不是 8 的倍数
    expect(byName['uv']! % 8).not.toBe(0);
  });

  it('arrayStride 是 4 的倍数', () => {
    const l = layoutStruct(VERTEX);
    expect(() => assertVertexStride(l.stride)).not.toThrow();
    expect(() => assertVertexStride(30)).toThrow(RangeError);
  });
});

describe('bytesPerRow 必须是 256 的倍数', () => {
  it('把行宽向上对齐到 256', () => {
    expect(alignRowPitch(4 * 100)).toBe(512); // 400 → 512
    expect(alignRowPitch(256)).toBe(256);
    expect(alignRowPitch(257)).toBe(512);
  });
});

describe('writeVertex', () => {
  it('按偏移写入,颜色落在第 24 字节', () => {
    const l = layoutStruct(VERTEX);
    const buf = new ArrayBuffer(l.stride * 2);
    const color = packRGBA(255, 128, 0, 255);
    writeVertex(l, buf, 0, { position: [1.5, -2.0, 3.25], pad: 0, uv: [0.5, 0.25], color });
    writeVertex(l, buf, l.stride, { position: [4, 5, 6], pad: 0, uv: [1, 0], color });

    const dv = new DataView(buf);
    expect(dv.getFloat32(0, true)).toBeCloseTo(1.5);
    expect(dv.getFloat32(8, true)).toBeCloseTo(3.25);
    expect(dv.getFloat32(16, true)).toBeCloseTo(0.5); // uv.u
    expect(dv.getUint32(24, true)).toBe(color); // color.rgba
    // 第二个顶点从 stride=32 开始
    expect(dv.getFloat32(32, true)).toBeCloseTo(4);
    expect(dv.getUint32(32 + 24, true)).toBe(color);
  });

  it('packRGBA 大端序排列', () => {
    expect(packRGBA(0xff, 0x80, 0x00, 0xff)).toBe(0xff8000ff);
  });
});

在 /tmp/enginebook 里跑 npx vitest run src/core/vertex-layout.test.ts,8 个用例全部通过。把布局单独打印出来看更直观(本机实测,绝对数值不可移植):

std140: position@0 pad@12 uv@16 color@24 | size 28 align 16 stride 32
tight : position@0 uv@12 color@20        | size 24 stride 24
rowPitch(400) = 512   rowPitch(256) = 256

对照 writeVertex 的用例:第二个顶点的 position.x 落在字节偏移 32,正是 stride = 32;颜色恒在第 24 字节。如果哪一天你把 vec3 换成 vec4 或者加了字段,偏移会整体移动——这类布局代码必须配断言,否则改动会静默破坏渲染。

bytesPerRow 必须是 256 的倍数

上面所有对齐都是「结构体内部」的。还有一条结构体外部的 WebGPU 硬约束:当你用 copyBufferToTexture / copyTextureToBuffer 搬运纹理数据时,bytesPerRow(每行字节数)必须是 256 的倍数。alignRowPitch 就是干这个的:一张宽 100 像素、每像素 4 字节的图,行宽 400 字节,必须补到 512 才能提交。

这条约束的坑在于:它和顶点布局是两码事,但经常出现在同一个 buffer 里。 你可能把顶点数据和一张小纹理打包进同一块 ArrayBuffer,然后发现顶点部分没问题、纹理部分却报校验错误——因为顶点只要求 arrayStride % 4 === 0,而纹理拷贝要求 bytesPerRow % 256 === 0。两个约束的粒度差了 64 倍。

实用建议:别把「顶点区」和「纹理工件区」塞进同一块 buffer。 分开成两块,各自满足各自的对齐要求,调试时也更容易定位。第 5 章会把「按用途分块 + 每块独立预算」这套做法正式化。

小结

本节只算了布局,没有真正创建 GPUBuffer,也没有写 WGSL 侧的 @location 声明——那些属于渲染卷的内容,站内 /graphics-render-graph/ 有更完整的讲法。本节的目标只有一个:让你在写 ArrayBuffer 布局时,能在 CPU 侧就把偏移算对并用断言锁死,而不是等到 GPU 报一个看不懂的校验错误再回头排查。

到这里,内存这条线的前半段就走完了:4.1 说明了为什么要把数据搬出 JS 堆,4.2 给了分配这些数据的 arena,4.3 保证了数据在 buffer 里的落点正确。下一章处理一个更棘手的问题——分配出来的内存什么时候可以复用、什么时候必须回收。

阅读导航:上一节:4.2 线性 arena 与栈式分配器 · 下一节:5.1 对象池与 freelist 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径