11.3 着色器调试与错误定位(getCompilationInfo)
着色器写错时的体验,和普通 TS 代码完全不同:TS 有类型检查器指到具体行,WGSL 的错误却往往是一行「no matching call to …」的天书。更麻烦的是,经过 11.1 的 include 展开后,编译器看到的是一份拼接好的大文件,行号根本对不上你正在编辑的那个文件。这一节把这条链路补全:真跑 getCompilationInfo(),再把诊断还原回原始文件。
本节产出
src/render/shader/diagnostics.ts(L1,vitest 直跑)与一个 L2 脚本:真跑一次好/坏两份源码的编译诊断,并把错误定位回被 include 的库文件。
getCompilationInfo 给了什么
createShaderModule() 只告诉你「建好了」或抛异常,真正有用的信息在 await module.getCompilationInfo() 里。它返回一组 GPUCompilationMessage,每条带这些字段:
| 字段 | 含义 |
|---|---|
type | error / warning / info |
message | 文本描述,可能多行(含候选函数列表) |
lineNum / linePos | 1 基的行号、列号 |
offset / length | 在源码字符串里的字符偏移与长度 |
注意 lineNum 是 1 基,offset 是 0 基;message 常常是多行的——比如「no matching call」会列出所有候选函数。这些字段就是定位的全部原料。
可运行产物:真跑一次诊断
下面这个 L1 模块把原始诊断映射回源文件,并提供计数与格式化。
// file: src/render/shader/diagnostics.ts
// tier: L1
// verify: npx vitest run src/render/shader/diagnostics.test.ts
//
// helio 渲染卷:把 getCompilationInfo() 的原始诊断映射回「原始文件:行:列」。
// 展开 include 之后,报错行号指向的是拼接后的大文件;这里用 11.1 产出的行映射
// 把它还原成用户真正编辑的那个文件,编辑器里才能一键跳转。
import type { LineMapping } from './include-resolver.ts';
export type DiagnosticType = 'error' | 'warning' | 'info';
/** getCompilationInfo() 返回的一条原始消息(字段名与 WebGPU 一致)。 */
export interface RawDiagnostic {
readonly type: string;
readonly message: string;
readonly lineNum: number;
readonly linePos: number;
readonly offset: number;
readonly length: number;
}
/** 映射回源文件之后的诊断。 */
export interface LocatedDiagnostic {
readonly severity: DiagnosticType;
readonly file: string;
readonly line: number;
readonly column: number;
readonly message: string;
}
function asType(t: string): DiagnosticType {
return t === 'error' || t === 'warning' ? t : 'info';
}
/** 把一条原始诊断按行映射还原到源文件位置。 */
export function mapDiagnostic(d: RawDiagnostic, lines: readonly LineMapping[]): LocatedDiagnostic {
const lm = lines[d.lineNum - 1];
return {
severity: asType(d.type),
file: lm ? lm.file : '<generated>',
line: lm ? lm.inLine : d.lineNum,
column: d.linePos,
message: d.message,
};
}
/** 单行文本形式:`error: shaders/lib/color.wgsl:3:10: 消息`。 */
export function formatDiagnostic(d: LocatedDiagnostic): string {
return `${d.severity}: ${d.file}:${d.line}:${d.column}: ${d.message}`;
}
/** 按严重级别计数。 */
export function summarize(messages: readonly RawDiagnostic[]): {
errors: number; warnings: number; infos: number;
} {
let errors = 0, warnings = 0, infos = 0;
for (const m of messages) {
const t = asType(m.type);
if (t === 'error') errors++;
else if (t === 'warning') warnings++;
else infos++;
}
return { errors, warnings, infos };
}
/** 只要有 error 就返回 true;供编译流程决定是否中止。 */
export function hasErrors(messages: readonly RawDiagnostic[]): boolean {
return summarize(messages).errors > 0;
}
npx vitest run src/render/shader/diagnostics.test.ts 本机实跑 Tests 5 passed,覆盖了「还原到库文件」「还原到根文件」「越界回退到 <generated>」以及按级别计数。
诊断的原料来自真实的编译。下面这份 shaders/lib/tonemap.wgsl 是好源码,被 include 进根模块后 getCompilationInfo() 0 error。
// file: shaders/lib/tonemap.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/shader/diagnostics-l2.ts
// 色调映射:Reinhard,把 HDR 值压回 [0,1)。
fn reinhard(c: vec3<f32>) -> vec3<f32> {
return c / (c + vec3<f32>(1.0));
}
把库文件里的一行改成错的(return pow(c);,少传一个参数),错误就出现了。这行是故意写错的片段,展示用,不参与编译:
// snippet: 故意写错的库文件(少传一个参数)
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
return pow(c);
}
L2 脚本把好/坏两份源码都喂给 createShaderModule() 并取诊断。它 import 的正是上面的 resolveIncludes() 与 diagnostics.ts,一次跑通「展开 → 编译 → 映射」整条链。
// file: src/render/shader/diagnostics-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/shader/diagnostics-l2.ts
//
// 11.3 的 L2 验收:真跑 getCompilationInfo()。
// 一份好源码要求 0 error;一份在「被 include 的库文件」里故意写错的源码,
// 拿到带行号列号的诊断,再用 11.1 的行映射还原回原始文件:行:列。
import { resolveIncludes } from './include-resolver.ts';
import { formatDiagnostic, hasErrors, mapDiagnostic, summarize, type RawDiagnostic } from './diagnostics.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 GOOD_COLOR = `// shaders/lib/color.wgsl
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
return pow(c, vec3<f32>(2.2));
}`;
const BAD_COLOR = `// shaders/lib/color.wgsl
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
return pow(c);
}`;
const ROOT = `// shaders/island.wgsl
#include "lib/color.wgsl"
#include "lib/tonemap.wgsl"
@fragment
fn fs() -> @location(0) vec4<f32> {
return vec4<f32>(reinhard(srgb_to_linear(vec3<f32>(0.5))), 1.0);
}`;
const TONEMAP = `// shaders/lib/tonemap.wgsl
fn reinhard(c: vec3<f32>) -> vec3<f32> {
return c / (c + vec3<f32>(1.0));
}`;
function toRaw(m: GPUCompilationMessage): RawDiagnostic {
return {
type: m.type, message: m.message,
lineNum: m.lineNum, linePos: m.linePos, offset: m.offset, length: m.length,
};
}
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 rootFile = { path: 'shaders/island.wgsl', text: ROOT };
// 好源码:color.wgsl 与 tonemap.wgsl 都干净
const good = resolveIncludes(rootFile, new Map([
['shaders/lib/color.wgsl', GOOD_COLOR],
['shaders/lib/tonemap.wgsl', TONEMAP],
]));
const goodMod = device.createShaderModule({ code: good.code });
const goodInfo = await goodMod.getCompilationInfo();
const goodMsgs = goodInfo.messages.map(toRaw);
data.goodSummary = summarize(goodMsgs);
push('好源码 0 error', !hasErrors(goodMsgs), JSON.stringify(data.goodSummary));
// 坏源码:错误在 include 进来的库文件里
const bad = resolveIncludes(rootFile, new Map([
['shaders/lib/color.wgsl', BAD_COLOR],
['shaders/lib/tonemap.wgsl', TONEMAP],
]));
const badMod = device.createShaderModule({ code: bad.code });
const badInfo = await badMod.getCompilationInfo();
const badMsgs = badInfo.messages.map(toRaw);
data.badSummary = summarize(badMsgs);
push('坏源码报出 error', hasErrors(badMsgs), JSON.stringify(data.badSummary));
const located = badMsgs.filter((m) => m.type === 'error').map((m) => mapDiagnostic(m, bad.lines));
data.located = located.map(formatDiagnostic);
data.rawFirst = badMsgs.find((m) => m.type === 'error') ?? null;
push('诊断还原到库文件而非拼接大文件',
located.length > 0 && located[0]!.file === 'shaders/lib/color.wgsl',
located[0] ? formatDiagnostic(located[0]) : 'none');
push('还原后的行号落在库文件内 (1..3)',
located.length > 0 && located[0]!.line >= 1 && located[0]!.line <= 3,
located[0] ? String(located[0]!.line) : 'none');
device.destroy();
return { checks, data };
}
本机实跑(SwiftShader,node tools/run-l2.mjs src/render/shader/diagnostics-l2.ts):
goodSummary {errors: 0, warnings: 0, infos: 0}
badSummary {errors: 1, warnings: 0, infos: 0}
rawFirst lineNum=4 linePos=10 offset=105 length=6
located error: shaders/lib/color.wgsl:3:10: no matching call to 'pow(vec3<f32>)'
好源码 0 error,坏源码精确报出 1 条 error。rawFirst 是展开后的坐标:行 4、列 10,offset 105、length 6(正好是 pow(c) 六个字符)。而 located 已经把它还原成 shaders/lib/color.wgsl:3:10——你正在编辑的那个文件。
把错误定位回原始文件
关键就在「行 4」变「行 3」这一步。展开后的源码里,根文件第 1 行之后紧跟着 color.wgsl 的四行,于是 color.wgsl 的第 3 行落在展开源码的第 4 行。mapDiagnostic() 用 11.1 记录的 LineMapping[] 查一下表,就把「大文件第 4 行」翻译成「color.wgsl 第 3 行」。列号无需换算——include 是整行替换,同一行内列偏移不变。
这条链路是编辑器体验的底座:错误面板点一下,光标跳到 color.wgsl 的第 3 行第 10 列,而不是跳到某个用户从没见过的拼接文件。message 里的候选函数列表原样保留,方便直接看出该传几个参数。
实现上有两个细节值得注意。其一,column 直接沿用 linePos,因为 include 是整行替换,不会在一行中间插入字符,列偏移天然不变。其二,如果某个行号落在没有映射的位置(比如编译器为「模块末尾缺少入口点」报错,指向的是文件尾),mapDiagnostic() 会回退到 <generated> 而不是给出一个错误的文件路径——宁可标成「生成代码」,也不能指错文件。
小结
getCompilationInfo() 是 WGSL 世界里唯一的「编译器反馈通道」。用好它只要两步:把 type/lineNum/linePos 结构化出来,再用 include 的行映射还原到原始文件。helio 把这两步收进 diagnostics.ts,编译失败时直接给出一串可点击的 文件:行:列。
值得一提的是,warning 与 info 也不要丢。它们不影响编译成功,却常常提前预告问题——比如某个 uniform 成员被声明了却没用到。把三类消息都收进同一条通道,热重载时顺手打印出来,很多小问题在变成 bug 之前就被看见了。
到此,第 11 章「材质与着色器(二)」收尾:11.1 把多个 WGSL 文件拼成一个模块,11.2 从模块里的 struct 反射出参数布局,11.3 在模块编译失败时把错误指回你编辑的那一行。下一章进入光照:有了材质与着色器变体这套地基,终于可以往场景里放第一盏灯。
阅读导航:上一节:11.2 反射驱动的参数绑定 · 下一节:12.1 光照模型与着色路径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。