Domain-Warped FBM Fog
A full-screen fragment shader on a single triangle. Two octaves of value-noise fbm are sampled twice to build a 2D offset, and that offset is fed back into a third sample — the result flows instead of cross-fading. The pointer enters the warp three times with different weights and a sign flip, so the fog leans toward the cursor without following it.
Domain-Warped FBM Fog
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/domain-warped-fbm.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Domain-Warped FBM Fog
A full-screen fragment shader on a single triangle. Two octaves of value-noise fbm are sampled twice to build a 2D offset, and that offset is fed back into a third sample — the result flows instead of cross-fading. The pointer enters the warp three times with different weights and a sign flip, so the fog leans toward the cursor without following it.
Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.
## What it does
Slow, low-contrast fog that flows rather than fades, with a faint light that trails the cursor.
- Category: webgl
- Runtime cost: webgl
- Dependencies: none beyond React
## How it works
1. `fbm(p)` sums two octaves of 3D value noise with amplitudes 0.6 and 0.3 and lacunarity 1.93. The third dimension is time, so the field evolves continuously instead of scrolling.
2. The domain warp is `q = vec2(fbm(sp + m*0.20, t), fbm(sp + vec2(2.3,1.1) - m*0.20, t))`, then `n = fbm(sp + q*0.4 + m*0.12, t*0.8) * 0.5 + 0.5`. The `vec2(2.3, 1.1)` offset decorrelates the second channel from the first; without it both return the same number and the warp collapses into a diagonal translation.
3. Both the sample point and the pointer are multiplied by `aspect` on x before use, so the noise stays isotropic and the cursor glow stays circular on a wide screen.
4. `shade = smoothstep(0.25, 0.85, n)` crushes the noise into a soft two-tone field, and the final colour is `base + tint * (gain * shade + lift)` where `lift = (pointerY - 0.5) * 0.03` tilts the whole frame slightly as the cursor moves up and down.
5. The cursor light is `smoothstep(0.85, 0.0, d)` — arguments deliberately reversed to produce a falling ramp, 1 at the cursor and 0 at distance 0.85, without the extra `1.0 -`.
6. A vignette `smoothstep(1.25, 0.5, distance(uv, 0.5))` multiplies the result rather than subtracting from it, so the cursor glow dims with the corners instead of punching through them.
## Constants — use these values
These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".
- `scale = 0.7`
Multiplier on the sample coordinate. Below 1.0 the noise features are LARGER than the screen, so you get a handful of slow masses. At 3.0 the same shader is television static.
- `warp = 0.4`
How far `q` displaces the final sample. Under ~0.15 the warp is invisible and you are looking at plain fbm; over ~0.9 the field folds back on itself and produces hard creases that read as compression artefacts.
- `octaves = 2`
Textbook fbm uses 4-6. Two is enough because the output goes straight through `smoothstep(0.25, 0.85, n)`, which discards the fine detail anyway. fbm is essentially the whole cost of this shader, so halving the octaves halves the frame time.
- `lacunarity = 1.93`
Frequency step between octaves. Exactly 2.0 makes the two lattices coincide on every other cell boundary and stamps a faint axis-aligned cross into the field; an off-integer step decorrelates them.
- `amplitude schedule = 0.6 then 0.3`
Starting below 1.0 keeps the two-octave sum inside roughly -0.9..0.9, so `n * 0.5 + 0.5` lands in 0..1 without clipping and the smoothstep window sits where the data actually is.
- `time scale = 0.03`
The field advances 0.0005 units per frame at 60 Hz. It has to read as weather, not animation — at 0.3 it becomes a lava lamp and starts competing with the content on top of it.
- `gain = 0.22`
Peak brightness above the base. The reference site sits at 0.08, which keeps the entire image inside the bottom 17% of the value range so white type sits on it perfectly. 0.22 is the standalone default; drop it toward 0.08 the moment you put text on top.
- `pointer smoothing = 0.02`
Fraction of the gap left after one second. The pointer displaces a noise domain, so raw values make the whole field jump between pointer events rather than drift.
## API
```ts
function DomainWarpedFbm(props: { scale?: number; warp?: number; gain?: number; speed?: number; base?: readonly [number, number, number]; tint?: readonly [number, number, number]; maxDpr?: number; className?: string }): JSX.Element
```
- `scale: number` — default `0.7`
Noise frequency. Below 1.0 the features are larger than the screen.
- `warp: number` — default `0.4`
How far the first two fbm samples displace the third.
- `gain: number` — default `0.22`
Peak brightness added above the base colour.
- `speed: number` — default `1`
Multiplier on the drift, which is already very slow.
- `base: readonly [number, number, number]` — default `[0.03, 0.032, 0.042]`
Base colour, linear 0..1 RGB, straight into the shader.
- `tint: readonly [number, number, number]` — default `[0.86, 0.9, 1]`
Colour of the lit fog and the cursor glow.
- `maxDpr: number` — default `2`
Upper bound on device pixel ratio.
- `className: string`
Merged onto the host element.
## Reference implementation of the core
This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.
```tsx
const TRIANGLE = new Float32Array([-1, -1, 3, -1, -1, 3]);
const SCALE = 0.7;
const WARP = 0.4;
const GAIN = 0.22;
const BASE: readonly [number, number, number] = [0.03, 0.032, 0.042];
const TINT: readonly [number, number, number] = [0.86, 0.9, 1];
interface Gpu {
gl: WebGL2RenderingContext;
program: WebGLProgram;
vao: WebGLVertexArrayObject;
buffer: WebGLBuffer;
locations: Map<string, WebGLUniformLocation | null>;
}
/* A local 60-line bootstrap rather than three.js or ogl. This component draws
three vertices; a framework would be 100x its own weight in dependency. */
function build(gl: WebGL2RenderingContext, vertSrc: string, fragSrc: string): Gpu | null {
const compile = (type: number, src: string): WebGLShader | null => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
console.error('[domain-warped-fbm]', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
};
const vert = compile(gl.VERTEX_SHADER, vertSrc);
const frag = compile(gl.FRAGMENT_SHADER, fragSrc);
const program = gl.createProgram();
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
if (!vert || !frag || !program || !vao || !buffer) return null;
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[domain-warped-fbm]', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, TRIANGLE, gl.STATIC_DRAW);
const attrib = gl.getAttribLocation(program, 'a_pos');
if (attrib >= 0) {
gl.enableVertexAttribArray(attrib);
gl.vertexAttribPointer(attrib, 2, gl.FLOAT, false, 0, 0);
}
gl.bindVertexArray(null);
return { gl, program, vao, buffer, locations: new Map() };
}
function loc(gpu: Gpu, name: string): WebGLUniformLocation | null {
const cached = gpu.locations.get(name);
if (cached !== undefined) return cached;
const found = gpu.gl.getUniformLocation(gpu.program, name);
gpu.locations.set(name, found);
return found;
}
export function DomainWarpedFbm({
scale = SCALE,
warp = WARP,
gain = GAIN,
speed = 1,
base = BASE,
tint = TINT,
maxDpr = 2,
className,
}: DomainWarpedFbmProps) {
const host = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const gpuRef = useRef<Gpu | null>(null);
const { width, height } = useResizeObserver(host);
const reduced = useReducedMotion();
const visible = useVisibility();
// Heavy smoothing: the pointer drives a warp offset, and an unsmoothed
// pointer makes the whole field snap between frames rather than drift.
const pointer = useSmoothPointer({ target: host, smoothing: 0.02 });
const [supported, setSupported] = useState(true);
const paramsRef = useRef({ scale, warp, gain, speed, base, tint });
paramsRef.current = { scale, warp, gain, speed, base, tint };
const drawRef = useRef<(elapsed: number) => void>(() => {});
drawRef.current = (elapsed: number) => {
const gpu = gpuRef.current;
if (!gpu) return;
const { gl } = gpu;
const params = paramsRef.current;
const p = pointer.current;
gl.useProgram(gpu.program);
gl.uniform2f(
loc(gpu, 'u_resolution'),
gl.drawingBufferWidth,
gl.drawingBufferHeight,
);
gl.uniform1f(loc(gpu, 'u_time'), elapsed * params.speed);
gl.uniform2f(loc(gpu, 'u_pointer'), p.nx, 1 - p.ny);
gl.uniform1f(loc(gpu, 'u_scale'), params.scale);
gl.uniform1f(loc(gpu, 'u_warp'), params.warp);
gl.uniform1f(loc(gpu, 'u_gain'), params.gain);
gl.uniform3f(loc(gpu, 'u_base'), params.base[0], params.base[1], params.base[2]);
gl.uniform3f(loc(gpu, 'u_tint'), params.tint[0], params.tint[1], params.tint[2]);
gl.bindVertexArray(gpu.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!hasWebGL()) {
setSupported(false);
return;
}
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'high-performance',
});
if (!gl) {
setSupported(false);
return;
}
const gpu = build(gl, DOMAIN_WARPED_FBM_VERT, DOMAIN_WARPED_FBM_FRAG);
if (!gpu) {
loseContext(gl);
setSupported(false);
return;
}
gpuRef.current = gpu;
setSupported(true);
return () => {
gpuRef.current = null;
gl.deleteVertexArray(gpu.vao);
gl.deleteBuffer(gpu.buffer);
gl.deleteProgram(gpu.program);
loseContext(gl);
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
const gpu = gpuRef.current;
if (!canvas || !gpu || width === 0 || height === 0) return;
const dpr = pixelRatio(maxDpr);
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
gpu.gl.viewport(0, 0, w, h);
// One frame here covers both the first paint and the reduced-motion path.
drawRef.current(reduced ? 12 : 0);
}, [width, height, maxDpr, supported, reduced]);
const running = supported && visible && !reduced && width > 0 && height > 0;
useRaf((_dt, elapsed) => drawRef.current(elapsed), running);
```
## Requirements
These are not optional. A version of this component that skips any of them is
not finished.
**Cleanup.** Deletes the VAO, the vertex buffer and the program, then `loseContext(gl)`. The rAF subscription and the pointer listener are removed by their hooks.
**Reduced motion.** The rAF loop never subscribes. One frame is drawn at a fixed `u_time` of 12 seconds — far enough into the sequence that the field is fully developed rather than at its symmetric t=0 state — and redrawn on resize. The cursor light does not track.
**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.
**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.
## Known pitfalls — avoid these specifically
- Aspect-correcting the sample space but not the pointer. The noise looks right and the cursor glow becomes an ellipse that stretches further the wider the window gets — the two must live in the same coordinate system.
- Using the same offset for both warp channels. `fbm(sp)` and `fbm(sp)` are the same number, so `q` becomes a scalar along the diagonal and the "warp" is a plain translation; you get sliding blobs with none of the folding.
- Feeding `sin()` a large argument inside the hash. `fract(sin(dot(p, ...)))` degrades badly once `p` is in the thousands: the noise develops repeating bands, and it fails on mobile GPUs at scales where the desktop still looks fine. Fold the input with `fract()` first.
- Running 5 octaves because that is what the tutorial did. The output is immediately crushed by a smoothstep, so octaves 3-5 cost real milliseconds and change nothing you can see.
- Letting the shader run in a hidden tab. `useVisibility` gates the loop; without it a backgrounded page keeps a GPU-bound fragment shader resident and the laptop fan tells the user about it.
- Relying on `dispose()`-style cleanup. Raw GL has none — the VAO, buffer and program are deleted explicitly and `loseContext(gl)` hands the context back, without which mounting this component twenty times kills the oldest contexts on the page.
## Notes
- Value noise rather than simplex: after `smoothstep(0.25, 0.85, n)` the two are indistinguishable, and value noise is eight hash calls and seven mixes with no permutation polynomial.
- Total brightness at the defaults runs roughly 0.03 to 0.30. Set `gain={0.08}` for the original hero behaviour, where the whole field stays under 0.18 and white type needs no scrim.
## Definition of done
Watch for ten seconds: the masses should visibly fold and stretch, not just brighten and dim in place. Move the cursor and a faint circular light follows it a beat late while the fog around it shears. Resize from 320px to full width and the noise features stay the same shape rather than stretching horizontally.
When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
Source
9 files, exactly as installed.
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';
import { useSmoothPointer } from '@/hooks/use-smooth-pointer';
import { useVisibility } from '@/hooks/use-visibility';
import { hasWebGL, loseContext, pixelRatio } from '@/lib/webgl';
import {
DOMAIN_WARPED_FBM_FRAG,
DOMAIN_WARPED_FBM_VERT,
} from '@/components/motion/shaders/domain-warped-fbm';
/* ==========================================================================
Domain-Warped FBM Fog
--------------------------------------------------------------------------
Raw WebGL2 on a single fullscreen triangle. Two octaves of value noise are
sampled twice to build an offset vector, and that offset is fed back into a
third sample — Iñigo Quílez's domain warp. It is the difference between
blobs that fade in and out, and a field that looks like it is flowing.
The whole image lives in a narrow, low band of the value range. That is the
point: it is a backdrop for type, not a picture.
========================================================================== */
export interface DomainWarpedFbmProps {
/**
* Noise frequency. Below 1.0 the features are larger than the screen, which
* is what makes this fog rather than static.
*/
scale?: number;
/** How far the first two fbm samples displace the third. */
warp?: number;
/** Peak brightness added above the base colour. */
gain?: number;
/** Multiplier on the (already very slow) drift. */
speed?: number;
/** Base colour, linear 0..1 RGB. Read straight into the shader. */
base?: readonly [number, number, number];
/** Colour of the lit part of the fog, linear 0..1 RGB. */
tint?: readonly [number, number, number];
maxDpr?: number;
className?: string;
}
// #region critical
const TRIANGLE = new Float32Array([-1, -1, 3, -1, -1, 3]);
const SCALE = 0.7;
const WARP = 0.4;
const GAIN = 0.22;
const BASE: readonly [number, number, number] = [0.03, 0.032, 0.042];
const TINT: readonly [number, number, number] = [0.86, 0.9, 1];
interface Gpu {
gl: WebGL2RenderingContext;
program: WebGLProgram;
vao: WebGLVertexArrayObject;
buffer: WebGLBuffer;
locations: Map<string, WebGLUniformLocation | null>;
}
/* A local 60-line bootstrap rather than three.js or ogl. This component draws
three vertices; a framework would be 100x its own weight in dependency. */
function build(gl: WebGL2RenderingContext, vertSrc: string, fragSrc: string): Gpu | null {
const compile = (type: number, src: string): WebGLShader | null => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
console.error('[domain-warped-fbm]', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
};
const vert = compile(gl.VERTEX_SHADER, vertSrc);
const frag = compile(gl.FRAGMENT_SHADER, fragSrc);
const program = gl.createProgram();
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
if (!vert || !frag || !program || !vao || !buffer) return null;
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[domain-warped-fbm]', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, TRIANGLE, gl.STATIC_DRAW);
const attrib = gl.getAttribLocation(program, 'a_pos');
if (attrib >= 0) {
gl.enableVertexAttribArray(attrib);
gl.vertexAttribPointer(attrib, 2, gl.FLOAT, false, 0, 0);
}
gl.bindVertexArray(null);
return { gl, program, vao, buffer, locations: new Map() };
}
function loc(gpu: Gpu, name: string): WebGLUniformLocation | null {
const cached = gpu.locations.get(name);
if (cached !== undefined) return cached;
const found = gpu.gl.getUniformLocation(gpu.program, name);
gpu.locations.set(name, found);
return found;
}
export function DomainWarpedFbm({
scale = SCALE,
warp = WARP,
gain = GAIN,
speed = 1,
base = BASE,
tint = TINT,
maxDpr = 2,
className,
}: DomainWarpedFbmProps) {
const host = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const gpuRef = useRef<Gpu | null>(null);
const { width, height } = useResizeObserver(host);
const reduced = useReducedMotion();
const visible = useVisibility();
// Heavy smoothing: the pointer drives a warp offset, and an unsmoothed
// pointer makes the whole field snap between frames rather than drift.
const pointer = useSmoothPointer({ target: host, smoothing: 0.02 });
const [supported, setSupported] = useState(true);
const paramsRef = useRef({ scale, warp, gain, speed, base, tint });
paramsRef.current = { scale, warp, gain, speed, base, tint };
const drawRef = useRef<(elapsed: number) => void>(() => {});
drawRef.current = (elapsed: number) => {
const gpu = gpuRef.current;
if (!gpu) return;
const { gl } = gpu;
const params = paramsRef.current;
const p = pointer.current;
gl.useProgram(gpu.program);
gl.uniform2f(
loc(gpu, 'u_resolution'),
gl.drawingBufferWidth,
gl.drawingBufferHeight,
);
gl.uniform1f(loc(gpu, 'u_time'), elapsed * params.speed);
gl.uniform2f(loc(gpu, 'u_pointer'), p.nx, 1 - p.ny);
gl.uniform1f(loc(gpu, 'u_scale'), params.scale);
gl.uniform1f(loc(gpu, 'u_warp'), params.warp);
gl.uniform1f(loc(gpu, 'u_gain'), params.gain);
gl.uniform3f(loc(gpu, 'u_base'), params.base[0], params.base[1], params.base[2]);
gl.uniform3f(loc(gpu, 'u_tint'), params.tint[0], params.tint[1], params.tint[2]);
gl.bindVertexArray(gpu.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!hasWebGL()) {
setSupported(false);
return;
}
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'high-performance',
});
if (!gl) {
setSupported(false);
return;
}
const gpu = build(gl, DOMAIN_WARPED_FBM_VERT, DOMAIN_WARPED_FBM_FRAG);
if (!gpu) {
loseContext(gl);
setSupported(false);
return;
}
gpuRef.current = gpu;
setSupported(true);
return () => {
gpuRef.current = null;
gl.deleteVertexArray(gpu.vao);
gl.deleteBuffer(gpu.buffer);
gl.deleteProgram(gpu.program);
loseContext(gl);
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
const gpu = gpuRef.current;
if (!canvas || !gpu || width === 0 || height === 0) return;
const dpr = pixelRatio(maxDpr);
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
gpu.gl.viewport(0, 0, w, h);
// One frame here covers both the first paint and the reduced-motion path.
drawRef.current(reduced ? 12 : 0);
}, [width, height, maxDpr, supported, reduced]);
const running = supported && visible && !reduced && width > 0 && height > 0;
useRaf((_dt, elapsed) => drawRef.current(elapsed), running);
// #endregion critical
return (
<div ref={host} className={cn('relative h-full w-full overflow-hidden', className)}>
{supported ? (
<canvas ref={canvasRef} className="block h-full w-full" />
) : (
<div
aria-hidden
className="h-full w-full bg-bg"
style={{
backgroundImage:
'radial-gradient(120% 90% at 30% 25%, var(--k-surface) 0%, transparent 55%), radial-gradient(90% 80% at 75% 70%, var(--k-bg-subtle) 0%, transparent 60%)',
}}
/>
)}
</div>
);
}
/* ==========================================================================
Domain-Warped FBM Fog — GLSL ES 3.00
--------------------------------------------------------------------------
`#version 300 es` must be the first token in the file, so the literal opens
flush against the backtick.
========================================================================== */
export const DOMAIN_WARPED_FBM_VERT = `#version 300 es
in vec2 a_pos;
out vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
export const DOMAIN_WARPED_FBM_FRAG = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_pointer; /* 0..1, y up, already smoothed on the CPU */
uniform float u_scale; /* < 1.0 makes features LARGER than the screen */
uniform float u_warp; /* how far the first two fbm samples push the third */
uniform float u_gain; /* peak brightness above the base colour */
uniform vec3 u_base;
uniform vec3 u_tint;
/* --------------------------------------------------------------------------
Value noise. Cheaper than simplex and indistinguishable once the result has
been through a smoothstep, which is exactly what happens below.
The hash folds its input into 0..1 with fract() BEFORE the sin(). Feeding
sin() a large argument is the classic way this hash falls apart: past a few
thousand radians the fractional part of the angle is quantised, the noise
develops visible repeating bands, and it breaks on mobile GPUs long before
it breaks on the desktop it was written on.
-------------------------------------------------------------------------- */
float hash13(vec3 p) {
p = fract(p * 0.3183099 + 0.1);
return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123);
}
float vnoise(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
/* Hermite fade. Plain linear interpolation leaves visible creases along the
lattice because the first derivative is discontinuous at the cell walls. */
vec3 u = f * f * (3.0 - 2.0 * f);
float n000 = hash13(i);
float n100 = hash13(i + vec3(1.0, 0.0, 0.0));
float n010 = hash13(i + vec3(0.0, 1.0, 0.0));
float n110 = hash13(i + vec3(1.0, 1.0, 0.0));
float n001 = hash13(i + vec3(0.0, 0.0, 1.0));
float n101 = hash13(i + vec3(1.0, 0.0, 1.0));
float n011 = hash13(i + vec3(0.0, 1.0, 1.0));
float n111 = hash13(i + vec3(1.0, 1.0, 1.0));
float x00 = mix(n000, n100, u.x);
float x10 = mix(n010, n110, u.x);
float x01 = mix(n001, n101, u.x);
float x11 = mix(n011, n111, u.x);
return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z) * 2.0 - 1.0;
}
/* Two octaves, amplitude 0.6 then 0.3, lacunarity 1.93. Standard fbm runs
four to six; two is enough because the result is immediately crushed by
smoothstep(0.25, 0.85, n) and the extra detail is thrown away. Halving the
octave count halves the cost of the shader, since fbm IS the shader. */
float fbm(vec3 p) {
float sum = 0.0;
float amp = 0.6;
for (int i = 0; i < 2; i++) {
sum += amp * vnoise(p);
p *= 1.93;
amp *= 0.5;
}
return sum;
}
void main() {
float aspect = u_resolution.x / max(u_resolution.y, 1.0);
/* Correct BOTH the sample space and the pointer. Correcting only the sample
space leaves the cursor glow as an ellipse on a wide screen. */
vec2 p = v_uv;
p.x *= aspect;
vec2 m = u_pointer;
m.x *= aspect;
float t = u_time * 0.03;
vec2 sp = p * u_scale;
/* Domain warp: two decorrelated fbm samples become an offset fed into a
third. The vec2(2.3, 1.1) shift on the second channel is what decorrelates
it — without it both channels return the same value and the "warp"
degenerates into a pure diagonal translation.
The pointer enters three times with different weights and a sign flip
between the two warp channels, so they shear against each other. Three
weak influences read as "the fog responds to you"; one strong influence
reads as "the fog is stuck to the cursor". */
vec2 q = vec2(
fbm(vec3(sp + m * 0.20, t)),
fbm(vec3(sp + vec2(2.3, 1.1) - m * 0.20, t))
);
float n = fbm(vec3(sp + q * u_warp + m * 0.12, t * 0.8)) * 0.5 + 0.5;
float shade = smoothstep(0.25, 0.85, n);
float lift = (u_pointer.y - 0.5) * 0.03;
vec3 col = u_base + u_tint * (u_gain * shade + lift);
/* Reversed smoothstep arguments make a FALLING ramp: 1 at the pointer, 0 at
distance 0.85. Cheaper and clearer than 1.0 - smoothstep(0.0, 0.85, d). */
float d = distance(p, m);
col += u_tint * smoothstep(0.85, 0.0, d) * (u_gain * 0.55);
/* Multiply, do not subtract: scaling the whole result keeps the cursor glow
inside the vignette instead of letting it punch through at the corners. */
float vig = smoothstep(1.25, 0.5, distance(v_uv, vec2(0.5)));
col *= mix(0.85, 1.0, vig);
fragColor = vec4(col, 1.0);
}
`;
/* ==========================================================================
Frame-rate-independent interpolation.
--------------------------------------------------------------------------
The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice
as fast on a 120 Hz display as on a 60 Hz one, so the same code feels
different on different hardware and visibly speeds up when the browser
drops frames. `damp` fixes it by raising the retention factor to the power
of elapsed time, which is the exact continuous form of the same curve.
========================================================================== */
/**
* Fraction of the remaining distance to close this frame.
*
* @param smoothing Fraction of the gap that REMAINS after one second.
* Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft,
* 0.5 ≈ slow drift.
* @param dt Seconds elapsed since the previous frame.
*/
export function dampFactor(smoothing: number, dt: number): number {
return 1 - Math.pow(smoothing, dt);
}
/** Move `current` toward `target` at a rate independent of frame rate. */
export function damp(
current: number,
target: number,
smoothing: number,
dt: number,
): number {
return current + (target - current) * dampFactor(smoothing, dt);
}
/**
* The per-frame lerp constant an author measured at 60fps, converted into the
* `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame"
* be ported without re-tuning by eye:
*
* damp(x, t, perFrameToSmoothing(0.1), dt)
*/
export function perFrameToSmoothing(alpha: number, baseFps = 60): number {
return Math.pow(1 - alpha, baseFps);
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
export function inverseLerp(a: number, b: number, v: number): number {
return a === b ? 0 : (v - a) / (b - a);
}
export function mapRange(
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
doClamp = true,
): number {
const t = inverseLerp(inMin, inMax, v);
const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t);
return r;
}
/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp(inverseLerp(edge0, edge1, x), 0, 1);
return t * t * (3 - 2 * t);
}
/**
* Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at
* distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and
* no branches, which is why carousels that use it feel analogue rather than
* stepped.
*/
export function gaussian(distance: number, falloff = 1): number {
const d = distance * falloff;
return Math.exp(-(d * d));
}
'use client';
import { useEffect, useRef } from 'react';
/* ==========================================================================
One shared requestAnimationFrame loop.
--------------------------------------------------------------------------
Forty components each calling requestAnimationFrame means forty callbacks
the browser schedules separately and forty chances to read layout after a
write. One loop, one ordered pass, one place to clamp delta time.
Delta is clamped to 100 ms. Without that, returning to a backgrounded tab
delivers a multi-second dt and every physics integration in the page
explodes on the first frame back.
========================================================================== */
export type RafCallback = (dt: number, elapsed: number) => void;
const callbacks = new Set<RafCallback>();
let frame = 0;
let last = 0;
let start = 0;
const MAX_DT = 0.1;
const DEFAULT_DT = 1 / 60;
function tick(now: number) {
frame = requestAnimationFrame(tick);
const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT);
last = now;
const elapsed = (now - start) / 1000;
// Copy: a callback may unsubscribe itself (or another) mid-iteration.
for (const cb of Array.from(callbacks)) {
if (callbacks.has(cb)) cb(dt, elapsed);
}
}
function subscribe(cb: RafCallback) {
callbacks.add(cb);
if (frame === 0) {
start = performance.now();
last = 0;
frame = requestAnimationFrame(tick);
}
return () => {
callbacks.delete(cb);
if (callbacks.size === 0 && frame !== 0) {
cancelAnimationFrame(frame);
frame = 0;
last = 0;
}
};
}
/**
* Run `cb` every frame while `enabled`.
*
* The callback is held in a ref, so it does not need to be memoised and the
* subscription is never torn down just because the closure changed identity.
*/
export function useRaf(cb: RafCallback, enabled = true): void {
const ref = useRef(cb);
ref.current = cb;
useEffect(() => {
if (!enabled) return;
return subscribe((dt, elapsed) => ref.current(dt, elapsed));
}, [enabled]);
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
prefers-reduced-motion, SSR-safe.
--------------------------------------------------------------------------
useSyncExternalStore rather than useState + useEffect: the server snapshot
is an explicit `false`, so the markup React renders on the server and the
markup it hydrates with agree, and the real value lands in the same commit
as hydration instead of one frame later. A useEffect version flashes the
animated variant before removing it — the exact thing the user asked not to
see.
Reduced motion is not "duration: 0". For most components here it means a
different render: the final state, a static poster, or a paused frame.
========================================================================== */
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
function getSnapshot() {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot() {
return false;
}
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/** Generic media-query hook, same SSR guarantees. */
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(query);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
},
() => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(query).matches;
},
() => false,
);
}
'use client';
import { useEffect, useState, type RefObject } from 'react';
/* ==========================================================================
Element size, from one shared ResizeObserver.
--------------------------------------------------------------------------
Sized from the element rather than the window because a preview in a grid
cell resizes when the grid reflows, not when the viewport does. Reading
`window.innerWidth` is the reason so many canvas components are correct on
load and wrong after a sidebar opens.
========================================================================== */
export interface Size {
width: number;
height: number;
}
type Handler = (size: Size) => void;
let observer: ResizeObserver | null = null;
const handlers = new Map<Element, Handler>();
function ensureObserver(): ResizeObserver | null {
if (typeof ResizeObserver === 'undefined') return null;
observer ??= new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.contentBoxSize?.[0];
const size: Size = box
? { width: box.inlineSize, height: box.blockSize }
: { width: entry.contentRect.width, height: entry.contentRect.height };
handlers.get(entry.target)?.(size);
}
});
return observer;
}
export function useResizeObserver(ref: RefObject<Element | null>): Size {
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
const ro = ensureObserver();
if (!el || !ro) return;
// Seed synchronously: waiting for the first observer callback leaves one
// frame where a canvas is sized 0x0, which some drivers log as an error.
const rect = el.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
handlers.set(el, (next) =>
setSize((prev) =>
prev.width === next.width && prev.height === next.height ? prev : next,
),
);
ro.observe(el);
return () => {
handlers.delete(el);
ro.unobserve(el);
};
}, [ref]);
return size;
}
'use client';
import { useEffect, useRef, type RefObject } from 'react';
import { useRaf } from '@/hooks/use-raf';
import { damp } from '@/lib/damp';
/* ==========================================================================
Pointer position, smoothed, in several coordinate spaces at once.
--------------------------------------------------------------------------
Almost every cursor effect wants the same four things and computes them
slightly differently each time: client pixels, element-local pixels, 0..1
normalised, and -1..1 NDC for shaders. Producing all four from one damped
source keeps a cursor dot and the shader it drives exactly in step.
Exposed via ref for the same reason as scroll velocity: per-frame values
must not re-render.
========================================================================== */
export interface PointerState {
/** Viewport pixels. */
clientX: number;
clientY: number;
/** Pixels relative to the target element's top-left. */
x: number;
y: number;
/** 0..1 across the element. */
nx: number;
ny: number;
/** -1..1, y up. Feed straight into a shader uniform. */
ndcX: number;
ndcY: number;
/** Whether the pointer has been seen. Nothing should render before. */
active: boolean;
}
export interface UseSmoothPointerOptions {
/** Fraction of the gap remaining after one second. Lower = tighter. */
smoothing?: number;
/** Listen on this element rather than the window. */
target?: RefObject<HTMLElement | null>;
/** Follow touch drags as well as mouse moves. */
touch?: boolean;
}
function makeInitialState(): PointerState {
return {
clientX: 0,
clientY: 0,
x: 0,
y: 0,
nx: 0.5,
ny: 0.5,
ndcX: 0,
ndcY: 0,
active: false,
};
}
export function useSmoothPointer(
options: UseSmoothPointerOptions = {},
): { current: PointerState } {
const { smoothing = 0.001, target, touch = true } = options;
const state = useRef<PointerState>(makeInitialState());
// `null` means "no real pointer yet", which is distinct from a legitimate
// pointer at (0, 0) in the top-left corner.
const targetPos = useRef<{ clientX: number; clientY: number } | null>(null);
const seeded = useRef(false);
// Options the listener needs but that must not re-subscribe it on change.
const touchRef = useRef(touch);
touchRef.current = touch;
useEffect(() => {
const el = target?.current ?? null;
const node: HTMLElement | Window = el ?? window;
const onMove = (event: Event) => {
const e = event as PointerEvent;
if (!touchRef.current && e.pointerType === 'touch') return;
targetPos.current = { clientX: e.clientX, clientY: e.clientY };
};
node.addEventListener('pointermove', onMove, { passive: true });
return () => node.removeEventListener('pointermove', onMove);
// `target` is a ref object; its identity is stable for the component's
// lifetime, so this subscribes once.
}, [target]);
useRaf((dt) => {
const el = target?.current ?? null;
const rect = el
? el.getBoundingClientRect()
: {
left: 0,
top: 0,
width: window.innerWidth,
height: window.innerHeight,
};
const pointer = targetPos.current;
if (pointer === null) {
// Park at the centre until a real pointer arrives, so the first move
// eases out from the middle rather than snapping in from (0, 0). Done
// once — re-centring every frame would fight a slow first movement.
if (!seeded.current && rect.width > 0 && rect.height > 0) {
seeded.current = true;
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
state.current = {
...state.current,
clientX: cx,
clientY: cy,
x: cx - rect.left,
y: cy - rect.top,
};
}
return;
}
const clientX = damp(state.current.clientX, pointer.clientX, smoothing, dt);
const clientY = damp(state.current.clientY, pointer.clientY, smoothing, dt);
const x = clientX - rect.left;
const y = clientY - rect.top;
const nx = rect.width > 0 ? x / rect.width : 0.5;
const ny = rect.height > 0 ? y / rect.height : 0.5;
state.current = {
clientX,
clientY,
x,
y,
nx,
ny,
ndcX: nx * 2 - 1,
ndcY: -(ny * 2 - 1),
active: true,
};
});
return state;
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
Document visibility, shared.
--------------------------------------------------------------------------
A backgrounded tab still runs `setInterval`, still holds WebGL contexts,
and — on some platforms — still gets throttled rAF ticks with multi-second
deltas. Anything expensive should stop when the tab is hidden and resume
when it comes back, rather than trying to catch up.
`useSyncExternalStore` with an explicit server snapshot so SSR markup and
the first client render agree.
========================================================================== */
function subscribe(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}
function getSnapshot(): boolean {
return document.visibilityState === 'visible';
}
/** Assume visible on the server: the alternative flashes a paused state. */
function getServerSnapshot(): boolean {
return true;
}
export function useVisibility(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
import type * as THREE from 'three';
/* ==========================================================================
WebGL context lifecycle.
--------------------------------------------------------------------------
Chromium allows roughly 16 live WebGL contexts per RENDERER PROCESS (8 on
low-end devices). Creating the 17th does not fail — it fires
`webglcontextlost` on the OLDEST one, so an unrelated component elsewhere
on the page goes black. Same-origin iframes share the renderer process, so
isolating previews in iframes does not raise the ceiling.
The only real fix is to release contexts on unmount, and `renderer.dispose()`
ALONE DOES NOT DO THAT. It frees GPU objects but leaves the context itself
alive until GC decides otherwise, which may be never. `forceContextLoss()`
is the call that actually hands it back.
========================================================================== */
/**
* Fully release a three.js renderer. Call this in the `useEffect` cleanup of
* any component that constructs a `WebGLRenderer` — the preview host drives
* unmount via `<Activity mode="hidden">`, so ordinary cleanup IS the disposal
* path and nothing component-specific is required.
*/
export function disposeRenderer(renderer: THREE.WebGLRenderer | null | undefined): void {
if (!renderer) return;
// Stop the loop first: a queued frame that renders into a lost context
// throws, and the throw escapes the cleanup function.
renderer.setAnimationLoop(null);
renderer.dispose();
renderer.forceContextLoss();
const canvas = renderer.domElement;
if (canvas) {
// Zeroing the backing store releases the drawing buffer immediately
// instead of holding width×height×4 bytes until the node is collected.
canvas.width = 0;
canvas.height = 0;
}
}
/**
* Release a raw WebGL context (ogl, or hand-written GL). The extension is the
* only portable way to do it; it is absent in some headless configurations,
* hence the optional call.
*/
export function loseContext(gl: WebGLRenderingContext | WebGL2RenderingContext | null | undefined): void {
if (!gl) return;
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
/** Recursively dispose geometries, materials and textures under a root. */
export function disposeObject(root: THREE.Object3D | null | undefined): void {
if (!root) return;
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
if (!material) return;
const materials = Array.isArray(material) ? material : [material];
for (const m of materials) {
for (const value of Object.values(m as unknown as Record<string, unknown>)) {
if (value && typeof value === 'object' && 'isTexture' in value) {
(value as THREE.Texture).dispose();
}
}
m.dispose?.();
}
});
}
/**
* Device pixel ratio capped for fill-rate. Above 2 the visual gain is nil and
* the cost is quadratic; on a 3x phone an uncapped full-screen shader is the
* single most common cause of a janky first paint.
*/
export function pixelRatio(max = 2): number {
if (typeof window === 'undefined') return 1;
return Math.min(window.devicePixelRatio || 1, max);
}
/**
* Whether WebGL is actually available. Some browsers expose the constructor
* but fail to create a context (blocklisted GPU, no hardware acceleration);
* a component that assumes success renders a permanently blank box.
*/
export function hasWebGL(): boolean {
if (typeof document === 'undefined') return false;
try {
const canvas = document.createElement('canvas');
const gl =
canvas.getContext('webgl2') ??
canvas.getContext('webgl') ??
canvas.getContext('experimental-webgl');
if (!gl) return false;
(gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
return true;
} catch {
return false;
}
}
API
Every parameter, its type and what happens when you change it.
function DomainWarpedFbm(props: { scale?: number; warp?: number; gain?: number; speed?: number; base?: readonly [number, number, number]; tint?: readonly [number, number, number]; maxDpr?: number; className?: string }): JSX.Element| Parameter | Type | Default | Description |
|---|---|---|---|
| scale | number | = 0.7 | Noise frequency. Below 1.0 the features are larger than the screen. |
| warp | number | = 0.4 | How far the first two fbm samples displace the third. |
| gain | number | = 0.22 | Peak brightness added above the base colour. |
| speed | number | = 1 | Multiplier on the drift, which is already very slow. |
| base | readonly [number, number, number] | = [0.03, 0.032, 0.042] | Base colour, linear 0..1 RGB, straight into the shader. |
| tint | readonly [number, number, number] | = [0.86, 0.9, 1] | Colour of the lit fog and the cursor glow. |
| maxDpr | number | = 2 | Upper bound on device pixel ratio. |
| className | string | — | Merged onto the host element. |
How it works
The technique in order, not the code in order.
- 01
fbm(p)sums two octaves of 3D value noise with amplitudes 0.6 and 0.3 and lacunarity 1.93. The third dimension is time, so the field evolves continuously instead of scrolling. - 02
The domain warp is
q = vec2(fbm(sp + m*0.20, t), fbm(sp + vec2(2.3,1.1) - m*0.20, t)), thenn = fbm(sp + q*0.4 + m*0.12, t*0.8) * 0.5 + 0.5. Thevec2(2.3, 1.1)offset decorrelates the second channel from the first; without it both return the same number and the warp collapses into a diagonal translation. - 03
Both the sample point and the pointer are multiplied by
aspecton x before use, so the noise stays isotropic and the cursor glow stays circular on a wide screen. - 04
shade = smoothstep(0.25, 0.85, n)crushes the noise into a soft two-tone field, and the final colour isbase + tint * (gain * shade + lift)wherelift = (pointerY - 0.5) * 0.03tilts the whole frame slightly as the cursor moves up and down. - 05
The cursor light is
smoothstep(0.85, 0.0, d)— arguments deliberately reversed to produce a falling ramp, 1 at the cursor and 0 at distance 0.85, without the extra1.0 -. - 06
A vignette
smoothstep(1.25, 0.5, distance(uv, 0.5))multiplies the result rather than subtracting from it, so the cursor glow dims with the corners instead of punching through them.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| scale | = 0.7 | Multiplier on the sample coordinate. Below 1.0 the noise features are LARGER than the screen, so you get a handful of slow masses. At 3.0 the same shader is television static. |
| warp | = 0.4 | How far q displaces the final sample. Under ~0.15 the warp is invisible and you are looking at plain fbm; over ~0.9 the field folds back on itself and produces hard creases that read as compression artefacts. |
| octaves | = 2 | Textbook fbm uses 4-6. Two is enough because the output goes straight through smoothstep(0.25, 0.85, n), which discards the fine detail anyway. fbm is essentially the whole cost of this shader, so halving the octaves halves the frame time. |
| lacunarity | = 1.93 | Frequency step between octaves. Exactly 2.0 makes the two lattices coincide on every other cell boundary and stamps a faint axis-aligned cross into the field; an off-integer step decorrelates them. |
| amplitude schedule | = 0.6 then 0.3 | Starting below 1.0 keeps the two-octave sum inside roughly -0.9..0.9, so n * 0.5 + 0.5 lands in 0..1 without clipping and the smoothstep window sits where the data actually is. |
| time scale | = 0.03 | The field advances 0.0005 units per frame at 60 Hz. It has to read as weather, not animation — at 0.3 it becomes a lava lamp and starts competing with the content on top of it. |
| gain | = 0.22 | Peak brightness above the base. The reference site sits at 0.08, which keeps the entire image inside the bottom 17% of the value range so white type sits on it perfectly. 0.22 is the standalone default; drop it toward 0.08 the moment you put text on top. |
| pointer smoothing | = 0.02 | Fraction of the gap left after one second. The pointer displaces a noise domain, so raw values make the whole field jump between pointer events rather than drift. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The rAF loop never subscribes. One frame is drawn at a fixed u_time of 12 seconds — far enough into the sequence that the field is fully developed rather than at its symmetric t=0 state — and redrawn on resize. The cursor light does not track.
Cleanup
Deletes the VAO, the vertex buffer and the program, then loseContext(gl). The rAF subscription and the pointer listener are removed by their hooks.
Definition of done
Watch for ten seconds: the masses should visibly fold and stretch, not just brighten and dim in place. Move the cursor and a faint circular light follows it a beat late while the fog around it shears. Resize from 320px to full width and the noise features stay the same shape rather than stretching horizontally.
Pitfalls
- Aspect-correcting the sample space but not the pointer. The noise looks right and the cursor glow becomes an ellipse that stretches further the wider the window gets — the two must live in the same coordinate system.
- Using the same offset for both warp channels.
fbm(sp)andfbm(sp)are the same number, soqbecomes a scalar along the diagonal and the "warp" is a plain translation; you get sliding blobs with none of the folding. - Feeding
sin()a large argument inside the hash.fract(sin(dot(p, ...)))degrades badly oncepis in the thousands: the noise develops repeating bands, and it fails on mobile GPUs at scales where the desktop still looks fine. Fold the input withfract()first. - Running 5 octaves because that is what the tutorial did. The output is immediately crushed by a smoothstep, so octaves 3-5 cost real milliseconds and change nothing you can see.
- Letting the shader run in a hidden tab.
useVisibilitygates the loop; without it a backgrounded page keeps a GPU-bound fragment shader resident and the laptop fan tells the user about it. - Relying on
dispose()-style cleanup. Raw GL has none — the VAO, buffer and program are deleted explicitly andloseContext(gl)hands the context back, without which mounting this component twenty times kills the oldest contexts on the page.
Notes
- Value noise rather than simplex: after
smoothstep(0.25, 0.85, n)the two are indistinguishable, and value noise is eight hash calls and seven mixes with no permutation polynomial. - Total brightness at the defaults runs roughly 0.03 to 0.30. Set
gain={0.08}for the original hero behaviour, where the whole field stays under 0.18 and white type needs no scrim.