Guide

GuideEffectsCustom mesh shaders

Custom mesh shaders

Attach a custom GLSL or WGSL shader to a Mesh through a MeshMaterial for backend-portable visual effects.

Advanced~7 min read

Before you start

Custom mesh shaders

A Mesh normally renders with the engine’s built-in batch shader — textured or untextured, vertex-colored or uniform-tinted, lit with the mesh’s own tint and blend mode. That covers most use cases. When you need something the default shader does not do — procedural displacement, custom lighting, multi-texture mixing, a Shadertoy-style effect mapped onto geometry — you attach a custom MeshMaterial.

A custom look is two objects. A Shader holds the immutable shader text — one program for WebGL2 (GLSL), one for WebGPU (WGSL). A MeshMaterial wraps that Shader together with a map of uniform values, extra textures, and a blend mode. One Shader can back many materials, and one MeshMaterial can be shared across multiple meshes; the renderer caches the compiled program/pipeline per shader source and reuses it for every material that references it.

When to use a mesh material vs. a filter

A custom ShaderFilter applies a post-processing effect to a drawable’s final rendered output — it works in screen space on the full viewport or on the drawable’s rendered texture. A MeshMaterial replaces the drawable’s vertex and fragment stages entirely. The distinction matters:

  • Use a filter when the effect is screen-space — blur, color grade, CRT scanlines, vignette.
  • Use a MeshMaterial when the effect is geometry-space — vertex displacement, per-vertex animation, custom UV mapping, or when the fragment shader needs to compute its own world position from interpolated attributes.
  • Use both together: a MeshMaterial on the mesh for geometry effects, plus a filter on its parent container for post-processing.

Construction

Build a Shader from the shader text, then wrap it in a MeshMaterial with the initial uniform values. At least one language source is required on the Shader. Pass glsl for WebGL2, wgsl for WebGPU, or both for cross-backend meshes:

import { MeshMaterial, Shader } from '@codexo/exojs';

const waveMaterial = new MeshMaterial({
    shader: new Shader({
        glsl: {
            vertex: `
                #version 300 es
                layout(location = 0) in vec2 a_position;
                layout(location = 1) in vec2 a_texcoord;
                layout(location = 2) in vec4 a_color;

                uniform mat3 u_projection;
                uniform mat3 u_translation;
                uniform float u_time;

                out vec2 v_texcoord;
                out vec4 v_color;

                void main() {
                    vec2 pos = a_position;
                    pos.y += sin(pos.x * 0.05 + u_time) * 10.0;

                    gl_Position = vec4((u_projection * u_translation * vec3(pos, 1.0)).xy, 0.0, 1.0);
                    v_texcoord = a_texcoord;
                    v_color = a_color;
                }
            `,
            fragment: `
                #version 300 es
                precision mediump float;

                in vec2 v_texcoord;
                in vec4 v_color;

                uniform vec4 u_tint;
                uniform sampler2D u_texture;

                out vec4 fragColor;

                void main() {
                    vec4 texColor = texture(u_texture, v_texcoord);
                    fragColor = texColor * v_color * u_tint;
                }
            `,
        },
        wgsl: `
            struct VertexInput {
                @location(0) position: vec2<f32>,
                @location(1) texcoord: vec2<f32>,
                @location(2) color: vec4<f32>,
            };

            struct VertexOutput {
                @builtin(position) position: vec4<f32>,
                @location(0) texcoord: vec2<f32>,
                @location(1) color: vec4<f32>,
            };

            struct MeshUniforms {
                projection: mat3x3<f32>,
                translation: mat3x3<f32>,
                tint: vec4<f32>,
            };

            struct UserUniforms {
                time: f32,
            };

            @group(0) @binding(0) var<uniform> u_mesh: MeshUniforms;
            @group(1) @binding(0) var u_texture: texture_2d<f32>;
            @group(1) @binding(1) var u_sampler: sampler;
            @group(2) @binding(0) var<uniform> u_user: UserUniforms;

            @vertex
            fn vertexMain(in: VertexInput) -> VertexOutput {
                var out: VertexOutput;
                var pos = in.position;
                pos.y += sin(pos.x * 0.05 + u_user.time) * 10.0;

                let clip = u_mesh.projection * u_mesh.translation * vec3<f32>(pos, 1.0);
                out.position = vec4<f32>(clip.xy, 0.0, 1.0);
                out.texcoord = in.texcoord;
                out.color = in.color;
                return out;
            }

            @fragment
            fn fragmentMain(in: VertexOutput) -> @location(0) vec4<f32> {
                let tex = textureSample(u_texture, u_sampler, in.texcoord);
                return tex * in.color * u_mesh.tint;
            }
        `,
    }),
    uniforms: { u_time: 0 },
});

