Skip to content

Custom shaders

A shader asset is GLSL you write yourself, and assigning it to an object replaces the whole material — your fragment shader decides every pixel. Reach for one when the built-in material types can't express the look: a scrolling force field, a hologram, a dissolve, a heat shimmer, cel bands you want exact control over.

For anything a standard surface can already do — colour, metalness, roughness, a texture, transparency — use a material instead. A shader opts out of lighting, shadows and fog, because those are chunks the standard materials compile in and yours does not.

Learn from the kits

The Custom Shaders Kit (Packs ▸ Built-in Kits) ships 8 ready surface shaders — portal vortex, force field, hologram, dissolve, lava, fire, plasma, iridescent — plus a walkable Shader Showcase scene with a working teleport-portal pair. The Post Effects Kit does the same for fullscreen effects. Every shader in both is a normal asset you can open in the Shader Editor and remix.

Making one

Project panel ▸ Create ▸ Shader, then double-click it to open the Shader Editor. It has four tabs: Vertex, Fragment, WebGPU, and Uniforms. A new shader starts with a working lit-ish surface you can edit down.

From the AI or the terminal:

json
[ { "op": "createShader", "name": "Magenta",
    "fragmentSource": "precision mediump float;\nvoid main(){ gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0); }" } ]

Assigning it

Pick the shader in the Inspector's Material dropdown (it lists shader assets under the material types), or set it directly:

json
[ { "op": "setComponent", "name": "Pylon", "component": "meshRenderer",
    "props": { "shaderAssetId": "Magenta" } } ]

A material asset can carry a shader too, in which case every object using that material draws with it.

What's already declared

Galatrix builds a three.js ShaderMaterial, so the usual attributes and matrices are declared for you:

Available
Attributesposition, normal, uv
MatricesprojectionMatrix, modelViewMatrix, modelMatrix, viewMatrix, normalMatrix
CameracameraPosition

Declaring any of those yourself is a duplicate-declaration compile error — leave them out and just use them. This is the same convention every three.js shader snippet on the web follows.

Uniforms

Uniforms come from your source. Declare one and it exists:

glsl
uniform float uSpeed;
uniform vec3  uTint;
uniform sampler2D uMask;

Four are supplied automatically when you declare them, so a shader can react to the object it's on:

UniformValue
uTimeseconds since the game started, advancing every frame
uColorthe entity's own Color from the Inspector
uOpacitythe entity's Opacity
uMapthe entity's texture, if it has one

Default values live on the asset (Shader Editor ▸ Uniforms). Per-object values go on the renderer, so one shader can drive many objects that differ:

json
[ { "op": "setComponent", "name": "Pylon", "component": "meshRenderer",
    "props": { "shaderAssetId": "Forcefield", "shaderUniforms": { "uSpeed": 2.5, "uTint": "#00ffcc" } } } ]

Write numbers for float/int, [x, y, z] for vectors, "#rrggbb" for a colour, and a texture asset id for sampler2D. A script can change them while the game runs — a uniform-only change updates in place without recompiling, so driving one every frame is fine:

js
onUpdate() {
  const hp = this.health / this.maxHealth
  this.entity.setComponent('meshRenderer', { shaderAssetId: 'Forcefield', shaderUniforms: { uSpeed: 8 - 6 * hp } })
}

A worked example

A scanline force field that pulses and fades toward its edges:

glsl
precision mediump float;
uniform float uTime;
uniform vec3  uColor;
uniform float uSpeed;
varying vec2 vUv;

void main() {
  float scan  = sin((vUv.y * 40.0) - uTime * uSpeed) * 0.5 + 0.5;
  float edge  = 1.0 - abs(vUv.x - 0.5) * 2.0;
  gl_FragColor = vec4(uColor * (0.4 + scan * 0.6), edge * 0.8);
}

vUv comes from the vertex shader — the starter vertex source already passes it through. Set the object's Opacity below 1 (or tick Transparent) so the alpha you write is actually blended.

When something doesn't draw

  • A GLSL error is printed to the browser console, naming the shader, the stage and the line in your own source — open the console (F12) and it will say what the driver rejected.
  • If the object turns grey and the console warns about a shader id that no longer exists, the asset was deleted while an object still referenced it. Re-assign or clear it.
  • A shader with no gl_FragColor write, or one that writes alpha 0 on an opaque object, draws nothing — that is your shader running correctly, not a failure to run.

Post effects — a shader over the whole frame

The same shader assets can run as a camera post-effect: a fullscreen pass over the rendered frame, after the built-in Bloom / SSAO / Vignette / Color Grading components. This is how you make your own screen effects — CRT scanlines, underwater ripple, night vision, damage flash, pixelation.

Add the Post Effect component to your Main Camera entity (Add Component ▸ Post Effect), add one or more effects to its stack, and press Play. The stack runs top to bottom — each effect sees the previous one's output, so a grayscale pass followed by a vignette-tint reads differently than the reverse; reorder with the ↑/↓ buttons, and untick an effect to skip it without losing its settings. From the AI / a script:

json
[ { "op": "setComponent", "name": "Main Camera", "component": "postEffect",
    "props": { "effects": [
      { "shaderAssetId": "CRT", "uniforms": { "uStrength": 0.6 } },
      { "shaderAssetId": "NightVision" }
    ] } } ]

The contract is fragment-only — the vertex stage of a fullscreen pass is boilerplate the engine supplies (your asset's vertex source is ignored here). Your fragment gets:

UniformValue
tDiffusethe rendered frame — sample it at vUv
tOriginalthe pristine frame from before the effect stack ran (multi-pass composites — see below)
uDepthscene depth — declaring it makes the engine capture a depth buffer for you (see below)
uCameraNear / uCameraFarthe camera range, for linearizing uDepth
uTimeseconds, advancing every frame
uResolutionthe resolution this pass renders at, in pixels (vec2)

plus anything you declare yourself, with per-map values editable in the component's Uniforms rows — same contract as shaderUniforms on a renderer.

Scene depth

Declare uniform sampler2D uDepth; and the engine renders a depth pass for you (only for stacks that ask). The raw value is non-linear — convert it to world-space distance with the camera range:

glsl
uniform sampler2D uDepth;
uniform float uCameraNear;
uniform float uCameraFar;
float linearDepth(vec2 uv) {
  float z = texture2D(uDepth, uv).x * 2.0 - 1.0;
  return (2.0 * uCameraNear * uCameraFar) / (uCameraFar + uCameraNear - z * (uCameraFar - uCameraNear));
}

That unlocks the depth family: distance fog (the kit's Depth Fog), depth-of-field blends, depth outlines, scanner sweeps. The depth pass re-renders the scene with a cheap depth-only material, so it costs roughly one extra (unshaded) scene draw.

Multi-pass effects

The stack is a pass chain: each entry's tDiffuse is the previous entry's output, so a blur-horizontal → blur-vertical sequence is just two stacked shaders. What makes real multi-pass effects possible is tOriginal — declare uniform sampler2D tOriginal; in any pass and it samples the frame as it stood before the custom stack started. Early passes can then destroy the frame on purpose (keep only the bright pixels, blur them) and the final pass composites the result back onto the untouched image:

glsl
// pass 3 of 3 — the first two reduced the frame to blurred bright spots
uniform sampler2D tDiffuse;    // the blurred brights
uniform sampler2D tOriginal;   // the pristine frame
uniform float uStrength;
varying vec2 vUv;
void main() {
  gl_FragColor = vec4(texture2D(tOriginal, vUv).rgb + texture2D(tDiffuse, vUv).rgb * uStrength, 1.0);
}

That is a separable gaussian bloom — the Post Effects Kit ships it as the three Pro Bloom shaders (add all three to the stack in order).

Each non-final pass can also render at reduced resolution — the per-effect Res dropdown in the Inspector (or scale: 0.5 on the stack entry). A blur at 50% res looks the same for a quarter of the fill cost, which is where multi-pass chains earn their frame budget on weaker hardware; uResolution always reports the resolution the pass actually renders at, so pixel-offset math stays correct. The final pass always renders full-res.

A worked example — animated CRT scanlines with a strength dial:

glsl
precision mediump float;
uniform sampler2D tDiffuse;
uniform float uTime;
uniform vec2  uResolution;
uniform float uStrength;
varying vec2 vUv;

void main() {
  vec4 c = texture2D(tDiffuse, vUv);
  float line = sin(vUv.y * uResolution.y * 1.5 + uTime * 8.0) * 0.5 + 0.5;
  c.rgb *= 1.0 - line * 0.25 * uStrength;               // rolling scanlines
  float d = distance(vUv, vec2(0.5));
  c.rgb *= 1.0 - d * d * 0.8 * uStrength;               // soft CRT corner falloff
  gl_FragColor = c;
}

Post effects draw in play mode (and the standalone / multiplayer builds), not in the edit viewport — press Play to see them, like the built-in camera effects. While playing they stay live: Inspector edits and script setComponent calls (switching stacks, tweaking a uniform) rebuild the chain on the spot — that's what the Post Effects Kit's arrow-key gallery uses.

The WebGPU version

Everything above is GLSL, which is the language of the WebGL 2 renderer. Browsers now offer a second graphics API, WebGPU, and it does not speak GLSL — it speaks WGSL. So a shader asset can carry a second source: the same effect written in WGSL, kept alongside the GLSL, with each renderer picking the one it can use. It is one shader asset with two sources, not two shaders — see Rendering: WebGPU and WebGL 2 for how the two backends fit together.

The WGSL version is optional. Players run WebGL 2, so a GLSL-only shader is a finished shader and its map publishes normally — write one only if you want the game to look right for someone who has chosen WebGPU in their own settings, since a GLSL-only map moves them back to WebGL 2 for it. The publish dialog names any shader without a WebGPU version so you know which, and then lets you publish anyway.

A standalone build works the same way: it ships one backend, so a GLSL-only shader is fine and the Render API setting resolves to WebGL 2 rather than shipping something that cannot compile.

Open the Shader Editor's WebGPU tab and press "Add a WebGPU version". The tab compiles what you type against your browser's own WebGPU and reports errors with your line numbers, so you find a mistake while writing rather than at export time. The dot beside the tab is green once it compiles. Both kits ship WGSL versions for every shader, so the fastest way to learn the shape is to open one.

The shape of it

A WGSL version is one function, and the engine binds its parameters by name. Declare only what you use, in any order — there is no fixed signature to match. Anything the engine does not recognise is looked up among your own uniforms, by the same name you gave it.

For a surface shader the function returns vec4<f32> and can ask for:

ParameterValue
uv: vec2<f32>the mesh uv, the WGSL twin of vUv
time: f32seconds, the twin of uTime
color: vec3<f32>the entity's Color, the twin of uColor
opacity: f32the entity's Opacity, the twin of uOpacity
normal: vec3<f32>the world-space normal
worldPos: vec3<f32>the world-space position
cameraPos: vec3<f32>the camera position
wgsl
fn gxShade(uv: vec2<f32>, time: f32, color: vec3<f32>, uSpeed: f32) -> vec4<f32> {
  let scan = sin((uv.y * 40.0) - time * uSpeed) * 0.5 + 0.5;
  let edge = 1.0 - abs(uv.x - 0.5) * 2.0;
  return vec4<f32>(color * (0.4 + scan * 0.6), edge * 0.8);
}

That is the force field from the worked example above, line for line. The function name is yours; only the parameter names matter. Helper functions can sit in the same source, below or above it.

A texture uniform arrives already sampled at the mesh uv, so uMask: vec4<f32> is the twin of texture2D(uMask, vUv). To sample somewhere else, ask for the texture and its sampler — WGSL takes them as two arguments, and a uniform named uMask also answers to uMaskSampler:

wgsl
fn gxShade(uv: vec2<f32>, uMask: texture_2d<f32>, uMaskSampler: sampler) -> vec4<f32> {
  return textureSample(uMask, uMaskSampler, uv * 4.0);   // the same texture, tiled 4x
}

A texture uniform with nothing assigned reads as white, so a shader that multiplies by one keeps working until you assign it.

Moving vertices

If your GLSL vertex stage does more than pass values through — displacing along normals for a wobble, a wave, a bulge — it needs a WGSL version too, or the WebGPU build draws the shape undisplaced. Add one alongside the fragment version. It returns the local-space position to draw the vertex at, and can ask for position, normal, uv and time:

wgsl
fn gxVertex(position: vec3<f32>, normal: vec3<f32>, time: f32, uWobble: f32) -> vec3<f32> {
  let w = sin(position.y * 6.0 + time * 4.0) * 0.5 + sin(position.x * 5.0 - time * 3.2) * 0.5;
  return position + normal * w * uWobble;
}

Normals are not recalculated from the displacement, matching what the GLSL version does — a wobbling surface keeps the shading of the shape it started as.

Post effects in WGSL

A post effect's WGSL version follows the same by-name rule and returns vec4<f32>:

ParameterValue
color: vec4<f32>the frame at this pixel, the twin of texture2D(tDiffuse, vUv)
uv: vec2<f32>this pixel's coordinate
time: f32seconds
resolution: vec2<f32>the pass resolution in pixels
frame: texture_2d<f32> + frameSampler: samplerthe frame as a texture, for reading other pixels
original: texture_2d<f32> + originalSampler: samplerthe frame from before the stack, the twin of tOriginal
depth: f32scene distance at this pixel, in world units
cameraNear: f32 / cameraFar: f32the camera range

Two of those are worth spelling out.

Asking for frame is how you say "I read pixels other than my own" — pixelate, blur, chromatic aberration, fisheye. That one declaration is the whole signal: the engine sees it and gives the effect a real texture of everything the stack has drawn so far, so it works at any position in the stack, exactly like tDiffuse does in GLSL. An effect that only recolours its own pixel takes color instead and costs nothing extra.

depth arrives already converted to a world-unit distance — there is no uCameraNear arithmetic to repeat, because the two renderers store raw depth differently and resolving that is not your problem. The kit's Depth Fog shows the difference:

wgsl
fn gxPost(color: vec4<f32>, depth: f32, uDensity: f32, uFogColor: vec3<f32>) -> vec4<f32> {
  let f = clamp(1.0 - exp(-depth * uDensity * 0.05), 0.0, 1.0);
  return vec4<f32>(mix(color.rgb, uFogColor, vec3<f32>(f)), color.a);
}

The per-effect Res dropdown has no WGSL equivalent — every pass runs at full resolution on WebGPU.

Where shaders work

Everywhere: the editor viewport, play mode, the exported standalone build and published multiplayer games all draw them, and uTime advances in each. Shaders travel inside the map like every other asset, so nothing extra ships alongside it.

See also