《游戏引擎实现原理》8.1 向量代数与 TypedArray 布局

向量在引擎里怎么存,决定了批量运算能跑多快。本节把三维向量放进 Float32Array,对照 AoS 交错布局与 SoA 分离布局的字节分布,给出零分配的 addInto 约定,以及可编译的 Vec3Store 与 Vec3SoA 实现和实测求和结果。

8.1 向量代数与 TypedArray 布局

第 7 章把容器做完了,从这一节起进入第三部分的数学。游戏里几乎没有一帧不碰向量:位置、朝向、速度、法线、缩放,全是三个浮点数。《微光岛》里成百上千个物体每帧都要被遍历、被加、被点乘,这些数怎么摆,直接决定了循环能跑多快。

本节只回答一个问题:向量在引擎里到底怎么存。公式推导留给站内既有文章,我们只盯着 Float32Array 上的真实字节。

本章与站内既有内容的分工

第 8、9 两章是数学章。为避免和站内既有内容重复,先把边界划清楚。

既有文章它负责什么本节只做什么
/graphics-math-foundations/点积、叉积、矩阵乘、投影与法线变换的几何推导与证明不重复任何公式推导,只写这些运算在 Float32Array 上的布局与零分配实现
4.3 对齐、padding 与 TypedArray 上的真实布局顶点结构体的字段对齐、std140 补位与 arrayStride向量作为纯数学量的存储;不涉及 GPU 结构体的字段对齐
11.2 SoA / AoS / AoSoA 的真实差异三种布局的速度与内存基准、什么时候不该用 SoA先把布局「是什么」讲清楚;基准数字留到 11.2 实测

一句话:公式去看既有文章,本节只讲「在引擎里怎么存、怎么算、怎么排内存」。

向量在内存里的三种存法

在 helio 里,同一个三维向量会以三种形态出现,各有各的用处:

  1. 标量元组 Vec3:[x, y, z],一个只读元组。读起来最顺,写起来最快,代价是每次运算都要分配一个新数组。适合初始化、配置、偶尔算一次的地方。
  2. AoS(interleaved,交错):一整块 Float32Array,每 3 个 float 存一个向量,第 i 个向量的 x / y / z 落在下标 3i / 3i+1 / 3i+2。适合「一个物体一个物体地处理」。
  3. SoA(分离):x、y、z 各占一条 Float32Array。适合「所有物体的 x 一起处理」的批量算法。

前两种我们前面见过(4.3 讲顶点结构时就是交错布局),第三种是数据导向设计的地基。关键认知是:AoS 和 SoA 的总字节数完全一样,变的只是「同一个分量的邻居是谁」。

拿 4 个向量 [1,0,0]、[0,2,0]、[0,0,3]、[1,1,1] 举例,两种布局的落点如下:

向量下标 iAoS:x/y/z 的数组下标AoS:x 的字节偏移SoA:在 xs/ys/zs 里的下标
00 / 1 / 200
13 / 4 / 5121
26 / 7 / 8242
39 / 10 / 11363

4 个向量 = 12 个 float = 48 字节,两种布局都占满这 48 字节。AoS 里「一个向量的 x、y、z」是相邻的;SoA 里「四个向量的 x」是相邻的。哪一边占优,取决于你的循环是「按向量」还是「按分量」。

可运行产物:Vec3Store 与 Vec3SoA

下面是完整可编译的 src/core/vec3.ts。它同时给出标量 API(add / sub / dot / cross / normalize)、零分配 API(addInto)和两种布局的容器。层级 L1,Node 直跑。

// file: src/core/vec3.ts
// tier: L1
// verify: npx vitest run src/core/vec3.test.ts
//
// helio 内核:三维向量。两种存储视角并存——
// 标量元组 Vec3(易读、每次运算分配)与 Float32Array 上的连续布局(零分配、可批处理)。

/** 可读的向量表示:三元素只读元组。 */
export type Vec3 = readonly [number, number, number];

