《游戏引擎实现原理》11.1 WGSL 组织与 include

WGSL 没有 #include,但引擎要把公共函数拆成多个文件复用:本节在 createShaderModule() 之前做一层文本级 include 展开,处理递归、去重与循环检测,并真跑一次编译与像素回读,验证拼接后的模块确实可编译可用。

11.1 WGSL 组织与 include

10.3 把「变体爆炸」按住了,这一节换一个更底层的问题:着色器源码本身怎么组织。helio 的着色器会越来越多——前向 PBR、阴影、后处理、UI 各有一份,但它们都要用同一批函数:颜色空间转换、法线编码、噪声、菲涅尔。WGSL 没有 C 那样的 #include,把这几百行函数复制进每个 .wgsl 是维护灾难。helio 的办法是在 createShaderModule() 之前,自己写一层文本级预处理。

本节产出 src/render/shader/include-resolver.ts(L1,Node/vitest 直跑)与一个 L2 脚本:把展开后的 WGSL 真喂给 createShaderModule(),要求 getCompilationInfo() 0 error,并画一帧回读像素。

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

既有文章它负责什么本节只做什么
/graphics-shader-compilation-pipeline/着色器从源码到二进制的完整编译流水线、各后端差异与缓存只做 WGSL 的 include 展开与模块组织,并真跑一次编译
/graphics-gltf-asset-pipeline/glTF 资产导入与材质、着色器的对接只处理着色器源码的拼接,不碰资产格式
/posts/graphics/图形学原理与多引擎着色器语言对照在 helio 的 RHI 上从零搭这套组织方式

编译原理、SPIR-V 生成属于既有文章的地盘,本卷不重推;这里只回答「在 helio 上怎么把多个 WGSL 文件拼成一个模块,并且能跑出产物」。

换句话说,本节关心的是工程组织——文件怎么拆、路径怎么解析、错误怎么回指——渲染算法本身一概外链。它不产生任何新画面,却是后面每一节都要踩的地基。

这也是本卷一贯的取舍:原理部分已经足够多,缺的是「把它落到能跑的代码上」这一步。

WGSL 没有 include,那就自己做一个

device.createShaderModule() 只吃一个完整的模块字符串。要在「一个模块」里复用「多个文件」的函数,只有两条路:运行时做文本拼接,或构建期做代码生成。helio 选前者——它对热重载友好,也能原样跑在浏览器里。

展开器要处理三种情况,缺一个都会在真实项目里翻车:

  • 递归:被 include 的文件里还能再 include,深度不限;
  • include once:同一个文件被多条路径引用时只展开一次,否则函数重复定义;
  • 环检测:a 引 b、b 又引 a,必须报错而不是死循环。

语法沿用 C 的直觉:#include "lib/color.wgsl",路径相对当前文件所在目录解析,允许 ../。

下面这个 L1 模块把三件事一次做完,并顺带产出「展开后行号 → 原始文件行号」的映射——11.3 定位错误时要靠它。

// file: src/render/shader/include-resolver.ts
// tier: L1
// verify: npx vitest run src/render/shader/include-resolver.test.ts
//
// helio 渲染卷:WGSL 的 #include 预处理。
// WGSL 语言本身没有 include,但引擎需要把公共函数拆成多个文件复用。
// 这里在 createShaderModule() 之前做一层文本级展开:递归拼接、去重(include once)、
// 检测环,并产出「展开后行号 -> 原文件行号」的映射,供 11.3 的错误定位使用。

/** 一个待展开的源文件。 */
export interface SourceFile {
  /** 相对路径,如 `shaders/lib/color.wgsl`。 */
  readonly path: string;
  readonly text: string;
}

/** 展开后某一行的来源。 */
export interface LineMapping {
  /** 展开后源码里的 1 基行号。 */
  readonly outLine: number;
  /** 该行来自哪个源文件。 */
  readonly file: string;
  /** 在源文件里的 1 基行号。 */
  readonly inLine: number;
}

export interface ResolveResult {
  /** 展开后的完整 WGSL,可直接喂给 createShaderModule()。 */
  readonly code: string;
  /** 与 code 每一行一一对应的来源映射(长度 = code 行数)。 */
  readonly lines: LineMapping[];
  /** 按首次出现顺序记录被展开的文件(不含根文件)。 */
  readonly included: string[];
}

