The complete SKILL.md as loaded by agents — verbatim, rendered.
Rain, Snow, and Wet Surfaces
Treat weather as one coupled GPU system: a shared weather envelope drives
precipitation particles, surface masks, normals, roughness, residue, lighting,
and diagnostics. The first design decision is algorithm class: immutable
analytic motion, sparse CPU-updated events, and dense GPU-resident recurrence
have different asymptotic and bandwidth costs.
Run threejs-choose-skills preflight for backend, budget, and resource owner
decisions when precipitation joins a larger scene. Dense recurrent state can
benefit from GPU residency; analytic or sparse branch-heavy work can be faster
without a compute dispatch. The target measurement decides.
Required Architecture
Build new work on pinned Three.js r185 with WebGPURenderer from three/webgpu,
TSL from three/tsl, NodeMaterial classes such as
MeshStandardNodeMaterial, MeshPhysicalNodeMaterial, and
SpriteNodeMaterial, and the node post stack with RenderPipeline, pass(),
mrt(), PassNode.setResolutionScale(), outputColorTransform, and
renderOutput().
The canonical frame graph exposes the selected state path:
shared weather envelope
-> immutable analytic seeds OR compute-updated recurrent precipitation
-> domain-selected world cells: unbounded streamed, or localized bounded
-> TSL surface masks for snow, wetness, puddles, roughness, and normals
-> GPU impact/splash event buffers or generated ripple-normal quality tier
-> node presentation with one tone-map and one output transform owner
Choose the precipitation update before allocating dynamic storage:
| Required behavior |
Default algorithm |
| Constant ballistic fall/wind, no collisions |
Immutable seeds; derive world-cell position analytically from time in vertex TSL; stream/wrap only for unbounded visual weather |
| Authored time-varying wind with analytic integral |
Immutable seeds plus accumulated/integrated wind displacement integral v_wind(t) dt; never multiply the current wind by total elapsed time |
| Turbulence, collisions, or recurrent particle state |
Compute-updated storage instances |
| World impacts/accumulation |
World-stable precipitation cells plus a compact impact/coverage field; camera-wrapped visual particles may not author physical contacts |
| Sparse close splashes |
Event pool over impacted tiles/receivers, not a global particle scan |
Analytic precipitation removes hot-buffer writes and a dispatch. Camera wrapping
is a presentation optimization; hash cells in world space so camera motion does
not make rain/snow phase or impact locations jump.
Legacy WebGL implementations (deprecated, do not extend): examples/snow-accumulation/snow-system.js, examples/wet-puddle-rain/rain-puddle-system.js.
Diagnostic/source scaffold:
examples/webgpu-rain-snow-and-wet-surfaces/. Its descriptor and token checks
do not prove renderer initialization, shader compilation, GPU execution,
readback, images, or timing. Run
node examples/webgpu-rain-snow-and-wet-surfaces/validate.js after edits.
Read
references/precipitation-surface-systems.md
for the full contracts, quality tiers, budgets, diagnostics, and replacement
notes.
Shared Physics Boundary
When weather interacts with any other simulated domain, first read the router's
physics-domain and interaction contract.
Bind this skill to its versioned PhysicsContext,
EnvironmentForcingSnapshot, SurfaceExchange, InteractionRecord, and
PhysicsPresentationCandidate, CameraViewPublication,
ViewPreparationPublication, and PhysicsPresentationSnapshot; do not create
a second weather clock, wind uniform, contact record, or unit convention.
The project/environment coordinator exclusively owns and publishes
EnvironmentForcingSnapshot. This skill consumes that immutable snapshot and
may own downstream precipitation transport, exchanges, and receiver state; it
never republishes forcing under a rain-owned revision.
- Consume atmospheric wind from
EnvironmentForcingSnapshot in meters per
second at its exact sampleInstant: PhysicsInstant, with its declared frame,
altitude/support domain, cadence,
requested/actual oriented spatial footprint and spatial/temporal filter or
band, interpolation policy, and
per-channel error. Wind is air velocity. It is not plant
structural response, water material current, or water-surface point velocity.
- Consume temperature and humidity only with their declared thermodynamic
convention. Consume canonical precipitation as oriented mass-area flux.
Convert any external volume source through its physical support/Jacobian, or
any water-equivalent-depth rate through explicit reference density/provenance,
before publishing the forcing snapshot. Preserve liquid/ice fractions,
physical support/Jacobian, and arrival-time information. A scalar
forcing may
coordinate art direction but cannot author deposition or conservation.
- Treat clouds in one declared mode. Appearance-only clouds publish no physical
precipitation channel. A causal cloud producer may publish a separate
immutable
PrecipitationEmissionSnapshot with
emissionInterval: PhysicsTimeInterval, cloud support,
fall-delay/transport policy, cadence, and per-channel error; this skill
transports it on the declared later scheduler edge to physics-frame-stable receivers.
- Emit distributed rain/snow transfer as
SurfaceExchange with mass and
momentum fluxes over its exact applicationInterval: PhysicsTimeInterval.
Intensive flux uses InteractionFootprint.distributionKind: intensive-field:
physical-area quadrature weights include Jacobians and sum to
representedMeasure; no normalized W multiplies the flux. A normalized
inverse-square-metre kernel is permitted only for
distributionKind: extensive-distributed, where it distributes one
extensive rate or interval transfer.
Emit sparse impacts as InteractionRecord with impulse, footprint in the
declared SI physics frame, source/receiver IDs, exact
applicationInterval: PhysicsTimeInterval, frame/transform revision,
ordering key, reaction owner, and batch/partition identity. Put capacity
outcomes in this downstream SurfaceExchange.batchLedger as the canonical
immutable InteractionBatchLedger, never on the cloud emission or individual
records. Rendered streak/flake count never changes either integral. Every
causal physical impact carries
InteractionRecord.partitionMembership: InteractionPartitionMembership
with its parentExchangeId, parentInteractionIds, partitionGroupId,
partitionId, partitionMeasure, and closureGroupId; disjoint partitions
close their parent exchange exactly. Non-authoritative visual splashes use a
presentation-event stream that references the exchange; neither path deposits
a second copy.
- Assign exactly one wetness/coverage owner per receiver. That owner integrates
rain deposition, water run-up/inundation, melt, drainage, infiltration, and
evaporation into one state. Water, this skill, and materials may not each
integrate private copies. A material consumes the published receiver state;
it never integrates precipitation in a fragment node.
Order coupled updates as follows: latch one immutable forcing snapshot at its
sampleInstant: PhysicsInstant for the graph's
coordinationInterval: PhysicsTimeInterval; give every participating
PhysicsGraphStage an exact executionInterval: PhysicsTimeInterval; advance
analytic or recurrent precipitation; resolve/bin impacts; publish exchanges and
interaction records with exact applicationInterval: PhysicsTimeInterval; let
the selected receiver owner integrate wetness/snow/coverage; and commit domain
state. Do not read a newly written receiver field inside the update that
produced it unless the graph declares the exact dispatch dependency, pass
boundary, same-queue order, or host-visible completion. A workgroup barrier
never orders a whole grid.
After commit, publish one view-independent PhysicsPresentationCandidate at
requestedPresentationInstant: PhysicsInstant, containing
presentedStatePairs, resourceLeases, and eventSequenceRanges, but no
camera, render-origin transform, visibility, shadow, cache, or reset state.
Each pair's previousPresented.provenance and
currentPresented.provenance are independent
PresentationSampleProvenance records, and each arm carries its own
presentedInstant: PhysicsInstant. The camera owner then publishes one
CameraViewPublication per target/view with
previousRenderSampleInstant: PhysicsInstant and
currentRenderSampleInstant: PhysicsInstant plus
globalToRenderPrevious/globalToRenderCurrent, view/projection matrices,
jitter, viewport, and depth state; visibility, acceleration, shadow, cache,
reactive, and reset owners publish the corresponding
ViewPreparationPublication with visibilityPublicationRefs,
accelerationPublicationRefs, shadowViewPublicationRefs,
cachePublicationRefs, reactiveEpochs, reactivePublications,
resetDependencies, full resourceLeases for newly created camera-dependent
generations, and resourceLeaseRefs. Finally,
seal a PhysicsPresentationSnapshot whose state/resource payload is only
presentedStatePairRefs and resourceLeaseRefs plus the exact candidateId,
cameraPublicationId, and viewPreparationId, with an exact
closureManifest. Present airborne precipitation, receiver state, and any
causal cloud-emission generation used by the view as distinct
PresentedStatePair bindings. The snapshot never copies PresentedStatePair
records, provenance, or global-to-render transforms.
Use the shared QualityTransition for every physics-authoritative change to
precipitation equations, recurrent state, cadence, receiver support/filter,
exchange representation, partition/ledger identity, or stable IDs. A
render-only change to streak count, beauty resolution, sprite geometry, or
ripple-normal presentation may remain local only when physical state,
integrals, descriptors, filters, cadence, and identities are unchanged.
Capability Gate
Initialize the renderer before allocating compute or storage resources. The
high tier requires the WebGPU backend; the reduced tiers keep the same weather
envelope and switch quality, not implementation doctrine.
await renderer.init();
if (renderer.backend.isWebGPUBackend !== true) {
throw new Error(
'WebGPU is required for the canonical weather path; explicit fallback teaching belongs to threejs-compatibility-fallbacks.'
);
}
Native WebGPU quality tiers preserve the shared weather cause:
full: analytic or recurrent motion as required, world-stable sparse
impacts, integrated receiver fields, and measured reconstruction/post.
balanced: lower projected density/history extent and fewer field bands,
with response conservation and image-error gates intact.
budgeted: analytic precipitation where possible, bounded event pools,
lower-rate/reduced receiver state, and optional explicitly stylized generated
ripple normals.
Build Order
- Define one immutable weather projection from the latched forcing snapshot:
sample time comes from
sampleInstant: PhysicsInstant, step duration is
derived from the owning stage's executionInterval: PhysicsTimeInterval,
and wind, temperature, precipitation flux, and debug state preserve their
source versions. Rendered particle count is a
sampling/appearance choice: accepted impacts use deterministic quadrature
over the declared exposed-surface measure. Physical-area weights include
Jacobians and sum to represented area; deposition is
sum_i flux_i * areaWeight_i * dt. Do not normalize these area weights.
Extensive impact sample weights instead close the already integrated parent
transfer. Equal division by particle count is valid only for a proven
uniform measure/integrand. Wetness and snow coverage are integrated
state with deposition, drainage/evaporation, or melt terms; they consume the
same forcing but are not aliases of instantaneous precipitation progress.
- Allocate static per-instance seeds once. Evaluate ballistic/domain motion
analytically when possible. Update only recurrent particle state with
queued
renderer.compute() into storage; r185 computeAsync() is not a
GPU-completion fence.
- Choose the visual domain. Unbounded precipitation uses camera-centred
world-cell streaming with stable world hashes. Localized weather uses a
world-anchored bounded volume whose boundary is physically hidden or softly
modelled. Impacts/accumulation always use an independent world-stable
receiver field.
- Author surfaces as
MeshStandardNodeMaterial or
MeshPhysicalNodeMaterial. Drive color, roughness, metalness, normal,
opacity, and displacement through node slots, not string patching.
- Use one field per phenomenon: one snow height function feeds both
displacement and normals; one wetness/puddle mask gates roughness, ripple
normals, splash intensity, and debug output.
- For rain surfaces, split early wetness from heavy-rain ripple response.
Roughness should change before ripple normals appear.
- For splashes, generate or compact impact candidates on the GPU only past the
measured CPU/dirty-upload crossover. Weight by world-space upward normals, reject hidden/downward
surfaces, and animate flipbook progress without per-splash CPU rewrites.
- Present with
RenderPipeline. Use built-in nodes first: GTAONode or
ao() for contact grounding, BloomNode or bloom() only for bright
splash highlights, TRAANode or traa() when temporal stability matters,
and CSMShadowNode or TileShadowNode for large precipitation-lit scenes.
Required Controls
- precipitation density, rate, speed, and quality tier;
- wind direction, strength, and gust phase;
- shared weather forcing plus independently integrated wetness/snow state;
- visual domain bounds, cell-streaming/wrapping policy, and receiver-field extent;
- wetness, snow, or puddle mask threshold and softness;
- ripple source: dynamic field, generated variant A/B/C, or disabled;
- ripple or drift normal strength;
- surface roughness and color response;
- particle, residue, and splash opacity;
- debug modes for masks, normals, particles, event buffers, forcing, and
integrated surface state.
Performance Contract
Derive particle count from projected coverage and overdraw, not a device-class
lookup table. An analytic camera-relative precipitation field can render with
immutable seeds and no simulation dispatch. A recurrent particle solver pays
storage plus compute only when interaction, collision, or persistent state is
visible. Sparse world impacts use a bounded event pool; do not update a dense
world grid merely because precipitation is dense on screen.
- Storage: keep recurrent instance buffers packed to the fields actually read.
For an Authored example capacity of 100,000 instances with three
vec4<f32> records, the Derived payload is
100000 * 3 * 16 = 4,800,000 B = 4.58 MiB, excluding allocator padding,
render targets, and duplicate/history slots.
- Passes: one beauty pass, optional MRT only when later nodes reuse depth,
normals, wetness, or velocity; reduced-resolution post effects must use
PassNode.setResolutionScale().
- Draw calls: one draw per visible spatial page and compatible precipitation
or splash material class; never trade submission savings for one uncullable
world-wide batch. Use no per-drop or per-splash object allocation.
Record {visibleInstances, pixelsCovered, mean/max layersPerPixel, streakQuadPx, solverKind, storageBytes, dirtyImpactTiles, renderExtent, sampleCount}. The
router assigns a whole-frame allocation; report contemporaneous full-frame
p50/p95 and a paired marginal A/B result for precipitation. Gate sustained GPU
and CPU p50/p95, hot bytes/frame, transparent overdraw, impact-field work, peak
live memory, and thermal behavior on the named target. On tile GPUs compare
analytic/no-history, reduced field, and recurrent tiers under the same visual
error contract; instance count alone says nothing about mobile suitability.
Color And Output
- LDR albedo/emissive textures encoded as sRGB use
SRGBColorSpace; HDR/EXR
radiance remains loader-declared linear.
- Data textures, normal maps, roughness maps, masks, noise, LUTs, and weather
fields use
NoColorSpace or linear treatment.
- Decide mipmaps per use: pregenerated ripple-normal variants should have
stable filtering; storage textures written by compute need explicit mip
ownership.
- Keep HDR working buffers as
HalfFloatType until the tone-map step.
- The node pipeline owns exactly one tone map and one output conversion through
outputColorTransform or an explicit renderOutput() node.
Replacement Doctrine
- For dense recurrent state, replace per-frame CPU instance rewrites with
GPU-resident compute/storage only after the state remains resident and the
measured dispatch is cheaper. Analytic seeds need neither path; sparse,
branch-heavy authoritative events may remain CPU-updated dirty ranges.
- Replace disconnected particle clocks and surface clocks with one weather
envelope. This prevents rain, splashes, puddles, and snow coverage from
drifting apart.
- Replace string-injected material customization with TSL node slots on
NodeMaterial classes. This is the current renderer path and keeps material
fields composable.
- Replace expensive analytic ripple evaluation on every wet pixel with dynamic
fields only when justified; otherwise use the generated normal variants under
assets/generated-variants/ as the cheap tier.
- Replace local-space splash weighting with world-space normal tests and
optional depth or occlusion rejection.
Failure Conditions
- falling precipitation ignores the wind, time, or progress used by surfaces;
- instance positions or splash progress are rewritten on the CPU every frame;
- an unbounded streamed volume exposes emitter edges, or a localized volume
hides an unexplained hard boundary;
- snow height and snow normals come from different fields;
- model snow slides in world space or sticks to vertical faces;
- puddles only lower roughness without a mask, normal response, or ripple tier;
- splashes appear on downward, vertical, hidden, or transformed faces because
normals were not evaluated in world space;
- temporal wetness is faked with unrelated noise instead of shared weather
progress;
- color textures and data textures use the same color-space settings;
- the node pipeline double-applies tone mapping or output conversion.
Routing Boundary
Use $threejs-water-optics for bounded pool simulation, caustics, Fresnel,
refraction, and Beer-Lambert water volumes. Use $threejs-particles-trails-and-effects for
general sparks, plasma, trails, and non-weather particles. Use
$threejs-dynamic-surface-effects for screen-space touch history or frost clearing.
Use $threejs-image-pipeline for full-frame post ownership when precipitation
is part of a larger HDR pipeline. Use $threejs-scalable-real-time-shadows when weather
visibility depends on large-scene shadow budgets. This skill owns
precipitation transport and its typed exchanges. The route-selected receiver
owner owns integrated wetness/coverage; surface materials only consume it.