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.
- package/README.md +60 -4
- package/dist/{DLSSNeural-BNSJ8V3z.js → DLSSNeural-BhBCBgKf.js} +5 -5
- package/dist/{DLSSSuperRes-BoEW89Cl.js → DLSSSuperRes-BCA7F_k5.js} +5 -5
- package/dist/ToneMapGPU-BzcLheoR.js +533 -0
- package/dist/rayzee.es.js +27198 -9047
- package/dist/rayzee.umd.js +255 -187
- package/package.json +1 -1
- package/src/AssetConfig.js +15 -2
- package/src/Color/BakedViews.js +134 -0
- package/src/Color/BuiltinViews.js +261 -0
- package/src/Color/ColorManagement.js +1130 -0
- package/src/Color/ColorSpaces.js +432 -0
- package/src/Color/Displays.js +19 -0
- package/src/Color/InputColorSpaces.js +243 -0
- package/src/Color/LutBake.js +371 -0
- package/src/Color/OcioRuntime.js +338 -0
- package/src/Color/OcioViews.js +419 -0
- package/src/Color/ViewTransforms.js +323 -0
- package/src/Color/WorkingMatrix.js +86 -0
- package/src/EngineDefaults.js +55 -3
- package/src/EngineIssues.js +3 -0
- package/src/Passes/AIUpscaler.js +16 -7
- package/src/PathTracerApp.js +227 -7
- package/src/Processor/AssetLoader.js +1 -2
- package/src/Processor/EmissiveTriangleBuilder.js +34 -2
- package/src/Processor/GeometryExtractor.js +151 -138
- package/src/Processor/LightSerializer.js +8 -3
- package/src/Processor/MaterialPacking.js +168 -0
- package/src/Processor/PackedRayBuffer.js +6 -2
- package/src/Processor/ProceduralSky.js +4 -0
- package/src/Processor/SceneProcessor.js +25 -1
- package/src/Processor/SimpleSky.js +8 -0
- package/src/Processor/TextureCreator.js +137 -91
- package/src/Processor/TextureReadback.js +92 -0
- package/src/Processor/ToneMapCPU.js +80 -165
- package/src/Processor/ToneMapGPU.js +122 -151
- package/src/RenderSettings.js +1 -0
- package/src/Stages/Compositor.js +11 -2
- package/src/Stages/PathTracer.js +2 -0
- package/src/Stages/PathTracerStage.js +1 -1
- package/src/TSL/Common.js +15 -0
- package/src/TSL/Debugger.js +2 -3
- package/src/TSL/EmissiveSampling.js +6 -8
- package/src/TSL/ExtendKernel.js +9 -0
- package/src/TSL/Fresnel.js +23 -1
- package/src/TSL/HitFacet.js +102 -0
- package/src/TSL/LightsDirect.js +12 -33
- package/src/TSL/LightsSampling.js +15 -7
- package/src/TSL/MaterialEvaluation.js +26 -19
- package/src/TSL/MaterialProperties.js +74 -37
- package/src/TSL/MaterialTransmission.js +2 -3
- package/src/TSL/ShadeKernel.js +34 -21
- package/src/TSL/ShadowTerminator.js +125 -0
- package/src/TSL/Struct.js +9 -0
- package/src/TSL/Subsurface.js +2 -3
- package/src/index.js +29 -1
- package/src/managers/EnvironmentManager.js +20 -0
- package/src/managers/MaterialDataManager.js +118 -208
- package/src/managers/UniformManager.js +1 -0
- package/dist/ToneMapGPU-DzqSyzTx.js +0 -341
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning an arbitrary OCIO transform into one table that the live canvas, the two readback paths
|
|
3
|
+
* and the host menu can all run identically.
|
|
4
|
+
*
|
|
5
|
+
* OCIO itself can generate WGSL, and for some transforms that WGSL is exact where a table only
|
|
6
|
+
* approximates. It is still not what this engine wants, for one reason: the view transform has to
|
|
7
|
+
* run in four places — a TSL shader graph, a hand-written WGSL compute pass, plain JavaScript, and
|
|
8
|
+
* a host's menu — and only a table is the same thing in all four. A saved image that differs from
|
|
9
|
+
* the viewport is a worse failure than a third of a code value.
|
|
10
|
+
*
|
|
11
|
+
* So OCIO is the source of truth and the validator, not the runtime: `bakeLut` samples a real OCIO
|
|
12
|
+
* processor on a grid, and `measureBakeError` puts a number on what the table costs.
|
|
13
|
+
*
|
|
14
|
+
* Shape: a log2 shaper over `minEv`..`maxEv` feeds a `size`³ cube, interpolated tetrahedrally —
|
|
15
|
+
* the same arrangement OCIO emits for its own GPU path, and the same interpolation it defaults to.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ Grid index 0 is baked from exactly 0, not from 2^minEv. Without that, true black leaves the
|
|
18
|
+
* table one code value above zero and every render has a raised black floor.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Smallest shaper step, so log2 of the darkest sample is finite. */
|
|
22
|
+
const EPS = 1e-10;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Grid index → the scene-linear value it stands for.
|
|
26
|
+
* @param {number} i - 0..size-1
|
|
27
|
+
*/
|
|
28
|
+
export function unshape( i, size, minEv, maxEv ) {
|
|
29
|
+
|
|
30
|
+
if ( i <= 0 ) return 0;
|
|
31
|
+
const t = i / ( size - 1 );
|
|
32
|
+
return Math.pow( 2, minEv + t * ( maxEv - minEv ) );
|
|
33
|
+
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Scene-linear value → position along the grid, in [0, size-1]. */
|
|
37
|
+
export function shape( v, size, minEv, maxEv ) {
|
|
38
|
+
|
|
39
|
+
const ev = Math.log2( Math.max( v, EPS ) );
|
|
40
|
+
const t = ( ev - minEv ) / ( maxEv - minEv );
|
|
41
|
+
return Math.min( Math.max( t, 0 ), 1 ) * ( size - 1 );
|
|
42
|
+
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sample `apply` over the cube.
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} spec
|
|
49
|
+
* @param {number} spec.size - grid edge; 65 is the working default, 33 the coarse end
|
|
50
|
+
* @param {function(Float32Array): void} spec.apply - transforms an RGBA-F32 buffer in place
|
|
51
|
+
* @returns {Float32Array} size³ × 4, red fastest
|
|
52
|
+
*/
|
|
53
|
+
export function bakeLut( { size, minEv, maxEv, apply } ) {
|
|
54
|
+
|
|
55
|
+
const n = size * size * size;
|
|
56
|
+
const buf = new Float32Array( n * 4 );
|
|
57
|
+
|
|
58
|
+
const axis = new Float64Array( size );
|
|
59
|
+
for ( let i = 0; i < size; i ++ ) axis[ i ] = unshape( i, size, minEv, maxEv );
|
|
60
|
+
|
|
61
|
+
let o = 0;
|
|
62
|
+
for ( let b = 0; b < size; b ++ ) {
|
|
63
|
+
|
|
64
|
+
for ( let g = 0; g < size; g ++ ) {
|
|
65
|
+
|
|
66
|
+
for ( let r = 0; r < size; r ++ ) {
|
|
67
|
+
|
|
68
|
+
buf[ o ++ ] = axis[ r ];
|
|
69
|
+
buf[ o ++ ] = axis[ g ];
|
|
70
|
+
buf[ o ++ ] = axis[ b ];
|
|
71
|
+
buf[ o ++ ] = 1;
|
|
72
|
+
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
apply( buf );
|
|
80
|
+
return buf;
|
|
81
|
+
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Float32 RGBA → Float16 RGBA, for upload as an rgba16float 3D texture. */
|
|
85
|
+
export function packHalf( f32 ) {
|
|
86
|
+
|
|
87
|
+
const out = new Uint16Array( f32.length );
|
|
88
|
+
const scratchF = new Float32Array( 1 );
|
|
89
|
+
const scratchU = new Uint32Array( scratchF.buffer );
|
|
90
|
+
|
|
91
|
+
for ( let i = 0; i < f32.length; i ++ ) {
|
|
92
|
+
|
|
93
|
+
scratchF[ 0 ] = f32[ i ];
|
|
94
|
+
const x = scratchU[ 0 ];
|
|
95
|
+
const sign = ( x >>> 16 ) & 0x8000;
|
|
96
|
+
let exp = ( x >>> 23 ) & 0xff;
|
|
97
|
+
let mant = x & 0x7fffff;
|
|
98
|
+
|
|
99
|
+
if ( exp === 0xff ) {
|
|
100
|
+
|
|
101
|
+
out[ i ] = sign | 0x7c00 | ( mant ? 0x200 : 0 );
|
|
102
|
+
|
|
103
|
+
} else if ( exp > 0x70 + 0x1e ) {
|
|
104
|
+
|
|
105
|
+
out[ i ] = sign | 0x7bff;
|
|
106
|
+
|
|
107
|
+
} else if ( exp < 0x71 ) {
|
|
108
|
+
|
|
109
|
+
// Subnormal half, or zero. `>>>` wraps modulo 32 in JavaScript, so a shift that would
|
|
110
|
+
// discard the whole mantissa has to be caught rather than performed.
|
|
111
|
+
const shift = 13 + ( 0x71 - exp );
|
|
112
|
+
mant |= 0x800000;
|
|
113
|
+
out[ i ] = shift >= 32 ? sign : ( sign | ( mant >>> shift ) );
|
|
114
|
+
|
|
115
|
+
} else {
|
|
116
|
+
|
|
117
|
+
// Round to nearest, ties to even. Truncating biased every value toward zero by up to
|
|
118
|
+
// one unit — a quarter of a code value near white.
|
|
119
|
+
let h = sign | ( ( exp - 0x70 ) << 10 ) | ( mant >>> 13 );
|
|
120
|
+
const rest = mant & 0x1fff;
|
|
121
|
+
if ( rest > 0x1000 || ( rest === 0x1000 && ( h & 1 ) ) ) h ++;
|
|
122
|
+
out[ i ] = ( h & 0x7fff ) >= 0x7c00 ? sign | 0x7bff : h;
|
|
123
|
+
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return out;
|
|
129
|
+
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Float16 → Float32, the inverse of `packHalf`. */
|
|
133
|
+
export function unpackHalf( u16, out = new Float32Array( u16.length ) ) {
|
|
134
|
+
|
|
135
|
+
for ( let i = 0; i < u16.length; i ++ ) {
|
|
136
|
+
|
|
137
|
+
const h = u16[ i ];
|
|
138
|
+
const sign = ( h & 0x8000 ) ? - 1 : 1;
|
|
139
|
+
const exp = ( h & 0x7c00 ) >> 10;
|
|
140
|
+
const mant = h & 0x03ff;
|
|
141
|
+
|
|
142
|
+
if ( exp === 0 ) out[ i ] = sign * Math.pow( 2, - 14 ) * ( mant / 1024 );
|
|
143
|
+
else if ( exp === 0x1f ) out[ i ] = mant ? NaN : sign * Infinity;
|
|
144
|
+
else out[ i ] = sign * Math.pow( 2, exp - 15 ) * ( 1 + mant / 1024 );
|
|
145
|
+
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return out;
|
|
149
|
+
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let HALF_TO_FLOAT = null;
|
|
153
|
+
|
|
154
|
+
/** Every float16 bit pattern decoded once: 256 KB, shared by every sampler. */
|
|
155
|
+
function halfToFloatTable() {
|
|
156
|
+
|
|
157
|
+
if ( ! HALF_TO_FLOAT ) {
|
|
158
|
+
|
|
159
|
+
const all = new Uint16Array( 65536 );
|
|
160
|
+
for ( let i = 0; i < 65536; i ++ ) all[ i ] = i;
|
|
161
|
+
HALF_TO_FLOAT = unpackHalf( all );
|
|
162
|
+
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return HALF_TO_FLOAT;
|
|
166
|
+
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The six tetrahedra of a cube, as corner offsets and weights.
|
|
171
|
+
*
|
|
172
|
+
* Written once and shared: the CPU sampler calls it, the WGSL generator prints the same six cases,
|
|
173
|
+
* and a test compares the two. Three hand-maintained copies is how the readback drifts from the
|
|
174
|
+
* viewport.
|
|
175
|
+
*
|
|
176
|
+
* @returns {{ w: number[], c: number[][] }} three weights and three corner offsets, added to c000
|
|
177
|
+
*/
|
|
178
|
+
export function tetrahedron( fx, fy, fz ) {
|
|
179
|
+
|
|
180
|
+
if ( fx > fy ) {
|
|
181
|
+
|
|
182
|
+
if ( fy > fz ) return { w: [ fx, fy, fz ], c: [[ 1, 0, 0 ], [ 1, 1, 0 ], [ 1, 1, 1 ]] };
|
|
183
|
+
if ( fx > fz ) return { w: [ fx, fz, fy ], c: [[ 1, 0, 0 ], [ 1, 0, 1 ], [ 1, 1, 1 ]] };
|
|
184
|
+
return { w: [ fz, fx, fy ], c: [[ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ]] };
|
|
185
|
+
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if ( fz > fy ) return { w: [ fz, fy, fx ], c: [[ 0, 0, 1 ], [ 0, 1, 1 ], [ 1, 1, 1 ]] };
|
|
189
|
+
if ( fz > fx ) return { w: [ fy, fz, fx ], c: [[ 0, 1, 0 ], [ 0, 1, 1 ], [ 1, 1, 1 ]] };
|
|
190
|
+
return { w: [ fy, fx, fz ], c: [[ 0, 1, 0 ], [ 1, 1, 0 ], [ 1, 1, 1 ]] };
|
|
191
|
+
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A JavaScript sampler over a baked table.
|
|
196
|
+
* @returns {function(number, number, number, Float32Array|number[]): void}
|
|
197
|
+
*/
|
|
198
|
+
export function makeCpuSampler( { data, size, minEv, maxEv } ) {
|
|
199
|
+
|
|
200
|
+
const s2 = size * size;
|
|
201
|
+
const last = size - 1;
|
|
202
|
+
|
|
203
|
+
// Half-precision tables are read in place through a shared decode table, rather than expanded
|
|
204
|
+
// to a float copy: the copy was 4.4 MB a view, twice the table itself, to save one lookup.
|
|
205
|
+
const halves = data instanceof Uint16Array;
|
|
206
|
+
const decode = halves ? halfToFloatTable() : null;
|
|
207
|
+
const at = halves
|
|
208
|
+
? ( ir, ig, ib, ch ) => decode[ data[ ( ir + ig * size + ib * s2 ) * 4 + ch ] ]
|
|
209
|
+
: ( ir, ig, ib, ch ) => data[ ( ir + ig * size + ib * s2 ) * 4 + ch ];
|
|
210
|
+
|
|
211
|
+
return function sampleLut( r, g, b, out ) {
|
|
212
|
+
|
|
213
|
+
const pr = shape( r, size, minEv, maxEv );
|
|
214
|
+
const pg = shape( g, size, minEv, maxEv );
|
|
215
|
+
const pb = shape( b, size, minEv, maxEv );
|
|
216
|
+
|
|
217
|
+
const ir = Math.min( Math.floor( pr ), last - 1 );
|
|
218
|
+
const ig = Math.min( Math.floor( pg ), last - 1 );
|
|
219
|
+
const ib = Math.min( Math.floor( pb ), last - 1 );
|
|
220
|
+
|
|
221
|
+
const fx = Math.min( Math.max( pr - ir, 0 ), 1 );
|
|
222
|
+
const fy = Math.min( Math.max( pg - ig, 0 ), 1 );
|
|
223
|
+
const fz = Math.min( Math.max( pb - ib, 0 ), 1 );
|
|
224
|
+
|
|
225
|
+
const { w, c } = tetrahedron( fx, fy, fz );
|
|
226
|
+
|
|
227
|
+
for ( let ch = 0; ch < 3; ch ++ ) {
|
|
228
|
+
|
|
229
|
+
const base = at( ir, ig, ib, ch );
|
|
230
|
+
let v = base;
|
|
231
|
+
let prev = base;
|
|
232
|
+
for ( let k = 0; k < 3; k ++ ) {
|
|
233
|
+
|
|
234
|
+
const corner = at( ir + c[ k ][ 0 ], ig + c[ k ][ 1 ], ib + c[ k ][ 2 ], ch );
|
|
235
|
+
v += ( corner - prev ) * w[ k ];
|
|
236
|
+
prev = corner;
|
|
237
|
+
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
out[ ch ] = v;
|
|
241
|
+
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The same sampler in WGSL, reading a 3D texture by integer fetch.
|
|
250
|
+
*
|
|
251
|
+
* `textureLoad` rather than a sampler: filtering an rgba16float 3D texture would need the
|
|
252
|
+
* `float32-filterable` device feature on some backends, and the interpolation here is tetrahedral
|
|
253
|
+
* anyway, which no sampler does.
|
|
254
|
+
*
|
|
255
|
+
* @returns {string} a WGSL fragment declaring `fn <fnName>( c: vec3<f32> ) -> vec3<f32>`
|
|
256
|
+
*/
|
|
257
|
+
export function lutWgsl( { fnName, texName, size, minEv, maxEv } ) {
|
|
258
|
+
|
|
259
|
+
const f = v => ( Number.isInteger( v ) ? `${v}.0` : String( v ) );
|
|
260
|
+
|
|
261
|
+
return /* wgsl */ `
|
|
262
|
+
fn ${fnName}_shape( v: vec3<f32> ) -> vec3<f32> {
|
|
263
|
+
let ev = log2( max( v, vec3<f32>( 1e-10 ) ) );
|
|
264
|
+
let t = ( ev - vec3<f32>( ${f( minEv )} ) ) / ${f( maxEv - minEv )};
|
|
265
|
+
return clamp( t, vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) ) * ${f( size - 1 )};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn ${fnName}_fetch( i: vec3<i32> ) -> vec3<f32> {
|
|
269
|
+
return textureLoad( ${texName}, clamp( i, vec3<i32>( 0 ), vec3<i32>( ${size - 1} ) ), 0 ).rgb;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn ${fnName}( c: vec3<f32> ) -> vec3<f32> {
|
|
273
|
+
let p = ${fnName}_shape( c );
|
|
274
|
+
let i0 = clamp( vec3<i32>( floor( p ) ), vec3<i32>( 0 ), vec3<i32>( ${size - 2} ) );
|
|
275
|
+
let f = clamp( p - vec3<f32>( i0 ), vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
|
|
276
|
+
|
|
277
|
+
let c000 = ${fnName}_fetch( i0 );
|
|
278
|
+
var w: vec3<f32>;
|
|
279
|
+
var o1: vec3<i32>;
|
|
280
|
+
var o2: vec3<i32>;
|
|
281
|
+
var o3: vec3<i32>;
|
|
282
|
+
|
|
283
|
+
if ( f.x > f.y ) {
|
|
284
|
+
if ( f.y > f.z ) {
|
|
285
|
+
w = vec3<f32>( f.x, f.y, f.z );
|
|
286
|
+
o1 = vec3<i32>( 1, 0, 0 ); o2 = vec3<i32>( 1, 1, 0 ); o3 = vec3<i32>( 1, 1, 1 );
|
|
287
|
+
} else if ( f.x > f.z ) {
|
|
288
|
+
w = vec3<f32>( f.x, f.z, f.y );
|
|
289
|
+
o1 = vec3<i32>( 1, 0, 0 ); o2 = vec3<i32>( 1, 0, 1 ); o3 = vec3<i32>( 1, 1, 1 );
|
|
290
|
+
} else {
|
|
291
|
+
w = vec3<f32>( f.z, f.x, f.y );
|
|
292
|
+
o1 = vec3<i32>( 0, 0, 1 ); o2 = vec3<i32>( 1, 0, 1 ); o3 = vec3<i32>( 1, 1, 1 );
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
if ( f.z > f.y ) {
|
|
296
|
+
w = vec3<f32>( f.z, f.y, f.x );
|
|
297
|
+
o1 = vec3<i32>( 0, 0, 1 ); o2 = vec3<i32>( 0, 1, 1 ); o3 = vec3<i32>( 1, 1, 1 );
|
|
298
|
+
} else if ( f.z > f.x ) {
|
|
299
|
+
w = vec3<f32>( f.y, f.z, f.x );
|
|
300
|
+
o1 = vec3<i32>( 0, 1, 0 ); o2 = vec3<i32>( 0, 1, 1 ); o3 = vec3<i32>( 1, 1, 1 );
|
|
301
|
+
} else {
|
|
302
|
+
w = vec3<f32>( f.y, f.x, f.z );
|
|
303
|
+
o1 = vec3<i32>( 0, 1, 0 ); o2 = vec3<i32>( 1, 1, 0 ); o3 = vec3<i32>( 1, 1, 1 );
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let v1 = ${fnName}_fetch( i0 + o1 );
|
|
308
|
+
let v2 = ${fnName}_fetch( i0 + o2 );
|
|
309
|
+
let v3 = ${fnName}_fetch( i0 + o3 );
|
|
310
|
+
|
|
311
|
+
return c000 + ( v1 - c000 ) * w.x + ( v2 - v1 ) * w.y + ( v3 - v2 ) * w.z;
|
|
312
|
+
}
|
|
313
|
+
`;
|
|
314
|
+
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* What the table costs against the transform it was baked from.
|
|
319
|
+
*
|
|
320
|
+
* Samples colours the way a render actually produces them — a spread of luminances at a spread of
|
|
321
|
+
* saturations — rather than uniformly over the cube, where most of the volume is colours no scene
|
|
322
|
+
* contains.
|
|
323
|
+
*
|
|
324
|
+
* @returns {{ mean: number, p95: number, max: number, samples: number }} in 8-bit code values
|
|
325
|
+
*/
|
|
326
|
+
export function measureBakeError( { sampler, apply, count = 4000, seed = 31337 } ) {
|
|
327
|
+
|
|
328
|
+
let rng = seed >>> 0;
|
|
329
|
+
const rnd = () => ( rng = ( rng * 1664525 + 1013904223 ) >>> 0 ) / 4294967296;
|
|
330
|
+
|
|
331
|
+
const cases = [];
|
|
332
|
+
for ( let i = 0; i < count; i ++ ) {
|
|
333
|
+
|
|
334
|
+
const luminance = 0.18 * Math.pow( 2, - 7 + rnd() * 13 );
|
|
335
|
+
const sat = Math.pow( rnd(), 1.2 );
|
|
336
|
+
const hue = rnd() * 6;
|
|
337
|
+
const base = [ 0, 1, 2 ].map( k => 1 - sat * Math.min( 1, Math.abs( ( ( hue + k * 2 ) % 6 ) - 3 ) ) );
|
|
338
|
+
const peak = Math.max( ...base ) || 1;
|
|
339
|
+
cases.push( base.map( v => Math.max( v, 0 ) / peak * luminance ) );
|
|
340
|
+
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const truth = new Float32Array( cases.length * 4 );
|
|
344
|
+
cases.forEach( ( c, i ) => {
|
|
345
|
+
|
|
346
|
+
truth[ i * 4 ] = c[ 0 ];
|
|
347
|
+
truth[ i * 4 + 1 ] = c[ 1 ];
|
|
348
|
+
truth[ i * 4 + 2 ] = c[ 2 ];
|
|
349
|
+
truth[ i * 4 + 3 ] = 1;
|
|
350
|
+
|
|
351
|
+
} );
|
|
352
|
+
apply( truth );
|
|
353
|
+
|
|
354
|
+
const out = [ 0, 0, 0 ];
|
|
355
|
+
const deltas = [];
|
|
356
|
+
for ( let i = 0; i < cases.length; i ++ ) {
|
|
357
|
+
|
|
358
|
+
sampler( cases[ i ][ 0 ], cases[ i ][ 1 ], cases[ i ][ 2 ], out );
|
|
359
|
+
for ( let c = 0; c < 3; c ++ ) deltas.push( Math.abs( out[ c ] - truth[ i * 4 + c ] ) * 255 );
|
|
360
|
+
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
deltas.sort( ( a, b ) => a - b );
|
|
364
|
+
return {
|
|
365
|
+
mean: deltas.reduce( ( s, v ) => s + v, 0 ) / deltas.length,
|
|
366
|
+
p95: deltas[ Math.floor( deltas.length * 0.95 ) ],
|
|
367
|
+
max: deltas[ deltas.length - 1 ],
|
|
368
|
+
samples: deltas.length,
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenColorIO runtime, and whichever config the engine is colour-managed by right now.
|
|
3
|
+
*
|
|
4
|
+
* The engine never names the OCIO package. A bare specifier in engine source would make a 6 MB
|
|
5
|
+
* WebAssembly bundle a hard dependency of every host, and marking it `@vite-ignore` leaves the
|
|
6
|
+
* browser unable to resolve it at all. So the host supplies it, either way round:
|
|
7
|
+
*
|
|
8
|
+
* configureAssets( { ocioRuntimeFactory: () => import( '@bb-studio/ocio' ) } ) // bundled
|
|
9
|
+
* configureAssets( { ocioRuntimeUrl: '/vendor/ocio/index.js' } ) // served
|
|
10
|
+
*
|
|
11
|
+
* Exactly one config is live at a time. Everything downstream — the working space, input
|
|
12
|
+
* resolution, view transforms, export — reads it from here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getAssetConfig } from '../AssetConfig.js';
|
|
16
|
+
import { createLogger } from '../utils/Logger.js';
|
|
17
|
+
|
|
18
|
+
const log = createLogger( 'ocio' );
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* OpenColorIO's own messages, routed through the engine's leveled logger instead of straight to the
|
|
22
|
+
* console. Its "Info" lines are notes about the config — the ACES CG v1.0.0 config names four Studio
|
|
23
|
+
* displays as inactive that it does not define, and says so on every load — so they go to `debug`,
|
|
24
|
+
* hidden by default and one `rayzee.log.setLevel( 'debug' )` away. Warnings and errors still show.
|
|
25
|
+
*/
|
|
26
|
+
function ocioPrint( text ) {
|
|
27
|
+
|
|
28
|
+
const line = String( text );
|
|
29
|
+
if ( /\[OpenColorIO Error\]/.test( line ) ) log.error( line );
|
|
30
|
+
else if ( /\[OpenColorIO Warning\]/.test( line ) ) log.warn( line );
|
|
31
|
+
else log.debug( line );
|
|
32
|
+
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let runtimePromise = null;
|
|
36
|
+
let runtime = null;
|
|
37
|
+
let configInfo = null;
|
|
38
|
+
let configId = null;
|
|
39
|
+
|
|
40
|
+
/** The config's own YAML — the only place its context variables are actually declared. */
|
|
41
|
+
let configText = null;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the host-supplied OCIO module.
|
|
45
|
+
* @returns {Promise<Object>} the module namespace, with `createOcioRuntime`
|
|
46
|
+
*/
|
|
47
|
+
async function loadRuntimeModule() {
|
|
48
|
+
|
|
49
|
+
const { ocioRuntimeFactory, ocioRuntimeUrl } = getAssetConfig();
|
|
50
|
+
|
|
51
|
+
if ( typeof ocioRuntimeFactory === 'function' ) return await ocioRuntimeFactory();
|
|
52
|
+
|
|
53
|
+
if ( typeof ocioRuntimeUrl === 'string' && ocioRuntimeUrl ) {
|
|
54
|
+
|
|
55
|
+
return await import( /* @vite-ignore */ ocioRuntimeUrl );
|
|
56
|
+
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error(
|
|
60
|
+
'OpenColorIO runtime not configured. Call configureAssets({ ocioRuntimeFactory: ' +
|
|
61
|
+
"() => import('@bb-studio/ocio') }) before loading a config."
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The live runtime, created on first use. Repeated calls share one instance. */
|
|
67
|
+
export async function ensureRuntime() {
|
|
68
|
+
|
|
69
|
+
if ( runtime ) return runtime;
|
|
70
|
+
|
|
71
|
+
if ( ! runtimePromise ) {
|
|
72
|
+
|
|
73
|
+
runtimePromise = ( async () => {
|
|
74
|
+
|
|
75
|
+
const mod = await loadRuntimeModule();
|
|
76
|
+
if ( typeof mod.createOcioRuntime !== 'function' ) {
|
|
77
|
+
|
|
78
|
+
throw new Error( 'OCIO module has no createOcioRuntime() — wrong package or version' );
|
|
79
|
+
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { ocioWasmUrl } = getAssetConfig();
|
|
83
|
+
runtime = await mod.createOcioRuntime( {
|
|
84
|
+
...( ocioWasmUrl ? { wasmUrl: ocioWasmUrl } : {} ),
|
|
85
|
+
moduleOptions: { print: ocioPrint, printErr: ocioPrint },
|
|
86
|
+
} );
|
|
87
|
+
return runtime;
|
|
88
|
+
|
|
89
|
+
} )().catch( err => {
|
|
90
|
+
|
|
91
|
+
runtimePromise = null;
|
|
92
|
+
throw err;
|
|
93
|
+
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return await runtimePromise;
|
|
99
|
+
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True once a runtime exists, without creating one. */
|
|
103
|
+
export function isRuntimeReady() {
|
|
104
|
+
|
|
105
|
+
return runtime !== null;
|
|
106
|
+
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The OCIO module's own built-in configs, which need no files on disk. */
|
|
110
|
+
export async function listBuiltinConfigs() {
|
|
111
|
+
|
|
112
|
+
const rt = await ensureRuntime();
|
|
113
|
+
return rt.ocio.listBuiltinConfigs();
|
|
114
|
+
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Load a config and make it the engine's.
|
|
119
|
+
*
|
|
120
|
+
* Three shapes, one of which must be given:
|
|
121
|
+
* { builtin: 'ocio://cg-config-v4.0.0_aces-v2.0_ocio-v2.5' }
|
|
122
|
+
* { text: '<.ocio yaml>', files: [ { relativePath, data } ], configPath: 'config.ocio' }
|
|
123
|
+
* { files: [...], configPath: 'config.ocio' }
|
|
124
|
+
*
|
|
125
|
+
* A config that references LUTs on disk needs those files mounted alongside it — a partial mount
|
|
126
|
+
* fails at the first missing file, so pass the whole support directory.
|
|
127
|
+
*
|
|
128
|
+
* @returns {Promise<Object>} the config description (see `describeConfig`)
|
|
129
|
+
*/
|
|
130
|
+
export async function loadConfig( { builtin, text, files = [], configPath = 'config.ocio', id } = {} ) {
|
|
131
|
+
|
|
132
|
+
const rt = await ensureRuntime();
|
|
133
|
+
|
|
134
|
+
if ( builtin ) {
|
|
135
|
+
|
|
136
|
+
configInfo = rt.loadBuiltinConfig( builtin );
|
|
137
|
+
configId = builtin;
|
|
138
|
+
configText = safeBuiltinYaml( rt, builtin );
|
|
139
|
+
|
|
140
|
+
} else {
|
|
141
|
+
|
|
142
|
+
const packaged = files.map( f => ( {
|
|
143
|
+
relativePath: f.relativePath ?? f.path ?? f.name,
|
|
144
|
+
data: f.data instanceof Uint8Array ? f.data : new Uint8Array( f.data ),
|
|
145
|
+
} ) );
|
|
146
|
+
|
|
147
|
+
if ( text !== undefined ) {
|
|
148
|
+
|
|
149
|
+
packaged.unshift( { relativePath: configPath, data: new TextEncoder().encode( text ) } );
|
|
150
|
+
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if ( ! packaged.some( f => f.relativePath === configPath ) ) {
|
|
154
|
+
|
|
155
|
+
throw new Error( `config package has no "${configPath}" — pass configPath to name the .ocio file` );
|
|
156
|
+
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
configInfo = rt.loadConfigPackage(
|
|
160
|
+
{ configRelativePath: configPath, files: packaged },
|
|
161
|
+
id ? { id } : undefined
|
|
162
|
+
);
|
|
163
|
+
configId = id ?? configInfo.id;
|
|
164
|
+
const main = packaged.find( f => f.relativePath === configPath );
|
|
165
|
+
configText = main ? new TextDecoder().decode( main.data ) : null;
|
|
166
|
+
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return describeConfig();
|
|
170
|
+
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Forget the config. The engine falls back to its uncoloured-managed behaviour. */
|
|
174
|
+
export function unloadConfig() {
|
|
175
|
+
|
|
176
|
+
configInfo = null;
|
|
177
|
+
configId = null;
|
|
178
|
+
configText = null;
|
|
179
|
+
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function hasConfig() {
|
|
183
|
+
|
|
184
|
+
return configInfo !== null;
|
|
185
|
+
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The raw runtime, for callers that need a processor. Null before a config is loaded. */
|
|
189
|
+
export function getRuntime() {
|
|
190
|
+
|
|
191
|
+
return configInfo ? runtime : null;
|
|
192
|
+
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The raw `OcioRuntimeConfigInfo`, or null. */
|
|
196
|
+
export function getConfigInfo() {
|
|
197
|
+
|
|
198
|
+
return configInfo;
|
|
199
|
+
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* What the config offers, in the engine's own shape.
|
|
204
|
+
*
|
|
205
|
+
* The important part is `roles`: a config tells us what its own scene-linear space is, what a
|
|
206
|
+
* colour picker should work in, and what "no colour management" means. Reading those beats
|
|
207
|
+
* hardcoding names, which is what makes an arbitrary studio config work rather than only ACES.
|
|
208
|
+
*/
|
|
209
|
+
export function describeConfig() {
|
|
210
|
+
|
|
211
|
+
if ( ! configInfo ) return null;
|
|
212
|
+
|
|
213
|
+
const roles = Object.fromEntries( configInfo.roles.map( r => [ r.name, r.colorSpace ] ) );
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
id: configId,
|
|
217
|
+
// The config's own `name:`, when it declares one — what a menu should call a loaded folder.
|
|
218
|
+
name: configText?.match( /^name:\s*(\S.*?)\s*$/m )?.[ 1 ] ?? null,
|
|
219
|
+
ocioVersion: configInfo.ocioVersion,
|
|
220
|
+
version: configInfo.version,
|
|
221
|
+
|
|
222
|
+
roles,
|
|
223
|
+
sceneLinear: roles.scene_linear ?? null,
|
|
224
|
+
colorPicking: roles.color_picking ?? roles.texture_paint ?? null,
|
|
225
|
+
texturePaint: roles.texture_paint ?? roles.color_picking ?? null,
|
|
226
|
+
data: roles.data ?? null,
|
|
227
|
+
compositingLog: roles.compositing_log ?? roles.color_timing ?? null,
|
|
228
|
+
|
|
229
|
+
colorSpaces: configInfo.colorSpaces.map( c => ( {
|
|
230
|
+
name: c.name,
|
|
231
|
+
family: c.family,
|
|
232
|
+
// What the config author says each space is for — `working-space`, `texture`, `file-io`.
|
|
233
|
+
// Menus are meant to be filtered by these; without them every menu lists everything.
|
|
234
|
+
categories: c.categories ?? [],
|
|
235
|
+
encoding: c.encoding,
|
|
236
|
+
isData: c.isData,
|
|
237
|
+
aliases: c.aliases,
|
|
238
|
+
description: c.description,
|
|
239
|
+
} ) ),
|
|
240
|
+
|
|
241
|
+
displays: configInfo.displays.slice(),
|
|
242
|
+
defaultDisplay: configInfo.defaultDisplay,
|
|
243
|
+
views: Object.fromEntries(
|
|
244
|
+
Object.entries( configInfo.viewsByDisplay ).map( ( [ d, vs ] ) => [ d, vs.map( v => ( {
|
|
245
|
+
name: v.name,
|
|
246
|
+
transform: v.transform,
|
|
247
|
+
colorSpace: v.colorSpace,
|
|
248
|
+
looks: v.looks,
|
|
249
|
+
description: v.description,
|
|
250
|
+
} ) ) ] )
|
|
251
|
+
),
|
|
252
|
+
defaultViews: { ...configInfo.defaultViewsByDisplay },
|
|
253
|
+
|
|
254
|
+
looks: configInfo.looks.map( l => ( {
|
|
255
|
+
name: l.name,
|
|
256
|
+
processSpace: l.processSpace,
|
|
257
|
+
description: l.description,
|
|
258
|
+
forward: l.hasForwardTransform,
|
|
259
|
+
inverse: l.hasInverseTransform,
|
|
260
|
+
} ) ),
|
|
261
|
+
|
|
262
|
+
namedTransforms: configInfo.namedTransforms.map( n => n.name ),
|
|
263
|
+
fileRules: configInfo.fileRules.map( r => ( {
|
|
264
|
+
index: r.index, name: r.name, colorSpace: r.colorSpace,
|
|
265
|
+
pattern: r.pattern, extension: r.extension, regex: r.regex,
|
|
266
|
+
} ) ),
|
|
267
|
+
|
|
268
|
+
contextVariables: collectContextVariables(),
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function safeBuiltinYaml( rt, name ) {
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
|
|
277
|
+
return rt.ocio.getBuiltinConfigYaml( name );
|
|
278
|
+
|
|
279
|
+
} catch {
|
|
280
|
+
|
|
281
|
+
return null;
|
|
282
|
+
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The context variables a config uses, with the defaults it declares.
|
|
289
|
+
*
|
|
290
|
+
* Read from the config's own text, because that is the only place they are. OCIO's description of
|
|
291
|
+
* a loaded config lists colour spaces, looks and rules, but a `$SHOT` almost always sits inside a
|
|
292
|
+
* file transform's path, which that description does not carry — reading the description found
|
|
293
|
+
* none in practice. Two sources, merged:
|
|
294
|
+
* - the `environment:` block, where an OCIO v2 config declares its variables and their defaults
|
|
295
|
+
* - every `$VAR` / `${VAR}` reference, for configs that use one without declaring it
|
|
296
|
+
*
|
|
297
|
+
* @returns {Array<{ name: string, default: ?string, declared: boolean }>}
|
|
298
|
+
*/
|
|
299
|
+
function collectContextVariables() {
|
|
300
|
+
|
|
301
|
+
if ( ! configText ) return [];
|
|
302
|
+
|
|
303
|
+
const found = new Map();
|
|
304
|
+
|
|
305
|
+
const lines = configText.split( /\r?\n/ );
|
|
306
|
+
const at = lines.findIndex( l => /^environment:\s*(\{\s*\})?\s*$/.test( l ) );
|
|
307
|
+
if ( at >= 0 && ! /\{\s*\}/.test( lines[ at ] ) ) {
|
|
308
|
+
|
|
309
|
+
for ( let i = at + 1; i < lines.length; i ++ ) {
|
|
310
|
+
|
|
311
|
+
const line = lines[ i ];
|
|
312
|
+
if ( /^\S/.test( line ) ) break;
|
|
313
|
+
const m = line.match( /^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$/ );
|
|
314
|
+
if ( m ) found.set( m[ 1 ], { name: m[ 1 ], default: m[ 2 ].replace( /^["']|["']$/g, '' ) || null, declared: true } );
|
|
315
|
+
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for ( const m of configText.matchAll( /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g ) ) {
|
|
321
|
+
|
|
322
|
+
if ( ! found.has( m[ 1 ] ) ) found.set( m[ 1 ], { name: m[ 1 ], default: null, declared: false } );
|
|
323
|
+
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return [ ...found.values() ].sort( ( a, b ) => a.name.localeCompare( b.name ) );
|
|
327
|
+
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Drop everything, including the runtime itself. Tests and `dispose()` use this. */
|
|
331
|
+
export function resetOcio() {
|
|
332
|
+
|
|
333
|
+
unloadConfig();
|
|
334
|
+
runtime?.dispose?.();
|
|
335
|
+
runtime = null;
|
|
336
|
+
runtimePromise = null;
|
|
337
|
+
|
|
338
|
+
}
|