export const VEC3_ZERO: Vec3 = [0, 0, 0];
export const VEC3_ONE: Vec3 = [1, 1, 1];
export const VEC3_X: Vec3 = [1, 0, 0];
export const VEC3_Y: Vec3 = [0, 1, 0];
export const VEC3_Z: Vec3 = [0, 0, 1];

export function add(a: Vec3, b: Vec3): Vec3 {
  return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}

export function sub(a: Vec3, b: Vec3): Vec3 {
  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}

export function scale(a: Vec3, s: number): Vec3 {
  return [a[0] * s, a[1] * s, a[2] * s];
}

export function dot(a: Vec3, b: Vec3): number {
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}

export function cross(a: Vec3, b: Vec3): Vec3 {
  return [
    a[1] * b[2] - a[2] * b[1],
    a[2] * b[0] - a[0] * b[2],
    a[0] * b[1] - a[1] * b[0],
  ];
}

export function length(a: Vec3): number {
  return Math.hypot(a[0], a[1], a[2]);
}

export function normalize(a: Vec3): Vec3 {
  const len = length(a);
  if (len === 0) return VEC3_ZERO;
  return [a[0] / len, a[1] / len, a[2] / len];
}

/** 把 a + b 写进 out[o..o+2],不分配任何对象。 */
export function addInto(out: Float32Array, o: number, a: Vec3, b: Vec3): void {
  out[o] = a[0] + b[0];
  out[o + 1] = a[1] + b[1];
  out[o + 2] = a[2] + b[2];
}

/**
 * AoS(interleaved):一个 Float32Array 里每 3 个 float 存一个向量,
 * 第 i 个向量的 x / y / z 分别落在下标 3i / 3i+1 / 3i+2。
 */
export class Vec3Store {
  readonly data: Float32Array;
  #count = 0;

  constructor(capacity: number) {
    this.data = new Float32Array(capacity * 3);
  }

  get count(): number {
    return this.#count;
  }

  push(v: Vec3): number {
    const i = this.#count++;
    const o = i * 3;
    this.data[o] = v[0];
    this.data[o + 1] = v[1];
    this.data[o + 2] = v[2];
    return i;
  }

  get(i: number): Vec3 {
    const o = i * 3;
    return [this.data[o]!, this.data[o + 1]!, this.data[o + 2]!];
  }

  /** 一次连续扫描算出所有向量的长度平方和——零分配。 */
  sumLengthSquared(): number {
    let sum = 0;
    const d = this.data;
    const end = this.#count * 3;
    for (let o = 0; o < end; o += 3) {
      sum += d[o]! * d[o]! + d[o + 1]! * d[o + 1]! + d[o + 2]! * d[o + 2]!;
    }
    return sum;
  }
}

/**
 * SoA:x / y / z 各占一条 Float32Array。
 * 只更新 x 分量的批量算法只碰 xs 一条数组,另外两条完全不进缓存。
 */
export class Vec3SoA {
  readonly xs: Float32Array;
  readonly ys: Float32Array;
  readonly zs: Float32Array;
  #count = 0;

  constructor(capacity: number) {
    this.xs = new Float32Array(capacity);
    this.ys = new Float32Array(capacity);
    this.zs = new Float32Array(capacity);
  }

  get count(): number {
    return this.#count;
  }

  push(v: Vec3): number {
    const i = this.#count++;
    this.xs[i] = v[0];
    this.ys[i] = v[1];
    this.zs[i] = v[2];
    return i;
  }

  get(i: number): Vec3 {
    return [this.xs[i]!, this.ys[i]!, this.zs[i]!];
  }

  sumLengthSquared(): number {
    let sum = 0;
    for (let i = 0; i < this.#count; i++) {
      sum += this.xs[i]! * this.xs[i]! + this.ys[i]! * this.ys[i]! + this.zs[i]! * this.zs[i]!;
    }
    return sum;
  }
}

注意两个类的 get 都会新建一个元组返回——这是刻意的:读接口面向「偶尔取一个」,不追求零分配;真正要零分配的批量循环直接操作 data / xs,不经过 get。把「好读」和「快」分成两个入口,是内核里反复出现的模式。

