动态 Mesh 更新事务:顶点、Bounds 与上传版本必须一起提交
可破坏地形每帧都可能改写顶点,但渲染错误往往不在变形公式,而在三个状态跨帧失配:顶点已变、Bounds 仍旧,或同一版本被重复上传。本文把一次更新定义为候选缓冲通过索引校验、完成 Bounds 计算后,由 CPU 网格所有者原子提交的事务;GPU 只接收已提交版本。
// .tmp/daily-labs/2026-07-24-2000/dynamic-mesh.js
// symbols: MeshUpdateError, DynamicMesh
export class MeshUpdateError extends Error {}
export class DynamicMesh {
constructor(vertices, indices) {
this.vertices = new Float32Array(vertices);
this.indices = new Uint16Array(indices);
this.bounds = this.#measureBounds(this.vertices);
this.vertexVersion = 0;
this.uploadedVersion = 0;
}
}

状态边界
DynamicMesh 独占 CPU 顶点、索引、Bounds 与两个版本号;调用者只能提交完整候选数组,GPU 适配器只能上传。两个不变量约束可见状态:任何已提交顶点都必须与同次计算的 Bounds 配对;uploadedVersion 不得大于 vertexVersion。把 Bounds 更新交给剔除系统的下一个阶段看似解耦,实际会制造一帧“新几何、旧可见域”。
// .tmp/daily-labs/2026-07-24-2000/dynamic-mesh.js
// symbol: DynamicMesh.updateVertices
updateVertices(nextVertices) {
if (nextVertices.length !== this.vertices.length) {
throw new MeshUpdateError("vertex layout changed");
}
const candidate = new Float32Array(nextVertices);
this.#validateIndices(candidate, this.indices);
const bounds = this.#measureBounds(candidate);
this.vertices = candidate;
this.bounds = bounds;
this.vertexVersion += 1;
return this.snapshot();
}
提交顺序
正常时序为“复制候选 → 校验索引 → 计算 Bounds → 提交三个 CPU 字段 → 比较上传版本”。校验和计算都发生在成员赋值之前,因此异常不会留下半提交状态。固定输入含四个 xyz 顶点与六个三角形索引,变形后的可复算范围为 min=[-2,1,-1]、max=[2,3,1]。
// .tmp/daily-labs/2026-07-24-2000/dynamic-mesh.js
// symbols: DynamicMesh.#validateIndices, DynamicMesh.#measureBounds
#validateIndices(vertices, indices) {
const vertexCount = vertices.length / 3;
for (const index of indices) {
if (index >= vertexCount) {
throw new MeshUpdateError(`index ${index} outside ${vertexCount}`);
}
}
}
#measureBounds(vertices) {
if (vertices.length === 0 || vertices.length % 3 !== 0) {
throw new MeshUpdateError("vertices must be xyz triples");
}
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < vertices.length; i += 3) {
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], vertices[i + axis]);
max[axis] = Math.max(max[axis], vertices[i + axis]);
}
}
return { min, max };
}

上传判定
上传频率不由调用次数决定,而由已提交版本是否领先 GPU 版本决定。首次比较 0 != 1,上传十二个浮点数并推进版本;第二次比较 1 == 1,直接返回。该协议消除同一逻辑帧内多个渲染入口造成的重复写入,但没有宣称改善实际带宽,因为探针未做性能基准。
// .tmp/daily-labs/2026-07-24-2000/dynamic-mesh.js
// symbol: DynamicMesh.uploadIfDirty
uploadIfDirty(gpu) {
if (this.uploadedVersion === this.vertexVersion) {
return { uploaded: false, version: this.uploadedVersion };
}
gpu.uploadVertices(this.vertices);
this.uploadedVersion = this.vertexVersion;
return { uploaded: true, version: this.uploadedVersion };
}
snapshot() {
return {
vertexVersion: this.vertexVersion,
uploadedVersion: this.uploadedVersion,
bounds: structuredClone(this.bounds),
};
}
失败保持
失败时序固定使用三个顶点却保留索引 3。index 3 outside 3 在提交前抛出,断言版本与 Bounds 完全等于失败前快照;布局长度变化也不得推进版本。若先替换成员数组再校验,异常恢复就需要额外回滚副本,并可能让并行剔除读取到非法拓扑。
// .tmp/daily-labs/2026-07-24-2000/dynamic-mesh.test.js
// symbols: invalid index..., layout changes...
test("invalid index rejects candidate without mutating committed state", () => {
const mesh = new DynamicMesh(vertices.slice(0, 9), indices);
const before = mesh.snapshot();
assert.throws(
() => mesh.updateVertices(vertices.slice(0, 9)),
new MeshUpdateError("index 3 outside 3"),
);
assert.deepEqual(mesh.snapshot(), before);
});
test("layout changes are rejected before version advances", () => {
const mesh = new DynamicMesh(vertices, indices);
assert.throws(
() => mesh.updateVertices(vertices.slice(0, -3)),
/vertex layout changed/,
);
assert.equal(mesh.vertexVersion, 0);
assert.equal(mesh.uploadedVersion, 0);
});
运行证据
探针运行于 Node.js v26.5.0,使用内置测试运行器,无新增依赖。测试通过 3、失败 0、跳过 0,测试器耗时 39.197 毫秒,进程退出码为 0。该结果证明提交与失败合同,不代表引擎、驱动或总线性能。
$ cd $WORK_DIR/.tmp/daily-labs/2026-07-24-2000
$ npm test
✔ vertex commit updates bounds before one GPU upload (1.17325ms)
✔ invalid index rejects candidate without mutating committed state (0.240542ms)
✔ layout changes are rejected before version advances (0.069375ms)
tests 3
pass 3
fail 0
skipped 0
duration_ms 39.196917
exit_code=0
$ npm run probe
{"input":{"vertices":4,"indices":6},
"committed":{"vertexVersion":1,"uploadedVersion":0,
"bounds":{"min":[-2,1,-1],"max":[2,3,1]}},
"uploads":[{"uploaded":true,"version":1},
{"uploaded":false,"version":1}],
"events":[{"type":"gpu-upload","floats":12}]}
exit_code=0
演进边界
当前更新的时间复杂度为 O(v+i),空间为 O(v):v 是浮点数数量,i 是索引数。完整复制换取简单的原子提交,适用于单块、固定布局的动态网格。出现大网格局部雕刻、多个写入线程或上传窗口无法容纳整缓冲时,才应演进为脏区间、双缓冲与作业句柄;触发指标应是实测上传预算或复制时长超限,而非顶点数量猜测。即使分块,Bounds 与对应顶点版本仍须共同发布。
架构结论
动态 Mesh 的正确更新单位不是一个顶点数组,而是“顶点、可见域、版本”的一致性快照。候选阶段多一次复制,牺牲了原地改写的最低内存占用,却把索引错误限制在提交边界之外,并让 GPU 上传成为可判定的幂等动作。后续接入真实渲染引擎时应保留这一接口合同:剔除只读取已提交 Bounds,上传只消费已提交版本,任何局部更新方案都不得拆开二者的所有权。