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 里,同一个三维向量会以三种形态出现,各有各的用处:
- 标量元组
Vec3:[x, y, z],一个只读元组。读起来最顺,写起来最快,代价是每次运算都要分配一个新数组。适合初始化、配置、偶尔算一次的地方。 - AoS(interleaved,交错):一整块
Float32Array,每 3 个 float 存一个向量,第 i 个向量的 x / y / z 落在下标3i/3i+1/3i+2。适合「一个物体一个物体地处理」。 - SoA(分离):x、y、z 各占一条
Float32Array。适合「所有物体的 x 一起处理」的批量算法。
前两种我们前面见过(4.3 讲顶点结构时就是交错布局),第三种是数据导向设计的地基。关键认知是:AoS 和 SoA 的总字节数完全一样,变的只是「同一个分量的邻居是谁」。
拿 4 个向量 [1,0,0]、[0,2,0]、[0,0,3]、[1,1,1] 举例,两种布局的落点如下:
| 向量下标 i | AoS:x/y/z 的数组下标 | AoS:x 的字节偏移 | SoA:在 xs/ys/zs 里的下标 |
|---|---|---|---|
| 0 | 0 / 1 / 2 | 0 | 0 |
| 1 | 3 / 4 / 5 | 12 | 1 |
| 2 | 6 / 7 / 8 | 24 | 2 |
| 3 | 9 / 10 / 11 | 36 | 3 |
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 矩阵、行/列主序与左/右手坐标系 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。