rayzee 8.5.0 → 8.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +60 -4
  2. package/dist/{DLSSNeural-BNSJ8V3z.js → DLSSNeural-BhBCBgKf.js} +5 -5
  3. package/dist/{DLSSSuperRes-BoEW89Cl.js → DLSSSuperRes-BCA7F_k5.js} +5 -5
  4. package/dist/ToneMapGPU-BzcLheoR.js +533 -0
  5. package/dist/rayzee.es.js +27198 -9047
  6. package/dist/rayzee.umd.js +255 -187
  7. package/package.json +1 -1
  8. package/src/AssetConfig.js +15 -2
  9. package/src/Color/BakedViews.js +134 -0
  10. package/src/Color/BuiltinViews.js +261 -0
  11. package/src/Color/ColorManagement.js +1130 -0
  12. package/src/Color/ColorSpaces.js +432 -0
  13. package/src/Color/Displays.js +19 -0
  14. package/src/Color/InputColorSpaces.js +243 -0
  15. package/src/Color/LutBake.js +371 -0
  16. package/src/Color/OcioRuntime.js +338 -0
  17. package/src/Color/OcioViews.js +419 -0
  18. package/src/Color/ViewTransforms.js +323 -0
  19. package/src/Color/WorkingMatrix.js +86 -0
  20. package/src/EngineDefaults.js +55 -3
  21. package/src/EngineIssues.js +3 -0
  22. package/src/Passes/AIUpscaler.js +16 -7
  23. package/src/PathTracerApp.js +227 -7
  24. package/src/Processor/AssetLoader.js +1 -2
  25. package/src/Processor/EmissiveTriangleBuilder.js +34 -2
  26. package/src/Processor/GeometryExtractor.js +151 -138
  27. package/src/Processor/LightSerializer.js +8 -3
  28. package/src/Processor/MaterialPacking.js +168 -0
  29. package/src/Processor/PackedRayBuffer.js +6 -2
  30. package/src/Processor/ProceduralSky.js +4 -0
  31. package/src/Processor/SceneProcessor.js +25 -1
  32. package/src/Processor/SimpleSky.js +8 -0
  33. package/src/Processor/TextureCreator.js +137 -91
  34. package/src/Processor/TextureReadback.js +92 -0
  35. package/src/Processor/ToneMapCPU.js +80 -165
  36. package/src/Processor/ToneMapGPU.js +122 -151
  37. package/src/RenderSettings.js +1 -0
  38. package/src/Stages/Compositor.js +11 -2
  39. package/src/Stages/PathTracer.js +2 -0
  40. package/src/Stages/PathTracerStage.js +1 -1
  41. package/src/TSL/Common.js +15 -0
  42. package/src/TSL/Debugger.js +2 -3
  43. package/src/TSL/EmissiveSampling.js +6 -8
  44. package/src/TSL/ExtendKernel.js +9 -0
  45. package/src/TSL/Fresnel.js +23 -1
  46. package/src/TSL/HitFacet.js +102 -0
  47. package/src/TSL/LightsDirect.js +12 -33
  48. package/src/TSL/LightsSampling.js +15 -7
  49. package/src/TSL/MaterialEvaluation.js +26 -19
  50. package/src/TSL/MaterialProperties.js +74 -37
  51. package/src/TSL/MaterialTransmission.js +2 -3
  52. package/src/TSL/ShadeKernel.js +34 -21
  53. package/src/TSL/ShadowTerminator.js +125 -0
  54. package/src/TSL/Struct.js +9 -0
  55. package/src/TSL/Subsurface.js +2 -3
  56. package/src/index.js +29 -1
  57. package/src/managers/EnvironmentManager.js +20 -0
  58. package/src/managers/MaterialDataManager.js +118 -208
  59. package/src/managers/UniformManager.js +1 -0
  60. package/dist/ToneMapGPU-DzqSyzTx.js +0 -341
@@ -1,197 +1,100 @@
1
1
  /**
2
- * CPU tone mapping functions matching Three.js ToneMappingFunctions.js (WebGPU).
3
- * Shared between OIDNDenoiser and AIUpscaler for consistent HDR → sRGB conversion.
2
+ * The readback half of the view transform: linear float pixels to display bytes, in the output
3
+ * pass's order. Shared by OIDN, the AI upscaler and `renderToBuffer`.
4
4
  *
5
- * All functions write tonemapped linear RGB [0,1] into the `out` array.
5
+ * The curves themselves live in `../Color/ViewTransforms.js`, declared beside their WGSL and TSL
6
+ * counterparts, because a saved image that differs from the viewport is the failure this file
7
+ * exists to prevent. What stays here is everything around the curve — exposure, the saturation
8
+ * grade, the transfer function and the byte rounding.
9
+ *
10
+ * ⚠️ Rounding is deliberately bug-compatible. This writes `srgb * 255 + 0.5` into a
11
+ * `Uint8ClampedArray`, which rounds again, so the result has always been half a level bright.
12
+ * `ToneMapGPU.js` reproduces it exactly. Dropping the extra half would shift every image.
6
13
  */
