# FluidSimulationHero
A full bleed hero whose background is a live GPU fluid simulation the pointer stirs.
## 1. Setup
// npm install @phosphor-icons/react
File: components-workspace/fluid-simulation-hero/index.tsx · 'use client' · export default function FluidSimulationHero()
import { useEffect, useId, useRef, useState } from 'react'
import { PaperPlaneTilt, Sparkle } from '@phosphor-icons/react'
Nothing else is installed. One vertex shader, twelve fragment programs, the solver, the render loop and every piece of chrome live in this one file as inline GLSL strings. Raw WebGL2 with a WebGL1 fallback: no three.js, no shader library, no postprocessing package, no animation library.
ATTRIBUTION, not optional: the solver passes are adapted from PavelDoGreat/WebGL-Fluid-Simulation, MIT License, Copyright 2017 Pavel Dobryakov. The header comment at the top of block 2 carries the notice; ship it in the file. MIT permits use inside proprietary work only while the notice travels with it.
## 2. Constants
Everything above the component function, verbatim: the header, the three exported types, the one palette, the type scale, the emitter geometry, all thirteen shader sources, the GL helpers and the props table. There is ONE palette. This hero is light only, by construction, and block 5 says why; do not add a dark set. The palette and the shaders ARE the component, so a paraphrased hex or a summarised pass is a different product.
/**
* Fluid Simulation Hero. A full bleed hero driven by a real time Navier Stokes solver
* running on the GPU: advect velocity, advect dye, curl, vorticity confinement,
* divergence, Jacobi pressure, gradient subtract, then a surface normal pass and
* an edge detector over it that place the iridescent thin film. Raw WebGL2 with
* a WebGL1 fallback, no shader library, no 3D engine, all in this one file.
*
* Solver passes adapted from PavelDoGreat/WebGL-Fluid-Simulation (MIT License,
* Copyright 2017 Pavel Dobryakov). Palette, composition, the normal and normal
* delta passes, the deterministic spawner, the dithered display pass, headline
* veil, chrome and React lifecycle are original to this component.
*/
/* ------------------------------------------------------------------ types */
export type FluidPalette = {
/** Three stop background wash, painted in the display shader at 155 degrees. */
ground: [string, string, string]
vignette: string
vignetteAlpha: number
/** Dye at its densest. */
core: string
/** Dye at working density. */
body: string
/** Dye in the thin trailing filaments. */
tail: string
/** Thin film lip on one side of a filament. */
iridescenceA: string
/** Thin film lip on the other side. */
iridescenceB: string
/** Strength of both lips, 0 to 1. */
iridescence: number
/** Colour the headline veil pushes the type toward. */
veil: string
/** Veil alpha ceiling, 0 to switch the veil off. 0.4 is the contrast safe
* ceiling if it is on. */
veilMax: number
text: string
textSoft: string
watermark: string
pillBg: string
pillFg: string
}
export type FluidNavItem = { label: string; href: string }
/** Every type decision the hero makes, in one object, so the whole thing can be
* refaced without touching the shader half of the file. Sizes are raw CSS
* lengths, so clamp() is allowed anywhere a fixed px would go. */
export type FluidTypography = {
/** Family for all UI text. Empty string inherits the surrounding page font. */
fontFamily: string
/** Family for the oversized decorative watermark only. */
displayFontFamily: string
/** Watermark slant. A family with no real italic gets a browser faked one,
* which leans the glyphs into each other at this size. watermarkTracking is
* what buys the room back; 'normal' is the other way out. */
watermarkStyle: 'italic' | 'normal'
/** Letter spacing on the watermark. It carries more of the look than it
* sounds like: at 460px every 0.01em is another 4.6px between glyphs. */
watermarkTracking: string
eyebrowSize: string
headlineSize: string
headlineWeight: number
headlineTracking: string
headlineLeading: number
sublineSize: string
ctaSize: string
hintSize: string
wordmarkSize: string
navSize: string
watermarkSize: string
}
export type FluidSimulationHeroProps = {
headline?: string[]
eyebrow?: string
subline?: string
hint?: string
wordmark?: string
watermark?: string
navItems?: FluidNavItem[]
wordmarkHref?: string
ctaLabel?: string
onCtaClick?: () => void
palette?: FluidPalette
typography?: FluidTypography
chromeInsetTop?: string
chromeInsetSide?: string
simResolution?: number
dyeResolution?: number
curl?: number
densityDissipation?: number
velocityDissipation?: number
pressureIterations?: number
splatRadius?: number
splatForce?: number
intensity?: number
normalEpsilon?: number
iridescenceStrength?: number
spawnAmplification?: number
spawnVelocity?: number
className?: string
}
/* --------------------------------------------------------------- defaults */
const PALETTE: FluidPalette = {
ground: ['#C9C4E8', '#DCD3EC', '#E8D8E6'],
vignette: '#B7B1DC',
vignetteAlpha: 0.14,
core: '#FFFDFF',
body: '#F5B8D8',
tail: '#F0C8E0',
iridescenceA: '#FF4FD8',
iridescenceB: '#4FE8FF',
iridescence: 0.35,
veil: '#FFFFFF',
// Off. The veil is the dye washing over the headline; the h1 carries no
// background at all. Raise it to bring the effect back, and keep it at or
// under 0.4 if you do, which is where the headline still clears 4.5:1 on the
// brightest frame.
veilMax: 0,
text: '#141218',
// 0.73, not 0.72: the stir hint sits bottom right, over the darkest stop of
// the 155 degree wash with the vignette at full strength, and 0.72 measures
// 4.47:1 there. This clears every position on the frame at 4.58:1.
textSoft: 'rgba(42,36,52,0.73)',
watermark: 'rgba(90,75,120,0.08)',
pillBg: '#141218',
pillFg: '#F4EFFA',
}
// The two responsive bumps this scale replaced (subline 14 -> 16px at 768,
// wordmark 14 -> 16px at 480) are written as clamps whose vw slope hits the
// upper value at exactly that width, so both breakpoints land where they did.
const TYPO: FluidTypography = {
fontFamily: '',
displayFontFamily: 'Georgia, "Times New Roman", ui-serif, serif',
watermarkStyle: 'italic',
watermarkTracking: '0.08em',
eyebrowSize: '12px',
headlineSize: 'clamp(30px, 6.4vw, 84px)',
headlineWeight: 500,
headlineTracking: '-0.03em',
headlineLeading: 0.98,
sublineSize: 'clamp(14px, 2.084vw, 16px)',
ctaSize: '14px',
hintSize: '12px',
wordmarkSize: 'clamp(14px, 3.34vw, 16px)',
navSize: '14px',
watermarkSize: 'clamp(120px, 34vw, 460px)',
}
const NAV: FluidNavItem[] = [
{ label: 'Work', href: '#' },
{ label: 'Studio', href: '#' },
{ label: 'Contact', href: '#' },
]
// The resting composition: ONE contained mass, held in the upper right so it
// stays clear of the headline block on the left. Seed and spawner both derive
// from these three numbers, so the boot picture and the running picture can
// never drift apart. HOME_R is the emitter orbit radius in height units; the
// visible mass ends up roughly three times that across, about a fifth of the
// frame width on a 16:9 canvas.
const HOME_X = 0.78
const HOME_Y = 0.66
const HOME_R = 0.08
// The grid short edge every force default below is tuned against. Velocity is
// stored in texels per second, so forces are rescaled against the live grid and
// simResolution or a quality tier changes detail, never speed.
const SIM_REF = 128
/* --------------------------------------------------------------- shaders */
const VERT = `
precision highp float;
attribute vec2 aPosition;
varying vec2 vUv, vL, vR, vT, vB;
uniform vec2 texelSize;
void main () {
vUv = aPosition * 0.5 + 0.5;
vL = vUv - vec2(texelSize.x, 0.0);
vR = vUv + vec2(texelSize.x, 0.0);
vT = vUv + vec2(0.0, texelSize.y);
vB = vUv - vec2(0.0, texelSize.y);
gl_Position = vec4(aPosition, 0.0, 1.0);
}`
const CLEAR = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv; uniform sampler2D uTexture; uniform float value;
void main () { gl_FragColor = value * texture2D(uTexture, vUv); }`
const SPLAT = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uTarget; uniform float aspectRatio; uniform vec3 color;
uniform vec2 point; uniform float radius;
void main () {
vec2 p = vUv - point.xy;
p.x *= aspectRatio;
vec3 splat = exp(-dot(p, p) / radius) * color;
vec3 base = texture2D(uTarget, vUv).xyz;
gl_FragColor = vec4(base + splat, 1.0);
}`
// Semi Lagrangian advection. MANUAL_FILTERING is compiled in when the platform
// cannot filter float textures, so the bilinear tap is done by hand.
const ADVECT = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uVelocity; uniform sampler2D uSource;
uniform vec2 texelSize; uniform vec2 dyeTexelSize;
uniform float dt; uniform float dissipation;
#ifdef MANUAL_FILTERING
vec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) {
vec2 st = uv / tsize - 0.5;
vec2 iuv = floor(st);
vec2 fuv = fract(st);
vec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize);
vec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize);
vec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize);
vec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize);
return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y);
}
#endif
void main () {
#ifdef MANUAL_FILTERING
vec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).xy * texelSize;
vec4 result = bilerp(uSource, coord, dyeTexelSize);
#else
vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize;
vec4 result = texture2D(uSource, coord);
#endif
gl_FragColor = vec4(dissipation * result.xyz, 1.0);
}`
const DIVERGENCE = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv, vL, vR, vT, vB; uniform sampler2D uVelocity;
void main () {
float L = texture2D(uVelocity, vL).x;
float R = texture2D(uVelocity, vR).x;
float T = texture2D(uVelocity, vT).y;
float B = texture2D(uVelocity, vB).y;
vec2 C = texture2D(uVelocity, vUv).xy;
if (vL.x < 0.0) { L = -C.x; }
if (vR.x > 1.0) { R = -C.x; }
if (vT.y > 1.0) { T = -C.y; }
if (vB.y < 0.0) { B = -C.y; }
gl_FragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0);
}`
const CURL = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv, vL, vR, vT, vB; uniform sampler2D uVelocity;
void main () {
float L = texture2D(uVelocity, vL).y;
float R = texture2D(uVelocity, vR).y;
float T = texture2D(uVelocity, vT).x;
float B = texture2D(uVelocity, vB).x;
gl_FragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0);
}`
// Vorticity confinement. This pass is what grows the fine braided filaments.
const VORTICITY = `
precision highp float; precision highp sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uVelocity; uniform sampler2D uCurl;
uniform float curl; uniform float dt;
void main () {
float L = texture2D(uCurl, vL).x;
float R = texture2D(uCurl, vR).x;
float T = texture2D(uCurl, vT).x;
float B = texture2D(uCurl, vB).x;
float C = texture2D(uCurl, vUv).x;
vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
force /= length(force) + 0.0001;
force *= curl * C;
force.y *= -1.0;
vec2 velocity = texture2D(uVelocity, vUv).xy;
velocity += force * dt;
velocity = min(max(velocity, -1000.0), 1000.0);
gl_FragColor = vec4(velocity, 0.0, 1.0);
}`
const PRESSURE = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uPressure; uniform sampler2D uDivergence;
void main () {
float L = texture2D(uPressure, vL).x;
float R = texture2D(uPressure, vR).x;
float T = texture2D(uPressure, vT).x;
float B = texture2D(uPressure, vB).x;
float divergence = texture2D(uDivergence, vUv).x;
gl_FragColor = vec4((L + R + B + T - divergence) * 0.25, 0.0, 0.0, 1.0);
}`
const GRADIENT = `
precision mediump float; precision mediump sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uPressure; uniform sampler2D uVelocity;
void main () {
float L = texture2D(uPressure, vL).x;
float R = texture2D(uPressure, vR).x;
float T = texture2D(uPressure, vT).x;
float B = texture2D(uPressure, vB).x;
vec2 velocity = texture2D(uVelocity, vUv).xy;
velocity -= vec2(R - L, T - B);
gl_FragColor = vec4(velocity, 0.0, 1.0);
}`
// Reads the dye field as a height map and writes its surface normal. The
// magnitude over the dye channels responds to total dye, not to one colour, and
// the epsilon keeps flat water pointing straight up instead of exploding where
// the gradient is zero.
const NORMAL = `
precision highp float; precision highp sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uDye; uniform float uEps;
void main () {
float c = length(texture2D(uDye, vUv).xyz);
float r = length(texture2D(uDye, vR).xyz);
float b = length(texture2D(uDye, vB).xyz);
gl_FragColor = vec4(normalize(vec3(r - c, b - c, uEps)), 1.0);
}`
// Edge detector over the normal map. Bright exactly where the surface bends
// fastest, which is the filament boundary, dark wherever the surface is smooth.
// This, not curl, is what the iridescence rides.
const NORMAL_DELTA = `
precision highp float; precision highp sampler2D;
varying vec2 vUv, vL, vR, vT, vB;
uniform sampler2D uNormal;
void main () {
vec3 c = texture2D(uNormal, vUv).xyz;
vec3 r = texture2D(uNormal, vR).xyz;
vec3 b = texture2D(uNormal, vB).xyz;
gl_FragColor = vec4(abs(r - c) + abs(b - c), 1.0);
}`
// Display pass. The dye field is scalar: .r is density, .g carries an advected
// hue seed so filaments vary along their length. Colour, iridescent lips,
// vignette and the 4x4 Bayer dither all happen here, once, on screen.
const DISPLAY = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uDye, uNormal, uNormalDelta;
uniform float uAspect;
uniform vec3 uG0, uG1, uG2, uVig, uCore, uBody, uTail, uIriA, uIriB;
uniform float uVigA, uIriAmt;
float bayer2 (vec2 a) { a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
float bayer4 (vec2 a) { return bayer2(0.5 * a) * 0.25 + bayer2(a); }
void main () {
vec4 s = texture2D(uDye, vUv);
float d = s.x;
float hue = clamp(s.y / max(d, 0.0001), 0.0, 1.0);
vec2 dir = vec2(-0.9063, 0.4226);
float t = clamp(dot(vUv - 0.5, dir) + 0.5, 0.0, 1.0);
vec3 ground = t < 0.5 ? mix(uG0, uG1, t * 2.0) : mix(uG1, uG2, (t - 0.5) * 2.0);
float rad = length((vUv - 0.5) * vec2(uAspect, 1.0));
ground = mix(ground, uVig, uVigA * smoothstep(0.34, 0.95, rad));
// Thin film. The edge detected normal decides WHERE the film sits, a couple
// of texels wide on the filament boundary, and the normal direction decides
// WHICH lip you see, so the two colours land on opposite faces of a fold.
vec3 nrm = texture2D(uNormal, vUv).xyz;
float bend = length(texture2D(uNormalDelta, vUv).xyz);
float film = smoothstep(0.12, 0.85, bend) * clamp(d * 5.0, 0.0, 1.0);
float side = clamp(dot(nrm.xy, vec2(0.7071, 0.7071)) * 1.4, -1.0, 1.0);
vec3 iri = (uIriA * max(side, 0.0) + uIriB * max(-side, 0.0)) * film * uIriAmt;
float dens = clamp(d, 0.0, 1.6);
vec3 fluid = mix(uTail, uBody, hue);
fluid = mix(fluid, uCore, smoothstep(0.42, 1.05, dens));
// Light ground, so the dye is mixed into it rather than added on top of it.
// Additive over a pale ground clips to white instantly, which is why this
// hero is light only: the two need different compositing, not two palettes.
vec3 col = mix(ground, fluid, clamp(dens * 1.05, 0.0, 1.0));
col *= 1.0 - 0.035 * clamp(dens * 6.0, 0.0, 1.0) * (1.0 - clamp(dens * 1.6, 0.0, 1.0));
col += iri;
col += (bayer4(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(col, 1.0);
}`
// Renders only the headline rectangle, as premultiply free RGBA8 so it can be
// read back cheaply and pushed into the small veil canvas over the type.
const VEIL = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uDye; uniform vec2 uRectMin, uRectSize;
uniform vec3 uTint; uniform float uGain, uMaxAlpha;
void main () {
vec2 uv = uRectMin + vec2(vUv.x, 1.0 - vUv.y) * uRectSize;
float d = texture2D(uDye, uv).x;
gl_FragColor = vec4(uTint, clamp(d * uGain, 0.0, 1.0) * uMaxAlpha);
}`
/* ------------------------------------------------------------- gl helpers */
type GL = WebGL2RenderingContext
type Prog = { p: WebGLProgram; u: Record<string, WebGLUniformLocation | null> }
type FBO = {
tex: WebGLTexture
fbo: WebGLFramebuffer
w: number
h: number
tx: number
ty: number
}
type Double = { read: FBO; write: FBO; swap: () => void }
const hexCache = new Map<string, [number, number, number]>()
function hex3(v: string): [number, number, number] {
const hit = hexCache.get(v)
if (hit) return hit
const h = v.replace('#', '')
const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16)
const out: [number, number, number] = [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]
hexCache.set(v, out)
return out
}
// Deterministic value stream, 0 to 1. The composition must be reproducible frame
// for frame so the preview card and the publishing screenshot always catch the
// same picture, which is why nothing here reaches for Math.random.
function rnd(n: number) {
const s = Math.sin(n * 127.1) * 43758.5453
return s - Math.floor(s)
}
function shader(gl: GL, type: number, src: string, defines = ''): WebGLShader | null {
const s = gl.createShader(type)
if (!s) return null
gl.shaderSource(s, defines + src)
gl.compileShader(s)
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
gl.deleteShader(s)
return null
}
return s
}
/* ----------------------------------------------------------- props table */
/**
* @typedef {object} FluidNavItem
* @property {string} label Link text shown in the top right nav.
* @property {string} href Link target.
*/
/**
* @typedef {object} FluidTypography
* @property {string} fontFamily Family for all UI text. Empty inherits the surrounding page font.
* @property {string} displayFontFamily Family for the oversized watermark only.
* @property {string} watermarkStyle Watermark slant, 'italic' or 'normal'. Families with no real italic get a browser faked one that leans the glyphs together, which watermarkTracking buys back.
* @property {string} watermarkTracking Letter spacing on the watermark. At 460px every 0.01em is another 4.6px between glyphs.
* @property {string} eyebrowSize Font size of the small label above the headline.
* @property {string} headlineSize Font size of the h1. Any CSS length, clamp() included.
* @property {number} headlineWeight Font weight of the h1.
* @property {string} headlineTracking Letter spacing of the h1.
* @property {number} headlineLeading Line height of the h1, unitless.
* @property {string} sublineSize Font size of the paragraph under the headline.
* @property {string} ctaSize Font size of the pill button label.
* @property {string} hintSize Font size of the stir hint in the bottom right corner.
* @property {string} wordmarkSize Font size of the top left wordmark.
* @property {string} navSize Font size of the nav links.
* @property {string} watermarkSize Font size of the watermark bleeding off the lower left corner.
*/
/**
* @typedef {object} FluidPalette
* @property {string[]} ground Three background wash stops, painted at 155 degrees in the display shader.
* @property {string} vignette Colour mixed into the corners of the wash.
* @property {number} vignetteAlpha Vignette strength, 0 to 1.
* @property {string} core Dye colour where density is highest.
* @property {string} body Dye colour at working density.
* @property {string} tail Dye colour in the thin trailing filaments.
* @property {string} iridescenceA Thin film lip on the leading side of a filament.
* @property {string} iridescenceB Thin film lip on the trailing side.
* @property {number} iridescence Strength of both lips, 0 to 1.
* @property {string} veil Colour the fluid pushes the headline toward as it passes over.
* @property {number} veilMax Veil alpha ceiling, 0 to switch the veil off entirely. If you turn it on, 0.4 keeps the headline above 4.5:1.
* @property {string} text Headline colour.
* @property {string} textSoft Nav, eyebrow and sub line colour.
* @property {string} watermark Colour of the oversized script watermark.
* @property {string} pillBg Call to action background.
* @property {string} pillFg Call to action label colour.
*/
/**
* @typedef {object} FluidSimulationHeroProps
* @property {string[]} [headline=['Every pixel moves', 'like water']] Headline lines, rendered as a real h1.
* @property {string} [eyebrow=''] Small label above the headline. Empty by default, so nothing renders and the headline takes the top of the block; pass a string to bring it back.
* @property {string} [subline='A live Navier Stokes surface...'] Paragraph under the headline.
* @property {string} [hint='Drag anywhere to stir the surface'] Caption shown only to fine pointers with motion enabled.
* @property {string} [wordmark='Fluid Simulation Hero'] Top left wordmark.
* @property {string} [watermark='fluid'] Oversized script watermark bleeding off the lower left corner.
* @property {FluidNavItem[]} [navItems=NAV] Up to three nav links; below 768px only the first is shown.
* @property {string} [wordmarkHref='#'] Target of the top left wordmark link.
* @property {string} [ctaLabel='Start a project'] Label of the pill button.
* @property {() => void} [onCtaClick] Called when the pill button is pressed.
* @property {FluidPalette} [palette=PALETTE] The full colour set. This hero is light only; there is no dark variant to fall back to.
* @property {FluidTypography} [typography=TYPO] Fonts and the full type scale. fontFamily empty inherits the page font; displayFontFamily styles the watermark alone, and watermarkStyle must drop to 'normal' for any family with no real italic.
* @property {string} [chromeInsetTop='clamp(16px, 2.5vw, 32px)'] Distance from the top edge to the wordmark and nav.
* @property {string} [chromeInsetSide='clamp(32px, 3.8vw, 48px)'] Distance from the left and right edges. Larger than the top on purpose, see the note at the destructure.
* @property {number} [simResolution=256] Velocity and pressure grid on the short edge. Lower is faster and softer.
* @property {number} [dyeResolution=1024] Dye grid on the short edge, clamped to the canvas. This is the detail you actually see.
* @property {number} [curl=15] Vorticity confinement strength. Zero kills the filaments, above 50 boils.
* @property {number} [densityDissipation=0.997] Per frame dye decay. Near 1 keeps structure alive for many seconds.
* @property {number} [velocityDissipation=0.958] Per frame velocity decay.
* @property {number} [pressureIterations=12] Jacobi iterations per step. Fewer is faster and looser.
* @property {number} [splatRadius=0.0015] Injection radius as a fraction of the short edge.
* @property {number} [splatForce=5400] Velocity injected per unit of pointer travel.
* @property {number} [intensity=1] Master multiplier on every splat and on the spawner, 0 to 1.
* @property {number} [normalEpsilon=0.005] Flat area guard in the surface normal pass. Larger softens the iridescent film, smaller sharpens and speckles it.
* @property {number} [iridescenceStrength=1] Master multiplier over the palette's iridescence value. Zero removes the thin film entirely.
* @property {number} [spawnAmplification=0.018] Dye the spawner injects per frame. This is what holds the resting mass together with no pointer on screen; raise it and the mass gets denser, not bigger.
* @property {number} [spawnVelocity=8] Velocity the spawner injects per frame, in grid texels per second. This sets how fast the mass turns over. Push it far past 10 and the mass stops being contained and sprawls across the frame.
* @property {string} [className] Extra classes merged onto the outermost root element.
*/State
- every hook, handler, effect and disposal
- the animation loop, frame by frame
Tree
- the JSX, every className and inline style
Why · Remix · Check
- the mechanism, the tuning points, the checks
Unlock Fluid Simulation Hero
The full source and the remix prompt ship with Premium.