The shader compiles lazily on first draw. If the active backend has no source for the given language, the renderer throws a clear error — it does not silently fall back to the default shader.

Vertex layout

The vertex layout is fixed and matches the default mesh shader. Custom vertex shaders must declare these attributes at the specified locations:

GLSL:

layout(location = 0) in vec2 a_position;   // from mesh.vertices
layout(location = 1) in vec2 a_texcoord;   // from mesh.uvs (or 0,0 when absent)
layout(location = 2) in vec4 a_color;      // from mesh.colors (or 1,1,1,1 when absent)

WGSL:

struct VertexInput {
    @location(0) position: vec2<f32>,
    @location(1) texcoord: vec2<f32>,
    @location(2) color: vec4<f32>,
};

The renderer supplies the vertex stream from mesh.vertices, mesh.uvs, and mesh.colors at these locations before every draw, same as the built-in shader.

Auto-bound uniforms

The renderer automatically sets four uniforms when the shader declares them. You do not need to supply them in MeshMaterial.uniforms:

Uniform GLSL declaration WGSL binding Source
u_projection uniform mat3 u_projection; u_mesh.projection in @group(0) Active view’s projection matrix
u_translation uniform mat3 u_translation; u_mesh.translation in @group(0) Mesh’s global transform (position + rotation + scale + origin)
u_tint uniform vec4 u_tint; u_mesh.tint in @group(0) mesh.tint as RGBA in 0–1
u_texture uniform sampler2D u_texture; u_texture + u_sampler in @group(1) mesh.texture bound to slot 0

You can omit any of them. If your fragment shader ignores the texture and samples nothing, leave u_texture undeclared and the renderer skips it. The auto-bound uniforms are detected by name — the shader declares them, the renderer provides them.

User uniforms

Anything you put in MeshMaterial.uniforms is set after the auto-binds. Mutate the uniforms map between frames to drive animated effects:

update(delta: Seconds) {
    this.waveMaterial.uniforms.u_time = this.clock.elapsedSeconds;
    this.waveMaterial.uniforms.u_waveAmp = 10 + this.detector.pulse * 15;
}

Accepted uniform value types:

  • number — maps to a 1-component float
  • [number, number] / [number, number, number] / [number, number, number, number] — maps to vec2/3/4
  • Float32Array / Int32Array — passed as raw array uniform data
  • Texture / RenderTexture — bound to texture slots starting at slot 1 (slot 0 is the mesh’s own texture)

In WGSL, user uniforms belong to @group(2). Scalar/vector/matrix uniforms declared as a struct are packed into @group(2) @binding(0). Each Texture/RenderTexture uniform claims its own binding, declaration order, with its sampler at the next binding index.

Declaring uniforms on the shader source

The record above is the manual mode: you write the GLSL declarations, you write the WGSL struct, and you keep both in step with the values you pass. Declare the uniforms on the Shader instead and the engine owns all three. It computes one byte layout, generates the GLSL block and the WGSL struct from it, and hands the material typed accessors:

import { Matrix, MeshMaterial, Shader, UniformStruct, UniformType } from '@codexo/exojs';

const waveShader = new Shader({
    uniforms: {
        u_time: UniformType.Float,
        u_wave: new UniformStruct({
            amplitude: { type: UniformType.Float, default: 12 },
            frequency: UniformType.Float,
        }),
        u_uvTransform: UniformType.Mat3,
    },
    glsl: { vertex: '/* vertex source */', fragment: '/* fragment source */' },
    wgsl: '/* wgsl source */',
});

const waveMaterial = new MeshMaterial({ shader: waveShader });
const uvTransform = new Matrix();

waveMaterial.uniforms.u_time.set(1.5);
waveMaterial.uniforms.u_wave.amplitude.set(18);
waveMaterial.uniforms.u_uvTransform.set(uvTransform.rotate(0.4, 0.5, 0.5));

Neither shader body declares those uniforms. Both read them through an instance named uniforms, which the generated declaration supplies:

GLSL:

float wave = sin(a_position.x * uniforms.u_wave.frequency + uniforms.u_time) * uniforms.u_wave.amplitude;

WGSL:

let wave = sin(input.position.x * uniforms.u_wave.frequency + uniforms.u_time) * uniforms.u_wave.amplitude;

What this buys you over the record:

  • Unknown names and wrong shapes are compile errors, and in a development build they throw with the field path, the expected type and what arrived.
  • The layout is std140 on both backends, so a nested struct, a mat3 or an array of vec4 lands at the same offsets under WebGL2 and WebGPU. The record path instead gives every name its own 16-byte slot on WebGPU, which you have to reproduce by hand in WGSL.
  • Values live in the material’s own buffer with a revision, so an unchanged material uploads nothing per frame, and a Shader shared by many materials shares the declaration without sharing their values.
  • default on a field is the value a fresh material starts from. Everything else starts at zero, matrices included.

Declare uniformBlocks instead of uniforms for several named blocks; each takes the next binding of @group(2), and textures follow after them. Textures are bindings rather than block fields, so a material that declares a schema passes them in textures rather than in uniforms.

Mesh Typed UniformsOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Matrix, Mesh, MeshMaterial, type RenderingContext, Scene, type Seconds, Shader, UniformStruct, UniformType } from '@codexo/exojs';

const UV_GRID = assets.technical.filtering.uvGrid256;
const SIZE = 420;

// The declaration is the single source of truth for names, types and layout.
// The engine lays the block out once - a nested struct aligned to 16 bytes, a
// mat3 as three 16-byte columns - and generates a matching GLSL block and WGSL
// struct, so neither shader body declares a user uniform of its own.
const shader = new Shader({
  uniforms: {
    time: UniformType.Float,
    warp: new UniformStruct({ amplitude: UniformType.Float, frequency: UniformType.Float }),
    uvTransform: UniformType.Mat3,
  },
  glsl: {
    vertex: `#version 300 es
layout(location = 0) in vec2 a_position;
layout(location = 1) in vec2 a_texcoord;
uniform mat3 u_projection;
uniform mat3 u_translation;
out vec2 v_uv;
void main() {
    float wave = sin(a_position.x * uniforms.warp.frequency + uniforms.time) * uniforms.warp.amplitude;
    vec3 world = u_translation * vec3(a_position.x, a_position.y + wave, 1.0);
    gl_Position = vec4((u_projection * world).xy, 0.0, 1.0);
    v_uv = (uniforms.uvTransform * vec3(a_texcoord, 1.0)).xy;
}`,
    fragment: `#version 300 es
precision mediump float;
uniform sampler2D u_texture;
uniform vec4 u_tint;
in vec2 v_uv;
out vec4 fragColor;
void main() { fragColor = texture(u_texture, v_uv) * u_tint; }`,
  },
  wgsl: `
struct MeshUniforms {
    projection: mat3x3<f32>,
    translation: mat3x3<f32>,
    tint: vec4<f32>,
};
@group(0) @binding(0) var<uniform> u_mesh: MeshUniforms;
@group(1) @binding(0) var u_texture: texture_2d<f32>;
@group(1) @binding(1) var u_sampler: sampler;

struct VertexInput {
    @location(0) position: vec2<f32>,
    @location(1) texcoord: vec2<f32>,
    @location(2) color: vec4<f32>,
};

struct VertexOutput {
    @builtin(position) position: vec4<f32>,
    @location(0) uv: vec2<f32>,
};

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
    var out: VertexOutput;
    let wave = sin(input.position.x * uniforms.warp.frequency + uniforms.time) * uniforms.warp.amplitude;
    let world = u_mesh.translation * vec3<f32>(input.position.x, input.position.y + wave, 1.0);
    let clip = u_mesh.projection * world;
    out.position = vec4<f32>(clip.xy, 0.0, 1.0);
    out.uv = (uniforms.uvTransform * vec3<f32>(input.texcoord, 1.0)).xy;
    return out;
}

@fragment
fn fragmentMain(in: VertexOutput) -> @location(0) vec4<f32> {
    return textureSample(u_texture, u_sampler, in.uv) * u_mesh.tint;
}`,
});

class MeshTypedUniformsScene extends Scene {
  private mesh!: Mesh;
  private material = new MeshMaterial({
    shader,
    uniforms: { warp: { amplitude: 18, frequency: 0.02 }, uvTransform: Matrix.identity },
  });
  private uvRotation = new Matrix();
  private time = 0;

