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 着色器变体与宏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。