メインコンテンツまでスキップ

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 #if substitution

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

  1. Use regular defines for conditional compilation with #ifdef and #if
  2. Use injection defines {NAME} when you need direct string replacement in non-preprocessor contexts
  3. Prefer engine chunks when available to ensure compatibility across platforms