《游戏引擎实现原理》10.1 材质模型与参数

把材质拆成「模型(参数声明)+ 实例(参数值)」两层,按 std140 算出每个参数的字节偏移并打包成 uniform buffer;再在 headless Chromium 里把它绑进管线、渲染一帧、回读中心像素,断言它就是 baseColor。

10.1 材质模型与参数

从这一节起进入《渲染引擎》的材质层。渲染器走到这里,几何、纹理、管线都已就位,剩下的问题是:「同一个网格,为什么这块是石头、那块是苔藓?」 答案是材质——它把一组参数值和一段着色器程序绑在一起,交给绘制调用。

这一节先把材质拆成两层:模型(MaterialModel,声明「有哪些参数、什么类型、默认值多少」)和实例(MaterialInstance,只写「我要覆盖哪几个值」)。两层分离的好处是显而易见的:一百块石头共用一个模型,各自只覆盖几个颜色。

本节产出一个完整可跑的 src/render/material/material-model.ts(L1,Node 直跑),以及一个把参数真正送进 GPU 的 L2 脚本。文中所有偏移、字节数、回读像素都来自本机实跑。

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

本卷只做「在 helio 的 RHI 上从零搭出来、每节跑出真实产物」,渲染算法与多引擎对照不重推,一律外链:

既有文章它负责什么本节只做什么
/graphics-shader-compilation-pipeline/着色器编译流水线的原理与 Unreal / Unity 对照材质参数如何布局进 uniform 并绑定
/graphics-texture-memory/纹理与显存的组织方式(本节不涉及纹理,参数只有标量与向量)
/graphics-math-foundations/向量与矩阵的数学推导只算参数偏移与字节打包,不推导公式

材质模型:声明与实例分离

一个 MaterialModel 就是一张参数表。每个参数有名字、类型和默认值。类型不是任意 TS 类型,而是对齐规则不同的存储类别:float 占 4 字节、vec2 占 8、vec3 占 12 但必须从 16 的倍数开始、vec4 / color 占 16。

这几种类型的字节数与对齐,正好对上 WGSL 里 uniform 结构体的布局规则(也就是常说的 std140)。于是布局算法只有一句话:依次给每个参数找偏移——先把游标对齐到该类型的 align,记下偏移,再把游标推进该类型的 size;全部排完,整个结构体大小补到 16 的倍数。

参数排布为什么必须和 shader 里 struct 的成员顺序一致?因为 GPU 不知道名字。它只会按「你声明的偏移」去读字节;偏移算错一位,读到的就是隔壁参数的浮点,而且不报错。这就是卷一 4.3《对齐、padding 与 TypedArray 上的真实布局》在渲染侧的又一次落地。

resolveParams 负责把实例的覆盖值盖到模型默认值上:实例没写的用默认,写了未知名字的直接抛错——材质图里打错一个参数名,应该在加载期就炸,而不是在屏幕上悄悄少一个效果。

可运行产物:解析、对齐与打包(L1)

下面是完整的 src/render/material/material-model.ts。它只用纯计算:算偏移、解析覆盖、按偏移把浮点写进一块 Uint8Array。

// file: src/render/material/material-model.ts
// tier: L1
// verify: node src/render/material/material-model.ts
//
// helio 渲染卷 10.1:材质模型与参数。
// 一个材质 = 模型(参数声明,可复用)+ 实例(参数值,可覆盖)。
// 本文件把两者解析成一组浮点,并按 std140 对齐算好每个参数在
// uniform buffer 里的字节偏移,供 GPU 侧 struct 直接对上。

export type ParamType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'color';

type TypeInfo = { readonly size: number; readonly align: number; readonly comps: number };

/** 参数类型表:size 是占用字节,align 是 std140 要求的起始对齐。 */
export const PARAM_TYPES: Readonly<Record<ParamType, TypeInfo>> = {
  float: { size: 4, align: 4, comps: 1 },
  vec2: { size: 8, align: 8, comps: 2 },
  vec3: { size: 12, align: 16, comps: 3 },
  vec4: { size: 16, align: 16, comps: 4 },
  color: { size: 16, align: 16, comps: 4 },
};