7
- import {
8
- NoToneMapping, LinearToneMapping, ReinhardToneMapping,
9
- CineonToneMapping, ACESFilmicToneMapping, AgXToneMapping, NeutralToneMapping
10
- } from 'three';
11
-
12
- const clamp01 = x => Math.min( Math.max( x, 0 ), 1 );
13
-
14
- function noToneMap( r, g, b, _exposure, out ) {
15
14
 
16
- out[ 0 ] = clamp01( r );
17
- out[ 1 ] = clamp01( g );
18
- out[ 2 ] = clamp01( b );
19
-
20
- }
21
-
22
- function linearToneMap( r, g, b, exposure, out ) {
23
-
24
- out[ 0 ] = clamp01( r * exposure );
25
- out[ 1 ] = clamp01( g * exposure );
26
- out[ 2 ] = clamp01( b * exposure );
27
-
28
- }
29
-
30
- function reinhardToneMap( r, g, b, exposure, out ) {
31
-
32
- r *= exposure; g *= exposure; b *= exposure;
33
- out[ 0 ] = clamp01( r / ( r + 1 ) );
34
- out[ 1 ] = clamp01( g / ( g + 1 ) );
35
- out[ 2 ] = clamp01( b / ( b + 1 ) );
36
-
37
- }
38
-
39
- function cineonToneMap( r, g, b, exposure, out ) {
40
-
41
- r = Math.max( r * exposure - 0.004, 0 );
42
- g = Math.max( g * exposure - 0.004, 0 );
43
- b = Math.max( b * exposure - 0.004, 0 );
44
- const f = c => Math.pow( ( c * ( 6.2 * c + 0.5 ) ) / ( c * ( 6.2 * c + 1.7 ) + 0.06 ), 2.2 );
45
- out[ 0 ] = f( r );
46
- out[ 1 ] = f( g );
47
- out[ 2 ] = f( b );
48
-
49
- }
15
+ import { NoToneMapping } from 'three';
16
+ import { VIEW_TRANSFORMS, getViewTransform, onRegistryChange } from '../Color/ViewTransforms.js';
50
17
 
