threejs-procedural-vfx skill (Threejs-Awesome-Graphics-Agent-Skills)

From Public Agent Wiki

What it does. Author production real-time VFX in Three.js. Use for filmic HDR lens-flare compositors, highlight-derived ghosts, field-angle pupil deformation, localized glare and bloom, raymarched aurora curtains, finite-footprint emissive slabs, uniform volume integration, equirectangular radiance probes, WebGPU voxel fire and smoke, coupled volumetric fluid fields, mesh-surface emitters, signed-distance fire collisions, ship-conforming reentry plasma, generated capsule wakes, instanced analytic sparks, timed dissolving debris, dense-swap effect pools, additive holographic projections, Fresnel rim shells, scanline banding, glitch displacement, swept shape-to-shape handovers, and explicit scene-relative HDR emission hierarchy. Part of scottstts/Threejs-Awesome-Graphics-Agent-Skills (scottstts/Threejs-Awesome-Graphics-Agent-Skills).

Upstream scottstts/Threejs-Awesome-Graphics-Agent-Skills
Skill file skills/threejs-procedural-vfx/SKILL.md
License MIT
Author Scott Sun (scottstts)
Fetched 2026-09-10

Install

  • npx skills add scottstts/Threejs-Awesome-Graphics-Agent-Skills --skill threejs-procedural-vfx, or copy the skill folder into ~/.claude/skills/threejs-procedural-vfx/.
  • Raw file: curl -sL https://raw.githubusercontent.com/scottstts/Threejs-Awesome-Graphics-Agent-Skills/HEAD/skills/threejs-procedural-vfx/SKILL.md

SKILL.md (verbatim)

name: threejs-procedural-vfx
description: Author production real-time VFX in Three.js. Use for filmic HDR lens-flare compositors, highlight-derived ghosts, field-angle pupil deformation, localized glare and bloom, raymarched aurora curtains, finite-footprint emissive slabs, uniform volume integration, equirectangular radiance probes, WebGPU voxel fire and smoke, coupled volumetric fluid fields, mesh-surface emitters, signed-distance fire collisions, ship-conforming reentry plasma, generated capsule wakes, instanced analytic sparks, timed dissolving debris, dense-swap effect pools, additive holographic projections, Fresnel rim shells, scanline banding, glitch displacement, swept shape-to-shape handovers, and explicit scene-relative HDR emission hierarchy.

Procedural VFX

Build effects from an event envelope, motion field, geometry representation, and shading response. Avoid independent particle emitters that happen to share a color.

This skill contains exemplary examples and assets beyond descriptive guidance, they're worth studying, referencing, or even copying. Use them sufficiently when relevant and do NOT blindly skip them.

Effect graph

subject/event state
  → effect-specific geometry, voxel fields, or instance attributes
  → flow-facing masks or analytic age
  → material response
  → pool/lifetime ownership
  → HDR and bloom contribution

Read references/procedural-vfx-system.md for ship-conforming reentry shells, capsule wakes, dense instanced spark/debris pools, holographic projection shells, HDR hierarchy, and implementation limits.

Read references/volumetric-fluid-fire.md for three-dimensional texture ownership, fixed fluid-compute scheduling, mesh-surface injection, pressure projection, moving SDF boundaries, temperature-mapped HDR raymarching, exact presets, and failure diagnostics.

Read references/volumetric-aurora-curtains.md for finite emissive-slab bounds, warped curtain density, uniform ray steps, gentle start jitter, matching screen/probe materials, exact constants, limits, and failure diagnostics.

Read references/filmic-lens-flare.md for HDR emitter extraction, optical-axis invariants, field-angle pupil deformation, finite ghost families, spectral rings, localized bloom, film response, exact constants, limits, and failure diagnostics.

Read the reentry plasma implementation for closed layered wake shells, flow-axis deformation, advected filament fields, opacity shaping, and additive emission diagnostics.

Read the hologram projection material for the additive rim shell itself: squared Fresnel incidence with grazing falloff, footprint-filtered object-space scanlines, height-phased glitch displacement, and index-gated participation. Read its shape-transition driver for the shared sweep range across a shape set, the linear progress ramp inside a longer dwell, and the complementary-discard handover.

Read the volumetric fluid fire implementation for the complete WebGPU/TSL velocity, dye, pressure, vorticity, emitter, collision, raymarch, and diagnostic system plus its calibrated fire preset.

Read the raymarched aurora implementation for the reusable emitting field, perspective-ray material, four-sample equirectangular radiance material, shared uniforms, and calibrated curtain preset without sky, terrain, weather, lighting, or renderer setup.

Read the filmic lens-flare implementation for top-origin HDR sun detection, panorama-ray reconstruction, stable source projection, finite radial ghost families, field-deformed pupil shapes, spectral ring and star response, localized bloom, and the complete filmic composite.

Rules

  • Every layer must have a role in silhouette, motion, illumination, or residue.
  • Give velocity, dye, pressure, vorticity, and collisions explicit texture ownership and one fixed compute schedule.
  • For low-angle aurora, use a finite shallow emitting footprint, uniform ray steps, gentle start jitter, and step-length-weighted accumulation. Do not add extinction or an elevation gate after the footprint already removes the long limb path.
  • Keep aurora emission separable from sky, stars, atmosphere, terrain lighting, weather, and grading; expose the same radiance through perspective and equirectangular materials.
  • Convert velocity to volume UVW with the world-size vector; never advect a non-cubic volume as though its axes had equal scale.
  • Keep the pressure ping-pong endpoint consistent with the projection read.
  • Use normalized lifetime curves instead of scattered time constants.
  • Derive secondary motion from the same flow or event direction.
  • Keep bloom as a response to HDR emission, not as the effect's only shape.
  • Derive lens ghosts from an HDR emitter, keep every synthetic centre on one optical axis, and deform pupil footprints from object-space field angle rather than screen radius.
  • Keep the lens-flare family finite; do not extend calibrated terminal haze into an arbitrary bead chain or place diffraction stars at ghost centres.
  • Pool instances and trails; do not allocate per burst.
  • Filter every periodic band by pixel footprint, and fade it to the band's own mean rather than to zero.
  • Measure rim incidence in a frame built from an inverse-transpose normal matrix.
  • Give a multi-shape transition one shared normalised range and complementary discards, never per-shape ranges or overlapping coverage.
  • Expose spawn, simulation, overdraw, and luminance debug views.
  • Include a non-bloom baseline that remains legible.

