得益于 Webpack 和 Babel 的工程化思想,使用 JavaScript 开发复杂应用早已不是难事。 那 GLSL 代码一旦复杂起来,也会面临模块化和兼容性的难题。本文将介绍一些现有方案的思路。

GLSL 模块化

利用模块化方案组织功能代码,在一个复杂系统中可以实现代码的复用,也能够提升后续可维护性和扩展性。 JavaScript 模块加载方案在 ES6 之前主要有 CommonJS 和 AMD 两种。 ES Module 作为服务端和浏览器端统一的模块化方案,在编译时就能确定模块间的依赖关系。更多用法可以参考Module 的语法

在 GLSL 中是没有模块化系统的,但是参考或者直接利用 Node.js 的现有生态,glslify 以及一些 WebGL 引擎都实现了自己的模块化方案,下面就让我们来看一下。

glslify

在「WebGL Insights」一书中第 13 章「glslify:A Module System for GLSL」就介绍了这样一种类 Node.js 风格的模块打包工具。 大致实现了递归依赖解析,对源码进行语法解析,重命名变量和函数名,注入依赖,打包生成 Bundle 这几步。

其中依赖解析规则和 Node.js 保持一致,因此同样可以用 npm 管理和发布依赖包。 从模块化语法上看类似 Node.js 中使用的 CommonJS:

#pragma glslify: noise = require(glsl-noise/simplex/2d)

void main() {
  float brightness = noise(gl_FragCoord.xy);
  gl_FragColor = vec4(vec3(brightness), 1.);
}

在导入依赖时使用了预处理指令(preprocesser),借用了其中 pragma 的语义,帮助 glslify 编译器在语法分析时定位:

#pragma allows implementation dependent compiler control. Tokens following #pragma are not subject to preprocessor macro expansion. If an implementation does not recognize the tokens following #pragma, then it will ignore that pragma.

导出依赖只支持匿名导出,例如:

float myFunction(vec3 normal) {
  return dot(vec3(0, 1, 0), normal);
}

#pragma glslify: export(myFunction)

GLSL 可没有 JavaScript 中的闭包,在将依赖内联进代码之前,为了避免多个依赖间变量名和方法名发生冲突,重命名是必不可少的。 例如上述代码最终可能会被转译成这样:

// 内联的 glsl-noise/simplex/2d
float snoise_1_2(vec2 v) {...}

void main() {
  float brightness = snoise_1_2(gl_FragCoord.xy);
  gl_FragColor = vec4(vec3(brightness), 1.);
}

重命名虽然能解决冲突问题,但在某些场景下,我们就是希望在多个模块间共享变量,这时候就需要在导入一个模块时,将共享变量作为参数传入:

int bar; // 需要在模块间共享的变量
#pragma glslify: require('some-module',foo=bar,...)

最后,类似 Webpack 在构建时可以通过 DefinePlugin 完成某些变量的替换,glslify 也实现了简单的插件机制, 例如 glslify-hex 可以在构建时将 16 进制颜色转换成 vec3

