Generated ripple-normal asset preview

Rain, Snow, and Wet Surfaces

Build coupled WebGPU/TSL rain, snow, and wet-surface systems in Three.js. Use for compute-driven falling snow, rain streaks, snow accumulation, model snow caps, wet asphalt puddles, procedural or generated ripple normals, splash flipbooks, shared weather envelopes, and surface wetness or coverage transitions.

$threejs-rain-snow-and-wet-surfaces 2 primary implementations 3 flagships 1 secondary surface native evidence pending Latest skill update commit 9077075 ↗ SKILL.md on GitHub ↗ raw (for agents) ↗

The approach, mathematically

Weather is one envelope driving many systems: precipitation intensity $\rho_w \in [0,1]$ feeds particles, accumulation, and surface response so they can never disagree. Falling particles integrate gravity with terminal velocity and wind:

$$\mathbf v_{t+dt} = \mathbf v_t + \big(\mathbf g - k\,\mathbf v_t + \mathbf w(t)\big)\,dt$$

Wetness darkens albedo and sharpens specular response — a wet material interpolates:

$$\alpha' = \operatorname{lerp}(\alpha, \alpha_{wet}, w), \qquad c' = c\,(1 - 0.3\,w), \qquad F_0' = \operatorname{lerp}(F_0, 0.02\text{–}0.04, w_{film})$$

Snow accumulates by up-facing exposure $s = \operatorname{smoothstep}(c_0, c_1, \mathbf n\cdot\hat{\mathbf u})\cdot\rho_s$, and puddles fill height-field basins from the bottom up. Ripple normals advance phase per ring: $h(r,t) = A\,e^{-\gamma t}\cos(kr - \omega t)$, baked or generated as normal variants.

Preview and evidence ledger

Every image identifies what it proves. Page screenshots demonstrate the published presentation only; generated inputs demonstrate asset channels only; canonical acceptance still requires render-target readback and a schema-v2 bundle.

Canonical runtime evidence pending3 published images

The full skill

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

  1. 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.
  2. 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.
  3. 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.
  4. Author surfaces as MeshStandardNodeMaterial or MeshPhysicalNodeMaterial. Drive color, roughness, metalness, normal, opacity, and displacement through node slots, not string patching.
  5. 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.
  6. For rain surfaces, split early wetness from heavy-rain ripple response. Roughness should change before ripple normals appear.
  7. 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.
  8. 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.

Secondary provider surfaces

Preserved concept proxies and generated-asset previews. They are excluded from primary completion counts and link to the canonical lab through the schema-v2 registry.