Routing boundary

Use $threejs-temporal-surfaces only for the screen-space frost/touch-history pipeline. Use $threejs-precipitation-surfaces for falling rain or snow, splash flipbooks, and weather events that alter ground materials. Use $threejs-volumetric-clouds for atmospheric weather layers and planet-scale cloud volumes. Use $threejs-atmosphere-aerial-perspective for molecular/aerosol sky scattering and surface-segment aerial perspective. Keep standalone filmic HDR lens-flare compositors, emissive aurora curtain volumes, bounded interactive fire and smoke, subject-space plasma, generated wakes, sparks, pooled debris, and additive projection shells in this skill. Keep lens flare in $threejs-atmosphere-aerial-perspective when it remains one stage in a shared sky-scattering and aerial-perspective composition.

Other files in this skill

references/filmic-lens-flare.md (verbatim)

Filmic HDR lens flare

Use the filmic-lens-flare example for a fullscreen TSL compositor with HDR-emitter extraction, radial image ghosts, finite pupil families, field-angle deformation, localized bloom, veiling glare, and a film response.

Contents

  • Input and view contract
  • HDR emitter extraction
  • Optical-axis and field-angle contract
  • Highlight-derived ghosts
  • Finite pupil families
  • Spectral pupil ring and source star
  • Bloom and film composition
  • Configuration contract
  • Observed limits and defects
  • Failure diagnosis
  • Diagnostics

Input and view contract

The example consumes a linear equirectangular HDR texture. Horizontal sampling repeats and vertical sampling clamps. The panorama sampler reconstructs a world ray from top-origin screen UV, aspect ratio, vertical FOV, and an orthonormal camera basis:

ndcX = 2*screenU - 1
ndcY = 1 - 2*screenV
ray = normalize(forward
              + right*(ndcX*aspect*tan(fov/2))
              + up*(ndcY*tan(fov/2)))
longitude = atan(ray.x, -ray.z)
latitude  = asin(clamp(ray.y, -1, 1))
panoU     = fract(longitude/(2π) + 0.5)
panoVTop  = 0.5 - latitude/π
textureV  = 1 - panoVTop

The final inversion is required for EXR data whose decoded row zero is the bottom scanline. Do not invert at both load and sample time.

detectHdrSun() searches the decoded HDR pixels for maximum Rec. 709 luminance, then computes a luminance-weighted centroid inside a radius of max(5 px, floor(0.018*min(width,height))). Only pixels above 22% of the maximum contribute. The returned latitude coordinate is top-origin.

HDR emitter extraction

The plate luminance is:

luma = dot(rgb, (0.2126, 0.7152, 0.0722))
gate = smoothstep(4, 18, luma)
hot  = gate * (clamp(luma/14, 0, 4) + 0.18)

This threshold rejects ordinary sky and ground values while retaining the HDR sun and its immediate core. The plate receives only 0.00042 maximum screen-edge chromatic offset, a 0.79 gain, 0.92 saturation, and the channel gain (1.070, 0.985, 0.900) before flare composition. Keep global chromatic aberration restrained; spectral separation belongs to individual ghosts.

Optical-axis and field-angle contract

Every synthetic ghost centre lies on the segment from source UV to image centre:

ghostCenter(t) = mix(sourceUV, (0.5, 0.5), t)

Use aspect-correct sensor coordinates for all distances and axes. The radial field direction is the normalized vector from image centre to source. Retain its last finite value when source UV crosses the optical axis; normalizing a zero vector can poison the whole node graph with NaNs.

Pupil deformation uses the true three-dimensional field angle:

fieldCos = clamp(dot(sunDirection, viewForward), 0, 1)
fieldSin = sqrt(max(0, 1 - fieldCos²))
radialRadius = baseRadius * max(fieldCos, 0.34)
tangentRadius = baseRadius

Do not derive deformation from screen radius. Screen radius changes with FOV and aspect ratio even when the physical field angle is unchanged.

Source visibility is the product of a 0.015–0.12 facing smoothstep and a linear off-frame fade over 0.36 screen UV. Source coordinates are bounded to [-2,3] on each axis after visibility is evaluated.

Highlight-derived ghosts

Four plate-derived RGB ghosts use radial image warps with scale factors -1.05, -0.73, -0.50, and -0.31. Their per-channel scale offsets are respectively 0.010, 0.007, 0.0045, and 0.0030. A negative scale reflects the highlight across the compositor centre.

Sample red, green, and blue at k-dispersion, k, and k+dispersion. Keep these image-derived ghosts subordinate to the finite pupil structure; their RGB weights are (0.090,0.078,0.082), (0.075,0.068,0.074), (0.055,0.052,0.060), and (0.038,0.036,0.043).

Finite pupil families

The calibrated ghost positions are finite and explicit:

terminal family  t = 2.12, 2.28
cool family      t = 1.84
warm family      t = 1.62, 1.48
micro family     t = 1.34, 1.23, 1.15, 1.08, 1.03
residual haze    t = 2.34, 2.18

Each pupil ellipse and ring uses the same radial/tangential field basis. The principal radii in screen-height units are:

terminal A    0.086 outer, 0.052 inner, 0.132 halo
terminal B    0.102 outer, 0.064 inner, 0.154 halo
cool          0.047 core, 0.059 ring, 0.067 leak, 0.104 halo
warm 0        0.058 outer, 0.037 inner, 0.094 halo
warm 1        0.027 core, 0.052 halo
micro beads   0.0078, 0.0062, 0.0049, 0.0036, 0.0026

The terminal continuation is haze, not an unbounded bead chain. Preserve the finite family count so the composition reads as one optical system rather than an arbitrary line of sprites.

