rayzee 8.4.0 → 8.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "8.4.0",
3
+ "version": "8.4.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -10,13 +10,11 @@
10
10
  */
11
11
  export const RENDER_PROFILES = Object.freeze( {
12
12
  viewer: Object.freeze( {
13
- areaLightIntensityScale: 0.1, // scales glTF placeholder area-light power (viewer tuning)
14
13
  environmentRotation: 270.0, // degrees
15
14
  toneMapping: 4, // ACESFilmicToneMapping
16
15
  saturation: 1.2, // compensates for ACES desaturation — see Compositor
17
16
  } ),
18
17
  physical: Object.freeze( {
19
- areaLightIntensityScale: 1.0,
20
18
  environmentRotation: 0.0,
21
19
  // AgX, matching Blender/Cycles' default view transform. ACES crushes shadows on this
22
20
  // engine's own corpus (shade 1.48 vs AgX 3.01 from identical radiance).
@@ -27,7 +25,7 @@ export const RENDER_PROFILES = Object.freeze( {
27
25
 
28
26
  /**
29
27
  * @param {string} [name] - a RENDER_PROFILES key
30
- * @returns {{areaLightIntensityScale: number, environmentRotation: number}}
28
+ * @returns {{environmentRotation: number, toneMapping: number, saturation: number}}
31
29
  * @throws {Error} on an unknown name — a typo must not silently select viewer tuning
32
30
  */
33
31
  export function getRenderProfile( name = 'viewer' ) {
@@ -1062,7 +1060,8 @@ export const DEFAULT_TEXTURE_MATRIX = [ 0, 0, 1, 1, 0, 0, 0, 1 ];
1062
1060
  export const PRODUCTION_RENDER_CONFIG = {
1063
1061
  // maxSamples is a CEILING: adaptive sampling retires the frame once adaptiveStopFraction of pixels converge,
1064
1062
  // so easy scenes finish well under it while hard GI scenes use the full budget.
1065
- maxSamples: 150, bounces: 20, transmissiveBounces: 8, maxSubsurfaceSteps: 64,
1063
+ // Below 24 a ray that spends its transmissive budget is shaded opaque — black pixels, not dim glass.
1064
+ maxSamples: 30, bounces: 20, transmissiveBounces: 24, maxSubsurfaceSteps: 64,
1066
1065
  renderMode: 1, enableAlphaShadows: true,
1067
1066
  // 'high' is the only tier that reaches OIDN's _large weights (calb_cnrm); ~2x denoise cost.
1068
1067
  enableOIDN: true, oidnQuality: 'high',
@@ -1077,7 +1076,8 @@ export const PRODUCTION_RENDER_CONFIG = {
1077
1076
  export const INTERACTIVE_RENDER_CONFIG = {
1078
1077
  maxSamples: ENGINE_DEFAULTS.maxSamples, bounces: ENGINE_DEFAULTS.bounces,
1079
1078
  renderMode: ENGINE_DEFAULTS.renderMode, enableAlphaShadows: ENGINE_DEFAULTS.enableAlphaShadows,
1080
- transmissiveBounces: ENGINE_DEFAULTS.transmissiveBounces,
1079
+ // 12, not 5: a spent budget leaves black glass and costs MORE — the ray then bounces diffusely.
1080
+ transmissiveBounces: 12,
1081
1081
  maxSubsurfaceSteps: ENGINE_DEFAULTS.maxSubsurfaceSteps,
1082
1082
  // On, like production. The final pass costs one cheap denoise when the preview settles, and both
1083
1083
  // neural passes need a denoised frame to be worth running — on Monte-Carlo noise the upscaler
@@ -19,6 +19,7 @@ export const ISSUE_CODES = Object.freeze( {
19
19
  STAGE_RENDER_FAILED: 'stage.render_failed',
20
20
  RENDER_RESERVE_CAPPED: 'render.reserve_capped',
21
21
  SCENE_MEMORY_BUDGET: 'scene.memory_budget',
22
+ LIGHT_PLACEHOLDER_INVALID: 'light.placeholder_invalid',
22
23
  EMISSIVE_INSTANCES_COLLAPSED: 'emissive.instances_collapsed',
23
24
  REFIT_SHARED_GEOMETRY: 'refit.shared_geometry',
24
25
  } );
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The engine's light-unit rule, stated once.
3
+ *
4
+ * Every light in the scene graph is a **valid three.js light**: it carries the quantity three.js
5
+ * expects for its type, measured in radiometric (watt-based) units rather than photometric ones.
6
+ *
7
+ * point / spot → radiant intensity W/sr
8
+ * directional → irradiance W/m²
9
+ * rect area → radiance W/(sr·m²), `width`/`height` in world metres, unit scale
10
+ *
11
+ * three.js' shading maths is unit-agnostic — it divides by distance² and integrates a form factor,
12
+ * never caring whether the number it was handed is candela or W/sr. Feeding it radiometric values
13
+ * therefore makes the raster fallback, the light helpers and any host reading `sceneModel` correct
14
+ * without one of them knowing anything about the path tracer.
15
+ *
16
+ * Blender-style **Power in Watts** — what the Lights panel edits — is a view of that, converted
17
+ * here and nowhere else. `LightSerializer` converts the other way when it fills the GPU buffer.
18
+ */
19
+
20
+ /** Luminous efficacy that glTF, three.js and Blender's exporter all assume (lm/W). */
21
+ export const LUMENS_PER_WATT = 683;
22
+
23
+ /** Emitting area in m², including the ellipse/disk shape factor. Assumes unit world scale. */
24
+ export function areaLightArea( light ) {
25
+
26
+ const shape = light.userData?.shape;
27
+ const shapeFactor = shape === 'ellipse' || shape === 'disk' ? Math.PI / 4 : 1;
28
+ const area = Math.abs( light.width * light.height ) * shapeFactor;
29
+ return Number.isFinite( area ) && area > 0 ? area : 0;
30
+
31
+ }
32
+
33
+ /**
34
+ * Blender-style Power for the Lights panel: radiant power in W for point, spot and area lights,
35
+ * and the Sun's strength in W/m² unchanged.
36
+ */
37
+ export function lightPower( light ) {
38
+
39
+ if ( light.isRectAreaLight ) {
40
+
41
+ const normalize = light.userData?.normalize ?? true;
42
+ return light.intensity * Math.PI * ( normalize ? areaLightArea( light ) : 1 );
43
+
44
+ }
45
+
46
+ if ( light.isPointLight || light.isSpotLight ) return light.intensity * 4 * Math.PI;
47
+
48
+ return light.intensity;
49
+
50
+ }
51
+
52
+ /** Inverse of {@link lightPower} — what the panel writes back. */
53
+ export function setLightPower( light, watts ) {
54
+
55
+ if ( light.isRectAreaLight ) {
56
+
57
+ const normalize = light.userData?.normalize ?? true;
58
+ const denominator = Math.PI * ( normalize ? areaLightArea( light ) : 1 );
59
+ light.intensity = denominator > 0 ? watts / denominator : 0;
60
+ return;
61
+
62
+ }
63
+
64
+ if ( light.isPointLight || light.isSpotLight ) {
65
+
66
+ light.intensity = watts / ( 4 * Math.PI );
67
+ return;
68
+
69
+ }
70
+
71
+ light.intensity = watts;
72
+
73
+ }
74
+
75
+ /**
76
+ * Runs `mutate` with the light's Power held constant.
77
+ *
78
+ * Resizing a normalized area light is meant to keep its total power and change its radiance —
79
+ * Blender's Normalize toggle. Since the stored quantity is radiance, that only happens if the
80
+ * edit puts the Power back afterwards. With Normalize off, Power does not depend on the area,
81
+ * so this is a no-op and the same call is still correct.
82
+ */
83
+ export function preserveLightPower( light, mutate ) {
84
+
85
+ const watts = lightPower( light );
86
+ mutate();
87
+ setLightPower( light, watts );
88
+
89
+ }
90
+
91
+ /**
92
+ * Puts an area light's rectangle in world metres with unit scale, leaving its world size and
93
+ * placement untouched.
94
+ *
95
+ * ⚠️ three.js' rasteriser reads `width`/`height` as WORLD dimensions and drops any scale inherited
96
+ * from parent nodes — `RectAreaLightNode` builds the light's basis with `extractRotation`, which
97
+ * normalises the scale away. A light authored as 70 units under a 0.01 node scale therefore rasters
98
+ * as a **70 metre** panel while the path tracer, which uses the world matrix, renders the 0.7 m one
99
+ * it should. Measured on 24001884.glb: 58.7x too bright in the raster view. Baking the scale in and
100
+ * cancelling it on the light itself makes every reader agree.
101
+ *
102
+ * Idempotent: a light already at unit scale is left alone, so re-processing a tree is safe.
103
+ */
104
+ export function bakeAreaLightScale( light ) {
105
+
106
+ light.updateWorldMatrix( true, false );
107
+
108
+ // Column lengths of the world matrix, read directly so this module needs no three.js import.
109
+ const e = light.matrixWorld.elements;
110
+ const sx = Math.hypot( e[ 0 ], e[ 1 ], e[ 2 ] );
111
+ const sy = Math.hypot( e[ 4 ], e[ 5 ], e[ 6 ] );
112
+ const sz = Math.hypot( e[ 8 ], e[ 9 ], e[ 10 ] );
113
+
114
+ if ( ! Number.isFinite( sx ) || ! Number.isFinite( sy ) || sx === 0 || sy === 0 ) return;
115
+ if ( Math.abs( sx - 1 ) < 1e-9 && Math.abs( sy - 1 ) < 1e-9 ) return;
116
+
117
+ light.width = Math.abs( light.width * sx );
118
+ light.height = Math.abs( light.height * sy );
119
+ light.scale.set( light.scale.x / sx, light.scale.y / sy, sz === 0 ? light.scale.z : light.scale.z / sz );
120
+ light.updateMatrixWorld( true );
121
+
122
+ }
@@ -764,8 +764,9 @@ export class PathTracerApp extends EventDispatcher {
764
764
  * by reference, and any ancestor transform is baked in. Later edits to `object3d` do not
765
765
  * reach the render — mutate {@link sceneModel}, then {@link refitBVH}/{@link refitBLASes}.
766
766
  *
767
- * Lights keep three.js units — `RectAreaLight.intensity` in nits, point/spot in candela — and are
768
- * converted to radiant power on the copy. `areaLightIntensityScale` does not apply here.
767
+ * Lights are read as three.js lights in RADIOMETRIC units — W/sr for point and spot, W/m² for
768
+ * directional, radiance for area — and are left as they are; only an area light's rectangle is
769
+ * put into world metres on the copy. See `LightUnits.js`.
769
770
  *
770
771
  * @param {import('three').Object3D} object3d - The Object3D to render; left untouched.
771
772
  * @param {string} [name='object3d'] - Display name for the object
@@ -3106,6 +3107,7 @@ export class PathTracerApp extends EventDispatcher {
3106
3107
  this.denoisingManager.setOverlayManager( this.overlayManager );
3107
3108
  this.denoisingManager.setResetCallback( () => this.reset() );
3108
3109
  this.denoisingManager.setPostProcessRefreshCallback( () => this.requestPostProcessRefresh() );
3110
+ this.denoisingManager.setDisplayRefreshCallback( () => this.refreshFrame() );
3109
3111
  this.denoisingManager.setSettings( this.settings );
3110
3112
 
3111
3113
  // Expose environment manager (lives on pathTracer stage)
@@ -19,6 +19,7 @@ import { getAssetConfig } from '../AssetConfig.js';
19
19
  import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
20
20
  import { extractSceneMetadata } from './SceneMetadata.js';
21
21
  import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
22
+ import { bakeAreaLightScale, LUMENS_PER_WATT } from '../LightUnits.js';
22
23
  import { getRenderProfile } from '../EngineDefaults.js';
23
24
 
24
25
  // Define supported file formats
@@ -67,16 +68,43 @@ function standInForSplit( source ) {
67
68
 
68
69
  }
69
70
 
70
- // three.js nits → the engine's radiant power, inverting areaLightRadiance.
71
- function areaLightPowerFactor( node, width, height, userData ) {
71
+ const PLACEHOLDER_SHAPES = new Set( [ 'rectangle', 'square', 'disk', 'ellipse' ] );
72
72
 
73
- if ( ! ( userData.normalize ?? true ) ) return Math.PI;
73
+ /**
74
+ * Build the light a placeholder node stands for. glTF has no area light, so a three.js host's
75
+ * patched GLTFExporter writes the RectAreaLight into the node's `extras` and leaves the node
76
+ * itself carrying the transform. `intensity` is three.js radiance, which is what the engine
77
+ * stores, so the number is used as authored; only the rectangle needs normalising to world
78
+ * metres (see `bakeAreaLightScale`).
79
+ *
80
+ * `power`, `position`, `rotation` and `direction` are present in the record and deliberately
81
+ * unused. The node's matrix already carries the transform, `direction` is not even normalised,
82
+ * and `power` was written from the light's UNSCALED dimensions — on 24155522.glb's window light,
83
+ * whose ancestor scale is non-uniform (1.6 × 1.2 m in world, not 1.2 × 1.2), it is 25 % low.
84
+ *
85
+ * @returns {RectAreaLight|null} null when the record carries no usable size or intensity.
86
+ */
87
+ function areaLightFromPlaceholder( node, record ) {
74
88
 
75
- const shapeFactor = userData.shape === 'ellipse' || userData.shape === 'disk' ? Math.PI / 4 : 1;
76
- const scale = node.getWorldScale( new Vector3() );
77
- // abs: a mirrored ancestor decomposes negative; the serializer takes the area as |u × v|.
78
- const factor = Math.PI * shapeFactor * Math.abs( width * scale.x * height * scale.y );
79
- return Number.isFinite( factor ) ? factor : Math.PI;
89
+ const width = Number( record.width );
90
+ const height = Number( record.height );
91
+ const intensity = Number( record.intensity );
92
+ if ( ! ( width > 0 ) || ! ( height > 0 ) || ! Number.isFinite( intensity ) ) return null;
93
+
94
+ const shape = PLACEHOLDER_SHAPES.has( record.shape ) ? record.shape : 'rectangle';
95
+ const color = Array.isArray( record.color ) && record.color.length >= 3 ? record.color : [ 1, 1, 1 ];
96
+
97
+ const light = new RectAreaLight( new Color( ...color ), intensity, width, height );
98
+ light.name = record.name || 'RectAreaLight';
99
+ light.userData.normalize = record.normalize ?? true;
100
+ light.userData.spread = Number.isFinite( record.spread ) ? record.spread : Math.PI;
101
+ light.userData.shape = shape;
102
+
103
+ // Parented before baking: the bake needs the node's world scale, and on the append path
104
+ // nothing has refreshed the tree yet.
105
+ node.add( light );
106
+ bakeAreaLightScale( light );
107
+ return light;
80
108
 
81
109
  }
82
110
 
@@ -2081,50 +2109,40 @@ export class AssetLoader extends EventDispatcher {
2081
2109
 
2082
2110
  const userData = object.userData;
2083
2111
 
2084
- // An adopted light carries three.js units; convert as point/spot are below.
2085
- if ( object.isRectAreaLight && ! userData.__radianceConverted ) {
2112
+ // three.js states RectAreaLight.intensity as radiance, which is what the engine
2113
+ // stores, so only the rectangle needs putting into world metres.
2114
+ if ( object.isRectAreaLight ) bakeAreaLightScale( object );
2086
2115
 
2087
- object.intensity *= areaLightPowerFactor( object, object.width, object.height, userData );
2088
- userData.__radianceConverted = true;
2116
+ // Punctual lights arrive photometric: glTF (and three.js) state point/spot in
2117
+ // candela and directional in lux. The engine keeps the same QUANTITY in radiometric
2118
+ // units — W/sr and W/m² — so only the luminous efficacy is divided back out, which
2119
+ // is what Blender's own glTF importer does. Skipping it rendered every Blender lamp
2120
+ // 683x too bright.
2121
+ if ( ( object.isPointLight || object.isSpotLight ) && ! userData.__candelaConverted ) {
2122
+
2123
+ object.intensity /= LUMENS_PER_WATT;
2124
+ userData.__candelaConverted = true;
2089
2125
 
2090
2126
  }
2091
2127
 
2092
- // glTF punctual point/spot intensity is candela (W/sr); the engine's light
2093
- // model is Blender-style Power (W) that LightSerializer divides by 4π. Convert
2094
- // glTF candela → Power once at import (×4π) so it nets to the correct I/d².
2095
- // Directional (lux ≈ irradiance) is used directly and needs no conversion.
2096
- if ( ( object.isPointLight || object.isSpotLight ) && ! userData.__candelaConverted ) {
2128
+ if ( object.isDirectionalLight && ! userData.__luxConverted ) {
2097
2129
 
2098
- object.intensity *= 4 * Math.PI;
2099
- userData.__candelaConverted = true;
2130
+ object.intensity /= LUMENS_PER_WATT;
2131
+ userData.__luxConverted = true;
2100
2132
 
2101
2133
  }
2102
2134
 
2103
- // Process ceiling lights
2104
- if ( object.name.startsWith( 'RectAreaLightPlaceholder' ) &&
2105
- userData.name
2106
- // && userData.name.includes( "ceilingLight" )
2107
- ) {
2108
-
2109
- if ( userData.type === 'RectAreaLight' ) {
2110
-
2111
- const normalize = userData.normalize ?? true;
2112
- const shape = userData.shape ?? 'rectangle';
2113
- const power = userData.intensity * areaLightPowerFactor( object, userData.width, userData.height, userData );
2114
- const light = new RectAreaLight(
2115
- new Color( ...userData.color ),
2116
- power * this._profile.areaLightIntensityScale,
2117
- userData.width,
2118
- userData.height
2119
- );
2120
- light.userData.normalize = normalize;
2121
- light.userData.spread = Number.isFinite( userData.spread ) ? userData.spread : Math.PI;
2122
- light.userData.shape = shape;
2123
- light.userData.__radianceConverted = true; // already power, and traverse() reaches it
2124
- light.name = userData.name;
2125
- object.add( light );
2135
+ // An area light serialized into a placeholder node. Keyed on the record's own type
2136
+ // rather than the node's name, so a renamed placeholder still lights the scene.
2137
+ if ( userData.type === 'RectAreaLight' ) {
2126
2138
 
2127
- }
2139
+ const light = areaLightFromPlaceholder( object, userData );
2140
+ if ( ! light ) this._issues?.record(
2141
+ ISSUE_CODES.LIGHT_PLACEHOLDER_INVALID,
2142
+ `area-light placeholder "${object.name}" carries no usable size or intensity`,
2143
+ { node: object.name, record: userData },
2144
+ ISSUE_SEVERITY.WARNING,
2145
+ );
2128
2146
 
2129
2147
  }
2130
2148
 
@@ -1,10 +1,11 @@
1
1
  import { Vector3, Quaternion } from 'three';
2
2
  import { blackbodyToLinearRGB } from './blackbody.js';
3
+ import { lightPower } from '../LightUnits.js';
3
4
 
4
- // Point & spot lights specify Power in Watts; the shader uses radiant intensity
5
- // (W/sr) as `intensity / dist²`, so convert with I = P / 4π (isotropic emitter),
6
- // matching Blender. Area lights are already radiant power; the Sun is irradiance.
7
- const INV_4PI = 1 / ( 4 * Math.PI );
5
+ // Scene lights hold the three.js quantity in radiometric units (see LightUnits.js): point and
6
+ // spot in W/sr, the Sun in W/m², area lights in radiance. The GPU buffer wants radiant intensity
7
+ // for point/spot — already what they hold — and radiant POWER for area lights, which the shader
8
+ // turns back into radiance with its own π·area. This is where that adaptation happens.
8
9
 
9
10
  // Effective emission colour + intensity after per-light Exposure (EV stops) and
10
11
  // optional blackbody Temperature tint (Blender-style). Both fold into the values
@@ -61,9 +62,10 @@ export class LightSerializer {
61
62
 
62
63
  calculateLightImportance( light, type = 'directional' ) {
63
64
 
64
- // Calculate luminance-weighted importance
65
+ // Ranked on Power, so the ordering does not shift when a light type's stored quantity
66
+ // differs from what the panel shows.
65
67
  const luminance = 0.2126 * light.color.r + 0.7152 * light.color.g + 0.0722 * light.color.b;
66
- let importance = light.intensity * luminance;
68
+ let importance = lightPower( light ) * luminance;
67
69
 
68
70
  // Area lights get additional importance based on size
69
71
  if ( type === 'area' ) {
@@ -172,6 +174,12 @@ export class LightSerializer {
172
174
 
173
175
  const em = effectiveEmission( light );
174
176
 
177
+ // The shader recovers radiance as power/(π·area), so hand it the power that inverts to
178
+ // the radiance the light holds. Area is measured off u/v exactly as the shader does it.
179
+ const rectArea = 4 * u.clone().cross( v ).length();
180
+ const emittingArea = shape > 0.5 ? rectArea * Math.PI / 4 : rectArea;
181
+ const power = em.intensity * Math.PI * ( normalize > 0.5 ? emittingArea : 1 );
182
+
175
183
  // Store in cache with importance (16 floats, vec4-aligned)
176
184
  this.areaLightCache.push( {
177
185
  data: [
@@ -179,7 +187,7 @@ export class LightSerializer {
179
187
  u.x, u.y, u.z, // u half-vector (3)
180
188
  v.x, v.y, v.z, // v half-vector (3)
181
189
  em.r, em.g, em.b, // color (3) — incl. temperature tint
182
- em.intensity, // radiant power in Watts (1) — incl. exposure
190
+ power, // radiant power in Watts (1) — incl. exposure
183
191
  normalize, // power-normalize flag (1)
184
192
  spread, // emission spread in radians (1)
185
193
  shape, // 0 = rect, 1 = disk/ellipse (1)
@@ -209,7 +217,7 @@ export class LightSerializer {
209
217
  data: [
210
218
  position.x, position.y, position.z, // position (3)
211
219
  em.r, em.g, em.b, // color (3) — incl. temperature tint
212
- em.intensity * INV_4PI, // radiant intensity W/sr = power(W)/4π, incl. exposure (1)
220
+ em.intensity, // radiant intensity W/sr, incl. exposure (1)
213
221
  light.distance || 0.0, // cutoff distance (0 = infinite) (1)
214
222
  light.decay !== undefined ? light.decay : 2.0 // decay exponent (1)
215
223
  ],
@@ -252,7 +260,7 @@ export class LightSerializer {
252
260
  position.x, position.y, position.z, // position (3)
253
261
  direction.x, direction.y, direction.z, // direction (3)
254
262
  em.r, em.g, em.b, // color (3) — incl. temperature tint
255
- em.intensity * INV_4PI, // radiant intensity W/sr = power(W)/4π, incl. exposure (1)
263
+ em.intensity, // radiant intensity W/sr, incl. exposure (1)
256
264
  light.angle || Math.PI / 4, // cone half-angle in radians (1)
257
265
  light.penumbra || 0.0, // penumbra [0,1] (1)
258
266
  light.distance || 0.0, // cutoff distance (0 = infinite) (1)
@@ -88,6 +88,8 @@ export class PathTracer extends PathTracerStage {
88
88
  this._lastBounceEnergy = null;
89
89
  // maxBounces the curve was measured at; the curve is ignored once this no longer matches (-1 = none).
90
90
  this._lastBounceCountsBudget = - 1;
91
+ // Loop length the curve covers; maxBounces alone is not it (free bounces extend the loop).
92
+ this._lastBounceCountsLoopBound = - 1;
91
93
  this._readbackPending = false;
92
94
  this._readbackEveryNFrames = 4;
93
95
  this._readbackFrameCounter = 0;
@@ -341,7 +343,7 @@ export class PathTracer extends PathTracerStage {
341
343
 
342
344
  const maxBounces = this.maxBounces.value;
343
345
  // Transmissive/SSS steps consume iterations without advancing camera-bounce depth, so the loop must run far enough for deep glass/subsurface walks (mirror PathTracerCore); the survivor curve + early-exit break it early on non-SSS scenes.
344
- const loopBound = maxBounces + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
346
+ const loopBound = this._bounceLoopBound();
345
347
 
346
348
  const frameHeight = this._wfRenderHeight.value;
347
349
  const chunkRows = this._chunkRows;
@@ -356,9 +358,17 @@ export class PathTracer extends PathTracerStage {
356
358
  // new camera-bounce caps the counts are cap-independent, so trust it up to that cutoff (whole curve when
357
359
  // same/decreasing budget; only the [0, oldBudget) overlap when increasing). budget=-1 → cutoff 0 → full
358
360
  // dispatch everywhere. This avoids the full-work spike on every bounce-count change.
361
+ // A curve measured with a smaller free-bounce budget under-counts survivors at EVERY depth, not
362
+ // just past its tail, so sizing off it drops the deep glass paths the bigger budget bought.
363
+ if ( loopBound > this._lastBounceCountsLoopBound ) this._curveSizingValid = false;
364
+
359
365
  const curve = this._lastBounceCounts;
366
+ const curveMeasuredThisFar = maxBounces <= this._lastBounceCountsBudget
367
+ && loopBound <= this._lastBounceCountsLoopBound;
360
368
  const curveReliableUpto = ( singleChunk && curve )
361
- ? ( maxBounces <= this._lastBounceCountsBudget ? loopBound + 1 : this._lastBounceCountsBudget )
369
+ ? ( curveMeasuredThisFar
370
+ ? loopBound + 1
371
+ : Math.min( this._lastBounceCountsBudget, this._lastBounceCountsLoopBound ) )
362
372
  : 0;
363
373
 
364
374
  // Blender-style row-band streaming: the fixed-budget path pool processes the image in bands of ≤ chunkRows
@@ -698,6 +708,12 @@ export class PathTracer extends PathTracerStage {
698
708
 
699
709
  }
700
710
 
711
+ _bounceLoopBound() {
712
+
713
+ return this.maxBounces.value + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
714
+
715
+ }
716
+
701
717
  // Async readback of the per-bounce snapshot every N frames; never awaited, so the early-exit uses past-frame data.
702
718
  _maybeReadbackCounters() {
703
719
 
@@ -722,7 +738,8 @@ export class PathTracer extends PathTracerStage {
722
738
  // the budget 3 -> 20 doubles the frame's dispatch work (13 -> 26 bounce iterations, 109 -> 214
723
739
  // compute passes) for as long as that lasts. Waiting out the N-frame cadence just extends it, so
724
740
  // re-measure on the next frame instead — same idiom as the mid-motion priming above.
725
- if ( this.maxBounces.value !== this._lastBounceCountsBudget ) {
741
+ if ( this.maxBounces.value !== this._lastBounceCountsBudget
742
+ || this._bounceLoopBound() !== this._lastBounceCountsLoopBound ) {
726
743
 
727
744
  this._readbackFrameCounter = this._readbackEveryNFrames;
728
745
 
@@ -738,6 +755,7 @@ export class PathTracer extends PathTracerStage {
738
755
  this._readbackPending = true;
739
756
  const gen = this._readbackGeneration;
740
757
  const budget = this.maxBounces.value;
758
+ const measuredLoopBound = this._bounceLoopBound();
741
759
  const n = this._queueManager.MAX_BOUNCE_SNAPSHOTS;
742
760
  this.renderer.getArrayBufferAsync( attr ).then( ( buf ) => {
743
761
 
@@ -750,6 +768,7 @@ export class PathTracer extends PathTracerStage {
750
768
  this._lastBounceCounts = all.subarray( 0, n );
751
769
  this._lastBounceEnergy = all.subarray( n, 2 * n );
752
770
  this._lastBounceCountsBudget = budget;
771
+ this._lastBounceCountsLoopBound = measuredLoopBound;
753
772
  this._curveSizingValid = true;
754
773
 
755
774
  }
@@ -837,6 +856,7 @@ export class PathTracer extends PathTracerStage {
837
856
  this._lastBounceCounts = null;
838
857
  this._lastBounceEnergy = null;
839
858
  this._lastBounceCountsBudget = - 1;
859
+ this._lastBounceCountsLoopBound = - 1;
840
860
  this._curveSizingValid = false;
841
861
  this._readbackFrameCounter = 0;
842
862
  this._readbackGeneration ++;
@@ -251,10 +251,15 @@ export const getDistanceAttenuation = /*@__PURE__*/ wgslFn( `
251
251
  }
252
252
  ` );
253
253
 
254
- // Spot light attenuation
254
+ // Spot cone falloff, Blender/Cycles' shape: the soft edge is a smoothstep over a band of
255
+ // COSINE width (1 - cosCone) * blend, not one that ends at the inner cone's angle. The two
256
+ // agree at blend 0 and 1 and diverge in between — three.js' ramp ran twice as wide at 0.5,
257
+ // leaving the penumbra up to 2x dark against a Cycles render of the same lamp.
255
258
  export const getSpotAttenuation = /*@__PURE__*/ wgslFn( `
256
- fn getSpotAttenuation( coneCosine: f32, penumbraCosine: f32, angleCosine: f32 ) -> f32 {
257
- return smoothstep( coneCosine, penumbraCosine, angleCosine );
259
+ fn getSpotAttenuation( coneCosine: f32, blend: f32, angleCosine: f32 ) -> f32 {
260
+ let smoothWidth = ( 1.0 - coneCosine ) * blend;
261
+ if ( smoothWidth <= 0.0 ) { return 1.0; }
262
+ return smoothstep( 0.0, 1.0, ( angleCosine - coneCosine ) / smoothWidth );
258
263
  }
259
264
  ` );
260
265
 
@@ -261,10 +261,7 @@ export const sampleSpotLightWithRadius = Fn( ( [ light, rayOrigin, lightSelectio
261
261
 
262
262
  If( ls_valid, () => {
263
263
 
264
- // Penumbra: inner cone angle = outerAngle * (1 - penumbra)
265
- // Clamp penumbraCosAngle > coneCosAngle to avoid smoothstep UB when penumbra = 0
266
- const penumbraCosAngle = cos( light.angle.mul( float( 1.0 ).sub( light.penumbra ) ) ).max( coneCosAngle.add( 1e-5 ) ).toVar();
267
- const coneAttenuation = getSpotAttenuation( { coneCosine: coneCosAngle, penumbraCosine: penumbraCosAngle, angleCosine: spotCosAngle } );
264
+ const coneAttenuation = getSpotAttenuation( { coneCosine: coneCosAngle, blend: light.penumbra, angleCosine: spotCosAngle } );
268
265
  const distanceAttenuation = getDistanceAttenuation( { lightDistance: lightDist, cutoffDistance: light.distance, decayExponent: light.decay } );
269
266
 
270
267
  // Gobo projection mask + IES photometric profile — both 1.0 when not assigned.
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ export { EngineEvents } from './EngineEvents.js';
18
18
  export { renderHeadless, openHeadless, captureHeadless } from './Headless.js';
19
19
 
20
20
  // Degradation contract — what the engine survived instead of failing on
21
+ export { LUMENS_PER_WATT, areaLightArea, bakeAreaLightScale, lightPower, preserveLightPower, setLightPower } from './LightUnits.js';
21
22
  export { ISSUE_CODES, ISSUE_SEVERITY, IssueLog, EngineIssueError } from './EngineIssues.js';
22
23
 
23
24
  // Settings provenance + viewer-vs-physical tuning
@@ -149,6 +149,7 @@ export class DenoisingManager extends EventDispatcher {
149
149
  this._holdWhileMoving = null;
150
150
  this._onReset = null;
151
151
  this._onPostProcessRefresh = null;
152
+ this._onDisplayRefresh = null;
152
153
 
153
154
  // Resolution tracking — used for canvas restoration on reset
154
155
  this._lastRenderWidth = 0;
@@ -1057,6 +1058,10 @@ export class DenoisingManager extends EventDispatcher {
1057
1058
 
1058
1059
  if ( ! isStillComplete() ) return;
1059
1060
 
1061
+ // The loop stops one tick after a render completes, so nothing is left to draw the closing
1062
+ // denoise. Ordered after `_denoiserEndHandler`, which publishes the picture.
1063
+ if ( closing ) this._onDisplayRefresh?.();
1064
+
1060
1065
  // One owner of the overlay canvas, so the neural chain and the ONNX upscaler are
1061
1066
  // exclusive. The neural chain also runs on its own when only its detail pass is on.
1062
1067
  if ( this.neuralRendering || ( this.upscaler?.enabled && this.upscalerBackend === 'dlss' ) ) {
@@ -1220,6 +1225,10 @@ export class DenoisingManager extends EventDispatcher {
1220
1225
  this._upscalerProgressHandler = null;
1221
1226
  this._upscalerEndHandler = null;
1222
1227
 
1228
+ this._onReset = null;
1229
+ this._onPostProcessRefresh = null;
1230
+ this._onDisplayRefresh = null;
1231
+
1223
1232
  if ( this.upscalerCanvas?.parentNode ) {
1224
1233
 
1225
1234
  this.upscalerCanvas.parentNode.removeChild( this.upscalerCanvas );
@@ -1252,6 +1261,13 @@ export class DenoisingManager extends EventDispatcher {
1252
1261
 
1253
1262
  }
1254
1263
 
1264
+ /** @param {Function} fn - () => void, redraws the finished frame without re-running the completion chain */
1265
+ setDisplayRefreshCallback( fn ) {
1266
+
1267
+ this._onDisplayRefresh = fn;
1268
+
1269
+ }
1270
+
1255
1271
  /** @param {import('../RenderSettings.js').RenderSettings} settings */
1256
1272
  setSettings( settings ) {
1257
1273