export type MaterialParam = { readonly name: string; readonly type: ParamType; readonly value: readonly number[] };
export type MaterialModel = { readonly id: string; readonly params: readonly MaterialParam[] };
export type MaterialInstance = {
  readonly modelId: string;
  readonly overrides: Readonly<Record<string, readonly number[]>>;
};

export const roundUp = (n: number, a: number): number => Math.ceil(n / a) * a;

export type ParamSlot = { readonly name: string; readonly type: ParamType; readonly offset: number };

/** 依次排布:每个参数的 offset 先对齐到自身 align,整体再补到 16 的倍数。 */
export function layoutParams(model: MaterialModel): { slots: ParamSlot[]; size: number } {
  const slots: ParamSlot[] = [];
  let cursor = 0;
  for (const p of model.params) {
    const info = PARAM_TYPES[p.type];
    if (p.value.length !== info.comps) {
      throw new RangeError(`${p.name}: ${p.type} 需要 ${info.comps} 个分量,实为 ${p.value.length}`);
    }
    cursor = roundUp(cursor, info.align);
    slots.push({ name: p.name, type: p.type, offset: cursor });
    cursor += info.size;
  }
  return { slots, size: roundUp(cursor, 16) };
}

/** 解析参数值:实例覆盖优先,未覆盖的回落模型默认值。未知名字直接报错。 */
export function resolveParams(model: MaterialModel, instance: MaterialInstance): Map<string, number[]> {
  const out = new Map<string, number[]>();
  for (const p of model.params) {
    const v = instance.overrides[p.name] ?? p.value;
    out.set(p.name, v.slice());
  }
  for (const k of Object.keys(instance.overrides)) {
    if (!out.has(k)) throw new RangeError(`实例覆盖了模型未声明的参数 ${k}`);
  }
  return out;
}

/** 打包成 uniform buffer 的字节视图,未使用的 padding 保持 0。 */
export function packUniforms(
  model: MaterialModel,
  values: ReadonlyMap<string, readonly number[]>,
): { bytes: Uint8Array; size: number; slots: ParamSlot[] } {
  const { slots, size } = layoutParams(model);
  const bytes = new Uint8Array(size);
  const dv = new DataView(bytes.buffer);
  for (const s of slots) {
    const v = values.get(s.name);
    if (!v) throw new RangeError(`缺少参数值 ${s.name}`);
    for (let k = 0; k < v.length; k++) dv.setFloat32(s.offset + k * 4, v[k]!, true);
  }
  return { bytes, size, slots };
}

// ---- 直接运行本文件时打印布局与打包结果 ----
if (typeof process !== 'undefined' && (process as unknown as { argv?: readonly string[] }).argv?.[1]?.endsWith('material-model.ts')) {
  const foliage: MaterialModel = {
    id: 'glimmer-isle/foliage',
    params: [
      { name: 'baseColor', type: 'color', value: [0.35, 0.62, 0.28, 1.0] },
      { name: 'roughness', type: 'float', value: [0.7] },
      { name: 'metallic', type: 'float', value: [0.0] },
      { name: 'uvScale', type: 'vec2', value: [1.0, 1.0] },
      { name: 'tint', type: 'vec3', value: [1.0, 1.0, 1.0] },
    ],
  };
  const inst: MaterialInstance = {
    modelId: foliage.id,
    overrides: { baseColor: [0.82, 0.44, 0.18, 1.0], uvScale: [3.0, 2.0] },
  };
  const { slots, size } = layoutParams(foliage);
  const values = resolveParams(foliage, inst);
  const packed = packUniforms(foliage, values);
  console.log(`model   ${foliage.id}`);
  for (const s of slots) console.log(`slot    ${s.name.padEnd(10)} ${s.type.padEnd(6)} @${s.offset}`);
  console.log(`size    ${size}B  packed ${packed.bytes.byteLength}B`);
  console.log(`baseColor resolved ${values.get('baseColor')!.join(', ')}`);
  console.log(`uvScale   resolved ${values.get('uvScale')!.join(', ')}`);
}