gl_FragColor = vec4(#ff0000, 1.0);
// 转译后
gl_FragColor = vec4(vec3(1.0, 0.0, 0.0), 1.0);

此外,针对已有的构建工具和 WebGL 框架,glslify 也有对应的工具链方便接入。 例如志在打包一切静态资源的 Webpack,有了 glslify-loader,也可以直接 import 解析 GLSL 了。 再比如和 Three.js 结合使用,在运行时而非构建阶段生成 shader 代码:

const mat = new THREE.ShaderMaterial({
    vertexShader: glslify('./vert.glsl'),
    fragmentShader: glslify('./frag.glsl'),
    uniforms: {},
})

glslify 为 GLSL 带来了完整的模块化方案,但是否就适合一切应用场景呢?

WebGL 引擎

其实从上面的介绍可以看出 glslify 为了实现一个通用的模块化方案,是需要对 GLSL 及其依赖代码进行完整语法分析的。这样做开销虽然大,但在构建时进行也是没有任何问题的。

但是对于一个 WebGL 引擎,存在以下几点特殊情况:

  1. 依赖模块大多数为内置模块,不存在命名冲突问题,组装 Shader 代码时不需要进行变量名和方法名的替换
  2. 运行时 Shader 代码发生变动,需要重新构建,此时再进行完整语法分析成本很高
  3. 运行时 开发者需要注入代码到引擎默认生成的 Shader 代码中

基于以上几点,通常一个 WebGL 引擎都会实现自己的模块化方案。

luma.gl

我们以 luma.gl 为例,来看看其中的 ShaderTools 模块是如何解决这几个问题的。

假如如果我们想使用 color-coding-based 拾取模块,需要提供:

  1. 使用基于注释的占位符
  2. 使用该模块方法的调用语句,用于替换占位符
new Model(gl, {
    vs,
    fs: `void main() {
        gl_FragColor = vec4(1., 0., 0., 1.);
        // COLOR_FILTERS_HINT
    }`,
    modules: ['picking'] // 模块中包含 picking_filterColor 方法
    inject: {
        'COLOR_FILTERS_HINT': '  gl_FragColor = picking_filterColor(gl_FragColor)'
    }
});

这种采用占位符的方式实现起来十分简单,相比写一个编译器进行语法分析成本小的多。

转译

Babel 让我们能够使用最新甚至是实验性的特性,而不用考虑浏览器兼容性。而在发展相对较慢的 CSS 中,Houdini 也正扮演这样的角色。 同样的,GLSL 也面临着兼容性问题,如何能让使用 GLSL 3.00 ES 编写的 Shader 代码兼容 WebGL 1.00 ES 呢?

luma.gl 给出的方案大致从两个方面入手:开启扩展和语法替换。

开启扩展

WebGL2 的很多新特性在 WebGL1 中需要手动开启扩展。除了在 JS 中使用 gl.getExtension(),在 GLSL 中也需要开启。 因此在特性检测之后需要在 WebGL1 环境下使用 #extension,例如针对 DERIVATIVES 特性:

let versionDefines = `\
#if (__VERSION__ > 120)
    # define DERIVATIVES
#endif // __VERSION
`;
if (hasFeatures(gl, FEATURES.GLSL_DERIVATIVES)) {
    versionDefines += `\
    #ifdef GL_OES_standard_derivatives
    #extension GL_OES_standard_derivatives : enable
    # define DERIVATIVES
    #endif
    `;
  }

这样在后续引入的代码中只需要通过类似 ifdef DERIVATIVES 这样条件预处理指令,就能判断当前环境是否已经可以使用某个特性了。 其实,能使用 polyfill 解决的问题都是相对简单的,真正的难点在于转译 GLSL 3.00 ES 中的高级语法特性。

语法替换

luma.gl 给出了部分 GLSL 3.00 ES语法转换对照表。 在具体实现中,通过字符串替换就能完成一些简单的语法替换。例如针对 Vertex shader 中:

function convertVertexShaderTo100(source) {
    return source
        .replace(/^in\s+/gm, 'attribute ')
        .replace(/^out\s+/gm, 'varying ')
        .replace(/texture\(/g, 'texture2D(');
}

仔细观察正则可以发现只针对每行开头的 in/out 进行替换。 luma.gl 的解释是为了避免 vec2 func(vec2 a, out float result) 这样的情况被错误替换。 但问题是 WebGL2 还并不支持这种函数参数修饰符的写法吧。

其实这种字符串替换的方案总是存在局限性的,不是所有新特性都能简单替换的。 例如一些无法用 polyfill 解决的新增内置方法:texelFetch()transpose() 等等。 因此一旦决定需要兼容 WebGL1,就注定要有所取舍,毕竟 GLSL 在语言特性上不可能像 JavaScript 这么灵活可扩展。

总结

对于 glslify,我的理解是适合在构建阶段就能生成最终 GLSL 的场景下使用,一些用于纯展示的 DEMO 页面完全没问题。 而对于一个 WebGL 引擎来说,则需要根据自身定位制定合适的模块化和兼容方案。

参考资料