rayzee 8.3.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 +59 -3
- package/dist/DLSSNeural-BNSJ8V3z.js +202 -0
- package/dist/DLSSSuperRes-BoEW89Cl.js +266 -0
- package/dist/ToneMapGPU-DzqSyzTx.js +341 -0
- package/dist/rayzee.es.js +4603 -4606
- package/dist/rayzee.umd.js +264 -68
- package/dist/rolldown-runtime-CAFD8bLK.js +11 -0
- package/package.json +1 -1
- package/src/AssetConfig.js +7 -0
- package/src/EngineDefaults.js +10 -6
- package/src/EngineIssues.js +1 -0
- package/src/LightUnits.js +122 -0
- package/src/PathTracerApp.js +10 -6
- package/src/Processor/AssetLoader.js +61 -43
- package/src/Processor/LightSerializer.js +17 -9
- package/src/Processor/ToneMapGPU.js +317 -0
- package/src/Stages/PathTracer.js +23 -3
- package/src/TSL/LightsCore.js +8 -3
- package/src/TSL/LightsSampling.js +1 -4
- package/src/dlss/DLSSNeural.js +530 -0
- package/src/dlss/DLSSSuperRes.js +584 -0
- package/src/index.js +7 -0
- package/src/managers/DenoisingManager.js +288 -7
- package/src/managers/LightManager.js +15 -17
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var e = Object.defineProperty, t = (t, n) => {
|
|
3
|
+
let r = {};
|
|
4
|
+
for (var i in t) e(r, i, {
|
|
5
|
+
get: t[i],
|
|
6
|
+
enumerable: !0
|
|
7
|
+
});
|
|
8
|
+
return n || e(r, Symbol.toStringTag, { value: "Module" }), r;
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
export { t };
|
package/package.json
CHANGED
package/src/AssetConfig.js
CHANGED
|
@@ -32,6 +32,13 @@ const config = {
|
|
|
32
32
|
// AI upscaler ONNX model base URL. Quality presets resolve relative paths against this.
|
|
33
33
|
upscalerModelBaseUrl: 'https://huggingface.co/notaneimu/onnx-image-models/resolve/main/',
|
|
34
34
|
|
|
35
|
+
// DLSS runtime bundle. A plain script the host serves, not a module: it installs
|
|
36
|
+
// `globalThis.DLSSRuntime`, so it cannot be imported and the host must publish it somewhere.
|
|
37
|
+
dlssRuntimeUrl: '/dlss/dlss-runtime.js',
|
|
38
|
+
|
|
39
|
+
// Base for the DLSS weight manifests the runtime fetches (`generated/...` beneath this).
|
|
40
|
+
dlssAssetBaseUrl: 'https://pub-6e048ff014374f11bef33d76b6c2b5ef.r2.dev/v1',
|
|
41
|
+
|
|
35
42
|
// Prefix used when the engine writes to client-side stores (IndexedDB, etc).
|
|
36
43
|
// Set to a unique value to avoid collisions when multiple apps embed the engine on the same origin.
|
|
37
44
|
cacheNamespace: 'rayzee',
|
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,9 +1076,14 @@ 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
|
+
// neural passes need a denoised frame to be worth running — on Monte-Carlo noise the upscaler
|
|
1084
|
+
// measured worse than a plain resize. ⚠️ `ENGINE_DEFAULTS.enableOIDN` stays false: the bench
|
|
1085
|
+
// renders against it and every quality golden would move.
|
|
1086
|
+
enableOIDN: true, oidnQuality: 'fast',
|
|
1083
1087
|
interactionModeEnabled: true,
|
|
1084
1088
|
useAdaptiveSampling: true, // idle refine stops early when converged; frozen during motion
|
|
1085
1089
|
noiseThreshold: 0.1, // loose: preview wants a fast settle, not a clean one
|
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
|
|
@@ -2463,11 +2464,13 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
2463
2464
|
|
|
2464
2465
|
if ( ! this.renderer?.domElement ) return null;
|
|
2465
2466
|
|
|
2467
|
+
// Whatever is on the overlay is what the viewport shows, so it is also what a save must
|
|
2468
|
+
// write. Gating on `upscaler.enabled` instead missed the neural-rendering pass, which puts
|
|
2469
|
+
// a picture there without the ONNX upscaler being on at all — saves silently wrote the
|
|
2470
|
+
// un-enhanced render.
|
|
2466
2471
|
const dm = this.denoisingManager;
|
|
2467
|
-
const
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
if ( upscaled ) return dm.upscalerCanvas;
|
|
2472
|
+
const overlay = dm?.upscalerCanvas;
|
|
2473
|
+
if ( overlay && overlay.style.display !== 'none' ) return overlay;
|
|
2471
2474
|
|
|
2472
2475
|
// A presented WebGPU canvas only reads back what was drawn immediately before, so draw.
|
|
2473
2476
|
// This also puts the denoised picture on it: the Compositor prefers it over the raw render.
|
|
@@ -3104,6 +3107,7 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
3104
3107
|
this.denoisingManager.setOverlayManager( this.overlayManager );
|
|
3105
3108
|
this.denoisingManager.setResetCallback( () => this.reset() );
|
|
3106
3109
|
this.denoisingManager.setPostProcessRefreshCallback( () => this.requestPostProcessRefresh() );
|
|
3110
|
+
this.denoisingManager.setDisplayRefreshCallback( () => this.refreshFrame() );
|
|
3107
3111
|
this.denoisingManager.setSettings( this.settings );
|
|
3108
3112
|
|
|
3109
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)
|