Generated frost-map asset preview

Dynamic Surface Effects

Build dynamic screen-space surface effects in Three.js r185 WebGPU/TSL. Use for StorageTexture touch-history ping-pong, dt-correct frost/thaw masks, reduced-resolution node blur, static crystalline structure targets, and two-scale TSL normal refraction.

$threejs-dynamic-surface-effects 2 primary implementations 1 secondary surface native evidence pending Latest skill update commit 9077075 ↗ SKILL.md on GitHub ↗ raw (for agents) ↗

The approach, mathematically

Surface history is a storage-texture ping-pong: touches write into a mask that decays and diffuses with correct time dependence, so behavior is identical at 30 and 144 fps:

$$m_{t+dt} = \operatorname{clamp}\Big(m_t\,k^{dt} + D\,\nabla^2 m_t\,dt + \sum_i T_i\Big)$$

where $k^{dt}$ (not a per-frame constant) gives exponential decay per second. Frost grows toward a static crystalline structure target $S(\mathbf p)$ — the mask reveals authored structure instead of accumulating mush:

$$f = \operatorname{smoothstep}(0, 1, m)\cdot S(\mathbf p)$$

Refraction reads the frost normal at two scales — coarse lensing plus fine crystal detail — offsetting the scene sample by $\Delta\mathbf{uv} = \eta\,(w_1 \mathbf n_{coarse} + w_2 \mathbf n_{fine})_{xy}$, with the blur radius driven by the same mask through a reduced-resolution node blur.

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 pending4 published images

The full skill

The complete SKILL.md as loaded by agents — verbatim, rendered.

Dynamic Surface Effects

Use this skill for screen-space surface effects whose visible mask, thaw/clear state, or refractive response depends on persistent history. The only taught implementation path is pinned Three.js r185 with WebGPURenderer, TSL, NodeMaterial, RenderPipeline, pass(), storage textures, and compute.

Run $threejs-choose-skills before implementation when the request could also involve world-space residue, weather accumulation, object paint, water, or a larger post stack.

Presentation-Only Physics Boundary

Read the shared physics domain and interaction contract before showing state owned by a thermal, weather, contact, or surface solver. This skill remains a presentation consumer: its screen-UV R/A history is not authoritative temperature [K], phase or coverage fraction, frost/ice loading [kg m^-2], wetness, traction, heat flux, precipitation, contact, or weather state. Screen UV has no physics-frame measure.

Physics-driven frost may consume read-only signal channels described by PhysicsSignalDescriptor: dimensionless coverage/phase fraction, temperature in kelvin, and optional areal frost/ice loading, all reached through one sealed PhysicsPresentationSnapshot and its exact candidate/camera/view-preparation references. Resolve the candidate's PresentedStatePair; its previous and current states have independent PresentationSampleProvenance. Use that provenance and the associated PhysicsSignalDescriptor footprint/filter, source clock and mapped PhysicsInstant, state version, validity, and typed per-channel error. Missing or invalid channels are absent—not zero—and the effect either follows its declared decorative policy or does not render the physical claim.

Keep physical appearance and UI history as separate inputs. A local pointer mask must not subtract physical frost/ice coverage or loading unless the owner has published the corresponding updated state. If it does, label the result a decorative interaction overlay rather than simulated thaw.

Pointer history is UI input. If scraping, touch, deposition, or heat transfer must change physical state, input/interaction routing sends a typed InteractionRecord to the owning solver; this skill later consumes the solver-published snapshot. It emits neither InteractionRecord nor SurfaceExchange, and it never reads screen history back to fabricate physical state. NodeMaterial frost tint, IOR, normals, and roughness are visual state, not a PhysicsMaterialId or properties in PhysicsMaterialRegistry.

