threejs-procedural-vfx skill (Threejs-Awesome-Graphics-Agent-Skills)
- Install
- SKILL.md (verbatim)
- Effect graph
- Rules
- Routing boundary
- Other files in this skill
- references/filmic-lens-flare.md (verbatim)
- 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
- references/procedural-vfx-system.md (verbatim)
- Contents
- Reentry representation
- Reentry shell shading
- Wake construction
- Instanced spark contract
- Debris dissolve and pool ownership
- Holographic projection shells
- HDR contribution
- Observed limitations
- Diagnostics
- references/volumetric-aurora-curtains.md (verbatim)
- 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
- references/volumetric-fluid-fire.md (verbatim)
- 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
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
- agents/openai.yaml
- examples/filmic-lens-flare/filmic-lens-flare.js
- examples/holographic-shape-transition/hologram-material.js
- examples/holographic-shape-transition/hologram-transition.js
- examples/raymarched-aurora-curtains/aurora-curtains.js
- examples/reentry-plasma/reentry-plasma.js
- examples/volumetric-fluid-fire/source/EmitterManager.ts
- examples/volumetric-fluid-fire/source/FluidFireShaderContext.ts
- examples/volumetric-fluid-fire/source/VolumetricFluidFire.ts
- examples/volumetric-fluid-fire/source/pass/advectDyePass.ts
- examples/volumetric-fluid-fire/source/pass/advectVelocityPass.ts
- examples/volumetric-fluid-fire/source/pass/curlNoisePass.ts
- examples/volumetric-fluid-fire/source/pass/divergencePass.ts
- examples/volumetric-fluid-fire/source/pass/emitObjectPass.ts
- examples/volumetric-fluid-fire/source/pass/jacobiPass.ts
- examples/volumetric-fluid-fire/source/pass/projectPass.ts
- examples/volumetric-fluid-fire/source/pass/vorticityPass.ts
- examples/volumetric-fluid-fire/source/sdf/CollisionHandler.ts
- examples/volumetric-fluid-fire/source/sdf/sdfSampler.ts
- examples/volumetric-fluid-fire/source/sdf/shape/SDFBox.ts
- examples/volumetric-fluid-fire/source/sdf/shape/SDFEllipsoid.ts
- examples/volumetric-fluid-fire/source/sdf/shape/SDFShape.ts
- examples/volumetric-fluid-fire/source/util/createStorage3D.ts
- examples/volumetric-fluid-fire/volumetric-fluid-fire.js
- references/filmic-lens-flare.md
- references/procedural-vfx-system.md
- references/volumetric-aurora-curtains.md
- references/volumetric-fluid-fire.md
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^2rather than0.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
normalMatrixand 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 advanced10units 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 × 16equirectangular 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.
vertexEmissionRadiustherefore 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 to1/30 s, so loops below 30 updates per second lose wall-clock simulation time. Pause by skippingupdate(), 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.frecuencyconfiguration 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 whosemaxCountexceeds 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:
- confirm
initialize()completed before the render loop; - confirm every emitter proxy is active and lies inside the world box;
- inspect density before temperature or bloom;
- verify dye A/B swaps after emission;
- 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.