Spectral pupil ring and source star

The spectral ring shares the warm-family centre at t = 1.62. Its field-angle split is:

spread      = fieldSin * 0.0075
redRadius   = 0.056 + spread
greenRadius = 0.056
blueRadius  = max(0.043, 0.056 - spread)

Ring widths are 0.180, 0.168, and 0.185 for red, green, and blue. Fade the ring once its centre moves 0.02–0.18 UV beyond the frame.

Source-centred aperture response contains horizontal, vertical, diagonal ±45°, and shallow ±20° ray pairs. Gaussian cross-axis falloffs are 56000, 78000, 42000, and 36000; longitudinal falloffs are 11.5, 18, 14, and 22. Apply the combined star at 0.34 gain. Never place an additional star at a ghost centre.

Bloom and film composition

Use three localized bloom nodes:

source bloom  threshold 0.62, strength 0.80, radius 0.0105, composite gain 0.92
halo bloom    threshold 0.74, strength 0.96, radius 0.0080, composite gain 1.30
flare bloom   threshold 0.84, strength 0.90, radius 0.0300, composite gain 0.56

Source bloom gates luminance through smoothstep(2.9,10.5) and clamps the seed to 28. Halo bloom uses smoothstep(1.8,6.1), clamps to 18, and tints the seed (1,0.30,0.055).

After additive composition, two broad masks reduce local contrast and add warm neutral energy. The film stage then applies:

shadow transition       0.08–0.58 luminance
warm-highlight ramp     0.18–1.8 luminance
shoulder transition     0.55–3.2 luminance
print-density transition 1–4 luminance, darkening to 0.72
highlight desaturation  0.75–4.6 luminance, maximum 0.15
vignette transition     0.22–0.80 sensor-radius², maximum 0.11
grain grid              1919 × 1087
grain drift             (43.17,17.71) texels/s
grain amplitude         0.0052 below the 1.5–5.2 luminance fade

The renderer uses ACES filmic tone mapping with exposure 0.92 after this linear HDR node graph.

Configuration contract

The filmic-lens-flare example uses:

initial vertical FOV        58 degrees
permitted FOV range         26–105 degrees
initial source screen UV    (0.785,0.625), top-origin
strength range              0–2
effect mix                  0 or 1 for before/after
renderer exposure           0.92

Tune only after source projection, HDR scale, and output transform are correct. Adjust source response, ghost-family energy, localized bloom, veil, then film density in that order.

Observed limits and defects

  • The example models a calibrated artistic lens response, not a prescription traced through measured glass elements and coatings.
  • The spectral ring ordering is fixed. A physical sign and magnitude require a concrete glass/coating prescription.
  • Bloom nodes make this a WebGPU/TSL compositor and require renderer-owned post-processing execution.
  • The plate sampler assumes bottom-row-first decoded EXR data. Other texture loaders may require a different vertical convention.
  • Grain is animated without temporal reconstruction and can shimmer under aggressive downsampling.
  • The HDR sun detector assumes one dominant compact emitter. Multiple similarly bright emitters require an explicit emitter-selection contract.

Failure diagnosis

whole frame turns black when source UV crosses centre
  -> a zero optical-axis vector was normalized

ghosts change shape when only FOV changes
  -> screen radius replaced the object-space field cosine

panorama is upside down
  -> top-origin latitude was sampled without the EXR row inversion

snow or sky creates duplicate ghost chains
  -> the 4–18 HDR highlight gate was weakened or omitted

flare persists behind the camera
  -> source visibility does not include the 0.015–0.12 facing fade

composition resembles a sprite chain
  -> extra ghost centres were added beyond the finite family

global rainbow fringe dominates the frame
  -> scene-level chromatic aberration replaced per-ghost dispersion

flare is invisible until bloom is extreme
  -> the additive source, pupil, ring, and veil layers are not HDR-legible

Diagnostics

Inspect:

HDR plate without flare
flare contribution on black
source UV and visibility
field cosine, sine, and radial direction
highlight extraction mask
finite ghost-family centres and pupil ellipses
source, halo, and flare bloom seeds separately
final output with effect mix zero

references/procedural-vfx-system.md (verbatim)

Layered procedural VFX systems

Use this reference for ship-conforming reentry plasma, generated wakes, instanced analytic sparks, dissolving debris, dense-swap pools, holographic projection shells, and scene-relative HDR contribution.

Contents

  • Reentry representation
  • Reentry shell shading
  • Wake construction
  • Instanced spark contract
  • Debris dissolve and pool ownership
  • Holographic projection shells
  • HDR contribution
  • Observed limitations
  • Diagnostics

Reentry representation

A complete reentry system is not one particle emitter. It composes:

ship-shaped front shell
  + expanding capsule core wake
  + larger low-opacity haze wake
  + two asymmetric side shear lobes

The shell is a clone of the actual ship mesh, scaled by 1.005. This is the key silhouette decision: plasma follows authored hull topology instead of a generic sphere or cone.

The wake origin is found from sampled ship vertices. For the current local fall direction, select the support point with the greatest dot product. Build an orthonormal wake frame by projecting local up away from the fall direction, falling back to local right when nearly parallel.

wake forward = normalized fall direction
wake up = projected local up
wake right = cross(up, forward)
wake origin = hull support point along fall direction

The reentry-plasma example demonstrates the same grammar standalone: closed layered wake shells with authored per-shell configs, flow-axis deformation, advected filament fields, and opacity shaping, without requiring a host ship mesh. Its shell constants are its own contract; read the example for exact values.

Reentry shell shading

The shell mask uses actual flow-facing geometry:

facing = saturate(dot(normalWorld, -fallDirectionWorld))
facing mask = smoothstep(0.18, 0.96, facing)

Two world-space noise bands move along fall direction:

coarse frequency = 3.6
fine frequency = 11.2
coarse/fine mix = 0.62 / 0.38
fine filament exponent = 3.1
flow speed basis = time * 5.4 + external flow * 0.08

