Shader
<Shader fragment> is the material of the mesh it sits in, inside a World, drawn by a GLSL fragment shader you write. Every shader in the engine draws through it, the CRT, portal and energy orb materials included, so a shader you write takes the same inputs as one the engine ships, and you can start from the engine’s source.
Its props are in ShaderProps.
A ShaderValue is a number, a three Vector2, Vector3, Color or Texture. Change a uniform’s value from React state, and the next frame draws it; no frame callback is needed.
Godot’s shading language is the model: it declares TIME and UV for you, and you add a uniform per knob. Roblox has no shaders. Unity and Unreal build materials in a node graph, which an agent cannot write as text.
What the fragment gets
Section titled “What the fragment gets”The engine declares these ahead of your code. Do not declare them yourself.
| Name | GLSL type | What it holds |
|---|---|---|
uTime |
float |
Seconds of world time, from the same clock the frame steps. A paused world holds it. |
uPointer |
vec2 |
The pointer over the canvas, -1 to 1 on each axis, 0 at the centre. |
vUv |
vec2 |
The mesh’s texture coordinates: 0 to 1 across a plane, from the bottom left. |
vWorldPosition |
vec3 |
The point’s place in the world, in metres. With three’s cameraPosition, it gives the view angle. |
each uniforms key |
float, vec2, vec3 or sampler2D |
The value you passed, typed from it: a number is a float, a Color is a vec3, a Texture is a sampler2D. |
Because uTime is the world’s clock and not the wall’s, a paused game freezes every shader, a devtools step advances them by one step, and a headless screenshot taken at a given step is the same image every run. Two screenshots of the same step compare pixel for pixel, which is how an agent checks that a change did what it meant.
Write GLSL ES 1.00 as three’s own examples do: texture2D, varying and gl_FragColor. Three sets the precision and the GLSL version. Pass smoothstep its lower edge first: GLSL leaves smoothstep(0.5, 0.4, x) undefined, so a phone may draw it differently. For a falling edge, write 1.0 - smoothstep(0.4, 0.5, x).
Sample
Section titled “Sample”A ring that breathes on the world’s clock, in a colour the game chooses:
import { Entity, Shader, World } from "@daniel-zarinski/engine";import { Color } from "three";
const ring = `void main() { float distance = length(vUv - 0.5); float radius = 0.3 + 0.05 * sin(uTime * 3.0); float band = 1.0 - smoothstep(0.0, 0.03, abs(distance - radius)); gl_FragColor = vec4(uTint * band, band);}`;
export function Portal() { return ( <World map="meadow"> <Entity position={[0, 1.2, -3]}> <mesh> <planeGeometry args={[2, 2]} /> <Shader fragment={ring} uniforms={{ uTint: new Color("cyan") }} transparent /> </mesh> </Entity> </World> );}Include a LYGIA function
Section titled “Include a LYGIA function”LYGIA is a GLSL library with one function per file: noise, distance shapes, colour, blur and lighting. Include a file by its path in the package, and call its function:
#include "lygia/generative/snoise.glsl"
void main() { gl_FragColor = vec4(vec3(snoise(vUv * 4.0)), 1.0);}Include the function rather than retyping it from memory: a retyped noise or distance function can compile and still draw the wrong thing.
When the game builds, the engine’s Vite plugin replaces each #include with the file, along with the files that file includes. The shader therefore needs nothing at runtime: it draws offline, headless and the same way every run. A game whose fragments include nothing gets no LYGIA text in its bundle.
A game made from a template already loads the plugin. To add it to a Vite config of your own:
import { defineConfig } from "vite";import { lygia } from "@daniel-zarinski/engine/vite";
export default defineConfig({ plugins: [lygia()],});The plugin reads an include only where the fragment is written in the source: at the start of a line inside a template literal, as in the recipes below. It skips an include inside a // or /* */ comment, so commenting one out takes its file out of the shader. A fragment put together at runtime, such as one typed into a text box, cannot include: the #include reaches the GLSL compiler, which fails on it as on any compile error below.
Browse the files at lygia.xyz or in the repository. Each file’s header comment names its function, what it takes and an example.
A path that names no file stops the build. The dev server shows the same error over the page, with the module that holds the include:
[plugin engine:lygia] src/Portal.tsxShader: no LYGIA file at "lygia/generative/noise.glsl"Godot includes a .gdshaderinc when it imports the shader, and Unity and Unreal include .hlsl or .ush files when they compile it. The engine resolves LYGIA’s files at the same point: when the game builds, not when it runs.
Licence and credit
Section titled “Licence and credit”Games on the platform are treated as non-commercial. That makes LYGIA’s Prosperity licence, Shadertoy’s default CC BY-NC-SA and the sources under References usable. When you copy or port a shader, keep its author’s name and link in a comment above the fragment. When its licence is share-alike, such as CC BY-SA or CC BY-NC-SA, keep the licence line too. A LYGIA file you include keeps its own header, so it needs nothing from you.
Recipes
Section titled “Recipes”Each recipe is a mesh you can copy into a scene and change. Put the mesh inside an Entity to place it.
Glow ring
Section titled “Glow ring”A ring that breathes, with a glow falling off on both sides. circleSDF gives the distance from the centre: 0 at the centre and 1 at the plane’s edge.
import { Shader } from "@daniel-zarinski/engine";import { Color } from "three";
const glowRing = `#include "lygia/sdf/circleSDF.glsl"
void main() { float radius = 0.6 + 0.05 * sin(uTime * 3.0); float distance = abs(circleSDF(vUv) - radius); float glow = 0.02 / distance; gl_FragColor = vec4(uTint * glow, clamp(glow, 0.0, 1.0));}`;
export function GlowRing() { return ( <mesh> <planeGeometry args={[2, 2]} /> <Shader fragment={glowRing} uniforms={{ uTint: new Color("cyan") }} transparent /> </mesh> );}Scrolling scanlines
Section titled “Scrolling scanlines”Horizontal lines that scroll upwards, with a brighter band passing through them. LYGIA has no function for this, so the recipe is plain GLSL.
import { Shader } from "@daniel-zarinski/engine";import { Color } from "three";
const scanlines = `void main() { float lines = 0.5 + 0.5 * sin((vUv.y - uTime * 0.05) * 300.0); float band = 1.0 - smoothstep(0.0, 0.1, abs(fract(vUv.y - uTime * 0.3) - 0.5)); vec3 colour = uTint * (0.4 + 0.4 * lines + band); gl_FragColor = vec4(colour, 1.0);}`;
export function Scanlines() { return ( <mesh> <planeGeometry args={[2, 2]} /> <Shader fragment={scanlines} uniforms={{ uTint: new Color("#40ff90") }} /> </mesh> );}Noise fog
Section titled “Noise fog”A layer of fog that drifts, fading out towards the plane’s edges. fbm layers four octaves of noise. The third coordinate is time, so the fog changes shape as it moves. Lay the plane flat above the floor with rotation={[-Math.PI / 2, 0, 0]}.
import { Shader } from "@daniel-zarinski/engine";import { Color } from "three";
const fog = `#include "lygia/generative/fbm.glsl"
void main() { float noise = 0.5 + 0.5 * fbm(vec3(vUv * 3.0, uTime * 0.2)); float density = smoothstep(0.35, 0.75, noise); float edge = 1.0 - smoothstep(0.25, 0.5, length(vUv - 0.5)); gl_FragColor = vec4(uTint, density * edge);}`;
export function Fog() { return ( <mesh rotation={[-Math.PI / 2, 0, 0]}> <planeGeometry args={[6, 6]} /> <Shader fragment={fog} uniforms={{ uTint: new Color("#f4f8ff") }} transparent /> </mesh> );}Dissolve by threshold
Section titled “Dissolve by threshold”The surface burns away in patches, with a hot edge where it is about to go. Each point compares its noise value to a threshold and discards itself below it. The sample runs the threshold on uTime. To drive it from the game, pass a uProgress uniform from 0 to 1 in its place.
import { Shader } from "@daniel-zarinski/engine";import { Color } from "three";
const dissolve = `#include "lygia/generative/snoise.glsl"
void main() { float noise = 0.5 + 0.5 * snoise(vUv * 6.0); float threshold = 0.5 + 0.5 * sin(uTime); if (noise < threshold) discard; float edge = 1.0 - smoothstep(threshold, threshold + 0.06, noise); vec3 colour = mix(uTint, vec3(1.0, 0.45, 0.1), edge); gl_FragColor = vec4(colour, 1.0);}`;
export function Dissolve() { return ( <mesh> <planeGeometry args={[2, 2]} /> <Shader fragment={dissolve} uniforms={{ uTint: new Color("#8060ff") }} /> </mesh> );}Port a Shadertoy shader
Section titled “Port a Shadertoy shader”A Shadertoy shader runs here once you rename its inputs. This recipe ports Shadertoy’s default new shader, which is:
void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec3 col = 0.5 + 0.5 * cos(iTime + uv.xyx + vec3(0, 2, 4)); fragColor = vec4(col, 1.0);}To port it, make the following changes:
- Replace
void mainImage(out vec4 fragColor, in vec2 fragCoord)withvoid main(), andfragColorwithgl_FragColor. - Replace
fragCoord / iResolution.xywithvUv. It runs from 0 to 1 across the mesh, as the division does across the screen. - Replace
iTimewithuTime, andiMousewithuPointer, which runs from -1 to 1. - Write each integer in a
vecas a float, such asvec3(0.0, 2.0, 4.0), because GLSL ES 1.00 does not convert them. - Keep the author’s credit and licence in a comment, as Licence and credit says.
import { Shader } from "@daniel-zarinski/engine";
// Ported from Shadertoy's default new shader.const rainbow = `void main() { vec2 uv = vUv; vec3 colour = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0.0, 2.0, 4.0)); gl_FragColor = vec4(colour, 1.0);}`;
export function Rainbow() { return ( <mesh> <planeGeometry args={[2, 2]} /> <Shader fragment={rainbow} /> </mesh> );}A shader that reads iChannel0 needs a Texture uniform in its place, such as uniforms={{ uChannel: texture }}. A shader in several passes, with a Buffer A tab, does not port to one fragment.
Read a compile error
Section titled “Read a compile error”A fragment that does not compile draws nothing, and three prints the error to the console: the GL compiler’s message, then the source around the failing line, with that line marked >. The MCP’s read_console tool returns it. The line numbers count three’s own header of about 150 lines ahead of your code, so read the line marked >, not the number. This one names colour, which the fragment never declared:
THREE.WebGLProgram: Shader Error 0 - VALIDATE_STATUS false
Material Name:Material Type: ShaderMaterial
Program Info Log: Fragment shader is not compiled.
FRAGMENT
ERROR: 0:162: 'colour' : undeclared identifierERROR: 0:162: 'constructor' : not enough data provided for construction
158: uniform float uTime; 159: uniform vec2 uPointer; 160: varying vec2 vUv; 161: uniform vec3 uTint;> 162: void main() { gl_FragColor = vec4(colour, 1.0); }The browser then repeats WebGL: INVALID_OPERATION: useProgram: program not valid for every frame the broken material draws. Those lines follow from the first error; fix that one.
See it
Section titled “See it”pnpm exec game play screenshot --until "steps >= 60" steps the game headless to the first second and writes a PNG, as Your first game shows. The same step gives the same image, so take one before a change and one after, and compare them.
Start from a scaffold
Section titled “Start from a scaffold”The MCP’s add_shader tool writes src/shaders/<Name>.tsx into the game: a fragment with a LYGIA include, and a <Name>Material component to put inside a mesh. Templates shows the file.
Start from the engine’s shaders
Section titled “Start from the engine’s shaders”Each shader the engine ships is an exported GLSL string with a comment per block, so an agent can copy it, change one block and pass it as fragment. The CRT material and the water material link their source.
References
Section titled “References”Each of these collections can teach you a technique or give you a shader to copy:
| Source | Good for | How to use it here |
|---|---|---|
| LYGIA | Noise, distance shapes, colour, blur and lighting functions, tested and ready. | #include "lygia/…" as Include a LYGIA function shows. |
| Shadertoy | Thousands of complete effects: skies, water, fire, tunnels and glows. | Port one as Port a Shadertoy shader shows. The default licence is CC BY-NC-SA, so keep the credit and the licence. |
| godotshaders.com | Short shaders for game objects: outlines, dissolves, water, holograms and toon. | Take the fragment() body. Rename TIME to uTime and UV to vUv, and set gl_FragColor from ALBEDO and ALPHA. Keep the licence the page names. |
| three.js examples | Shaders written for the same renderer and GLSL version this engine uses. | Open an example’s source and copy its fragmentShader string from void main() on. Rename its time and UV inputs to uTime and vUv. |
| The Book of Shaders | Learning the ideas: shaping functions, colour, patterns, noise and cellular noise. | Read a chapter to learn why a recipe works. Its examples use u_time and gl_FragCoord.xy / u_resolution; rename them to uTime and vUv. |
| Inigo Quilez’s distance functions and palettes | The exact formula for a 2D shape’s distance, and a cosine colour palette. | Copy a function above main() and call it with vUv - 0.5. LYGIA includes many of them, such as lygia/color/palette.glsl. |
Where it runs
Section titled “Where it runs”Client. A shader changes nothing in the world; each player’s browser draws its own.