在 /tmp/enginebook 里 node src/render/material/material-model.ts(本机实测):

model   glimmer-isle/foliage
slot    baseColor  color  @0
slot    roughness  float  @16
slot    metallic   float  @20
slot    uvScale    vec2   @24
slot    tint       vec3   @32
size    48B  packed 48B
baseColor resolved 0.82, 0.44, 0.18, 1
uvScale   resolved 3, 2

五个参数的偏移是 0 / 16 / 20 / 24 / 32。注意 tint 是 vec3:它的数据只有 12 字节(32..44),但因为 vec3 的对齐是 16,整个结构体要补到 16 的倍数,于是 size = 48——44 之后那 4 个字节是 padding,打包时保持 0。baseColor 与 uvScale 被实例覆盖成了 0.82, 0.44, 0.18 和 3, 2,其余回落默认值。

可运行产物:把参数送进 GPU 并回读像素(L2)

CPU 侧算对只是一半。真正的验收是让 GPU 按这份布局读回来。下面是与上面偏移一一对应的着色器 shaders/material-flat.wgsl:struct Material 的成员顺序、类型与偏移必须和 layoutParams() 完全一致。

// file: shaders/material-flat.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/material/material-l2.ts

// 与 src/render/material/material-model.ts 的 std140 布局一一对应的 uniform struct。
// 成员顺序、类型、偏移必须和 layoutParams() 算出来的完全一致,否则读到的是乱码。

struct Material {
  baseColor: vec4<f32>,   // @0
  roughness: f32,         // @16
  metallic: f32,          // @20
  uvScale: vec2<f32>,     // @24
  tint: vec3<f32>,        // @32
};

@group(0) @binding(0) var<uniform> mat: Material;

// 覆盖全屏的大三角形:只用 vertex_index,无需顶点缓冲。
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
  let x = f32((i << 1u) & 2u) * 2.0 - 1.0;
  let y = f32(i & 2u) * 2.0 - 1.0;
  return vec4<f32>(x, y, 0.0, 1.0);
}

@fragment
fn fs() -> @location(0) vec4<f32> {
  // tint 默认 (1,1,1),因此输出即 baseColor,方便逐通道回读断言。
  let albedo = mat.baseColor.rgb * mat.tint;
  return vec4<f32>(albedo, mat.baseColor.a);
}

L2 脚本 src/render/material/material-l2.ts 直接复用 material-model.ts:解析、打包、写进 uniform buffer、建管线、渲染到一张 64×64 的 rgba8unorm 离屏纹理,最后 copyTextureToBuffer 回读中心像素。

// file: src/render/material/material-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/material/material-l2.ts
//
// 把 10.1 的材质参数真正送进 GPU:用 material-model.ts 解析并打包一份
// std140 uniform,绑到 shaders/material-flat.wgsl,渲染一帧到离屏纹理,
// 再把中心像素回读出来,断言它就是 baseColor。

import {
  layoutParams, packUniforms, resolveParams,
  type MaterialInstance, type MaterialModel,
} from './material-model.ts';

export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result {
  readonly checks: L2Check[];
  readonly data: Record<string, unknown>;
}

const MODEL: MaterialModel = {
  id: 'glimmer-isle/foliage',
  params: [
    { name: 'baseColor', type: 'color', value: [0.35, 0.62, 0.28, 1.0] },
    { name: 'roughness', type: 'float', value: [0.7] },
    { name: 'metallic', type: 'float', value: [0.0] },
    { name: 'uvScale', type: 'vec2', value: [1.0, 1.0] },
    { name: 'tint', type: 'vec3', value: [1.0, 1.0, 1.0] },
  ],
};