Advance visual history from the per-view CameraViewPublication.previousRenderSampleInstant and currentRenderSampleInstant. When current is later than previous, validate and form their half-open PhysicsTimeInterval before deriving seconds through the versioned clock mapping. Equal instants mean no elapsed interval; never invent a zero-length PhysicsTimeInterval. Never derive dt from one timestamp or subtract unrelated clock seconds. On seek, reversal, clock discontinuity, invalid mapping, or missing previous instant, execute the declared scoped reset/freeze/catch-up policy. This presentation interval is not a solver fixed step. Reset, remap, or reproject only through the resolved ViewPreparationPublication.resetDependencies when PhysicsContext.contextVersion/worldTransformRevision, descriptor transformRevision/physicsOriginEpoch, the camera publication's render-origin/transform/projection state, candidate-pair provenance or lease resourceGeneration, a discontinuous ReactivePublication.sourceVersion, render mapping/projection, validity, or a declared QualityTransition makes the history incompatible. An ordinary stateVersion advance is sampled; it is not itself a reset. A render-quality change cannot alter or rewrite the source physics signal.

Choose The State Update First

The history representation follows the state transition, not the visual theme:

Workload Required path Reject
Global decay/diffusion changes most texels every step Full-field ping-pong compute at the lowest history resolution that passes edge tests Dirty rectangles that leave untouched texels at the wrong age
Deposits are sparse and untouched texels are invariant Event bounding boxes or dirty tiles; dispatch only covered tiles A full-screen dispatch for one small mark
Many events overlap Bin events into screen tiles, prefix/compact bounded tile lists, then process dirty tiles or rasterize an aggregate deposit field Claiming O(pixels + events) when overlap still costs sum(P_t * E_t)
The effect is fixed for long intervals Bake the static structure once and stop history dispatches while idle Paying a nominally disabled compute pass

The full-field path is the canonical frost/thaw case when state must be materialized every frame. A lazy-decay tile stores lastUpdateTime and applies analytic catch-up on every visible/filter sample, not only on the next touch; otherwise old visible tiles remain stale. Diffusion couples neighbours and requires a global step or halo-expanded active domain—independent timestamped tiles are not equivalent.

Select Architecture From Update Topology

Build the high-throughput frame graph first. Do not start from a simple per-frame visual mask and later "upgrade" to history.

sealed PhysicsPresentationSnapshot
  -> resolve PhysicsPresentationCandidate bindings/leases
  -> resolve CameraViewPublication render interval/transforms
  -> resolve ViewPreparationPublication reactive/reset actions
  -> validate/project physical appearance channels ---------------------+
UI input events for this frame                                           |
  -> compute pointer deposit into next visual-history StorageTexture    |
  -> swap visual-history read/write ------------------------------------+
scene pass via pass(scene, camera)                                      |
  -> reduced-resolution vertical blur pass at a scale selected by edge/error gates
  -> reduced-resolution horizontal blur pass at the same measured scale
  -> static crystalline fields, generated once or loaded from assets
  -> full-resolution frost/thaw composite node <------------------------+
  -> two-scale TSL normal refraction node
  -> RenderPipeline output node with one output transform owner

History update comes before the frost composite so visible response can include the current frame's input. If a product deliberately wants a one-frame delayed feel, document that as a UX choice and keep the diagnostic contract identical.

When close-inspection error and target measurements require it, the top tier uses a full-resolution RGBA StorageTexture ping-pong:

  • R: accumulated visible touch/thaw mask.
  • A: accumulated tilt/refraction response mask.
  • G/B: optional duplicate or debug channels, never hidden state.

Use a declared dispatch shape: linear .compute(width * height, [64]) with instanceIndex, or explicit 2D .compute([gx, gy, 1], [wx, wy, 1]) with globalId.xy and extent guards. In the latter, [gx,gy,1] is workgroup count. Enqueue through renderer.compute(); computeAsync() only awaits renderer initialization in r185 and is not a GPU-completion fence. Keep the path read-back-free. Use PassNode.getPreviousTextureNode() only for temporal pass feedback that is naturally owned by a node pass; use storage textures when the history must be written from pointer/event data or compute.

Capability Gate

Compute and storage are required for the full-quality path.

import { WebGPURenderer, RenderPipeline, StorageTexture } from 'three/webgpu';
import { Fn, pass, storageTexture, textureStore } from 'three/tsl';

const renderer = new WebGPURenderer( { antialias: false } );
await renderer.init();