51
- function acesFilmicToneMap( r, g, b, exposure, out ) {
52
-
53
- r = r * exposure / 0.6;
54
- g = g * exposure / 0.6;
55
- b = b * exposure / 0.6;
18
+ /**
19
+ * Three.js clamps the fragment output with `.max( 0 )` *before* tone mapping (NodeMaterial.js,
20
+ * "force unsigned floats"), so the GPU curves never see a negative channel. The Compositor's
21
+ * saturation grade (default 1.2) drives complementary channels below zero on a third of a typical
22
+ * frame, and AgX/Neutral mix those negatives across channels instead of clipping them — measured
23
+ * 7-10 levels of shadow error against the viewport until the readback clamps the same way.
24
+ */
25
+ const clampNegative = fn => ( r, g, b, exposure, out ) =>
26
+ fn( r > 0 ? r : 0, g > 0 ? g : 0, b > 0 ? b : 0, exposure, out );
56
27
 
57
- let ir = 0.59719 * r + 0.35458 * g + 0.04823 * b;
58
- let ig = 0.07600 * r + 0.90834 * g + 0.01566 * b;
59
- let ib = 0.02840 * r + 0.13383 * g + 0.83777 * b;
28
+ /**
29
+ * Three.js ToneMapping constant → CPU function.
30
+ *
31
+ * Rebuilt in place rather than replaced: callers hold this Map, and loading a config adds entries
32
+ * to it long after they captured it.
33
+ */
34
+ export const TONE_MAP_FNS = new Map();
60
35
 
61
- const fit = c => ( c * ( c + 0.0245786 ) - 0.000090537 ) / ( c * ( 0.983729 * c + 0.4329510 ) + 0.238081 );
62
- ir = fit( ir ); ig = fit( ig ); ib = fit( ib );
36
+ function rebuild() {
63
37
 
64
- out[ 0 ] = clamp01( 1.60475 * ir - 0.53108 * ig - 0.07367 * ib );
65
- out[ 1 ] = clamp01( - 0.10208 * ir + 1.10813 * ig - 0.00605 * ib );
66
- out[ 2 ] = clamp01( - 0.00327 * ir - 0.07276 * ig + 1.07602 * ib );
38
+ TONE_MAP_FNS.clear();
39
+ for ( const t of VIEW_TRANSFORMS.values() ) TONE_MAP_FNS.set( t.id, clampNegative( t.cpu ) );
67
40
 
68
41
  }
69
42
 
70
- function agxToneMap( r, g, b, exposure, out ) {
71
-
72
- r *= exposure; g *= exposure; b *= exposure;
73
-
74
- let cr = 0.6274 * r + 0.3293 * g + 0.0433 * b;
75
- let cg = 0.0691 * r + 0.9195 * g + 0.0113 * b;
76
- let cb = 0.0164 * r + 0.0880 * g + 0.8956 * b;
77
-
78
- let ar = 0.856627153315983 * cr + 0.0951212405381588 * cg + 0.0482516061458583 * cb;
79
- let ag = 0.137318972929847 * cr + 0.761241990602591 * cg + 0.101439036467562 * cb;
80
- let ab = 0.11189821299995 * cr + 0.0767994186031903 * cg + 0.811302368396859 * cb;
81
-
82
- const AgxMinEv = - 12.47393, AgxMaxEv = 4.026069, range = AgxMaxEv - AgxMinEv;
83
- ar = clamp01( ( Math.log2( Math.max( ar, 1e-10 ) ) - AgxMinEv ) / range );
84
- ag = clamp01( ( Math.log2( Math.max( ag, 1e-10 ) ) - AgxMinEv ) / range );
85
- ab = clamp01( ( Math.log2( Math.max( ab, 1e-10 ) ) - AgxMinEv ) / range );
86
-
87
- const approx = x => {
88
-
89
- const x2 = x * x, x4 = x2 * x2;
90
- return 15.5 * x4 * x2 - 40.14 * x4 * x + 31.96 * x4 - 6.868 * x2 * x + 0.4298 * x2 + 0.1191 * x - 0.00232;
91
-
92
- };
93
-
94
- ar = approx( ar ); ag = approx( ag ); ab = approx( ab );
43
+ rebuild();
44
+ onRegistryChange( rebuild );
95
45
 
96
- let or = 1.1271005818144368 * ar - 0.11060664309660323 * ag - 0.016493938717834573 * ab;
97
- let og = - 0.1413297634984383 * ar + 1.157823702216272 * ag - 0.016493938717834257 * ab;
98
- let ob = - 0.14132976349843826 * ar - 0.11060664309660294 * ag + 1.2519364065950405 * ab;
99
-
100
- or = Math.pow( Math.max( 0, or ), 2.2 );
101
- og = Math.pow( Math.max( 0, og ), 2.2 );
102
- ob = Math.pow( Math.max( 0, ob ), 2.2 );
46
+ /**
47
+ * Whether this transform already returned display-encoded colour.
48
+ *
49
+ * Three.js's seven curves return linear and the output pass encodes them. An OCIO view returns
50
+ * colour encoded for its own display — sRGB, Rec.1886 or PQ — and encoding that again is the
51
+ * single most visible way to get colour management wrong.
52
+ */
53
+ export function isOutputEncoded( toneMapping ) {
103
54
 
104
- out[ 0 ] = clamp01( 1.6605 * or - 0.5876 * og - 0.0728 * ob );
105
- out[ 1 ] = clamp01( - 0.1246 * or + 1.1329 * og - 0.0083 * ob );
106
- out[ 2 ] = clamp01( - 0.0182 * or - 0.1006 * og + 1.1187 * ob );
55
+ return getViewTransform( toneMapping )?.outputEncoded === true;
107
56
 
108
57
  }
109
58
 
110
- function neutralToneMap( r, g, b, exposure, out ) {
111
-
112
- const StartCompression = 0.8 - 0.04;
113
- const Desaturation = 0.15;
114
-
115
- r *= exposure; g *= exposure; b *= exposure;
116
-
117
- const x = Math.min( r, Math.min( g, b ) );
118
- const offset = x < 0.08 ? x - 6.25 * x * x : 0.04;
119
-
120
- r -= offset; g -= offset; b -= offset;
121
-
122
- const peak = Math.max( r, Math.max( g, b ) );
123
-
124
- if ( peak < StartCompression ) {
125
-
126
- out[ 0 ] = r; out[ 1 ] = g; out[ 2 ] = b;
127
- return;
128
-
129
- }
130
-
131
- const d = 1 - StartCompression;
132
- const newPeak = 1 - d * d / ( peak + d - StartCompression );
133
- const scale = newPeak / peak;
134
- r *= scale; g *= scale; b *= scale;
135
- const gFactor = 1 - 1 / ( Desaturation * ( peak - newPeak ) + 1 );
136
-
137
- out[ 0 ] = r + ( newPeak - r ) * gFactor;
138
- out[ 1 ] = g + ( newPeak - g ) * gFactor;
139
- out[ 2 ] = b + ( newPeak - b ) * gFactor;
140
-
141
- }
59
+ /** sRGB gamma (1/2.2) — fast pow approximation. Prefer `linearToSRGB` when matching three.js. */
60
+ export const SRGB_GAMMA = 1 / 2.2;
142
61
 
143
62
  /**
144
- * Three.js clamps the fragment output with `.max( 0 )` *before* tone mapping
145
- * (NodeMaterial.js, "force unsigned floats"), so the GPU curves never see a negative
146
- * channel. Compositor's saturation grade (default 1.2) drives complementary channels
147
- * below zero on a third of a typical frame, and AgX/Neutral mix those negatives across
148
- * channels instead of clipping them — measured 7-10 levels of shadow error against the
149
- * viewport until the readback clamps the same way.
63
+ * Proper sRGB OETF, matching three.js `sRGBTransferOETF` (`1.055 * c^(1/2.4) - 0.055` with a
64
+ * `12.92 * c` linear segment below 0.0031308).
150
65
  */
151
- const clampNegative = fn => ( r, g, b, exposure, out ) =>
152
- fn( r > 0 ? r : 0, g > 0 ? g : 0, b > 0 ? b : 0, exposure, out );
66
+ export function linearToSRGB( c ) {
153
67
 
154
- /** Look-up table mapping Three.js ToneMapping constants to CPU functions. */
155
- export const TONE_MAP_FNS = new Map( [
156
- [ NoToneMapping, clampNegative( noToneMap ) ],
157
- [ LinearToneMapping, clampNegative( linearToneMap ) ],
158
- [ ReinhardToneMapping, clampNegative( reinhardToneMap ) ],
159
- [ CineonToneMapping, clampNegative( cineonToneMap ) ],
160
- [ ACESFilmicToneMapping, clampNegative( acesFilmicToneMap ) ],
161
- [ AgXToneMapping, clampNegative( agxToneMap ) ],
162
- [ NeutralToneMapping, clampNegative( neutralToneMap ) ]
163
- ] );
164
-
165
- /** sRGB gamma (1/2.2) — fast pow approximation. Prefer `linearToSRGB` when matching Three.js's output. */
166
- export const SRGB_GAMMA = 1 / 2.2;
68
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow( c, 1 / 2.4 ) - 0.055;
69
+
70
+ }
167
71
 
168
72
  /**
169
- * Proper sRGB OETF, matching Three.js `sRGBTransferOETF` (`1.055 * c^(1/2.4) - 0.055`
170
- * with a `12.92 * c` linear segment below 0.0031308). Use this when the CPU readback
171
- * needs to match the WebGPU output pass's sRGB encoding.
73
+ * The transfer step, skipped for a transform that encoded its own output.
74
+ * @param {number} c - a single channel, post-curve
172
75
  */
173
- export function linearToSRGB( c ) {
76
+ export function encodeForDisplay( c, toneMapping ) {
174
77
 
175
- return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow( c, 1 / 2.4 ) - 0.055;
78
+ return isOutputEncoded( toneMapping ) ? Math.min( Math.max( c, 0 ), 1 ) : linearToSRGB( c );
176
79
 
177
80
  }
178
81
 
179
82
  /**
180
83
  * Exposure as the WebGPU output pass applies it.
181
84
  *
182
- * Three.js applies `toneMappingExposure` *inside* ToneMappingNode's tone-mapping branch, and
183
- * that branch returns the colour untouched for NoToneMapping (ToneMappingNode.js: `if
184
- * ( toneMapping === NoToneMapping ) return colorNode`). So exposure is a no-op on screen there,
185
- * and a CPU readback that applies it anyway paints brighter than the viewport it replaces
186
- * (measured +33.6 % at exposure 2).
85
+ * Three.js applies `toneMappingExposure` *inside* ToneMappingNode's branch, and that branch
86
+ * returns the colour untouched for NoToneMapping (`if ( toneMapping === NoToneMapping ) return
87
+ * colorNode`). So exposure is a no-op on screen there, and a CPU readback that applied it anyway
88
+ * paints brighter than the viewport it replaces — measured +33.6 % at exposure 2.
187
89
  *
188
90
  * @param {number} exposure - renderer.toneMappingExposure
189
- * @param {number} toneMapping - Three.js ToneMapping constant
190
- * @returns {number}
91
+ * @param {number} toneMapping - a registered view transform id
191
92
  */
192
93
  export function effectiveExposure( exposure, toneMapping ) {
193
94
 
194
- return toneMapping === NoToneMapping ? 1.0 : exposure;
95
+ const t = getViewTransform( toneMapping );
96
+ if ( ! t ) return toneMapping === NoToneMapping ? 1.0 : exposure;
97
+ return t.appliesExposure ? exposure : 1.0;
195
98
 
196
99
  }
197
100
 
@@ -202,7 +105,7 @@ const LUM_R = 0.2126, LUM_G = 0.7152, LUM_B = 0.0722;
202
105
  * Pre-tonemapping saturation adjustment matching Display's GPU shader:
203
106
  * mix( vec3(luma), exposed, saturation )
204
107
  * Operates in-place on the `out` array (expects exposed linear RGB).
205
- * @param {Float32Array} out - [r, g, b] to adjust
108
+ * @param {Float32Array|number[]} out - [r, g, b] to adjust
206
109
  * @param {number} saturation - 1.0 = neutral
207
110
  */
208
111
  export function applySaturation( out, saturation ) {
@@ -216,13 +119,13 @@ export function applySaturation( out, saturation ) {
216
119
  }
217
120
 
218
121
  /**
219
- * Linear float RGBA → sRGB bytes, in the output pass's order: exposure, saturation, curve,
122
+ * Linear float RGBA → display bytes, in the output pass's order: exposure, saturation, curve,
220
123
  * transfer function. Not interchangeable — moving any step shifts every mid-tone.
221
124
  *
222
125
  * @param {Float32Array} linear - RGBA, 4 floats per pixel
223
126
  * @param {Object} options
224
127
  * @param {number} options.exposure - renderer.toneMappingExposure, raw
225
- * @param {number} options.toneMapping - Three.js ToneMapping constant
128
+ * @param {number} options.toneMapping - a registered view transform id
226
129
  * @param {number} [options.saturation=1]
227
130
  * @param {boolean} [options.preserveAlpha=false]
228
131
  * @returns {Uint8ClampedArray} RGBA bytes
@@ -231,6 +134,7 @@ export function toneMapToRGBA8( linear, { exposure, toneMapping, saturation = 1,
231
134
 
232
135
  const curve = TONE_MAP_FNS.get( toneMapping ) ?? TONE_MAP_FNS.get( NoToneMapping );
233
136
  const gain = effectiveExposure( exposure, toneMapping );
137
+ const encoded = isOutputEncoded( toneMapping );
234
138
  const out = new Uint8ClampedArray( linear.length );
235
139
  const scratch = [ 0, 0, 0 ];
236
140
 
@@ -243,9 +147,20 @@ export function toneMapToRGBA8( linear, { exposure, toneMapping, saturation = 1,
243
147
  applySaturation( scratch, saturation );
244
148
  curve( scratch[ 0 ], scratch[ 1 ], scratch[ 2 ], 1.0, scratch );
245
149
 
246
- out[ i ] = linearToSRGB( scratch[ 0 ] ) * 255 + 0.5;
247
- out[ i + 1 ] = linearToSRGB( scratch[ 1 ] ) * 255 + 0.5;
248
- out[ i + 2 ] = linearToSRGB( scratch[ 2 ] ) * 255 + 0.5;
150
+ if ( encoded ) {
151
+
152
+ out[ i ] = scratch[ 0 ] * 255 + 0.5;
153
+ out[ i + 1 ] = scratch[ 1 ] * 255 + 0.5;
154
+ out[ i + 2 ] = scratch[ 2 ] * 255 + 0.5;
155
+
156
+ } else {
157
+
158
+ out[ i ] = linearToSRGB( scratch[ 0 ] ) * 255 + 0.5;
159
+ out[ i + 1 ] = linearToSRGB( scratch[ 1 ] ) * 255 + 0.5;
160
+ out[ i + 2 ] = linearToSRGB( scratch[ 2 ] ) * 255 + 0.5;
161
+
162
+ }
163
+
249
164
  out[ i + 3 ] = preserveAlpha ? linear[ i + 3 ] * 255 + 0.5 : 255;
250
165
 
251
166
  }
@@ -1,157 +1,45 @@
1
1
  /**
2
- * The engine's tone curve in WGSL, and a pass that turns a packed rgba16float buffer into RGBA
3
- * bytes on the card.
2
+ * The engine's view transform in WGSL, and a pass that turns a packed rgba16float buffer into
3
+ * display bytes on the card.
4
4
  *
5
- * A mirror of `ToneMapCPU.js` — same curves, same order (exposure, saturation, curve, sRGB
6
- * transfer), same rounding. It exists because reading half-floats back and converting them in
7
- * JavaScript costs more than the neural upscale itself at production sizes. `bench:tonemap`
8
- * compares the two implementations on a real device and fails on any drift.
5
+ * A mirror of `ToneMapCPU.js` — same curves, same order (exposure, saturation, curve, transfer),
6
+ * same rounding. It exists because reading half-floats back and converting them in JavaScript
7
+ * costs more than the neural upscale itself at production sizes. `bench:upscale` compares the two
8
+ * implementations on a real device and fails on any drift.
9
+ *
10
+ * Both sides are generated from `../Color/ViewTransforms.js`, so a view baked from an OCIO config
11
+ * reaches this pass without anything here being edited — the shader is rebuilt when the registry
12
+ * moves, and each table-backed transform gets its own 3D texture binding.
9
13
  *
10
14
  * ⚠️ Rounding is deliberately bug-compatible. `toneMapToRGBA8` writes `srgb * 255 + 0.5` into a
11
- * `Uint8ClampedArray`, which rounds again — so the CPU has always been half a level bright. WGSL's
12
- * `round()` is round-half-to-even like the clamped array, so `round( srgb * 255 + 0.5 )` reproduces
13
- * it exactly, including black staying at 0. Dropping the extra half would shift every image against
14
- * the OIDN and Real-ESRGAN readbacks, which still go through the CPU function.
15
+ * `Uint8ClampedArray`, which rounds again, so the CPU has always been half a level bright. WGSL's
16
+ * `round()` is round-half-to-even like the clamped array, so `round( srgb * 255 + 0.5 )`
17
+ * reproduces it exactly, including black staying at 0.
15
18
  */
16
19
 
17
- import {
18
- NoToneMapping, LinearToneMapping, ReinhardToneMapping,
19
- CineonToneMapping, ACESFilmicToneMapping, AgXToneMapping, NeutralToneMapping
20
- } from 'three';
20
+ import { buildToneMapWGSL, getRegistryVersion } from '../Color/ViewTransforms.js';
21
21
 
22
- /**
23
- * WGSL functions: `rayzee_tone_map( linearRGB, mode, exposure, saturation )` and
24
- * `rayzee_linear_to_srgb( rgb )`, plus `rayzee_to_u8( channel )`.
25
- *
26
- * `mode` is the Three.js ToneMapping constant, interpolated in from the import above so the two
27
- * sides cannot disagree about which number means which curve.
28
- */
29
- export const TONE_MAP_WGSL = /* wgsl */ `
30
- const TM_NONE: u32 = ${NoToneMapping}u;
31
- const TM_LINEAR: u32 = ${LinearToneMapping}u;
32
- const TM_REINHARD: u32 = ${ReinhardToneMapping}u;
33
- const TM_CINEON: u32 = ${CineonToneMapping}u;
34
- const TM_ACES: u32 = ${ACESFilmicToneMapping}u;
35
- const TM_AGX: u32 = ${AgXToneMapping}u;
36
- const TM_NEUTRAL: u32 = ${NeutralToneMapping}u;
37
-
38
- fn tm_reinhard( c: vec3<f32> ) -> vec3<f32> {
39
- return clamp( c / ( c + vec3<f32>( 1.0 ) ), vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
40
- }
22
+ /** The caller owns bindings 0-2; tables start after them. */
23
+ const TABLE_GROUP = 0;
24
+ const FIRST_TABLE_BINDING = 3;
41
25
 
42
- fn tm_cineon( c0: vec3<f32> ) -> vec3<f32> {
43
- let c = max( c0 - vec3<f32>( 0.004 ), vec3<f32>( 0.0 ) );
44
- let v = ( c * ( 6.2 * c + vec3<f32>( 0.5 ) ) ) / ( c * ( 6.2 * c + vec3<f32>( 1.7 ) ) + vec3<f32>( 0.06 ) );
45
- return pow( max( v, vec3<f32>( 0.0 ) ), vec3<f32>( 2.2 ) );
46
- }
26
+ function currentShader() {
47
27
 
48
- fn tm_aces( c0: vec3<f32> ) -> vec3<f32> {
49
- let c = c0 / 0.6;
50
- let m_in = mat3x3<f32>(
51
- vec3<f32>( 0.59719, 0.07600, 0.02840 ),
52
- vec3<f32>( 0.35458, 0.90834, 0.13383 ),
53
- vec3<f32>( 0.04823, 0.01566, 0.83777 ) );
54
- let v = m_in * c;
55
- let a = v * ( v + vec3<f32>( 0.0245786 ) ) - vec3<f32>( 0.000090537 );
56
- let b = v * ( 0.983729 * v + vec3<f32>( 0.4329510 ) ) + vec3<f32>( 0.238081 );
57
- let m_out = mat3x3<f32>(
58
- vec3<f32>( 1.60475, -0.10208, -0.00327 ),
59
- vec3<f32>( -0.53108, 1.10813, -0.07276 ),
60
- vec3<f32>( -0.07367, -0.00605, 1.07602 ) );
61
- return clamp( m_out * ( a / b ), vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
62
- }
28
+ const { wgsl, bindings } = buildToneMapWGSL( { group: TABLE_GROUP, firstBinding: FIRST_TABLE_BINDING } );
29
+ return { wgsl, bindings, version: getRegistryVersion() };
63
30
 
64
- fn tm_agx( c0: vec3<f32> ) -> vec3<f32> {
65
- let m_in = mat3x3<f32>(
66
- vec3<f32>( 0.6274, 0.0691, 0.0164 ),
67
- vec3<f32>( 0.3293, 0.9195, 0.0880 ),
68
- vec3<f32>( 0.0433, 0.0113, 0.8956 ) );
69
- let m_agx = mat3x3<f32>(
70
- vec3<f32>( 0.856627153315983, 0.137318972929847, 0.11189821299995 ),
71
- vec3<f32>( 0.0951212405381588, 0.761241990602591, 0.0767994186031903 ),
72
- vec3<f32>( 0.0482516061458583, 0.101439036467562, 0.811302368396859 ) );
73
-
74
- var v = m_agx * ( m_in * c0 );
75
-
76
- let minEv = -12.47393;
77
- let maxEv = 4.026069;
78
- v = clamp( ( log2( max( v, vec3<f32>( 1e-10 ) ) ) - vec3<f32>( minEv ) ) / ( maxEv - minEv ),
79
- vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
80
-
81
- let x2 = v * v;
82
- let x4 = x2 * x2;
83
- v = 15.5 * x4 * x2 - 40.14 * x4 * v + 31.96 * x4 - 6.868 * x2 * v + 0.4298 * x2 + 0.1191 * v
84
- - vec3<f32>( 0.00232 );
85
-
86
- let m_out = mat3x3<f32>(
87
- vec3<f32>( 1.1271005818144368, -0.1413297634984383, -0.14132976349843826 ),
88
- vec3<f32>( -0.11060664309660323, 1.157823702216272, -0.11060664309660294 ),
89
- vec3<f32>( -0.016493938717834573, -0.016493938717834257, 1.2519364065950405 ) );
90
- let o = pow( max( m_out * v, vec3<f32>( 0.0 ) ), vec3<f32>( 2.2 ) );
91
-
92
- let m_srgb = mat3x3<f32>(
93
- vec3<f32>( 1.6605, -0.1246, -0.0182 ),
94
- vec3<f32>( -0.5876, 1.1329, -0.1006 ),
95
- vec3<f32>( -0.0728, -0.0083, 1.1187 ) );
96
- return clamp( m_srgb * o, vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
97
31
  }
98
32
 
99
- fn tm_neutral( c0: vec3<f32> ) -> vec3<f32> {
100
- let startCompression = 0.8 - 0.04;
101
- let desaturation = 0.15;
102
-
103
- let x = min( c0.r, min( c0.g, c0.b ) );
104
- var offset = 0.04;
105
- if ( x < 0.08 ) { offset = x - 6.25 * x * x; }
106
- var c = c0 - vec3<f32>( offset );
107
-
108
- let peak = max( c.r, max( c.g, c.b ) );
109
- if ( peak < startCompression ) { return c; }
110
-
111
- let d = 1.0 - startCompression;
112
- let newPeak = 1.0 - d * d / ( peak + d - startCompression );
113
- c = c * ( newPeak / peak );
114
- let gFactor = 1.0 - 1.0 / ( desaturation * ( peak - newPeak ) + 1.0 );
115
- return mix( c, vec3<f32>( newPeak ), gFactor );
116
- }
117
-
118
- // Three.js clamps the fragment output with max(0) before tone mapping, so the curves never see a
119
- // negative channel. The saturation grade drives channels below zero on much of a typical frame, and
120
- // AgX/Neutral mix negatives across channels instead of clipping them.
121
- fn rayzee_tone_curve( color: vec3<f32>, mode: u32 ) -> vec3<f32> {
122
- let c = max( color, vec3<f32>( 0.0 ) );
123
- if ( mode == TM_REINHARD ) { return tm_reinhard( c ); }
124
- if ( mode == TM_CINEON ) { return tm_cineon( c ); }
125
- if ( mode == TM_ACES ) { return tm_aces( c ); }
126
- if ( mode == TM_AGX ) { return tm_agx( c ); }
127
- if ( mode == TM_NEUTRAL ) { return tm_neutral( c ); }
128
- return clamp( c, vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
129
- }
130
-
131
- fn rayzee_tone_map( linearRGB: vec3<f32>, mode: u32, exposure: f32, saturation: f32 ) -> vec3<f32> {
132
- // Three.js returns early for NoToneMapping without applying exposure, so a readback that applied
133
- // it would paint brighter than the viewport it replaces.
134
- var c = linearRGB * select( exposure, 1.0, mode == TM_NONE );
135
- if ( saturation != 1.0 ) {
136
- let luma = vec3<f32>( dot( c, vec3<f32>( 0.2126, 0.7152, 0.0722 ) ) );
137
- c = luma + ( c - luma ) * saturation;
138
- }
139
- return rayzee_tone_curve( c, mode );
140
- }
141
-
142
- fn rayzee_linear_to_srgb( c: vec3<f32> ) -> vec3<f32> {
143
- return select(
144
- 1.055 * pow( max( c, vec3<f32>( 0.0 ) ), vec3<f32>( 1.0 / 2.4 ) ) - vec3<f32>( 0.055 ),
145
- 12.92 * c,
146
- c <= vec3<f32>( 0.0031308 ) );
147
- }
148
-
149
- fn rayzee_to_u8( srgb: vec3<f32> ) -> vec3<u32> {
150
- return vec3<u32>( clamp( round( srgb * 255.0 + vec3<f32>( 0.5 ) ), vec3<f32>( 0.0 ), vec3<f32>( 255.0 ) ) );
151
- }
152
- `;
33
+ /**
34
+ * `rayzee_tone_map( linearRGB, mode, exposure, saturation )`, `rayzee_encode( mapped, mode )` and
35
+ * `rayzee_to_u8( c )` for the registry as it stands right now.
36
+ *
37
+ * A snapshot, not a live value: `PackedToneMapper` regenerates its own copy when the registry
38
+ * changes. Exported for tests and for a host embedding the curve in its own pass.
39
+ */
40
+ export const TONE_MAP_WGSL = currentShader().wgsl;
153
41
 
154
- const PACKED_WGSL = /* wgsl */ `
42
+ const packedWGSL = transformWGSL => /* wgsl */ `
155
43
  struct Params {
156
44
  width: u32,
157
45
  height: u32,
@@ -165,7 +53,7 @@ struct Params {
165
53
  @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
166
54
  @group(0) @binding(2) var<uniform> params: Params;
167
55
 
168
- ${TONE_MAP_WGSL}
56
+ ${transformWGSL}
169
57
 
170
58
  @compute @workgroup_size(8, 8)
171
59
  fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
@@ -179,7 +67,7 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
179
67
  let ba = unpack2x16float( src[ a + 1u ] );
180
68
 
181
69
  let mapped = rayzee_tone_map( vec3<f32>( rg.x, rg.y, ba.x ), params.mode, params.exposure, params.saturation );
182
- let b8 = rayzee_to_u8( rayzee_linear_to_srgb( mapped ) );
70
+ let b8 = rayzee_to_u8( rayzee_encode( mapped, params.mode ) );
183
71
  dst[ gid.y * params.width + gid.x ] = b8.x | ( b8.y << 8u ) | ( b8.z << 16u ) | ( 255u << 24u );
184
72
  }
185
73
  `;
@@ -207,13 +95,91 @@ export class PackedToneMapper {
207
95
  this._paramData = new ArrayBuffer( PARAMS_BYTES );
208
96
  this._paramU32 = new Uint32Array( this._paramData );
209
97
  this._paramF32 = new Float32Array( this._paramData );
98
+ this._shaderVersion = - 1;
99
+ this._bindings = [];
100
+ this._tables = new Map();
210
101
  this.disposed = false;
211
102
 
212
103
  }
213
104
 
105
+ /**
106
+ * Rebuild the pipeline when the registry has moved.
107
+ *
108
+ * A config loaded after this pass was first compiled adds curves the compiled shader has never
109
+ * heard of. Without this the readback silently falls through to the clamp and a saved image
110
+ * comes back untone-mapped.
111
+ */
112
+ _ensurePipeline() {
113
+
114
+ const { wgsl, bindings, version } = currentShader();
115
+ if ( this._pipeline && this._shaderVersion === version ) return;
116
+
117
+ this._bindings = bindings;
118
+ this._shaderVersion = version;
119
+ this._pipeline = this.device.createComputePipeline( {
120
+ label: this.label,
121
+ layout: 'auto',
122
+ compute: {
123
+ module: this.device.createShaderModule( { label: this.label, code: packedWGSL( wgsl ) } ),
124
+ entryPoint: 'main',
125
+ },
126
+ } );
127
+
128
+ }
129
+
130
+ /** One 3D texture per table-backed transform, uploaded once and kept until it is replaced. */
131
+ _ensureTables() {
132
+
133
+ const live = new Set();
134
+
135
+ for ( const { index, transform } of this._bindings ) {
136
+
137
+ const { data, size } = transform.table;
138
+ live.add( index );
139
+
140
+ const held = this._tables.get( index );
141
+ if ( held && held.data === data ) continue;
142
+
143
+ held?.texture.destroy();
144
+
145
+ const texture = this.device.createTexture( {
146
+ label: `${this.label}-${transform.wgslConst}`,
147
+ size: [ size, size, size ],
148
+ dimension: '3d',
149
+ format: 'rgba16float',
150
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
151
+ } );
152
+
153
+ this.device.queue.writeTexture(
154
+ { texture },
155
+ data,
156
+ { bytesPerRow: size * 8, rowsPerImage: size },
157
+ [ size, size, size ]
158
+ );
159
+
160
+ this._tables.set( index, { texture, data, view: texture.createView() } );
161
+
162
+ }
163
+
164
+ for ( const [ index, held ] of this._tables ) {
165
+
166
+ if ( ! live.has( index ) ) {
167
+
168
+ held.texture.destroy();
169
+ this._tables.delete( index );
170
+
171
+ }
172
+
173
+ }
174
+
175
+ }
176
+
214
177
  ensureSize( width, height ) {
215
178
 
216
- if ( this.width === width && this.height === height && this._pipeline ) return;
179
+ this._ensurePipeline();
180
+ this._ensureTables();
181
+
182
+ if ( this.width === width && this.height === height && this._storage ) return;
217
183
 
218
184
  this._releaseBuffers();
219
185
 
@@ -234,12 +200,6 @@ export class PackedToneMapper {
234
200
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
235
201
  } );
236
202
 
237
- this._pipeline ??= this.device.createComputePipeline( {
238
- label: this.label,
239
- layout: 'auto',
240
- compute: { module: this.device.createShaderModule( { label: this.label, code: PACKED_WGSL } ), entryPoint: 'main' },
241
- } );
242
-
243
203
  this.width = width;
244
204
  this.height = height;
245
205
 
@@ -257,7 +217,12 @@ export class PackedToneMapper {
257
217
  async toRGBA8( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false } = {} ) {
258
218
 
259
219
  if ( this.disposed ) throw new Error( 'PackedToneMapper: disposed' );
260
- if ( ! this._pipeline ) throw new Error( 'PackedToneMapper: call ensureSize() first' );
220
+ if ( ! this._storage ) throw new Error( 'PackedToneMapper: call ensureSize() first' );
221
+
222
+ // A config can be loaded between two readbacks, so the registry is re-checked here rather
223
+ // than only on resize.
224
+ this._ensurePipeline();
225
+ this._ensureTables();
261
226
 
262
227
  const { width, height } = this;
263
228
 
@@ -269,9 +234,13 @@ export class PackedToneMapper {
269
234
  this._paramF32[ 5 ] = saturation;
270
235
  this.device.queue.writeBuffer( this._params, 0, this._paramData );
271
236
 
237
+ const tableEntries = [ ...this._tables.entries() ]
238
+ .map( ( [ binding, held ] ) => ( { binding, resource: held.view } ) );
239
+
272
240
  const group = this.device.createBindGroup( {
273
241
  layout: this._pipeline.getBindGroupLayout( 0 ),
274
242
  entries: [
243
+ ...tableEntries,
275
244
  { binding: 0, resource: { buffer: src, size: width * height * 8 } },
276
245
  { binding: 1, resource: { buffer: this._storage } },
277
246
  { binding: 2, resource: { buffer: this._params } },
@@ -310,6 +279,8 @@ export class PackedToneMapper {
310
279
  if ( this.disposed ) return;
311
280
  this.disposed = true;
312
281
  this._releaseBuffers();
282
+ for ( const held of this._tables.values() ) held.texture.destroy();
283
+ this._tables.clear();
313
284
  this._pipeline = null;
314
285
 
315
286
  }
@@ -42,6 +42,7 @@ const SETTING_ROUTES = {
42
42
  anamorphicRatio: { uniform: 'anamorphicRatio', reset: true },
43
43
  samplingTechnique: { uniform: 'samplingTechnique', reset: true },
44
44
  fireflyThreshold: { uniform: 'fireflyThreshold', reset: true },
45
+ shadowTerminatorOffset: { uniform: 'shadowTerminatorOffset', reset: true },
45
46
  enableAlphaShadows: { uniform: 'enableAlphaShadows', reset: true },
46
47
  // Adaptive sampling — whole-frame early-stop (useAdaptiveSampling) + per-pixel freeze (usePixelFreeze)
47
48
  useAdaptiveSampling: { uniform: 'useAdaptiveSampling', reset: true },