6.1 顶点格式、属性布局与程序化网格
从这一节起我们进入《渲染引擎》的资源层。渲染器要做的第一件事,是描述一个顶点长什么样:它有哪些属性、各占几个字节、在缓冲区里从第几字节开始、对应 WGSL 里的哪个 @location。这一节把「格式 → 偏移 → arrayStride → shaderLocation」写成一条可运行的数据链,再用它程序化生成《微光岛》的一块低多边形地形。
本节产出一个完整可跑的
src/render/mesh/procedural-mesh.ts(L1,Node 直跑),以及一个 headless Chromium 的 L2 上传回读脚本。文中所有顶点数、包围盒、回读差异都来自本机实跑。
本章与站内既有内容的分工
本卷只做「在 helio 的 RHI 上从零搭出来、每节跑出真实产物」,渲染算法本身不重推,一律外链:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-math-foundations/ | 向量/矩阵与投影的数学推导 | 把顶点属性排布进缓冲区并绑到 @location |
/graphics-gltf-asset-pipeline/ | glTF 顶点数据的规范与解析 | 程序化生成顶点数据,并算出它的布局 |
/graphics-texture-memory/ | 纹理与显存的组织方式 | (下一节)纹理格式与 mip 链的字节数 |
顶点格式、arrayStride 与 shaderLocation
一个顶点在 GPU 眼里就是一串字节。WebGPU 用 GPUVertexBufferLayout 告诉它怎么切分这串字节:arrayStride 是「走一个顶点跨多少字节」,attributes[] 里每一项给出 shaderLocation、offset、format。三者的关系是:
format决定该属性占几个字节。float32x3是 12 字节,float32x2是 8 字节,unorm8x4把 4 个分量压进 4 字节。offset是该属性在顶点内的起始字节。它按声明顺序累加,且必须是 4 的倍数。arrayStride是所有属性字节数之和向上取整到 4;offset + bytes ≤ arrayStride必须成立。shaderLocation是这条属性在 WGSL 里的入口:@location(0)的vec3<f32>只会去读shaderLocation: 0的那条属性,与名字无关。
这就是卷一的 4.3《对齐、padding 与 TypedArray 上的真实布局》在渲染侧的落地:那节算的是「结构体字段落在哪个字节」,这节算的是「同一个字节区间怎么描述给 GPU」。两者的坑是同一个——偏移算错,GPU 读出来就是乱码,而且往往不报错。
本节的顶点布局沿用卷一 4.3 的那个结构,只是把颜色换成了法线:position(float32x3)@0、normal(float32x3)@12、uv(float32x2)@24,三者字节数之和正好 32,无需补位,所以 arrayStride = 32。
可运行产物:程序化生成一块低多边形地形
下面是完整的 src/render/mesh/procedural-mesh.ts。它先用一张格式表算出布局,再用一个确定性高度场(径向衰减圆丘 + 整数哈希起伏,全程不碰 Math.random)生成规则网格,最后按 arrayStride 交错打包。
// file: src/render/mesh/procedural-mesh.ts
// tier: L1
// verify: node src/render/mesh/procedural-mesh.ts
//
// helio 渲染卷 6.1:顶点格式、属性布局与程序化网格。
// 纯计算(L1),Node 26 直跑:node src/render/mesh/procedural-mesh.ts
export type VertexFormat = 'float32x2' | 'float32x3' | 'unorm8x4';
export type FormatInfo = { readonly bytes: number; readonly components: number };
/** 顶点格式表:字节数与分量数决定 offset 与 arrayStride。 */
export const FORMATS: Readonly<Record<VertexFormat, FormatInfo>> = {
float32x2: { bytes: 8, components: 2 },
float32x3: { bytes: 12, components: 3 },
unorm8x4: { bytes: 4, components: 4 },
};
export type Attribute = { readonly name: string; readonly shaderLocation: number; readonly format: VertexFormat };
export type AttributeLayout = Attribute & { readonly offset: number };
export type VertexLayout = { readonly attributes: readonly AttributeLayout[]; readonly stride: number };
/**
* 依次排布偏移:本子集所有格式字节数都是 4 的倍数,故 offset 天然对齐 4;
* arrayStride 再向上取整到 4(WebGPU 的硬要求)。
*/
export function layoutVertex(attributes: readonly Attribute[]): VertexLayout {
const seen = new Set<number>();
const laid: AttributeLayout[] = [];
let offset = 0;
for (const a of attributes) {
if (a.shaderLocation < 0 || a.shaderLocation > 15 || seen.has(a.shaderLocation)) {
throw new RangeError(`bad shaderLocation ${a.shaderLocation} (${a.name})`);
}
seen.add(a.shaderLocation);
laid.push({ ...a, offset });
offset += FORMATS[a.format].bytes;
}
return { attributes: laid, stride: (offset + 3) & ~3 };
}
/** 岛屿顶点布局:position@0 / normal@12 / uv@24,stride 32。 */
export const ISLAND_ATTRIBUTES: readonly Attribute[] = [
{ name: 'position', shaderLocation: 0, format: 'float32x3' },
{ name: 'normal', shaderLocation: 1, format: 'float32x3' },
{ name: 'uv', shaderLocation: 2, format: 'float32x2' },
];
export type IslandParams = {
readonly cols: number; readonly rows: number; readonly extent: number;
readonly peak: number; readonly seed: number;
};
export type Mesh = {
readonly positions: Float32Array; readonly normals: Float32Array; readonly uvs: Float32Array;
readonly indices: Uint16Array; readonly vertexCount: number; readonly triangleCount: number;
readonly stride: number; readonly min: readonly number[]; readonly max: readonly number[];
};
/** 确定性整数哈希到 [0,1)。同 (x,y,seed) 永远同值,取代 Math.random。 */
export function hash2(x: number, y: number, seed: number): number {
let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(seed, 1274126177);
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
/** 岛屿高度场:径向衰减圆丘 + 一点确定性起伏。 */
export function islandHeight(x: number, z: number, p: IslandParams): number {
const nx = (x / p.extent) * 2 - 1, nz = (z / p.extent) * 2 - 1;
const f = Math.max(0, 1 - (nx * nx + nz * nz));
return p.peak * f * f + (hash2(Math.round(x * 8), Math.round(z * 8), p.seed) - 0.5) * 0.6 * f;
}
/** 生成岛屿网格:规则网格顶点 + 每格两个三角形,并顺带算包围盒。 */
export function generateIsland(p: IslandParams): Mesh {
const stride = layoutVertex(ISLAND_ATTRIBUTES).stride;
const nx = p.cols + 1, nz = p.rows + 1, vc = nx * nz, tc = p.cols * p.rows * 2;
const positions = new Float32Array(vc * 3), normals = new Float32Array(vc * 3);
const uvs = new Float32Array(vc * 2), indices = new Uint16Array(tc * 3);
const sx = p.extent / p.cols, sz = p.extent / p.rows, dx = sx * 0.5;
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
for (let j = 0; j < nz; j++) for (let i = 0; i < nx; i++) {
const v = j * nx + i, x = i * sx, z = j * sz, y = islandHeight(x, z, p);
positions.set([x, y, z], v * 3);
const nX = islandHeight(x - dx, z, p) - islandHeight(x + dx, z, p), nY = 2 * dx;
const nZ = islandHeight(x, z - dx, p) - islandHeight(x, z + dx, p);
const l = Math.hypot(nX, nY, nZ) || 1;
normals.set([nX / l, nY / l, nZ / l], v * 3);
uvs.set([i / p.cols, j / p.rows], v * 2);
const xyz = [x, y, z];
for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k]!, xyz[k]!); max[k] = Math.max(max[k]!, xyz[k]!); }
}
let t = 0;
for (let j = 0; j < p.rows; j++) for (let i = 0; i < p.cols; i++) {
const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
indices.set([a, c, d, a, d, b], t); t += 6;
}
return { positions, normals, uvs, indices, vertexCount: vc, triangleCount: tc, stride, min, max };
}
/** 按 arrayStride 交错打包进一块 ArrayBuffer,字节布局与 GPU 侧一致。 */
export function packInterleaved(mesh: Mesh): Uint8Array {
const dv = new DataView(new ArrayBuffer(mesh.stride * mesh.vertexCount));
for (let v = 0; v < mesh.vertexCount; v++) {
const o = v * mesh.stride;
for (let k = 0; k < 3; k++) dv.setFloat32(o + k * 4, mesh.positions[v * 3 + k]!, true);
for (let k = 0; k < 3; k++) dv.setFloat32(o + 12 + k * 4, mesh.normals[v * 3 + k]!, true);
for (let k = 0; k < 2; k++) dv.setFloat32(o + 24 + k * 4, mesh.uvs[v * 2 + k]!, true);
}
return new Uint8Array(dv.buffer);
}
// ---- 直接运行本文件时打印统计(Node 26 的 import.meta.main)----
if ((import.meta as { main?: boolean }).main) {
const p: IslandParams = { cols: 15, rows: 15, extent: 32, peak: 3, seed: 7 };
const m = generateIsland(p), l = layoutVertex(ISLAND_ATTRIBUTES);
const f = (n: number) => n.toFixed(4);
console.log(`attrs ${l.attributes.map((a) => `${a.name}@${a.offset}:loc${a.shaderLocation}`).join(' ')}`);
console.log(`stride ${l.stride} packed ${packInterleaved(m).byteLength}B`);
console.log(`verts ${m.vertexCount} tris ${m.triangleCount} indices ${m.indices.length}`);
console.log(`bbox min(${m.min.map(f).join(', ')}) max(${m.max.map(f).join(', ')})`);
}
在 /tmp/enginebook 里 node src/render/mesh/procedural-mesh.ts(本机实测,数值不可移植):
attrs position@0:loc0 normal@12:loc1 uv@24:loc2
stride 32 packed 8192B
verts 256 tris 450 indices 1350
bbox min(0.0000, -0.0055, 0.0000) max(32.0000, 3.1560, 32.0000)
16×16 的网格给出 256 个顶点、450 个三角形(15×15×2)、1350 个索引,整块打包 8192 字节(32 × 256)。包围盒 y 从 -0.0055 到 3.1560:峰顶被 peak = 3 限住,边缘那一点点负值来自哈希起伏——这正是「程序化地形」该有的样子,不是被手填的常量。
可运行产物:上传到 GPU 并逐字节回读
L1 只证明了「CPU 侧算对」。真正的验收是把这份字节原样交给 GPU,再原样拿回来。下面先给出顶点着色器 shaders/island-flat.wgsl:它把 @location(0) 读成位置、@location(2) 读成 uv,正是上面布局的镜像。
// file: shaders/island-flat.wgsl
// tier: L2
// verify: node tools/l2-vertex-upload.mjs
// 俯视正交投影:把程序化岛屿的 xz 平面直接铺到裁剪空间,
// 顶点色取 uv,用于验证 @location 与顶点格式是否对得上。
struct VSOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
) -> VSOut {
var o: VSOut;
o.pos = vec4<f32>(position.x / 16.0 - 1.0, position.z / 16.0 - 1.0, 0.0, 1.0);
o.uv = uv + normal.xy * 0.0; // normal 参与只为占位,避免被优化掉
return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.uv.x, in.uv.y, 0.5, 1.0);
}
tools/l2-vertex-upload.mjs 起一个带 COOP/COEP 的本地 http 服务,在 headless Chromium 里:按同一高度场生成 2×2 地形、按 stride=32 交错打包、writeBuffer 上传、copyBufferToBuffer 回读、逐字节比对,并从回读字节重算包围盒。
// file: tools/l2-vertex-upload.mjs
// tier: L2
// verify: node tools/l2-vertex-upload.mjs
//
// 6.1 的 L2 验收:把一块 2x2 程序化地形按 arrayStride=32 交错打包后上传到
// GPUBuffer,再 copyBufferToBuffer 回读,逐字节比对;并从回读字节重算包围盒
// 与顶点数。另编译 shaders/island-flat.wgsl,要求 getCompilationInfo() 0 error。
// 需 headless Chromium + 本地 http 服务(navigator.gpu 只在安全上下文可用)。
import http from 'node:http';
import { readFileSync } from 'node:fs';
import { chromium } from '/opt/homebrew/lib/node_modules/playwright/index.mjs';
const wgsl = readFileSync(new URL('../shaders/island-flat.wgsl', import.meta.url), 'utf8');
const PAGE = `<!doctype html><meta charset=utf-8>
<script type="module">
const WGSL = ${JSON.stringify(wgsl)};
window.__run = async () => {
const out = {};
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
const cols = 2, rows = 2, extent = 32, stride = 32;
const h = (x, z) => { // 同 procedural-mesh.ts 的确定性高度场
const nx = (x / extent) * 2 - 1, nz = (z / extent) * 2 - 1;
const f = Math.max(0, 1 - (nx * nx + nz * nz));
let s = Math.imul(Math.round(x * 8), 374761393) ^ Math.imul(Math.round(z * 8), 668265263);
s = Math.imul(s ^ (s >>> 13), 1274126177) ^ (s >>> 16);
return 3 * f * f + (((s >>> 0) / 4294967296) - 0.5) * 0.6 * f;
};
const nx = cols + 1, nz = rows + 1, vc = nx * nz, tc = cols * rows * 2;
const bytes = new Uint8Array(stride * vc), dv = new DataView(bytes.buffer);
const mn = [1e9, 1e9, 1e9], mx = [-1e9, -1e9, -1e9];
for (let j = 0; j < nz; j++) for (let i = 0; i < nx; i++) {
const o = (j * nx + i) * stride;
const xyz = [i * (extent / cols), h(i * (extent / cols), j * (extent / rows)), j * (extent / rows)];
for (let k = 0; k < 3; k++) dv.setFloat32(o + k * 4, xyz[k], true); // position @0
dv.setFloat32(o + 12, 0, true); dv.setFloat32(o + 16, 1, true); dv.setFloat32(o + 20, 0, true); // normal @12
dv.setFloat32(o + 24, i / cols, true); dv.setFloat32(o + 28, j / rows, true); // uv @24
for (let k = 0; k < 3; k++) { mn[k] = Math.min(mn[k], xyz[k]); mx[k] = Math.max(mx[k], xyz[k]); }
}
out.mesh = { verts: vc, tris: tc, stride, bytes: bytes.byteLength };
out.cpuBBox = [...mn, ...mx];
const mod = device.createShaderModule({ code: WGSL });
const info = await mod.getCompilationInfo();
out.wgslErrors = info.messages.filter((m) => m.type === 'error').map((m) => m.message);
const vbuf = device.createBuffer({ size: bytes.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC });
device.queue.writeBuffer(vbuf, 0, bytes);
const rb = device.createBuffer({ size: bytes.byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const enc = device.createCommandEncoder();
enc.copyBufferToBuffer(vbuf, 0, rb, 0, bytes.byteLength);
device.queue.submit([enc.finish()]);
await rb.mapAsync(GPUMapMode.READ);
const back = new Uint8Array(rb.getMappedRange().slice(0)); rb.unmap();
let diff = 0;
for (let i = 0; i < bytes.length; i++) if (back[i] !== bytes[i]) diff++;
const rdv = new DataView(back.buffer), rmn = [1e9, 1e9, 1e9], rmx = [-1e9, -1e9, -1e9];
for (let v = 0; v < vc; v++) for (let k = 0; k < 3; k++) {
const c = rdv.getFloat32(v * stride + k * 4, true);
rmn[k] = Math.min(rmn[k], c); rmx[k] = Math.max(rmx[k], c);
}
out.readbackDiff = diff;
out.gpuBBox = [...rmn, ...rmx];
return out;
};
</script>`;
const srv = http.createServer((_req, res) => {
res.setHeader('content-type', 'text/html; charset=utf-8');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
res.end(PAGE);
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
let failures = 0;
const check = (name, ok, detail) => { if (!ok) failures++; console.log(`${ok ? 'PASS' : 'FAIL'} ${name} ${detail ?? ''}`); };
const browser = await chromium.launch({ headless: true,
args: ['--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'] });
try {
const page = await browser.newPage();
await page.goto(`http://127.0.0.1:${srv.address().port}/`);
const r = await page.evaluate(() => window.__run());
console.log('mesh', JSON.stringify(r.mesh));
console.log('cpuBBox', JSON.stringify(r.cpuBBox));
console.log('gpuBBox', JSON.stringify(r.gpuBBox));
check('wgsl.0error', r.wgslErrors.length === 0, `errors=${r.wgslErrors.length}`);
check('readback.identical', r.readbackDiff === 0, `diff=${r.readbackDiff}`);
check('bbox.gpu==cpu(f32)', r.gpuBBox.every((v, i) => Math.abs(v - r.cpuBBox[i]) < 1e-4), '');
} finally {
await browser.close();
srv.close();
}
console.log(`\n=== l2-vertex-upload: ${failures === 0 ? 'ALL PASS' : failures + ' FAILED'} ===`);
process.exit(failures === 0 ? 0 : 1);
本机实跑(SwiftShader):
mesh {"verts":9,"tris":8,"stride":32,"bytes":288}
cpuBBox [0,0,0,32,3.2587510837242006,32]
gpuBBox [0,0,0,32,3.258751153945923,32]
PASS wgsl.0error errors=0
PASS readback.identical diff=0
PASS bbox.gpu==cpu(f32)
两处值得盯住。第一,readbackDiff = 0:上传的 288 字节与回读的 288 字节逐字节相同,说明 arrayStride 与各属性的 offset 描述得没有歧义。第二,gpuBBox 与 cpuBBox 的 y 上限只差 7e-8(3.25875115… vs 3.25875108…)——这不是错误,而是 CPU 用双精度、GPU 缓冲用 float32 存位置带来的存储精度差;用 1e-4 容差比较即可,绝不能拿 === 比两个来自不同精度的浮点。
小结
这一节把「顶点」从概念变成了三样东西:一张格式表(字节数)、一个偏移算法(offset 与 arrayStride),以及一条绑定约定(shaderLocation ↔ @location)。它们合起来回答了渲染器的第一个问题——「这串字节怎么读」。
顶点有了,下一节处理它的搭档:贴在顶点上的纹理。纹理会引入一组全新的取舍——rgba8unorm 还是 srgba8unorm、要不要块压缩(BC / ETC2 / ASTC)、mipmap 怎么生成、显存账怎么算。
阅读导航:上一节:回到目录 · 下一节:6.2 纹理格式、压缩与 mipmap 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。