const INCLUDE_RE = /^\s*#include\s+"([^"]+)"\s*$/;

/** 把 `a/b/../c` 归一化成 `a/c`,相对路径解析用。 */
export function normalizePath(path: string): string {
  const parts: string[] = [];
  for (const seg of path.split('/')) {
    if (seg === '' || seg === '.') continue;
    if (seg === '..') parts.pop();
    else parts.push(seg);
  }
  return parts.join('/');
}

/** 把 include 目标解析成相对根文件的路径。 */
export function resolveIncludePath(fromFile: string, target: string): string {
  if (target.startsWith('/')) return normalizePath(target);
  const dir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : '';
  return normalizePath(dir ? `${dir}/${target}` : target);
}

/**
 * 递归展开 `#include "..."`。
 * - 同一个文件只展开一次(include once,按首次出现的位置);
 * - 检测环并抛错;
 * - 缺文件抛错,且报出是哪个文件 include 的。
 */
export function resolveIncludes(
  root: SourceFile,
  files: ReadonlyMap<string, string>,
): ResolveResult {
  const out: string[] = [];
  const lines: LineMapping[] = [];
  const included: string[] = [];
  const emitted = new Set<string>();
  const stack: string[] = [];

  const emit = (file: string, inLine: number, text: string): void => {
    out.push(text);
    lines.push({ outLine: out.length, file, inLine });
  };

  const visit = (file: SourceFile): void => {
    stack.push(file.path);
    const srcLines = file.text.split('\n');
    for (let i = 0; i < srcLines.length; i++) {
      const line = srcLines[i]!;
      const m = INCLUDE_RE.exec(line);
      if (m) {
        const target = resolveIncludePath(file.path, m[1]!);
        if (stack.includes(target)) {
          throw new Error(`循环 include: ${[...stack, target].join(' -> ')}`);
        }
        if (emitted.has(target)) continue; // include once
        const text = files.get(target);
        if (text === undefined) {
          throw new Error(`${file.path} 引用了不存在的文件 ${target}`);
        }
        emitted.add(target);
        included.push(target);
        visit({ path: target, text });
        continue;
      }
      emit(file.path, i + 1, line);
    }
    stack.pop();
  };

  emitted.add(root.path);
  visit(root);
  return { code: out.join('\n'), lines, included };
}

/** 展开后行号 -> 原始位置;越界返回 null。 */
export function lookupLine(result: ResolveResult, outLine: number): LineMapping | null {
  return result.lines[outLine - 1] ?? null;
}

本机实跑(npx vitest run src/render/shader/include-resolver.test.ts):Test Files 1 passed、Tests 6 passed。用例覆盖了路径折叠(a/../b)、include once(重复 include 后 srgb_to_linear 只出现 1 次)、环检测抛错、以及缺文件时报出是哪个文件引用的。

展开后的源码长什么样

把几个文件展开,得到的就是要喂给编译器的完整模块。以 L2 里那份根文件为例,展开结果的开头是这样——#include 那一行整个消失,被替换成被引文件的内容:

// shaders/island.wgsl
// shaders/lib/color.wgsl
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
  return pow(c, vec3<f32>(2.2));
}
// shaders/lib/mathx.wgsl
fn luma(c: vec3<f32>) -> f32 {
  return dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
}

struct VSOut { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32> };
...

这正是「行号对不上」的根源:color.wgsl 的第 3 行,到了展开源码里变成了第 4 行。11.3 要做的错误定位,就是把这一步反过来。

边界情况在真实项目里都会遇到,值得单独列一张表:

情况行为为什么
同一文件被 include 两次第二次跳过否则函数重复定义,编译直接失败
include 一个不存在的路径抛错并报出引用者只说「文件不存在」不够,得知道是谁引的
环 include(a→b→a)抛错并打印调用链include once 会让它静默通过,必须显式检测

normalizePath() 还负责折叠 ../,让 lib/../island.wgsl 与 island.wgsl 指向同一个文件——否则同一文件会因写法不同被当成两份,include once 就失效了。

可运行产物:展开后真编译、真渲染

纸上展开不算数,得让 WebGPU 的编译器点头。下面这份 shaders/lib/color.wgsl 是一个独立可编译的库文件——只有函数、没有入口点,在 WGSL 里是合法模块。