测试:两种布局必须给出相同结果

布局是内部实现,对外必须等价。下面这个用例同时验证标量运算和两种布局的一致性。

// file: src/core/vec3.test.ts
// tier: L1
// verify: npx vitest run src/core/vec3.test.ts

import { describe, expect, it } from 'vitest';
import { Vec3SoA, Vec3Store, add, cross, dot, normalize, type Vec3 } from './vec3.ts';

const SAMPLES: Vec3[] = [
  [1, 0, 0],
  [0, 2, 0],
  [0, 0, 3],
  [1, 1, 1],
];

describe('vec3 标量运算', () => {
  it('点积与叉积', () => {
    expect(dot([1, 2, 3], [4, 5, 6])).toBe(32);
    expect(cross([1, 0, 0], [0, 1, 0])).toEqual([0, 0, 1]);
  });

  it('加法与归一化', () => {
    expect(add([1, 2, 3], [4, 5, 6])).toEqual([5, 7, 9]);
    const n = normalize([0, 0, 5]);
    expect(n[2]).toBeCloseTo(1, 6);
  });
});

describe('AoS / SoA 两种布局给出相同结果', () => {
  it('AoS 的 sumLengthSquared 为 17', () => {
    const s = new Vec3Store(SAMPLES.length);
    for (const v of SAMPLES) s.push(v);
    expect(s.sumLengthSquared()).toBe(17);
  });

  it('SoA 的 sumLengthSquared 同样为 17', () => {
    const s = new Vec3SoA(SAMPLES.length);
    for (const v of SAMPLES) s.push(v);
    expect(s.sumLengthSquared()).toBe(17);
  });

  it('两种布局总字节数相同', () => {
    const aos = new Vec3Store(4);
    const soa = new Vec3SoA(4);
    expect(aos.data.byteLength).toBe(48);
    expect(soa.xs.byteLength + soa.ys.byteLength + soa.zs.byteLength).toBe(48);
  });

  it('随机存取下标一致', () => {
    const aos = new Vec3Store(SAMPLES.length);
    const soa = new Vec3SoA(SAMPLES.length);
    for (const v of SAMPLES) {
      aos.push(v);
      soa.push(v);
    }
    expect(aos.get(3)).toEqual(soa.get(3));
  });
});

本机实测:npx vitest run src/core/vec3.test.ts 六个用例全过。单跑一个打印脚本得到的具体数字是:AoS byteLength = 48,SoA 三条数组合计 byteLength = 48;第 4 个向量在 AoS 里起点在字节 36,在 SoA 里它的 x 落在 xs 的字节 12;两种布局的 sumLengthSquared 都是 17(1 + 4 + 9 + 3)。

17 这个数本身就是「两种布局等价」的证据——同样的数据、不同的排布,批量求和给出同一个结果。至于哪种排布更快,取决于循环形态,那是 11.2 的基准话题。

零分配约定:addInto 为什么存在

add 每次调用都 return [..],一次分配一个三元素数组。如果每帧对几千个物体各调一次 add,就是在稳定地制造垃圾——GC 迟早会来收,收的时候掉帧。

所以内核里所有「热路径」的运算都提供 out 参数版本:addInto(out, offset, a, b) 把结果写进调用方预先分配好的 Float32Array,自己不产生任何新对象。out 由调用方复用,一帧内零分配。

这条约定会在后面反复出现:第 11 章的数据导向重构、第 14 章的 Job System、第 15 章的任务图,都建立在「热路径不分配」之上。现在只需记住两个名字——好读的返回元组版和要快就传 out 版——它们成对出现,各司其职。

向量存好了、算对了,下一步是把它们组合成变换。8.2 进入矩阵,先讲清一个几乎所有 3D 引擎都会踩的坑:同一组 16 个数字,按行主序读和按列主序读,会得到两个完全不同的变换。

阅读导航:上一节:引擎内核 目录 · 下一节:8.2 矩阵、行/列主序与左/右手坐标系 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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