Custom mesh shaders
Attach a custom GLSL or WGSL shader to a Mesh through a MeshMaterial for backend-portable visual effects.
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.
Ship both GLSL and WGSL, or the skipped backend throws
Shaders compile lazily on first draw, and if the active backend has no source for its language the renderer throws — it does not fall back to the default shader. A mesh that works in WebGL2 hard-fails on WebGPU unless you supply both glsl and wgsl.
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
MeshMaterialwhen 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
MeshMaterialon 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/4Float32Array/Int32Array— passed as raw array uniform dataTexture/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.
Catch GLSL/WGSL uniform drift in CI
When a uniform is spelled differently across your two shader sources it silently misbinds on one backend. material.shader.detectUniformDrift() reports names present in only one language — assert both lists empty in CI to catch the typo before it ships.
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
std140on both backends, so a nested struct, amat3or an array ofvec4lands 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
Shadershared by many materials shares the declaration without sharing their values. defaulton 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.
A declared source has no `setUniform`
uniforms on a shader source is a clean break, not an addition: material.uniforms becomes the accessor namespace and material.setUniform is gone from the type. A source without a declaration keeps the record and both languages’ declarations stay yours to write.
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.