if (renderer.backend.isWebGPUBackend !== true) {
  throw new Error(
    'WebGPU is required for the canonical dynamic-surface path; route an explicit request for teaching fallback to threejs-compatibility-fallbacks.'
  );
}

// Native WebGPU tiers vary history resolution, format, event binning, blur,
// and refraction while preserving the same state transition.

Quality tiers:

Tier Required capability History Blur Refraction Intended use
Full WebGPU backend with storage texture compute full-res RGBA16F ping-pong when close inspection proves it necessary highest measured scale required by edge/error gates two-scale normals, height weighting, Fresnel/source inset close inspection
Balanced WebGPU backend with tighter budget half-res history or RG8/RG16F after measurement measured reduced scale one full normals plus reduced detail ordinary inspection distance
Budgeted WebGPU backend with minimal storage budget quarter-res RG8/RG16F or sparse updates minimum scale that passes edge gates tint plus single offset small projected footprint or strict traffic budget

If the user explicitly asks how to apply fallback when WebGPU is unavailable, route that teaching to ../threejs-compatibility-fallbacks/ instead of adding a non-WebGPU path here.

Implementation Rules

  • Treat both ping-pong slots as renderer-owned presentation resources. They are never registered as authoritative physics signals and have no physics readback path.
  • Keep persistent history, scene color, and static structure textures separate.
  • Record eventCount, dirty-tile count, texels dispatched, and the asymptotic update cost. A storage dispatch over the complete drawing buffer is not automatically efficient merely because it runs on the GPU.
  • Integrate decay and saturating deposition together. For channel state x, brush coverage b, decay rate lambda=-log(survivalPerSecond), and fill rate r=-log(1-depositPerSecond), use a=lambda+r*b, xEq=r*b/a, xNext=xEq+(x-xEq)*exp(-a*dt) (with the zero-rate limit handled explicitly). Consume timestamped pointer segments and rasterize a swept capsule; one endpoint stamp per render frame is not frame-rate invariant. Suspension policy is explicit rather than silently clamping away elapsed state.
  • Preserve separate visible-mask and tilt-response channels. The tilt channel should use smoother/noise-reduced deposit than the visible channel.
  • Update history with aspect from the history texture dimensions, not from a reduced blur target.
  • Use HalfFloatType/RGBA16F-equivalent history when accumulated precision needs it. Before selecting RG8, RGBA8, or another compact storage format, prove that the exact format is storage-writable and filterable on the target adapter and that quantization/decay remain stable at 30, 60, and 120 Hz.
  • Optional diffusion must be stable and explicit: apply a small Laplacian term to the R/A history after decay/deposit only when the visible signature improves edge cohesion without same-UV smearing. Validate disabled diffusion and enabled diffusion at 30, 60, and 120 FPS.
  • Use reduced-resolution separable blur with PassNode.setResolutionScale() or an equivalent node blur whose resolution scale is owned by the pass. Normalize alpha separately from RGB and guard zero-weight neighborhoods with an epsilon.
  • Generate static crystalline fields once at startup or on resize/quality change. Asset/data textures are NoColorSpace, repeat or mirrored-repeat wrap as authored, and only generate mipmaps when the sampling path needs them.
  • Build the final surface as TSL nodes feeding RenderPipeline.outputNode. Node materials for any helper meshes use the NodeMaterial family.
  • Screen-period uniforms must be named as periods, not texture sizes. Derive real texel dimensions from texture metadata when needed.
  • Define resize policy explicitly: clear, preserve by remapping, or preserve by reprojection. The default safe policy is to clear history and regenerate static structure textures.

Color And Output

  • Scene color entering the surface pipeline stays linear/HDR until the final output transform. Working buffers use HalfFloatType where precision matters.
  • LDR color assets encoded as sRGB use SRGBColorSpace; HDR/EXR radiance remains loader-declared linear. Data textures, normal maps, masks, noise, and LUTs use NoColorSpace.
  • The app has exactly one tone-map owner and one output conversion owner. Prefer RenderPipeline.outputColorTransform = true; if disabled, end the node graph with renderOutput(). Do not output-convert inside the effect.
  • Frost tint, brightness, saturation, blur mix, and normal-refraction math are all linear-light operations before final output conversion.

