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
@@ -0,0 +1,323 @@
1
+ /**
2
+ * The live list of view transforms, and the shader generated from it.
3
+ *
4
+ * A view transform has to exist in four places at once: the TSL graph that paints the canvas, the
5
+ * WGSL compute pass that packs a frame for readback, the JavaScript that OIDN and the upscaler go
6
+ * through, and the menu a host builds. Keeping four hand-written lists in step is how a saved
7
+ * image ends up different from the viewport, so there is one list and the other three are derived
8
+ * from it.
9
+ *
10
+ * Adding an OCIO view at runtime therefore reaches every consumer, including ones compiled before
11
+ * the config was loaded — `getRegistryVersion()` moves and the shader is rebuilt.
12
+ *
13
+ * ## The two kinds of output
14
+ *
15
+ * A built-in curve returns **linear** colour; the renderer's output pass and the readback both
16
+ * apply the sRGB transfer afterwards. An OCIO view returns colour already encoded for its display
17
+ * — sRGB, Rec.1886, or PQ — and applying a transfer on top of that would encode it twice.
18
+ * `outputEncoded` is what tells each consumer which it is holding.
19
+ */
20
+
21
+ import { NoToneMapping } from 'three';
22
+ import { BUILTIN_VIEWS } from './BuiltinViews.js';
23
+
24
+ /** First id an OCIO-derived view gets. Clear of three.js's own constants and room to grow. */
25
+ export const OCIO_VIEW_BASE = 200;
26
+
27
+ /**
28
+ * How many table-backed transforms may be registered at once.
29
+ *
30
+ * The readback binds every table in one shader and WebGPU only guarantees 16 sampled textures per
31
+ * stage. Without a cap the failure is a shader that will not compile, at the moment someone bakes
32
+ * one view too many — long after the mistake.
33
+ */
34
+ export const MAX_TABLE_TRANSFORMS = 12;
35
+
36
+ const transforms = [ ...BUILTIN_VIEWS ];
37
+ const byId = new Map( transforms.map( t => [ t.id, t ] ) );
38
+
39
+ let version = 1;
40
+ const listeners = new Set();
41
+
42
+ function announce() {
43
+
44
+ version ++;
45
+ for ( const fn of listeners ) fn();
46
+
47
+ }
48
+
49
+ /** Every registered transform, keyed by id. Read-only in spirit — mutate through the functions. */
50
+ export const VIEW_TRANSFORMS = byId;
51
+
52
+ /** Bumped whenever the list changes, so a compiled shader knows it is stale. */
53
+ export function getRegistryVersion() {
54
+
55
+ return version;
56
+
57
+ }
58
+
59
+ /** @returns {function(): void} unsubscribe */
60
+ export function onRegistryChange( listener ) {
61
+
62
+ listeners.add( listener );
63
+ return () => listeners.delete( listener );
64
+
65
+ }
66
+
67
+ export function getViewTransform( id ) {
68
+
69
+ return byId.get( id ) ?? null;
70
+
71
+ }
72
+
73
+ /** What a host menu should show, in registration order. */
74
+ export function listViewTransforms() {
75
+
76
+ return transforms.map( t => ( {
77
+ id: t.id,
78
+ name: t.name,
79
+ source: t.source,
80
+ display: t.ocio?.display ?? null,
81
+ view: t.ocio?.view ?? null,
82
+ look: t.ocio?.look ?? null,
83
+ } ) );
84
+
85
+ }
86
+
87
+ /** How many registered transforms need a texture binding. */
88
+ export function countTableTransforms() {
89
+
90
+ return transforms.filter( t => t.table ).length;
91
+
92
+ }
93
+
94
+ /** The lowest unused OCIO id. */
95
+ export function nextOcioId() {
96
+
97
+ let id = OCIO_VIEW_BASE;
98
+ while ( byId.has( id ) ) id ++;
99
+ return id;
100
+
101
+ }
102
+
103
+ /**
104
+ * Register a transform, replacing any with the same id.
105
+ *
106
+ * @param {Object} t - needs at least `id`, `name`, `wgslConst`, `cpu`
107
+ */
108
+ export function addViewTransform( t ) {
109
+
110
+ if ( typeof t?.id !== 'number' ) throw new Error( 'view transform needs a numeric id' );
111
+ if ( typeof t.cpu !== 'function' ) throw new Error( `view transform ${t.id} needs a cpu function` );
112
+ if ( ! t.name ) throw new Error( `view transform ${t.id} needs a name` );
113
+ if ( ! /^TM_[A-Z0-9_]+$/.test( t.wgslConst ?? '' ) ) {
114
+
115
+ throw new Error( `view transform ${t.id} needs a wgslConst like TM_SOMETHING` );
116
+
117
+ }
118
+
119
+ const existing = byId.get( t.id );
120
+ if ( ! existing && t.table && countTableTransforms() >= MAX_TABLE_TRANSFORMS ) {
121
+
122
+ throw new Error(
123
+ `cannot register "${t.name}": ${MAX_TABLE_TRANSFORMS} table-backed view transforms already ` +
124
+ 'registered, and WebGPU only guarantees 16 sampled textures per stage. Remove one first.'
125
+ );
126
+
127
+ }
128
+
129
+ const entry = { source: 'custom', outputEncoded: false, appliesExposure: true, ...t };
130
+
131
+ if ( existing ) {
132
+
133
+ transforms[ transforms.indexOf( existing ) ] = entry;
134
+
135
+ } else {
136
+
137
+ transforms.push( entry );
138
+
139
+ }
140
+
141
+ byId.set( entry.id, entry );
142
+ announce();
143
+ return entry;
144
+
145
+ }
146
+
147
+ /** @returns {boolean} whether anything was removed */
148
+ export function removeViewTransform( id ) {
149
+
150
+ const existing = byId.get( id );
151
+ if ( ! existing ) return false;
152
+
153
+ transforms.splice( transforms.indexOf( existing ), 1 );
154
+ byId.delete( id );
155
+ announce();
156
+ return true;
157
+
158
+ }
159
+
160
+ /** Drop every transform that came from a config, leaving the built-ins and the ids in `keep`. */
161
+ export function removeOcioViewTransforms( keep = null ) {
162
+
163
+ const doomed = transforms.filter( t => t.source === 'ocio' && ! keep?.has( t.id ) ).map( t => t.id );
164
+ if ( doomed.length === 0 ) return 0;
165
+
166
+ for ( const id of doomed ) {
167
+
168
+ const t = byId.get( id );
169
+ transforms.splice( transforms.indexOf( t ), 1 );
170
+ byId.delete( id );
171
+
172
+ }
173
+
174
+ announce();
175
+ return doomed.length;
176
+
177
+ }
178
+
179
+ /**
180
+ * Hand every transform that has a TSL node to the renderer, so `renderer.toneMapping = id`
181
+ * works on the live canvas. Built-ins are already there — three.js registers its own.
182
+ */
183
+ export function registerWithRenderer( renderer ) {
184
+
185
+ const library = renderer?.library;
186
+ if ( ! library?.addToneMapping ) return 0;
187
+
188
+ let count = 0;
189
+ for ( const t of transforms ) {
190
+
191
+ if ( ! t.tsl ) continue;
192
+
193
+ // ⚠️ `addToneMapping` refuses to redefine an id — it warns and returns without replacing.
194
+ // A rebaked view keeps its id, so without dropping the old entry first the canvas would go
195
+ // on running the previous table while the readback used the new one. Two images, one
196
+ // setting, no error.
197
+ library.toneMappingNodes?.delete( t.id );
198
+ library.addToneMapping( t.tsl, t.id );
199
+ count ++;
200
+
201
+ }
202
+
203
+ return count;
204
+
205
+ }
206
+
207
+ /**
208
+ * The WGSL for every registered transform, plus the bindings its tables need.
209
+ *
210
+ * @param {Object} [options]
211
+ * @param {number} [options.group=0] - bind group the caller owns
212
+ * @param {number} [options.firstBinding=3] - first binding index free in that group
213
+ * @returns {{ wgsl: string, bindings: Array<{ index: number, transform: Object }> }}
214
+ */
215
+ export function buildToneMapWGSL( { group = 0, firstBinding = 3 } = {} ) {
216
+
217
+ const bindings = [];
218
+ let next = firstBinding;
219
+
220
+ const declarations = [];
221
+ const bodies = [];
222
+
223
+ for ( const t of transforms ) {
224
+
225
+ if ( t.table ) {
226
+
227
+ const texName = `${t.wgslConst.toLowerCase()}_tex`;
228
+ declarations.push( `@group(${group}) @binding(${next}) var ${texName}: texture_3d<f32>;` );
229
+ bindings.push( { index: next, transform: t, texName } );
230
+ next += 1;
231
+
232
+ }
233
+
234
+ if ( t.wgsl ) bodies.push( t.wgsl );
235
+
236
+ }
237
+
238
+ const constants = transforms.map( t => `const ${t.wgslConst}: u32 = ${t.id}u;` ).join( '\n' );
239
+
240
+ const branches = transforms
241
+ .filter( t => t.call )
242
+ .map( t => `\tif ( mode == ${t.wgslConst} ) { return ${t.call}; }` )
243
+ .join( '\n' );
244
+
245
+ // Generated rather than hardcoded: a transform that returns display-encoded colour must not
246
+ // have the sRGB transfer applied on top of it.
247
+ const encodedIds = transforms.filter( t => t.outputEncoded ).map( t => t.wgslConst );
248
+ const encodedTest = encodedIds.length
249
+ ? encodedIds.map( c => `mode == ${c}` ).join( ' || ' )
250
+ : 'false';
251
+
252
+ const noExposureIds = transforms.filter( t => ! t.appliesExposure ).map( t => t.wgslConst );
253
+ const noExposureTest = noExposureIds.length
254
+ ? noExposureIds.map( c => `mode == ${c}` ).join( ' || ' )
255
+ : 'false';
256
+
257
+ const wgsl = /* wgsl */ `
258
+ ${constants}
259
+
260
+ ${declarations.join( '\n' )}
261
+
262
+ ${bodies.join( '\n' )}
263
+
264
+ // Three.js clamps the fragment output with max(0) before tone mapping, so the curves never see a
265
+ // negative channel. The saturation grade drives channels below zero on much of a typical frame,
266
+ // and AgX/Neutral mix negatives across channels instead of clipping them.
267
+ fn rayzee_tone_curve( color: vec3<f32>, mode: u32 ) -> vec3<f32> {
268
+ let c = max( color, vec3<f32>( 0.0 ) );
269
+ ${branches}
270
+ return clamp( c, vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
271
+ }
272
+
273
+ /** True when the curve already returned display-encoded colour. */
274
+ fn rayzee_output_encoded( mode: u32 ) -> bool {
275
+ return ${encodedTest};
276
+ }
277
+
278
+ fn rayzee_tone_map( linearRGB: vec3<f32>, mode: u32, exposure: f32, saturation: f32 ) -> vec3<f32> {
279
+ // Three.js returns early for NoToneMapping without applying exposure, so a readback that
280
+ // applied it would paint brighter than the viewport it replaces.
281
+ var c = linearRGB * select( exposure, 1.0, ${noExposureTest} );
282
+ if ( saturation != 1.0 ) {
283
+ let luma = vec3<f32>( dot( c, vec3<f32>( 0.2126, 0.7152, 0.0722 ) ) );
284
+ c = luma + ( c - luma ) * saturation;
285
+ }
286
+ return rayzee_tone_curve( c, mode );
287
+ }
288
+
289
+ fn rayzee_linear_to_srgb( c: vec3<f32> ) -> vec3<f32> {
290
+ return select(
291
+ 1.055 * pow( max( c, vec3<f32>( 0.0 ) ), vec3<f32>( 1.0 / 2.4 ) ) - vec3<f32>( 0.055 ),
292
+ 12.92 * c,
293
+ c <= vec3<f32>( 0.0031308 ) );
294
+ }
295
+
296
+ /** The transfer step, skipped for a curve that already encoded its own output. */
297
+ fn rayzee_encode( mapped: vec3<f32>, mode: u32 ) -> vec3<f32> {
298
+ if ( rayzee_output_encoded( mode ) ) { return clamp( mapped, vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) ); }
299
+ return rayzee_linear_to_srgb( mapped );
300
+ }
301
+
302
+ fn rayzee_to_u8( srgb: vec3<f32> ) -> vec3<u32> {
303
+ return vec3<u32>( clamp( round( srgb * 255.0 + vec3<f32>( 0.5 ) ), vec3<f32>( 0.0 ), vec3<f32>( 255.0 ) ) );
304
+ }
305
+ `;
306
+
307
+ return { wgsl, bindings };
308
+
309
+ }
310
+
311
+ /** Put the registry back to the seven built-ins. Tests and config unload use this. */
312
+ export function resetViewTransforms() {
313
+
314
+ transforms.length = 0;
315
+ transforms.push( ...BUILTIN_VIEWS );
316
+ byId.clear();
317
+ for ( const t of transforms ) byId.set( t.id, t );
318
+ announce();
319
+
320
+ }
321
+
322
+ /** The id to fall back to when a chosen one disappears. */
323
+ export const FALLBACK_VIEW = NoToneMapping;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The primaries change from the engine's native linear Rec.709 into the adopted working space.
3
+ *
4
+ * A leaf module with no imports at all, deliberately. The two hottest consumers — the material
5
+ * buffer and the light serializer — sit far from colour management and are exercised by tests that
6
+ * mock three.js; pulling `ColorManagement` into them would drag the whole OCIO stack, and three.js
7
+ * with it, into places that have no business knowing either exists.
8
+ *
9
+ * Null means "not colour-managed", which is also the identity, so every consumer's fast path is a
10
+ * single null check.
11
+ */
12
+
13
+ let matrix = null;
14
+ let spaceName = null;
15
+
16
+ /**
17
+ * @param {?number[]} m - row-major 3×3, or null for no conversion
18
+ * @param {?string} [name] - the working space it converts into, for logging
19
+ */
20
+ export function setWorkingMatrix( m, name = null ) {
21
+
22
+ matrix = m;
23
+ spaceName = m ? name : null;
24
+
25
+ }
26
+
27
+ export function getWorkingMatrix() {
28
+
29
+ return matrix;
30
+
31
+ }
32
+
33
+ export function getWorkingMatrixSpace() {
34
+
35
+ return spaceName;
36
+
37
+ }
38
+
39
+ /**
40
+ * Convert one linear RGB triple in place.
41
+ * @param {Float32Array|number[]} rgb - read and written at [0..2]
42
+ * @returns {boolean} whether anything changed
43
+ */
44
+ export function convertLinearTriple( rgb ) {
45
+
46
+ if ( ! matrix ) return false;
47
+
48
+ const r = rgb[ 0 ], g = rgb[ 1 ], b = rgb[ 2 ];
49
+ rgb[ 0 ] = matrix[ 0 ] * r + matrix[ 1 ] * g + matrix[ 2 ] * b;
50
+ rgb[ 1 ] = matrix[ 3 ] * r + matrix[ 4 ] * g + matrix[ 5 ] * b;
51
+ rgb[ 2 ] = matrix[ 6 ] * r + matrix[ 7 ] * g + matrix[ 8 ] * b;
52
+ return true;
53
+
54
+ }
55
+
56
+ /**
57
+ * Convert interleaved linear triples inside a larger buffer.
58
+ *
59
+ * @param {Float32Array} data
60
+ * @param {number} stride - floats between one record and the next
61
+ * @param {number[]} offsets - where each triple starts within one stride
62
+ * @returns {number} how many triples were converted
63
+ */
64
+ export function convertLinearTriples( data, stride, offsets ) {
65
+
66
+ if ( ! matrix || offsets.length === 0 ) return 0;
67
+
68
+ let count = 0;
69
+ for ( let base = 0; base + stride <= data.length; base += stride ) {
70
+
71
+ for ( const off of offsets ) {
72
+
73
+ const i = base + off;
74
+ const r = data[ i ], g = data[ i + 1 ], b = data[ i + 2 ];
75
+ data[ i ] = matrix[ 0 ] * r + matrix[ 1 ] * g + matrix[ 2 ] * b;
76
+ data[ i + 1 ] = matrix[ 3 ] * r + matrix[ 4 ] * g + matrix[ 5 ] * b;
77
+ data[ i + 2 ] = matrix[ 6 ] * r + matrix[ 7 ] * g + matrix[ 8 ] * b;
78
+ count ++;
79
+
80
+ }
81
+
82
+ }
83
+
84
+ return count;
85
+
86
+ }
@@ -11,9 +11,9 @@
11
11
  export const RENDER_PROFILES = Object.freeze( {
12
12
  viewer: Object.freeze( {
13
13
  areaLightIntensityScale: 0.1, // scales glTF placeholder area-light power (viewer tuning)
14
- environmentRotation: 270.0, // degrees
15
- toneMapping: 4, // ACESFilmicToneMapping
16
- saturation: 1.2, // compensates for ACES desaturation — see Compositor
14
+ environmentRotation: 0.0, // degrees — the HDRI as authored, as Blender shows it
15
+ toneMapping: 6, // AgXToneMapping
16
+ saturation: 1.0, // no grade
17
17
  } ),
18
18
  physical: Object.freeze( {
19
19
  areaLightIntensityScale: 1.0,
@@ -158,6 +158,9 @@ export const ENGINE_DEFAULTS = {
158
158
  emissiveBoost: 1.0,
159
159
 
160
160
  fireflyThreshold: 3.0,
161
+ // Cycles' Shadow Terminator → Geometry Offset, and its default: light shadow rays leave a
162
+ // smooth-shaded triangle from the smooth surface near the terminator. 0 disables.
163
+ shadowTerminatorOffset: 0.1,
161
164
  // Wavefront material-coherence sort: global counting-sort of entering rays by material before
162
165
  // Shade (material-pure workgroups), under dynamic dispatch. Measured −8% at 1024²/8b. Gated on
163
166
  // material count > 8; the histogram bin count is sized per-scene to the material count.
@@ -750,6 +753,55 @@ export const MATERIAL_DATA_LAYOUT = {
750
753
  // collapses both spellings to 0 so no shader divides by Inf.
751
754
  export const normalizeAttenuationDistance = d => ( Number.isFinite( d ) && d > 0 ? d : 0 );
752
755
 
756
+ // The only fallback for a property a three.js material doesn't carry: MeshPhysicalMaterial's own
757
+ // default (glTF with the extension absent), or the value that turns an engine-only feature off.
758
+ export const MATERIAL_DEFAULTS = deepFreeze( {
759
+ color: [ 1, 1, 1 ],
760
+ emissive: [ 0, 0, 0 ],
761
+ emissiveIntensity: 1,
762
+ roughness: 1,
763
+ metalness: 0,
764
+ ior: 1.5,
765
+ opacity: 1,
766
+ transmission: 0,
767
+ thickness: 0,
768
+ attenuationColor: [ 1, 1, 1 ],
769
+ attenuationDistance: Infinity,
770
+ dispersion: 0,
771
+ sheen: 0,
772
+ sheenRoughness: 1,
773
+ sheenColor: [ 0, 0, 0 ],
774
+ specularIntensity: 1,
775
+ specularColor: [ 1, 1, 1 ],
776
+ clearcoat: 0,
777
+ clearcoatRoughness: 0,
778
+ iridescence: 0,
779
+ iridescenceIOR: 1.3,
780
+ iridescenceThicknessRange: [ 100, 400 ],
781
+ normalScale: [ 1, 1 ],
782
+ bumpScale: 1,
783
+ displacementScale: 1,
784
+ transparent: 0,
785
+ alphaTest: 0,
786
+ alphaMode: 0,
787
+ side: 0,
788
+ depthWrite: 1,
789
+ subsurface: 0,
790
+ subsurfaceColor: [ 1, 1, 1 ],
791
+ subsurfaceRadius: [ 1, 0.2, 0.1 ],
792
+ subsurfaceRadiusScale: 1,
793
+ subsurfaceAnisotropy: 0,
794
+ anisotropy: 0,
795
+ anisotropyRotation: 0,
796
+ } );
797
+
798
+ function deepFreeze( object ) {
799
+
800
+ for ( const value of Object.values( object ) ) if ( typeof value === 'object' ) Object.freeze( value );
801
+ return Object.freeze( object );
802
+
803
+ }
804
+
753
805
  // BVH node leaf markers
754
806
  /**
755
807
  * Node tags, written into slot [3] of a BVH node as a raw u32 bit pattern.
@@ -21,6 +21,9 @@ export const ISSUE_CODES = Object.freeze( {
21
21
  SCENE_MEMORY_BUDGET: 'scene.memory_budget',
22
22
  EMISSIVE_INSTANCES_COLLAPSED: 'emissive.instances_collapsed',
23
23
  REFIT_SHARED_GEOMETRY: 'refit.shared_geometry',
24
+ VIEW_TRANSFORM_DISPLAY_MISMATCH: 'viewTransform.display_mismatch',
25
+ VIEW_TRANSFORM_BAKE_FAILED: 'viewTransform.bake_failed',
26
+ COLOR_CONFIG_LOAD_FAILED: 'color.config_load_failed',
24
27
  } );
25
28
 
26
29
  /** Strict throws on ERROR only. */
@@ -1,9 +1,15 @@
1
1
  import { EventDispatcher, ACESFilmicToneMapping } from 'three';
2
- import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
2
+ import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation, effectiveExposure, isOutputEncoded } from '../Processor/ToneMapCPU.js';
3
3
  import { getAssetConfig } from '../AssetConfig.js';
4
4
  import AIUpscalerWorker from '../Processor/Workers/AIUpscalerWorker.js?worker&inline';
5
5
 
6
6
 
7
+ // An OCIO view already returned colour encoded for its display, so the gamma step below would
8
+ // encode it twice. A built-in curve returns linear and does want it.
9
+ const displayEncoder = toneMapping => isOutputEncoded( toneMapping )
10
+ ? ( c => ( c > 1 ? 1 : c < 0 ? 0 : c ) )
11
+ : ( c => Math.pow( c, SRGB_GAMMA ) );
12
+
7
13
  // ─── Model Configuration ───────────────────────────────────────────────────────
8
14
  // Quality presets reference relative paths against asset-config `upscalerModelBaseUrl`.
9
15
 
@@ -400,6 +406,7 @@ export class AIUpscaler extends EventDispatcher {
400
406
  if ( sourceImageData.isHDR ) {
401
407
 
402
408
  this._hdrToneMapFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
409
+ this._hdrEncode = displayEncoder( this.getToneMapping() );
403
410
  this._hdrExposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
404
411
  this._hdrSaturation = this.getSaturation();
405
412
  this._tmOut = new Float32Array( 3 );
@@ -597,9 +604,10 @@ export class AIUpscaler extends EventDispatcher {
597
604
  }
598
605
 
599
606
  tmFn( er, eg, eb, 1.0, this._tmOut );
600
- floats[ dstIdx ] = Math.pow( this._tmOut[ 0 ], SRGB_GAMMA );
601
- floats[ pixelCount + dstIdx ] = Math.pow( this._tmOut[ 1 ], SRGB_GAMMA );
602
- floats[ 2 * pixelCount + dstIdx ] = Math.pow( this._tmOut[ 2 ], SRGB_GAMMA );
607
+ const encode = this._hdrEncode;
608
+ floats[ dstIdx ] = encode( this._tmOut[ 0 ] );
609
+ floats[ pixelCount + dstIdx ] = encode( this._tmOut[ 1 ] );
610
+ floats[ 2 * pixelCount + dstIdx ] = encode( this._tmOut[ 2 ] );
603
611
 
604
612
  } else {
605
613
 
@@ -794,6 +802,7 @@ export class AIUpscaler extends EventDispatcher {
794
802
  const pixels = imageData.data;
795
803
  const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
796
804
  const exposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
805
+ const encode = displayEncoder( this.getToneMapping() );
797
806
  const saturation = this.getSaturation();
798
807
  const out = new Float32Array( 3 );
799
808
 
@@ -810,9 +819,9 @@ export class AIUpscaler extends EventDispatcher {
810
819
  }
811
820
 
812
821
  tmFn( er, eg, eb, 1.0, out );
813
- pixels[ si ] = ( Math.pow( out[ 0 ], SRGB_GAMMA ) * 255 + 0.5 ) | 0;
814
- pixels[ si + 1 ] = ( Math.pow( out[ 1 ], SRGB_GAMMA ) * 255 + 0.5 ) | 0;
815
- pixels[ si + 2 ] = ( Math.pow( out[ 2 ], SRGB_GAMMA ) * 255 + 0.5 ) | 0;
822
+ pixels[ si ] = ( encode( out[ 0 ] ) * 255 + 0.5 ) | 0;
823
+ pixels[ si + 1 ] = ( encode( out[ 1 ] ) * 255 + 0.5 ) | 0;
824
+ pixels[ si + 2 ] = ( encode( out[ 2 ] ) * 255 + 0.5 ) | 0;
816
825
  pixels[ si + 3 ] = 255;
817
826
 
818
827
  }