Shared foundations
These conventions repeat across every effect below.
- End every custom fragment shader with the Three.js chunks so tone mapping and color space match the renderer. Without them, colors are off:
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
- Declare uniforms with
new THREE.Uniform(value), and update viamaterial.uniforms.uTime.value = elapsedTimein the tick - Attributes (
position,uv,normal) are read only in GLSL. Copy into a local (vec3 newPosition = position;) before modifying - The transparency trio for glowy or smoky things:
transparent: true,depthWrite: false(stops the mesh occluding itself and other transparent surfaces), and oftenblending: THREE.AdditiveBlendingfor light-like effects - Move a value from vertex to fragment with a varying. Varyings are interpolated between vertices, so a normalized vector arriving in the fragment is no longer length 1:
normalize(vNormal)again in the fragment shader - Recenter a 0..1 random or noise value with
- 0.5so offsets go both ways instead of drifting in one direction - Ease a 0..1 progress with
pow(p, 3.0)(slow start) or1.0 - pow(1.0 - p, 3.0)(fast start, ease out). Both only work because the range is 0..1 - Share GLSL helpers via file includes (
#include ../includes/rotate2D.glsl, works with vite-plugin-glsl), one function per file - Prefer a tiling noise TEXTURE over a noise FUNCTION when possible: sampling a 128px repeating Perlin image is far cheaper than running
cnoiseper fragment. SetwrapS = wrapT = THREE.RepeatWrapping. Pack up to 4 different noises in the rgba channels of one image
Raging sea: sin plus noise displacement, color by elevation
Core idea: displace a highly subdivided plane (512x512) in the vertex shader. Big waves are two multiplied sines, small waves are layered 3D Perlin noise where the third dimension is time. Color mixes by elevation passed as a varying.
// vertex
float elevation = sin(modelPosition.x * uBigWavesFrequency.x + uTime * uBigWavesSpeed)
* sin(modelPosition.z * uBigWavesFrequency.y + uTime * uBigWavesSpeed)
* uBigWavesElevation;
for(float i = 1.0; i <= uSmallIterations; i++)
{
elevation -= abs(cnoise(vec3(modelPosition.xz * uSmallWavesFrequency * i, uTime * uSmallWavesSpeed)) * uSmallWavesElevation / i);
}
modelPosition.y += elevation;
vElevation = elevation;
// fragment
float mixStrength = (vElevation + uColorOffset) * uColorMultiplier;
vec3 color = mix(uDepthColor, uSurfaceColor, mixStrength);
-= abs(noise)is the wave-shape trick: plain noise gives smooth hills,abs()gives sharp features, and subtracting flips them into rounded troughs with high crests, which is what real waves look like- The octave loop multiplies frequency by
iand divides amplitude byieach iteration for chaotic multi-frequency detail. GLSLforloops need a float counter - Elevation is roughly -0.2..0.2, useless as a mix factor directly.
uColorOffsetanduColorMultiplierremap it into 0..1 without touching the vertex shader - Colors as lil-gui tweaks need a plain
debugObjectholding hex strings, withonChangecallinguniform.value.set(...)
Modified built-in materials: onBeforeCompile
Core idea: keep everything MeshStandardMaterial gives you (lights, env maps, textures, shadows) and inject vertex animation by string-replacing shader chunks before compile.
const customUniforms = { uTime: { value: 0 } } // module scope, updated in tick
material.onBeforeCompile = (shader) =>
{
shader.uniforms.uTime = customUniforms.uTime
shader.vertexShader = shader.vertexShader.replace('#include <common>', `
#include <common>
uniform float uTime;
mat2 get2dRotateMatrix(float _angle)
{
return mat2(cos(_angle), - sin(_angle), sin(_angle), cos(_angle));
}
`)
shader.vertexShader = shader.vertexShader.replace('#include <beginnormal_vertex>', `
#include <beginnormal_vertex>
float angle = (position.y + uTime) * 0.9;
mat2 rotateMatrix = get2dRotateMatrix(angle);
objectNormal.xz = rotateMatrix * objectNormal.xz;
`)
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', `
#include <begin_vertex>
transformed.xz = rotateMatrix * transformed.xz;
`)
}
Key chunks (source in node_modules/three/src/renderers/shaders/ShaderChunk/):
common: present in all shaders, outsidemain(), the place for uniforms and helper functionsbegin_vertex: createstransformed, the vertex position to displacebeginnormal_vertex: createsobjectNormal. Rotate or transform the normal the same way as the position or lighting and core shadows are wrong. It runs BEFOREbegin_vertex, so declare shared variables there and reuse them (declaring twice gives aredefinitionerror since all chunks merge into one program)
Gotchas:
- Uniforms are not reachable from the material afterwards. Keep a module-scope
customUniformsobject, assign its entries intoshader.uniforms, and update it in tick - Drop shadows come from a separate MeshDepthMaterial render that knows nothing about your displacement. Fix: create
new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking }), apply the SAMEonBeforeCompilevertex patches to it (no normal patch needed, depth does not use normals), and assign it tomesh.customDepthMaterial. Traverse multi-mesh models to set it everywhere
Coffee smoke: perlin-textured alpha plus uv distortion
Core idea: a tall subdivided plane, bottom pinned at the mug. Fragment shader scrolls a tiling Perlin texture through the alpha channel. Vertex shader twists and sways using values sampled from the SAME texture.
// fragment
vec2 smokeUv = vUv;
smokeUv.x *= 0.5;
smokeUv.y *= 0.3;
smokeUv.y -= uTime * 0.03; // scroll upward
float smoke = texture(uPerlinTexture, smokeUv).r;
smoke = smoothstep(0.4, 1.0, smoke); // remap: kill values below 0.4 for sporadic puffs
// fade all four edges by multiplying smoothsteps on the ORIGINAL vUv
smoke *= smoothstep(0.0, 0.1, vUv.x);
smoke *= smoothstep(1.0, 0.9, vUv.x); // inverted limits invert the edge
smoke *= smoothstep(0.0, 0.1, vUv.y);
smoke *= smoothstep(1.0, 0.4, vUv.y);
gl_FragColor = vec4(0.6, 0.3, 0.2, smoke);
// vertex: twist by elevation, sway with wind
float twistPerlin = texture(uPerlinTexture, vec2(0.5, uv.y * 0.2 - uTime * 0.005)).r;
newPosition.xz = rotate2D(newPosition.xz, twistPerlin * 10.0);
vec2 windOffset = vec2(
texture(uPerlinTexture, vec2(0.25, uTime * 0.01)).r - 0.5,
texture(uPerlinTexture, vec2(0.75, uTime * 0.01)).r - 0.5
);
newPosition.xz += windOffset * pow(uv.y, 2.0) * 10.0;
- Sampling the texture along a fixed column (
vec2(0.5, uv.y)) turns a 2D texture into a cheap 1D noise. Use DIFFERENT columns (0.25 and 0.75) for wind x and z or they move diagonally in lockstep pow(uv.y, 2.0)anchors the smoke base: zero offset at the bottom, growing fast near the top- Sampling a texture in the VERTEX shader is fine and cheap
- Geometry transforms (
geometry.translate(0, 0.5, 0)thenscale) put the pivot at the base so vertex math stays simple - Material needs
transparent,side: THREE.DoubleSide,depthWrite: false - To debug vertex motion, temporarily set
gl_FragColorto solid red and turnwireframeon
Hologram: fresnel, scanlines, glitch
Core idea: world-space scanline stripes on the alpha, a fresnel term that brightens edges, additive blending, and a vertex glitch that ripples upward in sporadic bursts.
// fragment
vec3 normal = normalize(vNormal);
if(!gl_FrontFacing) normal *= - 1.0; // flip normals on back faces for DoubleSide
// scanlines: repeating gradient in world space, sharpened
float stripes = mod((vPosition.y - uTime * 0.02) * 20.0, 1.0);
stripes = pow(stripes, 3.0);
// fresnel: bright where view direction is perpendicular to normal
vec3 viewDirection = normalize(vPosition - cameraPosition);
float fresnel = dot(viewDirection, normal) + 1.0; // shift -1..1 to 0..2, facing = 0
fresnel = pow(fresnel, 2.0);
float falloff = smoothstep(0.8, 0.0, fresnel); // fade the extreme rim
float holographic = stripes * fresnel;
holographic += fresnel * 1.25;
holographic *= falloff;
gl_FragColor = vec4(uColor, holographic);
// vertex glitch, applied to modelPosition before view/projection
float glitchTime = uTime - modelPosition.y; // wave travels bottom to top
float glitchStrength = sin(glitchTime) + sin(glitchTime * 3.45) + sin(glitchTime * 8.76);
glitchStrength /= 3.0;
glitchStrength = smoothstep(0.3, 1.0, glitchStrength); // mostly off, occasional bursts
glitchStrength *= 0.25;
modelPosition.x += (random2D(modelPosition.xz + uTime) - 0.5) * glitchStrength;
modelPosition.z += (random2D(modelPosition.zx + uTime) - 0.5) * glitchStrength;
- Base stripes on
modelPosition(world space) so the pattern stays fixed while the object rotates. Use localpositionif you want the pattern to travel with the object - Transform normals with
modelMatrix * vec4(normal, 0.0): the w = 0.0 skips translation, because a normal is a direction, not a position cameraPositionis a built-in uniform, view direction isnormalize(vPosition - cameraPosition), dot of the two gives -1 facing camera, 0 at the silhouette. Adding 1.0 makes it a usable 0..2 rim value- GLSL random:
fract(sin(dot(v, vec2(12.9898, 78.233))) * 43758.5453123), seed with position PLUS time or the glitch freezes when the object stops - Summing sines at unrelated frequencies (1.0, 3.45, 8.76) then dividing by the count fakes aperiodic randomness for burst timing
- Material:
transparent,side: THREE.DoubleSide,depthWrite: false,blending: THREE.AdditiveBlending
Fireworks: phased particle animation from one progress value
Core idea: a THREE.Points cloud with positions on a jittered sphere. GSAP animates a single uProgress uniform 0 to 1 linearly. The vertex shader remaps that one value into overlapping phases: explode, fall, scale up/down, twinkle. Dispose on complete.
float remap(float value, float originMin, float originMax, float destinationMin, float destinationMax)
{
return destinationMin + (value - originMin) * (destinationMax - destinationMin) / (originMax - originMin);
}
void main()
{
float progress = uProgress * aTimeMultiplier; // per-particle speed (attribute 1..2)
vec3 newPosition = position;
// Exploding: 0.0 -> 0.1
float explodingProgress = clamp(remap(progress, 0.0, 0.1, 0.0, 1.0), 0.0, 1.0);
explodingProgress = 1.0 - pow(1.0 - explodingProgress, 3.0); // ease out
newPosition = mix(vec3(0.0), newPosition, explodingProgress);
// Falling: 0.1 -> 1.0
float fallingProgress = clamp(remap(progress, 0.1, 1.0, 0.0, 1.0), 0.0, 1.0);
fallingProgress = 1.0 - pow(1.0 - fallingProgress, 3.0);
newPosition.y -= fallingProgress * 0.2;
// Scaling: fast open, slow close, min of the two ramps
float sizeOpeningProgress = remap(progress, 0.0, 0.125, 0.0, 1.0);
float sizeClosingProgress = remap(progress, 0.125, 1.0, 1.0, 0.0);
float sizeProgress = clamp(min(sizeOpeningProgress, sizeClosingProgress), 0.0, 1.0);
// Twinkling: sine flicker gated by its own window, inverted so idle = visible
float twinklingProgress = clamp(remap(progress, 0.2, 0.8, 0.0, 1.0), 0.0, 1.0);
float sizeTwinkling = sin(progress * 30.0) * 0.5 + 0.5;
sizeTwinkling = 1.0 - sizeTwinkling * twinklingProgress;
vec4 modelPosition = modelMatrix * vec4(newPosition, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
gl_Position = projectionMatrix * viewPosition;
gl_PointSize = uSize * uResolution.y * aSize * sizeProgress * sizeTwinkling;
gl_PointSize *= 1.0 / - viewPosition.z; // perspective attenuation
if(gl_PointSize < 1.0)
gl_Position = vec4(9999.9); // Windows clamps points to min 1px, hide off-clip
}
- The remap-clamp-ease pattern is the whole trick: each phase gets its own window of the shared progress,
clamppins it outside the window,min()of an opening and a closing ramp gives an up-then-down envelope - Correct point sizing:
uSize * uResolution.ywhereuResolutionis render size TIMES pixel ratio, kept in a sharedVector2updated on resize. This makes particles scale with render height (Three.js FOV is vertical) and stay identical across pixel ratios. Some GPUs capgl_PointSizeat 64 - Particle textures: sample with
gl_PointCoordnot uv, settexture.flipY = false, take only the.rchannel of grayscale sprites as alpha - Sphere distribution:
new THREE.Spherical(radius * (0.75 + Math.random() * 0.25), Math.random() * Math.PI, Math.random() * Math.PI * 2)thenvector3.setFromSpherical(spherical). Jittering the radius breaks the too-perfect shell - Per-particle attributes:
aSize(random 0..1) andaTimeMultiplier(1..2) with item size 1. The multiplier desynchronizes lifespans while guaranteeing every particle finishes BEFORE the dispose - Cleanup on GSAP
onComplete:scene.remove(points); geometry.dispose(); material.dispose(). Keep textures alive for reuse - Drive with
gsap.to(material.uniforms.uProgress, { value: 1, duration: 3, ease: 'linear' }): linear, because easing lives in the shader
Wobbly sphere: CustomShaderMaterial plus 4D simplex noise
Core idea: keep the full MeshPhysicalMaterial (transmission, metalness, shadows) and displace vertices along their normals with warped 4D simplex noise, using the three-custom-shader-material (CSM) library instead of raw onBeforeCompile.
import CustomShaderMaterial from 'three-custom-shader-material/vanilla'
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js'
let geometry = new THREE.IcosahedronGeometry(2.5, 50)
geometry = mergeVertices(geometry) // tangents need an indexed geometry
geometry.computeTangents()
const uniforms = { uTime: new THREE.Uniform(0), /* frequencies, strengths, colors */ }
const material = new CustomShaderMaterial({
baseMaterial: THREE.MeshPhysicalMaterial,
vertexShader, fragmentShader, uniforms,
metalness: 0, roughness: 0.5, transmission: 0, ior: 1.5, thickness: 1.5,
})
const depthMaterial = new CustomShaderMaterial({
baseMaterial: THREE.MeshDepthMaterial,
vertexShader, uniforms, // same vertex shader, no fragment
depthPacking: THREE.RGBADepthPacking,
})
mesh.customDepthMaterial = depthMaterial
CSM output variables: write csm_Position, csm_Normal in the vertex shader; csm_DiffuseColor (pre-lighting color), csm_Metalness, csm_Roughness, csm_FragColor (final override, loses shading) in the fragment shader.
// vertex
float getWobble(vec3 position)
{
vec3 warpedPosition = position; // noise-warp the noise input for organic motion
warpedPosition += simplexNoise4d(vec4(position * uWarpPositionFrequency, uTime * uWarpTimeFrequency)) * uWarpStrength;
return simplexNoise4d(vec4(warpedPosition * uPositionFrequency, uTime * uTimeFrequency)) * uStrength;
}
void main()
{
vec3 biTangent = cross(normal, tangent.xyz); // attribute vec4 tangent
float shift = 0.01;
vec3 positionA = csm_Position + tangent.xyz * shift;
vec3 positionB = csm_Position + biTangent * shift;
float wobble = getWobble(csm_Position);
csm_Position += wobble * normal;
positionA += getWobble(positionA) * normal;
positionB += getWobble(positionB) * normal;
vec3 toA = normalize(positionA - csm_Position);
vec3 toB = normalize(positionB - csm_Position);
csm_Normal = cross(toA, toB);
vWobble = wobble / uStrength; // renormalize to -1..1 for the fragment
}
// fragment
float colorMix = smoothstep(- 1.0, 1.0, vWobble);
csm_DiffuseColor.rgb = mix(uColorA, uColorB, colorMix);
csm_Roughness = 1.0 - colorMix; // shiny tips
// or hard mirror tips: csm_Metalness = step(0.25, vWobble); csm_Roughness = 1.0 - csm_Metalness;
- Neighbour-normal recomputation for ARBITRARY meshes: neighbours are found along the tangent and bitangent (bitangent =
cross(normal, tangent)), both displaced with the same function, thencsm_Normal = cross(toA, toB). This works on imported models, unlike grid-based neighbour math computeTangents()requires index, position, normal, uv. Non-indexed geometry: runmergeVertices()first (expensive, cost grows steeply with vertex count, one-time at startup)- 4D noise: xyz = position, w = time. Warping (noise fed into noise) beats stacking octaves when you want blobby organic motion instead of waves
- One shared
uniformsobject feeds both the material and the depth material so shadows animate identically - Works on GLTF models directly: assign
materialandcustomDepthMaterialto the loaded mesh (imported geometry is often already indexed)
Sliced model: discard by radial angle
Core idea: cut a cake slice out of a mesh by discarding fragments whose polar angle falls inside a start + arc window, show the interior with DoubleSide, paint back faces a flat color, and patch the depth material so shadows are sliced too.
// fragment (CSM on MeshStandardMaterial)
float angle = atan(vPosition.y, vPosition.x); // vPosition = csm_Position.xyz varying, local space
angle -= uSliceStart;
angle = mod(angle, PI * 2.0); // GLSL mod wraps negatives up into 0..2PI (unlike JS %)
if(angle > 0.0 && angle < uSliceArc)
discard;
float csm_Slice; // dummy declaration activates the patchMap
atan(y, x)returns -PI..PI, so a start+arc window that crosses the seam fails a naive range check. Fix: rotate the frame by subtractinguSliceStart, thenmod(angle, 2.0 * PI)folds the negative branch into 0..2PI, and the test is simply0 < angle < uSliceArcPIalready exists in built-in materials via thecommonchunk, no#defineneeded- Flat interior color: real cap geometry is too expensive to rebuild per frame, so set
side: THREE.DoubleSideand color back faces (!gl_FrontFacing) a uniform color. In CSM you cannot usecsm_FragColorfor only one branch (its mere presence overrides the whole output), so inject viapatchMap:
const patchMap = {
csm_Slice: {
'#include <colorspace_fragment>': `
#include <colorspace_fragment>
if(!gl_FrontFacing)
gl_FragColor = vec4(0.75, 0.15, 0.3, 1.0);
`
}
}
// activate by declaring `float csm_Slice;` anywhere in the shader
- Shadow fix: duplicate the material as
baseMaterial: THREE.MeshDepthMaterialwithdepthPacking: THREE.RGBADepthPacking, same vertexShader, same FRAGMENT shader this time (the discard must run in the depth pass), same uniforms and patchMap. Assign tocustomDepthMaterialof the sliced mesh only - Apply the sliced material selectively by traversing the model and matching
child.name - When debugging positions as colors, temporarily disable tone mapping and set
renderer.outputColorSpace = THREE.LinearSRGBColorSpace, otherwise the debug colors lie
Procedural terrain: CSM displacement, octaves, elevation coloring
Core idea: a 500x500 plane rotated flat AT THE GEOMETRY level (geometry.rotateX(- Math.PI * 0.5)) so shader math ignores mesh transforms. Vertex shader builds elevation from warped simplex octaves, recomputes normals via grid neighbours, and the fragment shader banded-colors by height plus slope.
// vertex
float getElevation(vec2 position)
{
vec2 warpedPosition = position;
warpedPosition += uTime * 0.2; // terrain drifts
warpedPosition += simplexNoise2d(warpedPosition * uPositionFrequency * uWarpFrequency) * uWarpStrength;
float elevation = 0.0;
elevation += simplexNoise2d(warpedPosition * uPositionFrequency ) / 2.0;
elevation += simplexNoise2d(warpedPosition * uPositionFrequency * 2.0) / 4.0;
elevation += simplexNoise2d(warpedPosition * uPositionFrequency * 4.0) / 8.0;
float elevationSign = sign(elevation);
elevation = pow(abs(elevation), 2.0) * elevationSign; // flatten near sea level, keep crevices
elevation *= uStrength;
return elevation;
}
void main()
{
float shift = 0.01;
vec3 positionA = position.xyz + vec3(shift, 0.0, 0.0);
vec3 positionB = position.xyz + vec3(0.0, 0.0, - shift); // NEGATIVE z or the normal flips
float elevation = getElevation(csm_Position.xz);
csm_Position.y += elevation;
positionA.y += getElevation(positionA.xz);
positionB.y += getElevation(positionB.xz);
vec3 toA = normalize(positionA - csm_Position);
vec3 toB = normalize(positionB - csm_Position);
csm_Normal = cross(toA, toB);
vPosition = csm_Position;
vPosition.xz += uTime * 0.2; // keep fragment noise in sync with the drifting terrain
vUpDot = dot(csm_Normal, vec3(0.0, 1.0, 0.0)); // slope, computed once per vertex
}
// fragment: bottom-up color bands, each mix overwrites the last
vec3 color = vec3(1.0);
float surfaceWaterMix = smoothstep(- 1.0, - 0.1, vPosition.y); // smooth depth gradient
color = mix(uColorWaterDeep, uColorWaterSurface, surfaceWaterMix);
color = mix(color, uColorSand, step(- 0.1, vPosition.y)); // hard shoreline
color = mix(color, uColorGrass, step(- 0.06, vPosition.y));
float rockMix = 1.0 - step(0.8, vUpDot); // steep faces only
rockMix *= step(- 0.06, vPosition.y); // not underwater
color = mix(color, uColorRock, rockMix);
float snowThreshold = 0.45 + simplexNoise2d(vPosition.xz * 15.0) * 0.1; // ragged snow line
color = mix(color, uColorSnow, step(snowThreshold, vPosition.y));
csm_DiffuseColor = vec4(color, 1.0);
- Octaves: frequency x2, amplitude /2 per layer, and keep the total under 1.0 so the plateau
powbehaves powon signed values is the classic terrain bug: even exponents erase negatives, odd exponents double-apply the sign trick. The robust form ispow(abs(e), n) * sign(e)- Since
csm_Normalreplaces the attribute entirely, delete unused attributes:geometry.deleteAttribute('normal')anddeleteAttribute('uv') - Use the LOWEST-dimensional noise that works (2D here), noise cost grows with dimensions
- Same shared-uniforms depth material recipe as the wobble:
MeshDepthMaterialbase, same vertex shader,RGBADepthPacking, oncustomDepthMaterial - Water surface is not a shader: a plain plane at y = -0.1 with
MeshPhysicalMaterial({ transmission: 1, roughness: 0.3 })refracts and blurs the terrain below for free - Surrounding board: boolean-subtract two boxes with three-bvh-csg (
evaluator.evaluate(fill, hole, SUBTRACTION)), callgeometry.clearGroups()to drop per-brush materials, andupdateMatrixWorld()manually after transforming a Brush that never renders
Common mistakes
- Forgetting
#include <colorspace_fragment>at the end of a fragment shader, then wondering why colors look washed out - Setting alpha in the shader without
transparent: trueon the material - Transparent surfaces occluding themselves: missing
depthWrite: false - Displacing vertices without touching normals, giving flat-looking or wrongly lit results. Recompute via neighbours (grid offsets for planes, tangent/bitangent for arbitrary meshes)
- Displacing vertices and not patching the depth material, so shadows show the undeformed mesh. Every vertex-displacement recipe needs the
customDepthMaterial+RGBADepthPackingpair - Declaring the same variable in two onBeforeCompile chunk replacements: chunks merge into one program, declare once in the earliest chunk
- Transforming a normal with w = 1.0, which applies translation and bends every normal toward the offset. Directions use w = 0.0
- Trusting an interpolated varying normal to be unit length, causing a faint grid artifact. Re-normalize in the fragment shader
- Using
count - 1style range assumptions from JS on GLSLmod: GLSLmodwraps negatives upward (mod(-0.25, 1.0) = 0.75), JS%does not - Sizing points without resolution and pixel ratio, so particles differ across screens, and forgetting the Windows 1px minimum clamp (hide with
gl_Position = vec4(9999.9)) - Calling
computeTangents()on a non-indexed geometry (throws):mergeVertices()first - Sampling the same noise-texture line for two axes, producing perfectly diagonal motion. Offset the sample coordinates per axis
- Running a Perlin FUNCTION per fragment when a tiny tiling noise texture would do: the texture is the performance-safe default
- Easing a 0..1 progress with
powand getting the wrong end fast:pow(p, n)eases in,1.0 - pow(1.0 - p, n)eases out - Remapping a phase window without clamping, so the animation keeps running past the window bounds
- Forgetting to dispose geometry and material when removing short-lived objects like fireworks, leaking GPU memory per click