得益于 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 引擎,存在以下几点特殊情况:
- 依赖模块大多数为内置模块,不存在命名冲突问题,组装 Shader 代码时不需要进行变量名和方法名的替换
- 运行时 Shader 代码发生变动,需要重新构建,此时再进行完整语法分析成本很高
- 运行时 开发者需要注入代码到引擎默认生成的 Shader 代码中
基于以上几点,通常一个 WebGL 引擎都会实现自己的模块化方案。
luma.gl
我们以 luma.gl 为例,来看看其中的 ShaderTools 模块是如何解决这几个问题的。
假如如果我们想使用 color-coding-based 拾取模块,需要提供:
- 使用基于注释的占位符
- 使用该模块方法的调用语句,用于替换占位符
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 引擎来说,则需要根据自身定位制定合适的模块化和兼容方案。