Live concept-proxy screenshot · not canonical evidence

Exposure And Color Grading

Build a workload-selected WebGPU/TSL exposure and grading path in Three.js. Use for fixed, sampled, reduced, or histogram luminance metering; GPU-resident EV adaptation; explicit tone-map/output ownership; and domain-correct lut3D grading chosen from measured scene and target requirements.

$threejs-exposure-color-grading 1 primary implementation 2 flagships 1 secondary surface native evidence pending Latest skill update commit 9077075 ↗ SKILL.md on GitHub ↗ raw (for agents) ↗

The approach, mathematically

Exposure is metered on the GPU: a compute reduction averages log-luminance over the frame (log so that a bright sliver doesn't dominate):

$$\bar L = \exp\!\left(\frac{1}{N}\sum_i \log\big(\epsilon + L_i\big)\right), \qquad L_i = 0.2126R + 0.7152G + 0.0722B$$

Adaptation follows the eye asymmetrically — fast to light, slow to dark — as an exponential approach with split time constants:

$$E_{t+dt} = E_t + (E_{target} - E_t)\,\big(1 - e^{-dt/\tau}\big), \qquad \tau = \begin{cases}\tau_{up} & E_{target} > E_t\\ \tau_{down} & \text{otherwise}\end{cases}$$

Exposure state lives in a storage buffer — no CPU readback stall. One node owns tone mapping and output color transform; grading applies after tone mapping through a 3D LUT: $c' = \operatorname{LUT}_{3D}(\operatorname{tonemap}(E\cdot L))$. Two owners of the output transform is the classic double-transform bug.

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 pending1 published image

The full skill

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

Exposure And Color Grading

Use one WebGPURenderer, one RenderPipeline, one scene-linear HDR source, one GPU-resident exposure state per declared exposure-control group, one tone-map owner, and one output conversion owner. A group may span target/views only when their radiance binding, meter mask, exposure-key policy, sample-time policy, and reset history are identical. Choose the meter by measured marginal cost. For an exact global mean, a direct full-pixel reduction has fewer intermediate writes than an exposure-only pyramid; a sampled estimator reads fewer source pixels but is not exact.

Read references/scene-referred-color-pipeline.md before implementation. It defines the metering decision, temporal taps, EV adaptation, LUT domains, r185 API proof, and measured budget contract.

Numeric Evidence Rule

Every numeric value in an implementation or recommendation must carry one tag:

  • [Derived]: follows from a formula, format, dimensions, or verified API;
  • [Gated]: legal only after a named capability/correctness gate;
  • [Measured]: captured on the named browser, device, resolution, and graph;
  • [Authored]: an intentional look/control starting value, never a performance fact.

Do not publish an untagged workgroup size, sample grid, percentile, EV clamp, time constant, LUT dimension, cadence, memory figure, or timing target.

Canonical Ordering

scene-linear lighting / AO / atmosphere
  -> temporal reconstruction of stable scene radiance, when enabled
  -> meter tap from resolved pre-bloom HDR by default
  -> bloom and other authored scene-linear optical contributions
  -> apply adapted exposure
  -> tone map
  -> tone-mapped-linear LUT, when that is the LUT contract
  -> output conversion
  -> display-encoded effects / dither / UI, when their domains require it

The pre-bloom tap avoids an exposure/bloom feedback loop. Metering bloom is an [Authored] shot policy and must be regression-tested. Keep temporal history unexposed; otherwise rescale history by the exposure ratio or reject it.

Lighting-Transport And Physics Snapshot Contract

When the route declares a physics-to-render boundary, consume the immutable PhysicsPresentationCandidate -> CameraViewPublication -> ViewPreparationPublication -> PhysicsPresentationSnapshot chain and bind its matching LightingTransportSnapshot through a provider-wide PresentedStatePair (entityId: typed-absence) in the Candidate and referenced by the Snapshot, as defined by the physics domain and interaction contract. Validate the exact central lighting and presentation schemas; do not redeclare an exposure-local record. Each consumed incidentRadiance, surfaceIrradiance, directSolarIrradiance, skyIrradiance, transmittance, and sourceDirection channel has its own basis, quantity, SI unit, sampleInstant/actualPhysicsTime, validity, and error. Normalization belongs only to the derived render-local signal described below. Applied atmosphere/cloud/ visibility factors are versioned through attenuationFactorIds. Match the pair's context/provider/signal IDs, descriptor/state/resource generations, PresentationStateHandle, requested presentation instant and mapped source instant. Channel clocks may differ from the presentation clock only through the declared PresentationSampleProvenance clock mapping; validate channel actualPhysicsTime, age, filter, maximum staleness, validity, and error against the target/view currentRenderSampleInstant.

Canonical lighting-provider channels remain SI-valued. The meter input is scene-linear radiance in the declared working basis. A physically calibrated render basis may retain those units. A normalized RGB render basis is a separately named render-local signal produced by a versioned SI-to-render conversion with reference scale, provenance, and error; it is not a normalized canonical lighting channel. Do not let auto exposure conceal incompatible radiance/irradiance units or per-skill compensating gains. If transport values are irradiance, the lighting model converts them through the declared BRDF/emission contract before the radiance meter tap. A nonphysical route leaves the router physics fields not used and declares only its render-local color contract.

ViewPreparationPublication.reactivePublications and ViewPreparationPublication.resetDependencies distinguish local radiance changes from basis changes. Shadow commits and discontinuous foam/emissive/optical updates flow through the meter normally and do not reset adaptation by default. A change to radiance scale/basis, working primaries, quantity convention, or the authored exposure key must either provide an exact EV conversion or reseed meter accumulation and adapted state before use. Invalid lighting executes a bounded canonical hold-prior action or selects an explicitly authored fixed EV; it never mixes the bad sample into GPU state.

ViewPreparationPublication.resetDependencies is the immutable plan; record actual exposure conversion, reduction reset, reseed, hold, and GPU submission in FrameExecutionRecord. Do not use computeAsync() as a completion fence. Device loss invalidates the exposure buffers, meter history, and timing evidence, then appends a FrameExecutionRecord with overallStatus: device-lost, affected target execution statuses device-lost, cancelled dependent actions, and lost-generation entries in leaseDispositionById. The immutable snapshot remains audit evidence but its lost-generation resource references are not bindable; rebuild and reseed under the new backend/resource generation.

Meter Decision

Choose in dependency order:

  • Prefer authored/fixed exposure for a controlled shot or product view.
  • Otherwise use a stratified screen grid or tile sampler for the default global meter.
  • Escalate to direct full-pixel hierarchical reduction when exact masks or subpixel emitters are acceptance requirements.
  • Build an explicit log-luminance/weight pyramid only when another feature consumes its levels or spatial statistics.
  • Add a log-luminance histogram only when percentile clipping solves a proven outlier or bimodal-lighting failure.

Do not infer that an internal BloomNode pyramid is reusable: r185 exposes the bloom result, not its private mip chain.

Capability Gate

// r185 API literals and backend test: [Gated: installed source]
await renderer.init();

if ( renderer.backend.isWebGPUBackend !== true ) {
  throw new Error( 'Native WebGPU is required for this exposure path.' );
}

const canTimeGpu = renderer.hasFeature( 'timestamp-query' );
renderer.compute( exposureComputeNodes );

renderer.computeAsync() is valid and initializes on demand, but in r185 it does not constitute a GPU-completion fence. Use getArrayBufferAsync() only for scheduled diagnostics; exposure must never wait for CPU readback.

If, and only if, the user explicitly asks for teaching how to apply fallback when WebGPU is unavailable, route that request to ../threejs-compatibility-fallbacks/.

Ownership Invariants

  • meter scene-linear working color, never transfer-encoded or graded color;
  • adapt exposure in EV/log space in GPU state;
  • keep renderer.toneMappingExposure fixed when adapted exposure is applied explicitly;
  • use toneMapping() before a tone-mapped-linear LUT, then renderOutput(..., NoToneMapping, renderer.outputColorSpace);
  • use renderOutput() before a LUT only when that LUT is explicitly authored for the exact display primaries and transfer function;
  • set RenderPipeline.outputColorTransform = false whenever the graph contains an explicit renderOutput();
  • set renderPipeline.needsUpdate = true after changing outputNode or output ownership.

Failure Conditions

  • a sampled grid is described as a full-pixel reduction, or vice versa;
  • a one-sample-per-cell grid is trusted for tiny emitters without a jitter or box-filter validation;
  • a histogram uses global per-pixel atomics instead of local aggregation without [Measured] proof;
  • a luminance pyramid exists only for exposure and costs more traffic than the statistic it replaces;
  • CPU readback drives current-frame exposure;
  • readback staleness changes GPU exposure state;
  • exposure is applied before temporal history without ratio compensation;
  • bloom output meters itself through exposure feedback;
  • tone mapping, transfer conversion, or dynamic exposure has two owners;
  • LUT primaries, transfer, shaper, legal range, interpolation, and tone-map dependency are undocumented;
  • an sRGB-encoded LUT is sampled as though it were tone-mapped linear data;
  • authored numbers are presented as device budgets.

Routing Boundary

Run $threejs-choose-skills for broad ownership. Use $threejs-image-pipeline for shared MRT, temporal history, adaptive DPR, or transient-lifetime decisions; $threejs-bloom for the HDR glare source; and $threejs-visual-validation for fixed-view exposure and LUT evidence.

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.