Stained glass
Irregular panes of colored glass held in dark lead lines, from one pass of cellular noise.
- 01GoalPattern
- 02TechniqueVoronoi cells
- 03ExecutionStained glass
Parameters
Copy for your platform
- GLSLCopy
- GodotCopy
Node chain
Built step by step, in GLSL style, with its node names.
Built step by step, in Godot style, with its node names.
No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.
No Unity version of this card yet, so the nodes show the GLSL names.
No Blender version of this card yet, so the nodes show the GLSL names.
lengthTexture2Dlengthlengthlength UV01 Distance to the walls (F2 − F1)
cosCoscoscoscos In02 A color per pane
*Multiply*** In03 Darken toward the edges
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep In04 Lead lines
mixMixmixmixmix In05 Lay the lead over the glass
How it works
A stained-glass window is a Voronoi diagramA split of the plane into one region per seed point, where every spot belongs to the point nearest to it. The borders lie where two points are equally near. Voronoi diagram A split of the plane into one region per seed point, where every spot belongs to the point nearest to it. The borders lie where two points are equally near.
Step by step
- Distance to the walls (F2 − F1).
cellular()scales the UVA 2D position on a surface or the screen, usually from 0 to 1 on both axes. Shaders draw shapes and patterns as functions of it.FundamentalUV coordinates
A 2D position on a surface or the screen, usually from 0 to 1 on both axes. Shaders draw shapes and patterns as functions of it.
uv = gl_FragCoord.xy / resolutionSourcesby the panes across and up, so each grid cell holds one seed point, placed by a hashA function that scrambles its input into a value that looks random but is always the same for the same input. Shaders use it for per-pixel and per-cell randomness.- gl_FragCoord (opens in a new tab)Reference
- UV mapping (opens in a new tab)Reference
FundamentalHash
A function that scrambles its input into a value that looks random but is always the same for the same input. Shaders use it for per-pixel and per-cell randomness.
h = fract(sin(dot(p, k)) × big)of the cell. It measures the distance from the pixel to the points of its own cell and the eight around it withlength, and keeps the nearest (F1), the second nearest (F2) and which cell won (pane).F2 − F1is zero exactly where two points are equally near, on the border between their panes, and grows toward the middle of each pane. - A color per pane. The winning cell’s coordinates go through the hash again, offset by 71 so the color is not
tied to where the point sits. One random number picks a hue on a
coscolor wheel, the other a density. Squaring the hue in linear space pushes it toward the deep, saturated tones of real glass, and the density keeps two neighbors of similar hue apart. - Darken toward the edges.
smoothstep(0.0, 0.5, walls)rises from 0 at the border to 1 well inside the pane, and mixing the edge shade toward 1 with it dims each pane near its lead, a shading choice that gives each pane a slightly cushioned look and makes the lead read as a recess. Raise the edge shade to 1 for flat panes. - Lead lines.
1.0 - smoothstep(p_leadWidth, p_leadWidth + 0.03, walls)is 1 on the border band and 0 elsewhere. The 0.03 ramp is a soft edge in cell units, so the line is smooth at this size without a second sample. A lead width of 0 leaves only that hairline. - Lay the lead over the glass.
mix(lit, p_leadColor, lead)puts the lead color on top. In a real window the lead strips are came, H-shaped channels that hold the cut glass, and they read as the dark outline the eye uses to find each shape.
Two choices in cellular() matter. The points stay 0.1 away from their cell’s sides: with full jitter, a point two
cells away can be nearer than all nine checked, and the lead shows a straight seam where it should bend. And the hash
works on integers rather than the common fract(sin(…)): sin of a large number loses precision differently on each
GPU, so the panes would come out different from one device to another.
Production notes
Cost. About 370 operations per pixel, one pass, no texture reads. The nine cells cost about 36 each: the integer
hash (two rounds of multiply, shift and xor), the offset, a subtraction, a length and the comparisons that keep F1
and F2. The pane color adds a hash, a cos per channel and a few multiplies; the shade and the lead are two
smoothsteps and two mixes. That is heavy for a background but fine for a window or a prop, and it costs no memory
at any resolution.
The lead is not quite even. F2 − F1 is zero on the border, but it isn’t the distance to it: it grows twice as fast
as the distance where the border crosses the line between the two points, and slower further along the border, the more
so the closer the two points are. The same threshold therefore gives thin lead in the middle of a border and thicker
lead toward its ends and between close points. In stained glass that reads as handmade. For lines of one exact width,
the Voronoi edges article in the sources finds the nearest point first and then searches 5 × 5 cells around it for the
nearest bisector, at about four times the cost.
Far away. The lead is a hard band like the checkerboard’s edges, so it shimmers once a pane is only a few pixels across. Fade the lead toward the pane color with distance, or lower the pane counts, before that happens.
Variations. The same three values draw other cells: F1 alone for scales and bubbles, F2 − F1 with a thin lead and
a single color for cracked ground, the cell id for a random height or material per tile. On a mesh without good UVs,
the same search over 3 × 3 × 3 cells on world position gives seamless cells, at three times the cost.
Make it in GLSL
The copy button gives a complete WebGL2 fragment shader: #version 300 es, the uniforms, the Inputs struct and a
main() that fills it and calls effect(). To use it in your own WebGL2 code, draw a quad (or a full-screen triangle)
whose vertex shader writes v_uv from 0 to 1, plus v_position, v_normal and v_view (this card reads only the UV,
so constants will do), compile the pair, and set the uniforms:
p_acrossandp_upas floats: roughly how many panes fit across and up. Keep their ratio equal to the canvas’s (10 and 6 for a 1000 × 600 canvas) or the panes stretch with it.p_leadWidthandp_edgeShadeas floats,p_leadColoras a linear color. A color from a picker is sRGB; decode it on the CPU with the sRGB curve before uploading.
The shader writes the final color already encoded to sRGB, so it goes straight to the canvas. In a pipeline that
encodes later (rendering to a linear target first), drop the encode at the end of main().
Reusing the pieces. cellular() returns F1 and F2 and writes the winning cell to its out parameter, so it drops
into any shader that needs Voronoi: return distances.x for bubbles, threshold walls alone for cracks. hash22()
gives two numbers in 0..1 for any whole-number cell; add a different constant to the cell for each independent value,
as effect() does for the color.
Different seeds. The panes are fixed by the cell coordinates. Add an offset to the scaled UV (a uniform vec2) to
show another part of the infinite pattern, which works like a new seed.
Make it in Godot
VisualShader has no Voronoi node. In a node graph the first step is a Texture2D node sampling a NoiseTexture2D whose FastNoiseLite is set to Cellular; the steps after it are the same nodes as above.
The copy button gives a canvas_item shader. In a 2D scene, add a ColorRect (or any Control or Sprite2D), give it a
new ShaderMaterial, and paste the code into a new Shader on that material. The window fills the node’s rectangle:
across and up are in the inspector under Shader Parameters, and their ratio should match the node’s size (10 and 6
for a 500 × 300 rect) for panes that aren’t stretched. leadWidth, leadColor and edgeShade work as on the site.
Godot 4’s shading language has uint, hex literals and bit shifts, so the integer hash compiles unchanged and gives the
same panes as the web version.
On a 3D mesh. The same effect() works in a spatial shader, on the mesh’s UV. Start a new Shader with
shader_type spatial; and render_mode unshaded;, paste the uniforms, the helpers and effect(), and write a
fragment() that fills Inputs from UV and sets ALBEDO = effect(i).rgb;. Spatial shaders decode source_color
uniforms to linear and encode the output themselves, so drop the u_toLinear call there. For a window that glows,
write the glass to EMISSION instead and keep ALBEDO black.
As a node graph. In a VisualShader set to CanvasItem mode, sample a NoiseTexture2D with a Texture2D node: its noise is a FastNoiseLite with Noise Type Cellular and Return Type Distance2Sub (that is F2 − F1), and Cellular Jitter left at 1.0, which spreads the points about as far as this card does. A SmoothStep on its red channel gives the lead. For the colors, a second Texture2D on a NoiseTexture2D with the same settings but Return Type Cell Value gives one flat number per pane; feed it through Cos nodes as the palette, Multiply by the shade, and Mix with the lead color, into the Color output. The texture tiles if Seamless is on, and costs one or two texture reads instead of the nine cells of math.
No Unreal Engine 5 tutorial for this card yet.
No Unity tutorial for this card yet.
No Blender tutorial for this card yet.
Code
#version 300 es// Voronoi cells → Stained glass, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_across; // Panes across: 10.0 (2.0 to 40.0)uniform float p_up; // Panes up: 6.0 (2.0 to 40.0)uniform float p_leadWidth; // Lead width: 0.06 (0.0 to 0.3)uniform vec3 p_leadColor; // Lead color: vec3(0.1, 0.1, 0.11), sRGB; upload it decoded to linearuniform float p_edgeShade; // Edge shade: 0.45 (0.0 to 1.0)in vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// An integer hash rather than fract(sin(...)): sin loses precision differently on each GPU, so the panes would change// from one device to the next.uint mix32(uint x) { x ^= x >> 16u; x *= 0x85ebca6bu; x ^= x >> 13u; x *= 0xc2b2ae35u; x ^= x >> 16u; return x;}vec2 hash22(vec2 cell) { uvec2 q = uvec2(ivec2(cell)); uint h = mix32(q.x ^ mix32(q.y)); return vec2(float(h >> 16u), float(h & 0xffffu)) / 65536.0;}vec2 cellular(vec2 p, out vec2 id) { vec2 base = floor(p); vec2 local = fract(p); float f1 = 8.0; float f2 = 8.0; id = base; for (int y = -1; y <= 1; y++) { for (int x = -1; x <= 1; x++) { vec2 o = vec2(float(x), float(y)); // Points kept 0.1 away from the cell's sides: a point two cells away then almost never beats the nine // checked, which would show as a straight seam in the lead vec2 point = o + 0.1 + 0.8 * hash22(base + o); float d = length(point - local); if (d < f1) { f2 = f1; f1 = d; id = base + o; } else if (d < f2) { f2 = d; } } } return vec2(f1, f2);}vec4 effect(Inputs i) { vec2 pane; vec2 distances = cellular(i.uv * vec2(p_across, p_up), pane); float walls = distances.y - distances.x; // Offset from the point hash, so a pane's color doesn't follow where its point sits vec2 pick = hash22(pane + 71.0); vec3 hue = 0.5 + 0.5 * cos(6.2831853 * (pick.x + vec3(0.0, 0.33, 0.67))); // Squaring in linear space deepens the colors toward the saturated tones of real glass, and a density per pane // keeps two neighbors of the same hue apart vec3 glass = hue * hue * mix(0.3, 0.85, pick.y); float inset = smoothstep(0.0, 0.5, walls); vec3 lit = glass * mix(p_edgeShade, 1.0, inset); float lead = 1.0 - smoothstep(p_leadWidth, p_leadWidth + 0.03, walls); vec3 color = mix(lit, p_leadColor, lead); return vec4(color, 1.0);}void main() { Inputs i; i.uv = v_uv; i.position = v_position; // Interpolation shortens the vectors between vertices i.normal = normalize(v_normal); i.view = normalize(v_view); i.time = u_time; i.fragCoord = gl_FragCoord.xy; i.resolution = u_resolution; vec4 frag_out = effect(i); // The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1 fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}// Voronoi cells → Stained glass, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform float across : hint_range(2.0, 40.0, 0.5) = 10.0;uniform float up : hint_range(2.0, 40.0, 0.5) = 6.0;uniform float leadWidth : hint_range(0.0, 0.3, 0.01) = 0.06;uniform vec3 leadColor : source_color = vec3(0.1, 0.1, 0.11);uniform float edgeShade : hint_range(0.0, 1.0, 0.05) = 0.45;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// An integer hash rather than fract(sin(...)): sin loses precision differently on each GPU, so the panes would change// from one device to the next.uint mix32(uint x) { x ^= x >> 16u; x *= 0x85ebca6bu; x ^= x >> 13u; x *= 0xc2b2ae35u; x ^= x >> 16u; return x;}vec2 hash22(vec2 cell) { uvec2 q = uvec2(ivec2(cell)); uint h = mix32(q.x ^ mix32(q.y)); return vec2(float(h >> 16u), float(h & 0xffffu)) / 65536.0;}vec2 cellular(vec2 p, out vec2 id) { vec2 base = floor(p); vec2 local = fract(p); float f1 = 8.0; float f2 = 8.0; id = base; for (int y = -1; y <= 1; y++) { for (int x = -1; x <= 1; x++) { vec2 o = vec2(float(x), float(y)); // Points kept 0.1 away from the cell's sides: a point two cells away then almost never beats the nine // checked, which would show as a straight seam in the lead vec2 point = o + 0.1 + 0.8 * hash22(base + o); float d = length(point - local); if (d < f1) { f2 = f1; f1 = d; id = base + o; } else if (d < f2) { f2 = d; } } } return vec2(f1, f2);}vec4 effect(Inputs i) { vec2 pane; vec2 distances = cellular(i.uv * vec2(across, up), pane); float walls = distances.y - distances.x; // Offset from the point hash, so a pane's color doesn't follow where its point sits vec2 pick = hash22(pane + 71.0); vec3 hue = 0.5 + 0.5 * cos(6.2831853 * (pick.x + vec3(0.0, 0.33, 0.67))); // Squaring in linear space deepens the colors toward the saturated tones of real glass, and a density per pane // keeps two neighbors of the same hue apart vec3 glass = hue * hue * mix(0.3, 0.85, pick.y); float inset = smoothstep(0.0, 0.5, walls); vec3 lit = glass * mix(edgeShade, 1.0, inset); float lead = 1.0 - smoothstep(leadWidth, leadWidth + 0.03, walls); vec3 color = mix(lit, u_toLinear(leadColor), lead); return vec4(color, 1.0);}void fragment() { Inputs i; // UV starts at the top-left; the effect expects GLSL's bottom-left i.uv = vec2(UV.x, 1.0 - UV.y); // A flat quad facing the viewer, the same as the site's 2D preview i.position = vec3(i.uv * 2.0 - 1.0, 0.0); i.normal = vec3(0.0, 0.0, 1.0); i.view = vec3(0.0, 0.0, 1.0); i.time = TIME; // FRAGCOORD's origin is the top-left too i.resolution = 1.0 / SCREEN_PIXEL_SIZE; i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y); vec4 frag_out = effect(i); COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}No Unreal Engine 5 code for this card yet.
No Unity code for this card yet.
No Blender code for this card yet.
Further reading
- Cellular noise (opens in a new tab)Book
- Voronoi edges (opens in a new tab)Article
- Voronoi diagram (opens in a new tab)Reference
- Worley noise (opens in a new tab)Reference
- Came glasswork (opens in a new tab)Reference
- length (opens in a new tab)Reference
- smoothstep (opens in a new tab)Reference
- FastNoiseLite (opens in a new tab)Docs
- NoiseTexture2D (opens in a new tab)Docs
- CanvasItem shaders (opens in a new tab)Docs