The shell shader separates:

  • core heat from flow-facing area;
  • Fresnel envelope around silhouette;
  • a shock band requiring high facing, rim response, and filaments.

Color hierarchy is explicit:

hot core: orange -> near white
ion envelope: magenta -> violet
outer sheath: violet -> cyan
shock: white -> blue

The final shell uses additive blending, no depth write, depth test on, double sided, and negative polygon offset. Treat the additive multiplier as part of the scene’s HDR calibration, not a portable physical unit.

Wake construction

Each wake is a generated capsule-profile tube. Along normalized length t:

z = -trailLength * t
radial spread = 1 + t^1.24 * expansion
axial spread = 1 + 0.1 * t
profile turbulence = 1 + sin(theta * 3.3 + t * 8.7) * 0.1 * t

Dimensions relative to ship length:

profile length = 0.74
profile radius = 0.068
trail length = 1.55

core: 52 radial x 26 longitudinal, expansion 1.9
haze: 40 x 20, radius 1.2x, length 1.05x, opacity 0.28
lobes: 28 x 14, half profile, length 0.88x, opacity 0.34

Wake shading uses elliptical profile distance, a front gate, tail fade, coarse/fine longitudinal noise, Fresnel, and separate core/envelope/filament colors. The core and haze use different scales and speeds instead of one mesh with changed opacity.

Instanced spark contract

Preallocate a fixed sprite pool of 12000. Every instance stores:

startPosition vec3
startVelocity vec3
acceleration vec3
spawnTimeSeconds float

Lifetime is 1.3 s; velocity decay rate is 16. Spark size falls linearly to zero:

scale = max((1.3 - age) * 0.4 / 1.3, 0)

The fragment is a circular sprite with radius 0.4. HDR color interpolates from (1, 0.5, 0) * 80 toward dark red. Spawn adds random X/Z velocity in [-2, 2].

The pool is fixed-capacity and material attributes are per instance. No entity owns an individual mesh.

Debris dissolve and pool ownership

Debris spheres use:

radius = 0.45
lifetime = random 2 -> 4 seconds
mass = 0.1
friction = 0.4
restitution = 0.8
gravity scale = 1.2

Per-instance material data:

isOrange
removalTimeSeconds

Geometry-space noise creates a spatial dissolve against remaining lifetime. The material also adds a Fresnel-shaped color response, a directional fake-AO tint, and a low environment term of 0.05.

When an instance is removed, the render system swaps the last live instance into the vacant slot and copies:

  • the 4x4 instance matrix;
  • every custom attribute slice;
  • the entity-to-index mapping.

This dense-swap invariant is the reusable pooling mechanism. Updating only mesh.count without copying custom attributes would attach old effect state to the moved instance.

Holographic projection shells

A projected hologram is a rim effect, not a surface effect. The holographic-shape-transition example implements this tier: one shell pass per shape, front faces only, AdditiveBlending, depthWrite: false, transparent: true. Additive composition is order independent, so no sorting or depth prepass is required, and disabling depth write is what stops the shell from reading as a solid body.

Three terms build the response, in this order:

density  = mix(0.25, pow(mod((worldY - t*0.2) * 20.0, 1.0), 3.0), bandKeep)
fresnel  = pow(1.0 - abs(dot(N, V)), 2.0)
falloff  = smoothstep(0.8, 0.0, fresnel)
alpha    = (density * fresnel + fresnel * 1.25) * falloff

abs() on the incidence term is required: the shell is seen from both facings along a silhouette, and a signed dot would blank one of them. The falloff factor is not redundant with the exponent — squared Fresnel alone saturates the exact silhouette into a hard outline, and the falloff restores an inner edge so the rim reads as a glow. The 1.25 rim gain is the term that survives where the scanline band is dark, so the silhouette never disappears between bands.

Scanline phase is world height, so the bands belong to the world and a rotating shape turns inside them. The band field has no mip chain, so filter it by footprint: measure periods crossed per pixel with fwidth on the cycle coordinate and fade to the band's own mean, which for pow(fract(x), 3.0) is exactly 0.25. Fade the keep out between two samples per period and one (smoothstep(0.25, 0.5, footprint)). Fading a radiance band to zero instead makes a receding projection lose brightness with distance, which is a different defect from the aliasing being fixed.

Vertex glitch is a gated band, not noise on everything:

glitchTime = time - worldY
gate       = smoothstep(0.5, 1.0, (sin(gt)*sin(gt*3.45) + sin(gt*8.76)) / 3.0)
offsetXZ  += (random(worldXZ + time) - 0.5) * gate * 2.0

Three incommensurate sines rarely co-peak, so the smoothstep gate holds most of the surface at exactly zero and only narrow height bands displace. Phasing on world height is what makes the artifact travel through the projection.

Handover between shapes is a shared sweep plus complementary discards. One normalised range spans the union of every shape's bounding box:

minY = min(all boundingBox.min.y) + positionY - 0.1
maxY = max(all boundingBox.max.y) + positionY + 0.1
n    = (worldY - minY) / (maxY - minY)

discard if index is neither current nor next
discard if index == current && n < progress
discard if index == next    && n > progress

The range must be the union, never per shape: a per-shape range normalises two different heights onto the same 0..1, so the sweep line jumps vertically at the instant of handover. The 0.1 margins keep the extreme rows off the exact progress endpoints, where a whole row would switch in one frame. The two discards are complementary, so the participating shapes never overlap — that is what keeps an additive pass from doubling brightness mid-handover, and it is why this works without any transition-specific blending.

Timing is one linear ramp inside a longer dwell:

cycle speed        0.25 shapes/s  (one handover every 4 s)
sweep duration     1.5 s, linear
progress           min((elapsed - sweepStart) / 1.5, 1)
spin               0.5 rad/s on both x and y, per opted-in shape

The ramp is deliberately linear because the sweep is a moving line: any easing makes it decelerate visibly against the static scanlines. Read the cycle index from absolute elapsed time (floor(elapsed * cycleSpeed) % count) rather than accumulating, so a dropped frame cannot desynchronise shape order from the sweep. The set is preallocated; a handover creates and disposes nothing.

