GLSL macros can take parameters, as in C: #define SQUARE(x) ((x) * (x)) works in both GLSL ES versions. The bigger win is building shader variants from JavaScript: prepend #define lines to a shared source, and each combination compiles into a specialized program. Without #include, shared chunks are JavaScript strings joined together, which is how Three.js 30,815 assembles its materials from ShaderChunk pieces:
const lighting = `
#define SATURATE(x) clamp((x), 0.0, 1.0)
float lightSum(float strength) {
float total = 0.0;
for (int i = 0; i < NUM_LIGHTS; i++) total += strength * float(i + 1);
return SATURATE(total);
}`;
for (const lights of [1, 4]) {
const defines = `\n#define NUM_LIGHTS ${lights}`;
show('result = vec4(lightSum(0.1), NUM_LIGHTS, 0.0, 0.0);', defines + lighting);
}Output
0.1, 1, 0, 0 1, 4, 0, 0
Parenthesize macro parameters, name macros in capitals, and avoid the reserved GL_ prefix (ANGLE 2,592 answers #define GL_FOO 1 with "macro name is reserved"). Every variant is a separate compile and link, so build them at load time and cache programs by their #define string.
<!doctype html>
<style>
body { margin: 0; font: 12px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; }
.names { display: flex; max-width: 600px; text-align: center; font-family: monospace; font-size: 11px; }
.names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="view" width="600" height="200"></canvas>
<div class="names"><div>NUM_LIGHTS 1</div><div>NUM_LIGHTS 2</div><div>NUM_LIGHTS 4</div><div>NUM_LIGHTS 8</div></div>
<script>
const gl = document.getElementById('view').getContext('webgl2');
const vs = `#version 300 es
void main() { gl_Position = vec4(gl_VertexID == 1 ? 3.0 : -1.0, gl_VertexID == 2 ? 3.0 : -1.0, 0.0, 1.0); }`;
// Shared chunks are plain strings: GLSL has no #include.
const lighting = `
#define SATURATE(x) clamp((x), 0.0, 1.0)
#define SQUARE(x) ((x) * (x))
uniform float uTime;
vec3 lightSum(vec2 p) {
vec3 total = vec3(0.3);
for (int i = 0; i < NUM_LIGHTS; i++) {
float a = uTime + 6.2832 * float(i) / float(NUM_LIGHTS);
vec2 pos = vec2(0.5) + 0.32 * vec2(cos(a), sin(a));
vec3 hue = 0.6 + 0.4 * cos(a + vec3(0.0, 2.1, 4.2));
total += hue * 0.8 / (1.0 + SQUARE(distance(p, pos) * 5.0));
}
return SATURATE(total);
}`;
const main = `
uniform vec2 uOrigin;
out vec4 outColor;
void main() {
vec2 p = (gl_FragCoord.xy - uOrigin) / vec2(150.0, 200.0);
vec3 cover = vec3(0.93, 0.9, 0.84);
if (abs(p.x - 0.5) < 0.3 && abs(p.y - 0.5) < 0.4) cover = vec3(0.3, 0.6, 0.85);
outColor = vec4(cover * lightSum(p), 1.0);
}`;
const cache = new Map(); // programs cached by their #define string
function variant(defines) {
if (cache.has(defines)) return cache.get(defines);
const p = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, vs],
[gl.FRAGMENT_SHADER, '#version 300 es\nprecision mediump float;\n' + defines + lighting + main]]) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(p, s);
}
gl.linkProgram(p);
cache.set(defines, p);
return p;
}
const variants = [1, 2, 4, 8].map(n => variant(`#define NUM_LIGHTS ${n}\n`)); // build at load time
gl.bindVertexArray(gl.createVertexArray());
gl.enable(gl.SCISSOR_TEST);
function frame(ms) {
variants.forEach((p, i) => {
gl.useProgram(p);
gl.uniform1f(gl.getUniformLocation(p, 'uTime'), ms * 0.0008);
gl.uniform2f(gl.getUniformLocation(p, 'uOrigin'), i * 150, 0);
gl.scissor(i * 150 + 3, 0, 144, 200);
gl.drawArrays(gl.TRIANGLES, 0, 3);
});
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>