  override init(): void {
    const { width, height } = this.app;
    const half = SIZE / 2;

    this.mesh = new Mesh({
      vertices: new Float32Array([-half, -half, half, -half, half, half, -half, half]),
      uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
      indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
      texture: this.loader.get(UV_GRID),
      material: this.material,
    });
    this.mesh.setPosition((width / 2) | 0, (height / 2) | 0);
  }

  override update(delta: Seconds): void {
    this.time += delta;

    // Each write goes straight into the material's own block and moves its
    // revision; a value that did not change costs no upload at all.
    this.material.uniforms.time.set(this.time);
    this.material.uniforms.warp.amplitude.set(18 + Math.sin(this.time * 0.7) * 10);
    this.material.uniforms.uvTransform.set(this.uvRotation.set(1, 0, 0, 0, 1, 0, 0, 0, 1).rotate(this.time * 0.3, 0.5, 0.5));
  }

  override draw(context: RenderingContext): void {
    context.render(this.mesh);
  }
}

const app = new Application({
  scenes: { MeshTypedUniformsScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
});

await app.start(MeshTypedUniformsScene);

Attaching to a Mesh

Pass a MeshMaterial in the mesh constructor’s material option:

import { Mesh, MeshMaterial, Shader } from '@codexo/exojs';

// A minimal stand-in material — see the Construction section for the full
// cross-backend shader. Only the wiring into `Mesh` matters here.
const material = new MeshMaterial({
    shader: new Shader({
        glsl: {
            vertex: `
                #version 300 es
                layout(location = 0) in vec2 a_position;
                uniform mat3 u_projection;
                uniform mat3 u_translation;
                void main() {
                    gl_Position = vec4((u_projection * u_translation * vec3(a_position, 1.0)).xy, 0.0, 1.0);
                }
            `,
            fragment: `
                #version 300 es
                precision mediump float;
                uniform vec4 u_tint;
                out vec4 fragColor;
                void main() { fragColor = u_tint; }
            `,
        },
    }),
    uniforms: { u_time: 0 },
});

const mesh = new Mesh({
    material,
    vertices: new Float32Array([
         0,   0,
       100,   0,
         0, 100,
    ]),
    uvs: new Float32Array([
        0, 0,
        1, 0,
        0, 1,
    ]),
});

The mesh’s material is set at construction and is read-only afterward. It renders with the same transform, tint, blend mode, filters, masks, and cacheAsTexture behavior as a default mesh. Only the GPU program changes.

Sharing and lifecycle

One MeshMaterial can be attached to many meshes, and one Shader can back many materials. The renderer compiles one program (WebGL2) or pipeline (WebGPU) per unique Shader, then reuses it across every material that references that source.

Call material.destroy() to release cached GPU resources on every backend the material was used on. After destroy, the material can still be reused — renderers will recompile on the next draw — but the typical pattern is to drop the reference.

When a mesh is destroyed, its MeshMaterial is left untouched. The material outlives the mesh unless you destroy it explicitly. This means a single material can survive scene transitions.

Detecting uniform drift

When you write shaders for both GLSL and WGSL, keeping uniform names synchronized across languages becomes a manual task. The reflection helpers live on the Shader, reachable through material.shader. getDeclaredUniforms() reflects uniform declarations from each language’s source via lightweight regex parsing. Companion method detectUniformDrift() compares the two and reports names that appear in only one language:

import { MeshMaterial } from '@codexo/exojs';

function reportUniformDrift(material: MeshMaterial): void {
    const drift = material.shader.detectUniformDrift();
    console.log(drift.onlyInGlsl); // for example: ['u_waveAmp']
    console.log(drift.onlyInWgsl); // for example: ['u_displacement']
}

Auto-bound uniforms (u_projection, u_translation, u_tint, u_texture, u_mesh) are excluded from the comparison — they are intentionally declared differently across languages. Use detectUniformDrift in a CI check to catch typos and mismatches:

import assert from 'node:assert';

const drift = waveMaterial.shader.detectUniformDrift();
assert.deepStrictEqual(drift.onlyInGlsl, [], 'GLSL-only uniforms found');
assert.deepStrictEqual(drift.onlyInWgsl, [], 'WGSL-only uniforms found');

Reflection is best-effort regex parsing, not a full GLSL/WGSL grammar — it serves CI and editor tooling, not runtime binding decisions.

Where to go next

For screen-space shader effects — blur, color grade, CRT scanlines, vignette — the complementary approach is Filters, which covers ShaderFilter as a post-processing pass. To build the multi-pass render chain those filters fit into, see Post-processing.