HDR contribution

The compact signals are intentionally bright before bloom:

spark core multiplier: 80
homing projectile multiplier: 30
laser multiplier: 10

These values are evidence of relative hierarchy inside one calibrated scene, not universal exposure-independent constants. Preserve the relationship:

spark flash > projectile > laser > ordinary surface

Validate all three in the raw HDR buffer and with bloom disabled.

Observed limitations

  • Spark position multiplies an already integrated decayed velocity by elapsed time again. This is dimensionally inconsistent but visually deliberate. Preserve it only when that trajectory is explicitly required.
  • Acceleration uses a * t^2 rather than 0.5 * a * t^2, also an artistic choice.
  • Spark randomization uses Math.random, so captures are not deterministic. Replace it with a seeded generator for regression work.
  • The reentry wake disables depth test. This avoids hull intersections but can draw through unrelated geometry. Validate camera and occluder assumptions.
  • The shell and wakes are analytic procedural meshes, not fluid simulation. Do not describe them as physically simulated plasma.
  • A hologram rim is only as good as the frame its incidence is measured in. Multiplying a normal by the bare model basis is correct for rotation and uniform scale only; on a squashed instance the silhouette lights against a skewed frame. Resolve incidence in view space with the inverse-transpose normalMatrix and the displaced point's view position — a rigid view transform preserves the dot product, so this is the same incidence a correct world-space frame reports, without needing a per-object world normal matrix.
  • An unfiltered periodic band is an aliasing source at any frequency. A projection whose bands are not footprint-filtered breaks into moire the moment the shell recedes or is seen at a grazing angle. MSAA cannot fix it.
  • The glitch offsets world XZ, so it shears the shape laterally rather than along its own surface. That is the intended read for a projection artifact and is not a normal-space displacement.

Diagnostics

Expose:

fall direction and support point
shell facing/core/envelope/shock masks
coarse and fine wake noise
wake profile distance and tail fade
raw HDR emission by layer
bloom contribution by layer
spark age, velocity, and pool occupancy
debris remaining time and dissolve threshold
instance index/entity mapping
overdraw and depth-test modes
scanline density before and after the footprint keep
Fresnel rim before the falloff multiply
shared sweep range, normalised height, and live progress
glitch displacement magnitude per vertex

For a projection shell, read the sweep diagnostic while scrubbing time: the normalised height must be continuous across a handover, and the transition band must sit at the same screen height on both participating shapes. A band that steps at the handover means the sweep range is per shape rather than shared.

references/volumetric-aurora-curtains.md (verbatim)

Volumetric aurora curtains

Use the raymarched-aurora-curtains example for a finite-footprint, emissive slab with warped curtain density, uniform ray integration, gentle start jitter, and matching perspective/equirectangular materials.

Contents

  • Representation and coordinate contract
  • Curtain density field
  • Ray interval and uniform step schedule
  • Start jitter and radiance accumulation
  • Screen and probe materials
  • Configuration contract
  • Observed limits and defects
  • Failure diagnosis
  • Diagnostics

Representation and coordinate contract

Represent the aurora as emission inside a shallow horizontal slab:

lower bound  (-250, 50, -500) world units
upper bound  ( 250, 125, 500) world units
depth        75 world units
observer     (0, 10, 0) world units

The finite XZ bounds are perceptual, not incidental. They prevent a near-horizontal ray from accumulating kilometres of weak density into a flat emissive band. The 500 × 1000 world-unit footprint retains the principal curtain while letting the box sides terminate the unwanted limb path. Let the finite volume own the lower silhouette; do not recover horizon coverage by widening the box or masking an oversized volume by view angle.

Intersect every view ray with the slab box. When the ray begins inside the box, replace its entry distance with 0.0001 world units. Reject intervals whose entry is non-positive or not less than the exit.

Curtain density field

Drive time with:

timeFlow = elapsedSeconds * 0.65

For world point p, normalise height over the 75-unit slab and build the warped coordinate exactly:

h = (p.y - 50) / 75
q = 0.04 * (p.x, 2*timeFlow, 0.225*p.z + 0.5*timeFlow)
q.xz += (seed*17.3, seed*29.1)
q.x  += 0.3*h + 5.5*cos(0.005*p.z)
q.x  += 0.02*lineNoise(0.1*p.z + 2*timeFlow)
seed = 19.6

Evaluate three octaves of trilinearly interpolated value noise at frequencies 1, 2, and 4, with weights 1, 0.5, and 0.25. Invert each octave before the weighted average:

base = weightedAverage(1 - valueNoise(q*frequency))

Convert that broad field into a vertically compressed radiance filament:

shape = (base, p.y - 50, base) * (1, 0.006, 1)
shape.y += 0.48
shape.y += 0.015*lineNoise( timeFlow + q.z)
shape.y += 0.015*lineNoise(-2*timeFlow + q.z)
density = max(0, (0.55 / max(length(shape), 1e-7))^12 * cos(0.13*q.x))

The power-12 inverse-distance term forms narrow folds. The cosine breaks the field into alternating emissive curtains; clamping after multiplication keeps the negative lobes empty.

Ray interval and uniform step schedule

Use 75 screen-ray steps and 40 equirectangular-probe steps. Divide a ray interval of length L uniformly:

stepLength = L / N

The finite footprint bounds the longest interval, so uniform spacing retains enough samples across the whole slab. It is particularly important for the power-12 density field: changing to a growing step schedule changes how often thin folds are hit and therefore changes perceived density.

Do not introduce geometric growth merely to reach the horizon. That problem is removed by the finite volume rather than solved by redistributing samples over an oversized interval.

Start jitter and radiance accumulation

Advance the first sample by at most one quarter of a uniform step:

traceDistance = entryDistance + stepLength * jitter * 0.25

The screen material hashes fragmentCoordinate + (time*13, time*27) to one jitter value per pixel and frame. The equirectangular material hashes its texel plus sampleIndex*3.7 and remains static in time.

