One of my favourite shaders
This breakdown walks through Eidolon 1, one of my favourite pieces in the Eidolon series. It shows how layering simple geometry, motion, and colour can turn into something that feels bigger than the sum of its parts.

Inspiration
I'm a sucker for chromatic aberration — the way RGB channels peel apart and warp the image still hits for me, even though the "trick" is basically everywhere now.
These shaders started as experiments with literal chromatic aberration. They slid sideways into something quieter. Creating more ethereal, abstract planes and a glow that keeps the mood of that channel split.
Breakdown
The structure is straightforward: layered shapes, warped UVs, simple palette-driven colour, and motion pushed by a sine wave. Colour on each layer comes from a cosine palette.
Here's the shader in full:
import { abs, Fn, mix, oneMinus, rotate, screenSize, sin, smoothstep, time, uv, vec3 } from 'three/tsl'
import { grainTexturePattern } from '@/components/tsl/patterns/grain_texture_pattern'
import { cosinePalette } from '@/tsl/utils/color/cosine_palette'
import { screenAspectUV } from '@/tsl/utils/function'
import { sdBox2d, sdSphere } from '@/tsl/utils/sdf/shapes'
export const eidolon1 = Fn(() => {
const _uv = screenAspectUV(screenSize).toVar()
const _vuv = uv().toVar()
const finalColor = vec3(0.0).toVar()
const rotationScalar = sin(_vuv.x.mul(Math.PI)).toVar()
const uvR = rotate(_uv, rotationScalar.add(time))
const uvR2 = rotate(_uv, rotationScalar.add(time.mul(0.5)))
const uvR3 = rotate(_uv, rotationScalar.add(time.mul(0.75)))
const rotateGeometry = Fn(([_st, edge1 = 0.7, edge2 = 0.8]) => {
const shape = oneMinus(sdBox2d(abs(_st))).toVar()
shape.assign(smoothstep(edge1, edge2, shape))
return shape
})
const rotatedGeometry1 = rotateGeometry(uvR2)
const rotatedGeometry2 = rotateGeometry(uvR)
const rotatedGeometry3 = rotateGeometry(uvR3)
const colorCalc = Fn(([_st, timeScalar]) => {
const a = vec3(0.5, 0.5, 0.5)
const b = vec3(0.5, 0.5, 0.5)
const c = vec3(2.0, 1.0, 0.0)
const d = vec3(0.5, 0.2, 0.25)
const col = cosinePalette(sdSphere(_st.mul(0.5)).add(time.mul(timeScalar)), a, b, c, d).toVar()
return col
})
const col1 = colorCalc(_uv, 0.25)
const col2 = colorCalc(_uv, 0.27)
const col3 = colorCalc(_uv, 0.28)
col1.mulAssign(rotatedGeometry1)
col2.mulAssign(rotatedGeometry2)
col3.mulAssign(rotatedGeometry3)
const mixedColorStep = mix(col1, col2, 0.5)
finalColor.assign(mix(mixedColorStep, col3, 0.5))
const grainEffect = grainTexturePattern(_vuv).mul(0.2).toVar()
finalColor.addAssign(grainEffect)
return finalColor
})A few Fragments utilities hold it together:
screenAspectUVkeeps geometry from stretching when the viewport isn't squarerotateapplies a 2D rotation in shader space—more “spin the sampling grid” than rotating a JPEGcosinePaletteis the usual cosine ramp from Inigo Quilez
Curling UVs before the spin
_vuv.x feeds a sine that becomes rotationScalar. Curling UVs along one axis keeps the swirl from feeling like a tidy pinwheel—you get gentle bias across the horizontal field.
The three rotate calls share that curl but offset time by 1.0, 0.5, and 0.75, so the layers drift apart without bolting on three unrelated systems.
const _uv = screenAspectUV(screenSize).toVar()
const _vuv = uv().toVar()
const rotationScalar = sin(_vuv.x.mul(Math.PI)).toVar()
const uvR = rotate(_uv, rotationScalar.add(time))
const uvR2 = rotate(_uv, rotationScalar.add(time.mul(0.5)))
const uvR3 = rotate(_uv, rotationScalar.add(time.mul(0.75)))Soft boxes instead of razor edges
sdBox2d makes a rectangular distance field. Absolute value folds it into symmetrical shards. smoothstep(edge1, edge2, …) smears the cutoff so blobs touch without harsh aliasing.
How hard those edges read is mostly taste. I keep them soft, though you can narrow the smoothstep band if you want a harder cut.
const rotateGeometry = Fn(([_st, edge1 = 0.7, edge2 = 0.8]) => {
const shape = oneMinus(sdBox2d(abs(_st))).toVar()
shape.assign(smoothstep(edge1, edge2, shape))
return shape
})
const rotatedGeometry1 = rotateGeometry(uvR2)
const rotatedGeometry2 = rotateGeometry(uvR)
const rotatedGeometry3 = rotateGeometry(uvR3)Palette colour keyed to sphere distance
colorCalc is doing most of the mood work. Same cosine palette scaffolding for each lane, with timeScalar bumped in tiny steps (0.25, 0.27, 0.28—barely perceptible gaps). Those slivers of difference show up once each layer is masked and blended.
Driving the cosine input from sdSphere(_st.mul(0.5)).add(time.mul(timeScalar)) ties the ramp to spherical distance—you don't read it as literal spheres, but the field stays smooth and luminous.
const colorCalc = Fn(([_st, timeScalar]) => {
const a = vec3(0.5, 0.5, 0.5)
const b = vec3(0.5, 0.5, 0.5)
const c = vec3(2.0, 1.0, 0.0)
const d = vec3(0.5, 0.2, 0.25)
const col = cosinePalette(sdSphere(_st.mul(0.5)).add(time.mul(timeScalar)), a, b, c, d).toVar()
return col
})
const col1 = colorCalc(_uv, 0.25)
const col2 = colorCalc(_uv, 0.27)
const col3 = colorCalc(_uv, 0.28)Masking and grain
Per-layer multiply is blunt and predictable: when a mask collapses toward zero, that layer's colour goes dark. Stacks stay readable even when the geometry piles up.
Nested mix calls at fixed 0.5 read like translucent gels—calmer than stacking additive blends. Snap comes from palette tweaks and grain, not flashy blend gymnastics.
col1.mulAssign(rotatedGeometry1)
col2.mulAssign(rotatedGeometry2)
col3.mulAssign(rotatedGeometry3)
const mixedColorStep = mix(col1, col2, 0.5)
finalColor.assign(mix(mixedColorStep, col3, 0.5))Grain knocks down banding and hides spots where cosine ramps want to jitter. Scaling it by 0.2 keeps the texture from chewing through contrast.
const grainEffect = grainTexturePattern(_vuv).mul(0.2).toVar()
finalColor.addAssign(grainEffect)Variations
You can treat each piece as LEGO and swap freely. Starting points:
- Tune the three time ratios if you want more “ribbon” drift or quieter motion
- Nudge cosine palette knobs
c/dfor mood jumps without rewiring masking - Try a sharper
smoothstepwindow for crisper slabs - Try some different shapes for the boxes, such as diamonds or triangles
- Fractionate or repeat the sampling domain if you want a busier tiling read




Fragments is open until June 1
Fragments is open until June 1. If shaders and creative coding are on your list, it's worth a look.