Performance Contract

Set budgets before tuning visuals:

Work item Accounted cost
Pass graph one existing scene input, one state update when dirty/decaying, two separable blur passes only when enabled, one composite/refraction output
History storage Derived: 2 * width * height * bytesPerTexel; full-res RGBA16F is about 31.6 MiB at 1920x1080, before alignment
Static storage exact sum of selected data textures; generate once and share by content hash
Dispatch ceil(activeWidth / wx) * ceil(activeHeight / wy) workgroups; record active texels and measured workgroup choice
Draw calls no extra scene redraws beyond the source pass
Bandwidth lower bound bytes read + written by state, blur, and composite, including both ping-pong slots and any resolve/copy

The subsystem ceiling is allocated by $threejs-choose-skills from the whole frame; these rows are not permission to consume the entire mobile frame. Start native low-power trials at reduced history scale, but select the scale from projected feature error, peak-live memory, tile attachment behavior, and sustained p50/p95 target timing—not a universal pixel count. Report contemporaneous full-frame timing and paired marginal cost; raise blur/refraction quality only after state update and reconstruction fit the allocation.

Diagnostics And Validation

Expose debug outputs for:

scene color
vertical blur
horizontal blur
each static structure field
previous history R/A
current deposit R/A
next history R/A
frost mask before pointer application
frost mask after pointer application
sharp/blur mix
main refraction offset
detail refraction offset
final without refraction
final

Add pause and single-step controls. Validate:

  • The same pointer path produces matching accumulated masks at 30, 60, and 120 FPS.
  • Resize follows the documented clear/preserve policy.
  • Repeat or mirrored-repeat normal sampling is visible at boundaries.
  • RenderPipeline.render() owns presentation when the node pipeline is active.
  • Output screenshots are neither double-converted nor left in linear display space.
  • Optional physical inputs reject stale, unsupported, invalid, or incompatible candidate-pair provenance; context, transform/origin, lease generation, per-view camera interval, view-preparation reactive source, render-mapping/projection, and quality discontinuities take the declared scoped reset/remap/reprojection path, and the snapshot resolves rather than copies those exact publications.
  • The steady frame loop performs zero readbacks from presentation history and emits zero physical interaction, exchange, material, force, or contact state.

Interface anchors:

Interface Contract
Pointer NDC Convert [-1, 1] input to history UV explicitly.
Texel center Compute dispatches address storage texel centers, not CSS pixels.
Drawing-buffer size Storage dimensions use physical drawing-buffer pixels after DPR.
CSS size / DPR CSS size and DPR are metadata for resize policy, never hidden scale factors.
Screen period mainScreenPeriod and detailScreenPeriod are screen periods, not texture dimensions.
UV origin Name Y-up/Y-down assumptions before sampling history or normal maps.

Replaced Techniques

  • Replaced time-only procedural masks with storage-backed history because accumulation is stateful and must survive visual noise changes.
  • Replaced per-frame decay constants with exponential dt-correct survival so 30/60/120 FPS behavior matches.
  • Replaced full-resolution broad blur with reduced-resolution separable blur because it preserves the same broad frost feel at far lower bandwidth.
  • Replaced single-scale offset refraction with two-scale TSL normal refraction using height-weighted detail, Fresnel/source inset, and mask gating because it gives better frozen-surface structure per sample.

Routing Boundary

Use $threejs-particles-trails-and-effects for world- or object-space residue, particles, and dissolves. Use $threejs-rain-snow-and-wet-surfaces for rain wetness, puddles, snow accumulation, and weather-surface coupling. Use $threejs-water-optics for physically bounded water refraction/ripples. Use $threejs-image-pipeline when the work is mostly full-frame post ownership, tone mapping, bloom, GTAO, or anti-aliasing.

This skill owns screen-space persistent surface history and its composite. It does not own thermal phase, frost mass, wetness, friction, or physical contact even when those signals drive the composite.

Legacy WebGL implementation (deprecated, do not extend): examples/touch-history-frost/frost-surface-effect.js.

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.