《游戏引擎实现原理》6.1 顶点格式、属性布局与程序化网格

把顶点描述成「格式 → 偏移 → arrayStride → shaderLocation」这条可运行的数据链:写出 helio 的顶点布局模块,用它程序化生成《微光岛》一块低多边形地形,并在 headless Chromium 里上传、回读、重算包围盒。

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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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