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/README.md +5 -1
- package/dist/rayzee.es.js +1094 -1043
- package/dist/rayzee.umd.js +37 -35
- package/package.json +1 -1
- package/src/EngineDefaults.js +5 -5
- package/src/EngineIssues.js +1 -0
- package/src/LightUnits.js +122 -0
- package/src/PathTracerApp.js +4 -2
- package/src/Processor/AssetLoader.js +61 -43
- package/src/Processor/LightSerializer.js +17 -9
- package/src/Stages/PathTracer.js +23 -3
- package/src/TSL/LightsCore.js +8 -3
- package/src/TSL/LightsSampling.js +1 -4
- package/src/index.js +1 -0
- package/src/managers/DenoisingManager.js +16 -0
- package/src/managers/LightManager.js +15 -17
package/package.json
CHANGED
package/src/EngineDefaults.js
CHANGED
|
@@ -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 {{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/EngineIssues.js
CHANGED
|
@@ -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
|
+
}
|
package/src/PathTracerApp.js
CHANGED
|
@@ -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
|
|
768
|
-
*
|
|
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
|
-
|
|
71
|
-
function areaLightPowerFactor( node, width, height, userData ) {
|
|
71
|
+
const PLACEHOLDER_SHAPES = new Set( [ 'rectangle', 'square', 'disk', 'ellipse' ] );
|
|
72
72
|
|
|
73
|
-
|
|
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
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
//
|
|
2085
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
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
|
-
|
|
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
|
|
2099
|
-
userData.
|
|
2130
|
+
object.intensity /= LUMENS_PER_WATT;
|
|
2131
|
+
userData.__luxConverted = true;
|
|
2100
2132
|
|
|
2101
2133
|
}
|
|
2102
2134
|
|
|
2103
|
-
//
|
|
2104
|
-
|
|
2105
|
-
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
package/src/Stages/PathTracer.js
CHANGED
|
@@ -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 =
|
|
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
|
-
? (
|
|
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 ++;
|
package/src/TSL/LightsCore.js
CHANGED
|
@@ -251,10 +251,15 @@ export const getDistanceAttenuation = /*@__PURE__*/ wgslFn( `
|
|
|
251
251
|
}
|
|
252
252
|
` );
|
|
253
253
|
|
|
254
|
-
// Spot
|
|
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,
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|