Shader Preprocessor
Before shaders are compiled, PlayCanvas applies a C-style preprocessor to the source code. This allows you to manage shader variations, conditionally include code, and inject values. The preprocessor works with GLSL, WGSL, and compute shaders.
Preprocessor Directives
The following directives are supported:
Define and Undefine
#define FEATURE_ENABLED
#define MAX_LIGHTS 4
#define MULTIPLIER 2.5
#undef FEATURE_ENABLED
Defines can be simple flags (no value) or have associated values.
Conditional Compilation
#ifdef FEATURE_ENABLED
// Code included only if FEATURE_ENABLED is defined
#endif
#ifndef FEATURE_ENABLED
// Code included only if FEATURE_ENABLED is NOT defined
#endif
#if defined(FEATURE_A) && defined(FEATURE_B)
// Code included if both FEATURE_A and FEATURE_B are defined
#endif
#if MAX_LIGHTS > 2
// Code included if MAX_LIGHTS is greater than 2
#endif
If-Else-Elif Chains
#if QUALITY == 0
// Low quality path
#elif QUALITY == 1
// Medium quality path
#else
// High quality path
#endif
Logical Operators
The preprocessor supports && (AND), || (OR), and ! (NOT) operators:
#if defined(FEATURE_A) && !defined(FEATURE_B)
// FEATURE_A is defined but FEATURE_B is not
#endif
#if defined(FEATURE_A) || defined(FEATURE_B)
// At least one of FEATURE_A or FEATURE_B is defined
#endif
Comparison Operators
Supported operators: ==, !=, <, <=, >, >=
#if MAX_LIGHTS >= 4
// 4 or more lights supported
#endif
#if QUALITY != 0
// Not low quality
#endif
Include Directive
The #include directive inserts content from registered shader chunks:
#include "chunkName"
For example, to include engine-provided chunks:
#include "gammaPS"
#include "tonemappingPS"
Registering Custom Shader Chunks
The recommended way to add custom includes is by registering them with ShaderChunks. This allows you to provide both GLSL and WGSL versions, and the engine automatically uses the appropriate one:
// Get the shader chunks for each language
const chunksGLSL = pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL);
const chunksWGSL = pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_WGSL);
// Register your custom chunk in both languages
chunksGLSL.set('myUtilsPS', `
float myHelper(float x) {
return x * 2.0;
}
`);
chunksWGSL.set('myUtilsPS', `
fn myHelper(x: f32) -> f32 {
return x * 2.0;
}
`);
Once registered, use the chunk in your shaders with #include:
#include "myUtilsPS"
void main() {
float result = myHelper(0.5);
}
Looped Includes
You can include a chunk multiple times with a loop counter:
#define LIGHT_COUNT 4
#include "lightPS, LIGHT_COUNT"
This includes lightPS four times, with {i} in the chunk replaced by 0, 1, 2, 3.
Injection Defines vs Regular Defines
The preprocessor supports two types of defines, distinguished by their syntax:
Regular Defines
Regular defines work with preprocessor directives like #ifdef and #if:
#define FEATURE_ENABLED
#define MAX_LIGHTS 4
#ifdef FEATURE_ENABLED
// This code is included
#endif
#if MAX_LIGHTS > 2
// This code is included
#endif
The GLSL language natively supports using defines in array sizes and similar contexts:
#define SAMPLE_COUNT 8
float samples[SAMPLE_COUNT];
However, WGSL does not support this—use injection defines with the {NAME} syntax instead.
Injection Defines (Curly Brace Syntax)
Injection defines use curly braces {NAME} and perform direct string replacement throughout the shader source (excluding preprocessor directive lines):
#define {WORKGROUP_SIZE} 64
@compute @workgroup_size({WORKGROUP_SIZE}, 1, 1)
fn main() {
var<workgroup> data: array<f32, {WORKGROUP_SIZE}>;
}
After preprocessing, this becomes:
@compute @workgroup_size(64, 1, 1)
fn main() {
var<workgroup> data: array<f32, 64>;
}
Injection defines are particularly useful for:
- WGSL workgroup sizes (which must be compile-time constants)
- Values that need to appear in non-preprocessor contexts
- Parameterizing shader code that doesn't support
#ifsubstitution
Supplying Defines to Shaders
ShaderMaterial Defines
For ShaderMaterial, use setDefine():
material.setDefine('USE_TEXTURE', true);
material.setDefine('MAX_LIGHTS', '4');
Shader Definition Defines
When creating shaders programmatically (rather than using ShaderMaterial), you can supply defines.
Vertex and Fragment Shaders
Use ShaderUtils.createShader() to create vertex/fragment shaders with defines:
const shader = pc.ShaderUtils.createShader(device, {
uniqueName: 'MyShader',
vertexGLSL: vertexCodeGLSL,
vertexWGSL: vertexCodeWGSL,
fragmentGLSL: fragmentCodeGLSL,
fragmentWGSL: fragmentCodeWGSL,
vertexDefines: definesMap,
fragmentDefines: definesMap
});
Compute Shaders
Compute shaders are created directly using the Shader class:
const shader = new pc.Shader(device, {
name: 'MyComputeShader',
shaderLanguage: pc.SHADERLANGUAGE_WGSL,
cshader: computeCode,
cincludes: includesMap, // Custom includes for compute shader
cdefines: definesMap // Defines for compute shader
});
Includes Map
The includes map provides content for #include directives:
const includesMap = new Map([
['myChunk', 'float helper() { return 1.0; }'],
['anotherChunk', '// More shader code...']
]);
You can also use engine-provided chunks:
cincludes: pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_WGSL)
Defines Map
The defines map uses the key as the define name (including curly braces for injection defines):
// Regular defines (for #ifdef, #if)
const definesMap = new Map([
['FEATURE_ENABLED', ''], // Flag define (no value)
['MAX_LIGHTS', '4'] // Value define
]);
// Injection defines (for direct replacement)
const definesMap = new Map([
['{WORKGROUP_SIZE}', '64'],
['{TILE_SIZE}', '16']
]);
Best Practices
- Use regular defines for conditional compilation with
#ifdefand#if - Use injection defines
{NAME}when you need direct string replacement in non-preprocessor contexts - Prefer engine chunks when available to ensure compatibility across platforms