Accumulate the step-length-weighted emission without distance extinction:

radiance += density
          * mix(#59ff03, #00aaff, (sampleY - 50)/75)
          * stepLength
emission = radiance * 0.05 * intensity

The finite footprint prevents horizon-stuffing paths from existing, so neither distance extinction nor a direction-based elevation gate is needed. Keeping those suppressors after restoring the finite bounds makes legitimate lower folds sparse and dim.

Screen and probe materials

The screen material reconstructs the raster camera ray from the drawing-buffer resolution, vertical FOV, and the camera's world basis:

screen = fragmentCoordinate - 0.5*resolution
focal  = (0.5*resolution.y) / tan(0.5*radians(fov))
ray    = normalize(cameraBasis * (screen.x, screen.y, -focal))

Write linear HDR emission with additive blending, depth testing disabled, and depth writes disabled. Composite backdrop, stars, grading, terrain, and weather outside this material.

The second material writes a 32 × 16 texel upper-hemisphere equirectangular map. Supersample every texel at four offsets around its centre:

offsets = (-0.25,-0.25), (0.25,-0.25), (-0.25,0.25), (0.25,0.25)
azimuth   = uv.x * 2π
elevation = max(uv.y, 0) * 1.5407 radians
direction = (cos(el)*cos(az), sin(el), cos(el)*sin(az))

Average the four radiance samples. Keep this material additive as well so a scene can combine the same aurora radiance with its own environment probe without moving sky or lighting code into the effect.

Configuration contract

The raymarched-aurora-curtains tier uses:

screen ray steps       75
probe ray steps        40
slab floor             50 world units
slab depth             75 world units
slab X half-extent     250 world units
slab Z half-extent     500 world units
animation speed        0.65 simulation seconds / elapsed second
noise seed             19.6
intensity              1
lower colour           #59ff03
upper colour           #00aaff
radiance multiplier    0.05
screen start jitter    0.25 of one uniform step
probe resolution       32 × 16 texels
probe supersampling    4 samples / texel

Tune in causal groups: slab footprint and height; field shape; step count and start jitter; then intensity and colour. Do not compensate an oversized footprint with extinction, an elevation gate, or bloom.

Observed limits and defects

  • The observer anchor is fixed at (0, 10, 0) world units and advanced 10 units along the ray before intersection. Parameterise both together when the scene uses a materially different scale or origin.
  • The footprint is asymmetric in X and Z. Rotate the field coordinates with the intended curtain orientation instead of swapping or widening the bounds.
  • The screen jitter changes every frame and has no temporal reconstruction. It softens uniform-step banding but does not converge it.
  • The 32 × 16 equirectangular material is a radiance map, not a cosine-weighted irradiance convolution. Consumers own any lighting integration.
  • The material models emissive volume only. It does not own atmospheric sky scattering, stars, cloud extinction, terrain light transport, or grading.

Failure diagnosis

dense horizontal horizon stuffing
  -> the slab footprint was widened or replaced by an effectively unbounded field

main curtain lobes are clipped
  -> the camera is framed too low or the finite footprint is misoriented

white horizon band
  -> the finite footprint was widened or step length was removed from accumulation

horizontal slice flicker
  -> start jitter was removed or the uniform ray-step count is too low

curtains vanish overhead
  -> camera basis/FOV/resolution disagree with the raster camera

probe lighting pulses
  -> probe jitter depends on time instead of texel coordinates

probe seams at azimuth wrap
  -> the probe texture does not repeat on its horizontal axis

brightness changes with ray-step count
  -> density accumulation omitted the current step length

Diagnostics

Inspect:

aurora emission on black
scene with aurora disabled
ray entry/exit and interval length
uniform step length
raw density before colour
accumulated HDR radiance before the 0.05 multiplier
finite-box hit mask by view direction
32 × 16 equirectangular radiance map with horizontal wrap

references/volumetric-fluid-fire.md (verbatim)

Volumetric fluid fire

Use the volumetric-fluid-fire example for a bounded WebGPU fire and smoke volume driven by coupled velocity, dye, pressure, vorticity, emitter, and signed-distance fields.

Contents

  • Simulation volume and texture ownership
  • Compute schedule
  • Velocity advection and forces
  • Dye advection and mesh emission
  • Pressure projection
  • Signed-distance collision volume
  • Volume scattering and composition
  • Configuration contract
  • Observed limits and defects
  • Failure diagnosis
  • Diagnostics

Simulation volume and texture ownership

Own three related dimensions explicitly:

world size         metres in the fire object's local frame
physics resolution velocity, pressure, vorticity, and collision voxels
render resolution  density, temperature, age, and tint voxels

The example stage uses a 7.6200376 × 5.9751163 × 3.7697034 m world box. It derives voxel widths from the longest world axis and rounds each axis, producing a 100 × 78 × 49 render grid and an 80 × 63 × 40 physics grid. Do not force both grids to be cubic: equal world-space voxel scale matters more than equal axis counts.

Allocate these 11 three-dimensional textures:

curlNoise       RGBA half float, physics-independent 64³ periodic field
velocity A/B    RGBA half float, physics resolution
dye A/B         RGBA half float, render resolution
divergence      red float, physics resolution
pressure A/B    red float, physics resolution
vorticity       RGBA half float, physics resolution
SDF             RGBA half float, physics resolution: normal.xyz + distance.w
SDF velocity    RGBA half float, physics resolution: surface velocity.xyz

The dye channel contract is exact:

R density
G temperature
B age in seconds
A special-colour mass

Give every compute entry a 4 × 4 × 4 workgroup. Dispatch ceil(resolution / 4) workgroups per axis and reject global IDs outside the actual grid before any texture access.

Compute schedule

Precompute the 64³ curl-noise texture during initialize(). Each active update follows this ownership order. Step 1 runs once per update() call; steps 2–9 run once for every complete step in the accumulator:

1. bake every collider into SDF and SDF-velocity textures
2. compute vorticity from velocity A
3. advect velocity A into velocity B and apply forces
4. compute divergence from velocity B
5. run 4 alternating Jacobi iterations: A→B, B→A, A→B, B→A
6. subtract pressure-A gradient and write projected velocity A
7. advect dye A into dye B
8. inject mesh-vertex density, temperature, age, and tint into dye B
9. swap dye A and B texture ownership

The fixed base step is 1/30 s. The preset simulation speed is 1.5, so the shader step is 0.05 s; both the accumulator increment and threshold scale by the same speed. When update() runs at least 30 times per second, this retains 30 compute steps per wall-clock second while each step advances the field by 0.05 s.

Keep the pass order and ping-pong endpoints together. The projection pass reads pressure A, so use an even Jacobi count. An odd count leaves the newest pressure in B and projects with stale A data.

Velocity advection and forces

Backtrace the physics velocity in normalized volume coordinates:

velocityUVW = velocity / worldSize
previousUVW = uvw - velocityUVW * dt
newVelocity = sample(velocityA, previousUVW)

Apply buoyancy and smoke weight on world Y:

verticalForce = temperature * 2.3729 - density * 0.15
newVelocity.y += verticalForce * dt

Generate the periodic curl field from grid coordinates corrected by volume aspect:

p = (globalID / 64) * (worldSize.x / worldSize.y, 1, worldSize.z / worldSize.y)
frequency = 6.81
finiteDifferenceOffset = 0.1 / frequency
curl multiplier = 5.82

The field uses central differences of three simplex-vector samples per axis. Do not divide the curl by 2 * finiteDifferenceOffset; the 5.82 multiplier is calibrated to the unnormalised differences.

Sample two turbulence bands during velocity advection:

thermalUVW = uvw + (0, -age*0.6, age*0.13) / frequency
thermalDecay = exp(-age * 0.76)
thermal = curl(thermalUVW) * 0.2 * temperature * thermalDecay

ambientUVW = uvw + (0, time*0.15, time*0.01) / frequency
ambient = curl(ambientUVW) * 0.2 * density

turbulence = (thermal + ambient) * 0.2 * 0.1
newVelocity += turbulence * dt

The second 0.2 multiplication is intentional: the turbulence control affects both sampled bands and their combined force.

Apply linear damping with max(1 - 0.25*dt, 0). Fade velocity to zero within 0.02 normalized units of the volume boundary. Finally add vorticity confinement:

omega = curl(velocityA)
eta = 0.5 * gradient(length(omega))
N = eta / (length(eta) + 0.00001)
force = 7.01 * cross(N, omega)
newVelocity += force * dt

Dye advection and mesh emission

Backtrace dye with velocity A using the same world-size conversion. If the backtrace begins inside a collider, move the current world point outward by the absolute signed distance along the baked normal, transform it back into the volume frame, and use that corrected UVW.

Advance the dye channels as follows:

density'     = density * max(1 - 1.02*dt, 0)
temperature' = temperature * max(1 - 0.4831*dt, 0)
colourMass'  = colourMass * max(1 - 1.02*dt, 0)
age'         = nearestNeighbourAge(previousUVW) + dt
age'         = 0 when density' <= 0.01

Nearest-neighbour age avoids numerical diffusion while density, temperature, and tint retain filtered sampling.

Prepack each emitter definition's geometry positions into one read-only storage buffer. Preallocate proxy transforms up to maxCount, and upload only matrix, active/emission/tint properties, and velocity ranges that changed. Dispatch one compute invocation per predeclared emitter vertex.

For an active vertex inside the volume:

addedDensity = (emitDensity / 20) * emitMultiplier
addedTemperature = emitTemperature * 0.05
newDensity = clamp(oldDensity + addedDensity, 0, 1)
newTemperature = oldTemperature + addedTemperature
newColourMass = oldColourMass + addedDensity * tintFactor
freshWeight = clamp(addedDensity / max(newDensity, 0.001), 0, 1)
newAge = mix(oldAge, 0, freshWeight)

Here oldDensity, oldTemperature, oldColourMass, and oldAge come from dye A at the emitter UVW. The kernel writes the result to dye B, replacing the just-advected dye-B value at that voxel rather than adding to it.

The stage uses emitter multipliers 13 for the broad mesh and 22 with tint factor 1 for the teapot. Added temperature is gated by positive density emission but is not multiplied by the per-emitter multiplier.

Pressure projection

Compute divergence from the six velocity-B neighbours:

divergence = 0.5 * ((vR.x-vL.x) + (vU.y-vD.y) + (vF.z-vB.z))

When a neighbour is solid, reflect an inward current velocity across the baked normal before selecting its component. The discretisation intentionally omits explicit voxel-spacing division.

For each Jacobi iteration, write zero pressure inside a solid. For a fluid voxel, sum only open-fluid neighbours and divide by their count:

pressure = (sumOpenNeighbourPressure - divergence) / openNeighbourCount

During projection, treat a solid neighbour as having the current voxel's pressure. This imposes a zero pressure gradient at the wall. Subtract the central pressure gradient from velocity B, enforce the moving-collider boundary response, and write velocity A. Write exactly zero velocity for voxels inside a solid.

Signed-distance collision volume

Represent each collider with uniform arrays for inverse rigid transform, world position, linear velocity, angular velocity, half extents, and activity. The inverse transform removes translation and rotation but deliberately leaves scale in the half extents.

The built-in fields are:

box:
q = abs(localPosition) - halfExtents
d = length(max(q, 0)) + min(max(q.x, max(q.y, q.z)), 0)

ellipsoid:
k0 = length(position / radii)
k1 = length(position / (radii*radii))
d = k0 * (k0 - 1) / k1

At every physics voxel, find the minimum active distance. Multiply the selected distance by 1 - collisionMargin, where the preset collision margin is 0.034. Estimate the normal with world-space central differences at 0.1 m. Store the winning surface velocity:

surfaceVelocity = linearVelocity + cross(angularVelocity, point - centre)

Near a collider, use a separate hard boundary width of 0.1 m. Inside, eject fluid at abs(distance) * 20 m/s along the normal on top of surface velocity. Outside but within the boundary, blend toward surface velocity by proximity times friction 0.9, then remove any remaining inward relative-normal velocity.

Volume scattering and composition

Sample dye in the fire object's volume frame. Warp only the boundary mask by velocity/worldSize * 0.35 * turbulence; the dye lookup itself remains at the unwarped UVW. Modulate density with animated simplex detail:

detail = snoise(localPosition*5.5 + (0, -age*0.8, 0)) * turbulence
density *= detail*0.35 + 0.85
density *= smoothstep(0, 0.03, nearestWarpedBoundaryDistance)
crispDensity = density^1.5

Map temperature to emission with the preset:

radiance = temperature^3 * 14.78 + 1
selfAbsorption = exp(-crispDensity * 2)
fireAbsorption = mix(1, selfAbsorption, smoothstep(0.2, 0, temperature))
normalisedTemperature = temperature / 10

Interpolate the temperature palette through these smoothstep intervals:

base    #000000
tier 1  #ff5900 over 0.01 -> 0.10
tier 2  #ffff6c over 0.30 -> 0.50
tier 3  #ffffff over 0.70 -> 0.80
special #00ffff

The final ordinary emission is:

colour = palette(normalisedTemperature)
       * radiance
       * crispDensity
       * fireAbsorption

When colour mass exceeds 0.1, replace it with the length of the ordinary emission multiplied by the special colour.

Raymarch 22 steps through a double-sided additive volume with depth writes disabled. Jitter the ray offset by interleaved-gradient noise plus frameId * 0.118033988749895, then take the fractional part. Bind the scene depth texture to the volume material so opaque geometry terminates the march.

Render the volume pass at 0.75 resolution scale. Add a half-resolution bloom node with strength 0.01, radius 0.1, and HDR threshold 13. Keep the raw non-bloom volume legible; bloom expands existing high-temperature structure and does not define it.

Configuration contract

The example's complete perceptual preset is:

simulation speed             1.5
vorticity confinement        7.01
emission temperature         8.5
emission density             0.644
turbulence frequency         6.81
turbulence decay             0.76
turbulence strength          0.2
density dissipation          1.02 /s
cooling                      0.4831 /s
velocity damping             0.25 /s
buoyancy                     2.3729
smoke weight                 0.15
pressure iterations          4
curl multiplier              5.82
surface friction             0.9
angular velocity multiplier  1.36
collision margin             0.034
temperature at tier 3        10
radiance multiplier          14.78
raymarch steps               22
volume pass scale            0.75

Tune in causal groups: grid and step cost; velocity motion; dye lifetime; emission; palette/radiance; collision response; raymarch/composition. Do not compensate a weak velocity field by increasing bloom or a weak emitter by increasing vorticity.

Observed limits and defects

  • Mesh emission performs unordered storage-texture writes. Multiple vertices can target one dye voxel without atomics, so exact accumulation can vary by GPU scheduling.
  • The precomputed vertex-brush offset array is not consumed by the emission pass. vertexEmissionRadius therefore allocates offsets but does not widen the injected footprint.
  • A moving-emitter velocity kernel is assembled but not dispatched. Proxy motion relocates the injection points; it does not inject emitter velocity.
  • The simulation accumulator measures performance.now() internally rather than using the caller's delta. It clamps each measured interval to 1/30 s, so loops below 30 updates per second lose wall-clock simulation time. Pause by skipping update(), not by passing zero.
  • Mesh injection reads dye A and overwrites dye B at each injected voxel after advection. It does not accumulate onto the just-advected dye-B value.
  • The noise.frecuency configuration field does not drive the curl pass. The active frequency is the mutable turbulence-frequency uniform.
  • The ellipsoid-capacity field is not read when allocating the built-in ellipsoid slots; box capacity is used for both built-in shape arrays.
  • The emitter vertex table advances its next-definition offset by vertex count times maxCount, while the unique-vertex buffer stores one copy. Multiple definitions after a definition whose maxCount exceeds one can address the wrong vertex range.
  • Divergence and pressure gradients omit physical voxel-size factors. Grid resolution changes therefore retune the apparent motion.
  • The class has no explicit texture or storage-buffer disposal method. Let the owning renderer die with an isolated scene, or add lifecycle ownership before using repeated live creation/destruction.
  • Several uniforms are reserved but inactive in the final branch, including key-light position, special-colour multiplier, and tint blend range.

Failure diagnosis

If the volume is empty:

  1. confirm initialize() completed before the render loop;
  2. confirm every emitter proxy is active and lies inside the world box;
  3. inspect density before temperature or bloom;
  4. verify dye A/B swaps after emission;
  5. check that the volume layer is present in the dedicated pass.

If fire appears but does not move, inspect velocity magnitude, then vorticity, then buoyancy. A zero dye age with nonzero density points to repeated injection, not failed advection.

If fire crosses a collider, inspect the baked signed distance and normal before changing friction. A missing collider field cannot be repaired by a larger boundary drag. Confirm the proxy's world transform updates before the bake.

If pressure projection flickers, confirm the Jacobi count is even and the final result lands in pressure A. Inspect divergence before and after projection at the same voxel coordinates.

If the volume disappears when opaque geometry enters the frame, inspect the scene-depth node and volume layer composition. The volume pass must sample the main scene depth but must not render the opaque scene into its own colour pass.

Diagnostics

Expose at least these views without changing the final branch:

final        temperature palette, self-absorption, additive composition, bloom
no bloom     identical fire emission without the bloom node
density      post-detail density sampled during raymarching
temperature  temperature divided by the 10-unit palette maximum
velocity     absolute velocity components divided by 5 m/s
colliders    red where baked signed distance is negative

Also report render-grid dimensions, physics-grid dimensions, raymarch steps, and pressure-iteration count. For deeper inspection, capture velocity A/B, divergence, pressure A/B, vorticity, SDF distance/normal, surface velocity, dye age, and special-colour mass independently.

Back to scottstts/Threejs-Awesome-Graphics-Agent-Skills or Agent skills.