export async function runL2(): Promise<L2Result> {
  const checks: L2Check[] = [];
  const data: Record<string, unknown> = {};
  const push = (name: string, ok: boolean, detail: unknown): void => {
    checks.push({ name, ok, detail: String(detail) });
  };

  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('requestAdapter() returned null');
  const device = await adapter.requestDevice();

  const wgsl = await (await fetch('/shaders/material-flat.wgsl')).text();
  const module = device.createShaderModule({ code: wgsl });
  const info = await module.getCompilationInfo();
  const errors = info.messages.filter((m) => m.type === 'error');
  push('wgsl 0 error', errors.length === 0, errors.map((m) => m.message).join(' | '));

  // 实例覆盖 baseColor 与 uvScale,其余回落模型默认值。
  const inst: MaterialInstance = {
    modelId: MODEL.id,
    overrides: { baseColor: [0.82, 0.44, 0.18, 1.0], uvScale: [3.0, 2.0] },
  };
  const values = resolveParams(MODEL, inst);
  const packed = packUniforms(MODEL, values);
  const { size } = layoutParams(MODEL);
  data.uniformSize = size;
  push('uniform 大小 = 48', packed.size === 48, packed.size);

  const ubuf = device.createBuffer({ size: packed.size, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(ubuf, 0, packed.bytes);

  const format = 'rgba8unorm';
  const W = 64, H = 64;
  const tex = device.createTexture({
    size: { width: W, height: H },
    format,
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
  });

  const pipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: { module, entryPoint: 'vs' },
    fragment: { module, entryPoint: 'fs', targets: [{ format }] },
    primitive: { topology: 'triangle-list' },
  });
  const bindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: ubuf } }],
  });

  const enc = device.createCommandEncoder();
  const pass = enc.beginRenderPass({
    colorAttachments: [{
      view: tex.createView(),
      clearValue: { r: 0, g: 0, b: 0, a: 1 },
      loadOp: 'clear',
      storeOp: 'store',
    }],
  });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.draw(3);
  pass.end();

  const bytesPerRow = W * 4; // 256,满足 copyTextureToBuffer 的 256 对齐
  const rb = device.createBuffer({ size: bytesPerRow * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  enc.copyTextureToBuffer({ texture: tex }, { buffer: rb, bytesPerRow }, { width: W, height: H });
  device.queue.submit([enc.finish()]);

  await rb.mapAsync(GPUMapMode.READ);
  const px = new Uint8Array(rb.getMappedRange().slice(0));
  rb.unmap();
  const off = (H / 2) * bytesPerRow + (W / 2) * 4;
  const got = [px[off]!, px[off + 1]!, px[off + 2]!, px[off + 3]!];
  const want = [0.82, 0.44, 0.18, 1.0].map((c) => Math.round(c * 255));
  data.pixel = got;
  data.want = want;
  push('中心像素 = baseColor(unorm8, ±1)', got.every((v, i) => Math.abs(v - want[i]!) <= 1),
    `${got.join(',')} vs ${want.join(',')}`);

  device.destroy();
  return { checks, data };
}

node tools/run-l2.mjs src/render/material/material-l2.ts(headless Chromium + SwiftShader,本机实测):

PASS  wgsl 0 error
PASS  uniform 大小 = 48  48
PASS  中心像素 = baseColor(unorm8, ±1)  209,112,46,255 vs 209,112,46,255

回读到的中心像素是 209,112,46,255。0.82 × 255 = 209.1、0.44 × 255 = 112.2、0.18 × 255 = 45.9——GPU 按我们排的偏移读 baseColor、经 tint = (1,1,1) 原样输出,四舍五入到 unorm8 后逐通道对上。只要 struct Material 少一个成员或顺序颠倒,这里读出来就会是另一个参数的浮点,而不是报错——所以 L2 的像素断言是这节最实在的一道防线。

小结

这一节把「材质」落成了三样东西:一张类型表(字节数与对齐)、一个布局算法(偏移与总大小),以及一条绑定约定(struct 成员顺序 ↔ 参数声明顺序)。参数能正确地在 CPU 与 GPU 之间对齐传递,材质才有资格谈「外观」。

但材质不止参数。同一份材质要在「有法线贴图」和「没法线贴图」、「带顶点色」和「不带顶点色」等许多开关组合下都能跑——这就是着色器变体。下一节写出一个 WGSL 的宏展开器,把一份模板按特性开关裁剪成不同的程序。

阅读导航:上一节:回到目录 · 下一节:10.2 着色器变体与宏 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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