// file: shaders/lib/color.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/shader/wgsl-include-l2.ts

// 颜色工具:sRGB 近似转线性(gamma 2.2)。
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
  return pow(c, vec3<f32>(2.2));
}

根文件用 #include 把它和数学库拼起来。根文件带 include、不能单独编译,所以这里只作片段展示(抽取器会跳过 // snippet: 块):

// snippet: 带 #include 的根文件,须先展开才能编译
// shaders/island-material.wgsl
#include "lib/color.wgsl"
#include "lib/mathx.wgsl"

struct VSOut { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32> };

@vertex
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
  var p = array<vec2<f32>, 3>(
    vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0),
  );
  var o: VSOut;
  o.pos = vec4<f32>(p[i], 0.0, 1.0);
  o.uv = p[i] * 0.5 + vec2<f32>(0.5);
  return o;
}

@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
  let lin = srgb_to_linear(vec3<f32>(in.uv, 0.5));
  let y = luma(vec3<f32>(0.2126, 0.7152, 0.0722));
  return vec4<f32>(vec3<f32>(y), 1.0);
}

L2 脚本先用 resolveIncludes() 展开,再 createShaderModule(),最后建管线画一帧。本机实跑(SwiftShader,node tools/run-l2.mjs src/render/shader/wgsl-include-l2.ts):

included   [shaders/lib/color.wgsl, shaders/lib/mathx.wgsl]
codeLines  29
errors     0
center     [143,143,143,255]

included 说明两个库文件都被拼进了最终模块,errors 为 0 说明拼接结果的语法与类型都过。回读的中心像素是灰度 143——它正是 luma(0.2126,0.7152,0.0722) 三个权重的平方和 0.561923 × 255 ≈ 143,说明两个 include 进来的函数都真的参与了计算,不是摆设。

codeLines 是 29,可以对账:根文件 23 行里有两行是 #include,它们被各自的内容替换(4 + 4 行),于是 23 − 2 + 8 = 29。去重在这条路径上没被触发(每个库只 include 一次),但它是正确性而非优化——单测里让根文件重复 include color.wgsl 两次,srgb_to_linear 仍只出现一份;换成「每次 include 都拼一遍」的朴素实现,符号重复定义会让 createShaderModule() 直接报错。

include 的工程约定

落地时有几条经验值:

  • 库文件只放函数,不放入口点。入口点(@vertex/@fragment)只出现在根文件里,否则一个模块会有多个入口点,entryPoint 就歧义了。
  • 展开顺序即依赖顺序。WGSL 的函数可以后向引用,但结构体与全局变量不能——把公共结构体也放进库文件,并放在根文件 include 列表的靠前位置。
  • 缓存展开结果。同一份着色器在变体编译时会被反复展开(见 10.3),按「根文件 + 库文件内容哈希」缓存展开后的字符串,能省掉大量重复的字符串拼接。

还有一点容易被忽略:included 记录的是首次出现顺序,不是字母序。这既决定了拼接后的行号(也就决定了行映射),也决定了结构体定义的先后。调试时看到「某个类型未定义」,第一件事就是看它所在的库文件是不是排在了引用它的文件后面。

如果哪天觉得文本拼接太土,也可以换构建期方案:在打包阶段把 WGSL 也当模块,用 Rollup/Vite 插件在构建时展开,运行时拿到的就是已经拼好的字符串。代价是热重载要重新走一遍构建,换来的是运行时零开销。helio 先用运行时展开,正是为了保留「改一行着色器立刻看到效果」的手感。

小结

WGSL 没有 include,是语言层面的取舍;引擎要在它之上做工程,就必须自己补上这一层。helio 的 resolveIncludes() 只做三件事——递归拼接、include once、环检测——再顺手记下行映射。它很小,但它是后面一切的基础:没有它,公共函数只能复制粘贴;有了它,着色器才像一个可维护的代码库。

这一节把「一个模块」拼出来了,但模块里的 uniform 结构体,CPU 侧还要按同样的内存布局把数据填进去。下一节就做这件事:从 WGSL 里反射出参数布局,让绑定不再手填偏移。

阅读导航:上一节:回到目录 · 下一节:11.2 反射驱动的参数绑定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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