2.3 管线状态对象与着色器模块
有了 buffer,接下来要回答「怎么把这些顶点画成三角形」。在固定管线时代,这是个全局开关大杂烩;在现代图形 API 里,它被压成了一个不可变对象——管线状态对象(PSO, Pipeline State Object)。你一次性声明「顶点长什么样、图元怎么连、深度怎么测、颜色怎么混」,设备据此编译出一条 GPU 可执行管线。
PSO 的另外半边是着色器模块(ShaderModule)。PSO 说「怎么画」,着色器说「每个顶点/像素算什么」。两者一起提交,设备才肯给你一条能用的管线。
管线状态对象:把「怎么画」钉死
下面是 helio 的 PSO 描述符。它是 L3(仅类型):只声明字段,不含创建逻辑,命令行不可复现,需在浏览器或编辑器里手动核对。
// file: src/render/pipeline/pipeline.ts
// tier: L3
//
// 管线状态对象(PSO)与着色器模块(ShaderModule)的描述符:把「怎么解释顶点、
// 怎么光栅化、深度/混合怎么算、绑定长什么样」全部钉死。
// 仅类型,命令行不可复现,需在浏览器/编辑器里手动核对。
import type { RhiTextureFormatName } from '../rhi/rhi.ts';
export const PrimitiveTopology = {
TriangleList: 'triangle-list', TriangleStrip: 'triangle-strip',
LineList: 'line-list', PointList: 'point-list',
} as const;
export type PrimitiveTopologyName =
(typeof PrimitiveTopology)[keyof typeof PrimitiveTopology];
export const VertexFormat = {
Float32: 'float32', Float32x2: 'float32x2', Float32x3: 'float32x3',
Float32x4: 'float32x4', Uint8x4: 'uint8x4',
} as const;
export type VertexFormatName = (typeof VertexFormat)[keyof typeof VertexFormat];
export interface VertexAttribute {
readonly shaderLocation: number;
readonly offset: number;
readonly format: VertexFormatName;
}
export interface VertexBufferLayout {
readonly arrayStride: number;
readonly stepMode: 'vertex' | 'instance';
readonly attributes: readonly VertexAttribute[];
}
export interface BlendComponent {
readonly srcFactor: string;
readonly dstFactor: string;
readonly operation: 'add' | 'subtract' | 'min' | 'max';
}
export interface BlendState {
readonly color: BlendComponent;
readonly alpha: BlendComponent;
}
export interface ColorTargetState {
readonly format: RhiTextureFormatName;
readonly blend?: BlendState;
readonly writeMask?: number;
}
export interface DepthStencilState {
readonly format: RhiTextureFormatName;
readonly depthWriteEnabled: boolean;
readonly depthCompare: 'less' | 'less-equal' | 'always';
}
/** 绑定布局:PSO 声明它需要哪些 binding,供反射与校验用。 */
export interface BindGroupLayoutEntry {
readonly binding: number;
readonly visibility: 'vertex' | 'fragment' | 'compute';
readonly kind: 'uniform-buffer' | 'storage-buffer' | 'sampler' | 'texture';
}
export interface PipelineStateDesc {
readonly label: string;
readonly vertex: {
readonly module: string;
readonly entryPoint: string;
readonly buffers: readonly VertexBufferLayout[];
};
readonly fragment?: {
readonly module: string;
readonly entryPoint: string;
readonly targets: readonly ColorTargetState[];
};
readonly primitive: {
readonly topology: PrimitiveTopologyName;
readonly cullMode: 'none' | 'front' | 'back';
readonly frontFace: 'ccw' | 'cw';
};
readonly depthStencil?: DepthStencilState;
readonly multisample: { readonly count: number };
readonly bindGroupLayouts: readonly (readonly BindGroupLayoutEntry[])[];
}
export interface ShaderModuleDesc {
readonly label: string;
readonly language: 'wgsl' | 'glsl-es';
readonly source: string;
}
export interface ShaderDiagnostic {
readonly severity: 'error' | 'warning' | 'info';
readonly lineNum: number;
readonly linePos: number;
readonly message: string;
}
几个字段值得单独点一下。vertex.buffers 里的 arrayStride 是相邻两个顶点隔多少字节,attributes 里每个 offset 又描述这个字段在顶点内的偏移——两者对不上,GPU 就会读到错位的字节。primitive.topology 决定这些顶点是连成三角形、线段还是点。depthStencil 和 blend 是可选的两组开关:有深度就按 depthCompare 测,要透明就按 blend 混。bindGroupLayouts 是 PSO 对「我需要哪些资源绑定」的声明,反射与校验都读它。
着色器模块:WGSL 源码与编译诊断
着色器是 PSO 的两半。helio 的着色器放在 shaders/*.wgsl,下面这份是前向渲染的最小可用版本,L2:
// file: shaders/forward.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/pipeline/shader-module.ts
// helio 前向渲染的最小着色器:顶点带位置与颜色,直接透传给片元。
struct VertexOut {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec4<f32>,
}
@vertex
fn vs_main(
@location(0) position: vec3<f32>,
@location(1) color: vec4<f32>,
) -> VertexOut {
var out: VertexOut;
out.clip_position = vec4<f32>(position, 1.0);
out.color = color;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
return in.color;
}
@location(0) / @location(1) 正好对上 PSO 里 shaderLocation: 0 的位置、shaderLocation: 1 的颜色——顶点布局和着色器入口是同一份契约的两端,对不上就是一片黑。
这份着色器刻意做到最小:没有 uniform、没有纹理,只把顶点颜色原样透传。它要证明的不是「能画得多好看」,而是从 WGSL 源码到设备可执行模块这条路是通的。几条 WGSL 侧的约定值得先记住:
@builtin(position)是顶点着色器必须输出的内建变量,clip space 坐标。@location(n)的编号必须和 PSO 里的shaderLocation一一对应。- 入口函数名(
vs_main/fs_main)就是 PSO 里entryPoint填的那个字符串。
可运行产物:真编译一次,再故意写错
WGSL 是模板字符串里的代码,tsc 管不到它。真正兜底的是 WebGPU 自己的编译器。下面这段 L2 把 shaders/forward.wgsl 读进来编译,再编译一份故意写错的源码,对比两者的诊断。
// file: src/render/pipeline/shader-module.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/pipeline/shader-module.ts
//
// 真编译 WGSL:从 shaders/forward.wgsl 读入好源码,要求 getCompilationInfo()
// 0 error;再故意写错一处,要求拿到带行号、列号的诊断。最后用这份模块
// 建一条 render pipeline,验证 PSO 描述符被设备接受。
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result {
readonly checks: L2Check[];
readonly data: Record<string, unknown>;
}
// 故意把符号名写错,用来验证诊断能定位到行列。
const BAD_WGSL = `
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(missing_symbol, 0.0, 0.0, 1.0);
}
`;
async function compileErrors(device: GPUDevice, code: string): Promise<string[]> {
const module = device.createShaderModule({ code });
const info = await module.getCompilationInfo();
return info.messages
.filter((m) => m.type === 'error')
.map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
}
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 goodWgsl = await (await fetch('/shaders/forward.wgsl')).text();
const goodErrors = await compileErrors(device, goodWgsl);
data.goodErrors = goodErrors;
push('好源码 0 error', goodErrors.length === 0, goodErrors.length);
const badErrors = await compileErrors(device, BAD_WGSL);
data.badErrors = badErrors;
push('坏源码报错 >= 1', badErrors.length >= 1, badErrors.length);
push('诊断带行号列号', badErrors.length >= 1, badErrors[0] ?? 'no diagnostic');
const format = navigator.gpu.getPreferredCanvasFormat();
const module = device.createShaderModule({ code: goodWgsl });
device.pushErrorScope('validation');
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module,
entryPoint: 'vs_main',
buffers: [{
arrayStride: 28,
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
{ shaderLocation: 1, offset: 12, format: 'float32x4' },
],
}],
},
fragment: { module, entryPoint: 'fs_main', targets: [{ format }] },
primitive: { topology: 'triangle-list', cullMode: 'back', frontFace: 'ccw' },
});
const scoped = await device.popErrorScope();
push('render pipeline 建成功且无校验错误', pipeline !== null && scoped === null,
scoped ? scoped.message : 'ok');
data.targetFormat = format;
device.destroy();
return { checks, data };
}
在 headless Chromium(本地 http 服务 + --enable-unsafe-webgpu)里实跑的结果:
PASS 好源码 0 error 0
PASS 坏源码报错 >= 1 1
PASS 诊断带行号列号 4:20 unresolved value 'missing_symbol'
PASS render pipeline 建成功且无校验错误 ok
好源码拿到 0 条错误,坏源码拿到 1 条:4:20 unresolved value 'missing_symbol'——第 4 行、第 20 列,正好指到那个写错的符号。这就是 getCompilationInfo() 的价值:它不抛异常,而是给你一份带坐标的清单,让你能把错误直接映射回编辑器行号。最后那条 createRenderPipeline 也在 pushErrorScope('validation') 里通过,说明这份 PSO 描述符被设备接受了。
这里还有一条工程经验:getCompilationInfo() 是异步的,且必须在模块进入管线之前调用才有意义——一旦模块被 createRenderPipeline 消费,诊断就不再回头找你。所以 helio 把「编译 + 读诊断」固定成资源加载阶段的一步,任何一条 error 都直接中止加载,绝不让坏着色器溜进运行时。
对照一下诊断本身的形状:它不是一句「着色器编译失败」,而是 4:20 unresolved value 'missing_symbol' 这样行、列、原因三件套。上层要做的只是把它原样透传给日志或编辑器,不必自己解析文本。
到这一节为止,RHI 的三块地基都齐了:能力表(能做什么)、资源对象(用什么数据)、管线状态(怎么画)。下一章开始发命令——把管线、绑定、绘制装进 command encoder,再 submit 给队列。
阅读导航:上一节:2.2 资源对象:缓冲、纹理、采样器 · 下一节:3.1 命令编码器与渲染通道 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。