@pascal-app/viewer 1.0.6 → 1.1.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 +54 -21
- package/dist/components/viewer/daylight-atmosphere.d.ts +9 -0
- package/dist/components/viewer/daylight-atmosphere.d.ts.map +1 -0
- package/dist/components/viewer/daylight-atmosphere.js +315 -0
- package/dist/components/viewer/drone-controller.d.ts +15 -0
- package/dist/components/viewer/drone-controller.d.ts.map +1 -0
- package/dist/components/viewer/drone-controller.js +194 -0
- package/dist/components/viewer/glb-interactive.d.ts +4 -18
- package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
- package/dist/components/viewer/glb-interactive.js +32 -268
- package/dist/components/viewer/glb-replace-instances.d.ts +6 -3
- package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -1
- package/dist/components/viewer/glb-replace-instances.js +5 -4
- package/dist/components/viewer/glb-scene-motion.d.ts +8 -0
- package/dist/components/viewer/glb-scene-motion.d.ts.map +1 -0
- package/dist/components/viewer/glb-scene-motion.js +57 -0
- package/dist/components/viewer/glb-scene.d.ts +5 -1
- package/dist/components/viewer/glb-scene.d.ts.map +1 -1
- package/dist/components/viewer/glb-scene.js +290 -49
- package/dist/components/viewer/glb-selection-callout.d.ts +17 -0
- package/dist/components/viewer/glb-selection-callout.d.ts.map +1 -0
- package/dist/components/viewer/glb-selection-callout.js +54 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.js +6 -3
- package/dist/components/viewer/index.d.ts +5 -0
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +38 -10
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +192 -5
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +150 -94
- package/dist/components/viewer/registered-systems.d.ts.map +1 -1
- package/dist/components/viewer/registered-systems.js +2 -2
- package/dist/components/viewer/scene-environment.js +4 -5
- package/dist/components/viewer/selection-callout.d.ts +51 -0
- package/dist/components/viewer/selection-callout.d.ts.map +1 -0
- package/dist/components/viewer/selection-callout.js +197 -0
- package/dist/components/viewer/unsupported-gpu-fallback.d.ts.map +1 -1
- package/dist/components/viewer/unsupported-gpu-fallback.js +21 -1
- package/dist/components/viewer/use-daylight-rooms.d.ts +13 -0
- package/dist/components/viewer/use-daylight-rooms.d.ts.map +1 -0
- package/dist/components/viewer/use-daylight-rooms.js +278 -0
- package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
- package/dist/components/viewer/viewer-camera.js +12 -1
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +2 -1
- package/dist/index.d.ts +26 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -8
- package/dist/lib/asset-url.d.ts +0 -1
- package/dist/lib/asset-url.d.ts.map +1 -1
- package/dist/lib/asset-url.js +3 -3
- package/dist/lib/daylight/ground.d.ts +21 -0
- package/dist/lib/daylight/ground.d.ts.map +1 -0
- package/dist/lib/daylight/ground.js +53 -0
- package/dist/lib/daylight/location.d.ts +32 -0
- package/dist/lib/daylight/location.d.ts.map +1 -0
- package/dist/lib/daylight/location.js +74 -0
- package/dist/lib/daylight/look.d.ts +33 -0
- package/dist/lib/daylight/look.d.ts.map +1 -0
- package/dist/lib/daylight/look.js +13 -0
- package/dist/lib/daylight/material-hooks.d.ts +2 -0
- package/dist/lib/daylight/material-hooks.d.ts.map +1 -0
- package/dist/lib/daylight/material-hooks.js +150 -0
- package/dist/lib/daylight/room-context.d.ts +12 -0
- package/dist/lib/daylight/room-context.d.ts.map +1 -0
- package/dist/lib/daylight/room-context.js +54 -0
- package/dist/lib/daylight/room-map.d.ts +124 -0
- package/dist/lib/daylight/room-map.d.ts.map +1 -0
- package/dist/lib/daylight/room-map.js +630 -0
- package/dist/lib/daylight/room-scene.d.ts +56 -0
- package/dist/lib/daylight/room-scene.d.ts.map +1 -0
- package/dist/lib/daylight/room-scene.js +391 -0
- package/dist/lib/daylight/rooms.d.ts +292 -0
- package/dist/lib/daylight/rooms.d.ts.map +1 -0
- package/dist/lib/daylight/rooms.js +1268 -0
- package/dist/lib/daylight/runtime.d.ts +33 -0
- package/dist/lib/daylight/runtime.d.ts.map +1 -0
- package/dist/lib/daylight/runtime.js +42 -0
- package/dist/lib/daylight/sky.d.ts +26 -0
- package/dist/lib/daylight/sky.d.ts.map +1 -0
- package/dist/lib/daylight/sky.js +217 -0
- package/dist/lib/daylight/solar.d.ts +46 -0
- package/dist/lib/daylight/solar.d.ts.map +1 -0
- package/dist/lib/daylight/solar.js +156 -0
- package/dist/lib/device-tier.d.ts +29 -0
- package/dist/lib/device-tier.d.ts.map +1 -0
- package/dist/lib/device-tier.js +71 -0
- package/dist/lib/dispose-object3d.d.ts.map +1 -1
- package/dist/lib/dispose-object3d.js +4 -2
- package/dist/lib/drone-flight.d.ts +44 -0
- package/dist/lib/drone-flight.d.ts.map +1 -0
- package/dist/lib/drone-flight.js +121 -0
- package/dist/lib/editor-highlight.d.ts +16 -0
- package/dist/lib/editor-highlight.d.ts.map +1 -0
- package/dist/lib/editor-highlight.js +33 -0
- package/dist/lib/foundation-materials.d.ts.map +1 -1
- package/dist/lib/foundation-materials.js +2 -4
- package/dist/lib/glb-merge.d.ts +23 -0
- package/dist/lib/glb-merge.d.ts.map +1 -0
- package/dist/lib/glb-merge.js +184 -0
- package/dist/lib/hardware-geometry.d.ts +21 -0
- package/dist/lib/hardware-geometry.d.ts.map +1 -0
- package/dist/lib/hardware-geometry.js +283 -0
- package/dist/lib/ktx2-loader.d.ts.map +1 -1
- package/dist/lib/ktx2-loader.js +2 -1
- package/dist/lib/layer-pass.d.ts +2 -0
- package/dist/lib/layer-pass.d.ts.map +1 -1
- package/dist/lib/layer-pass.js +20 -0
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +50 -23
- package/dist/lib/measurement-label.d.ts +4 -0
- package/dist/lib/measurement-label.d.ts.map +1 -0
- package/dist/lib/measurement-label.js +54 -0
- package/dist/lib/measurement-presentation.d.ts +5 -0
- package/dist/lib/measurement-presentation.d.ts.map +1 -0
- package/dist/lib/measurement-presentation.js +8 -0
- package/dist/lib/merged-outline-node.d.ts.map +1 -1
- package/dist/lib/merged-outline-node.js +7 -1
- package/dist/lib/occlusion-culler.d.ts +34 -0
- package/dist/lib/occlusion-culler.d.ts.map +1 -0
- package/dist/lib/occlusion-culler.js +698 -0
- package/dist/lib/pointer-events.d.ts.map +1 -1
- package/dist/lib/pointer-events.js +3 -3
- package/dist/lib/post-processing-resources.d.ts +4 -0
- package/dist/lib/post-processing-resources.d.ts.map +1 -1
- package/dist/lib/post-processing-resources.js +5 -0
- package/dist/lib/readback-rows.d.ts +14 -0
- package/dist/lib/readback-rows.d.ts.map +1 -0
- package/dist/lib/readback-rows.js +31 -0
- package/dist/lib/reveal-pose.d.ts.map +1 -1
- package/dist/lib/reveal-pose.js +3 -0
- package/dist/lib/scene-matrices.d.ts +12 -0
- package/dist/lib/scene-matrices.d.ts.map +1 -0
- package/dist/lib/scene-matrices.js +287 -0
- package/dist/lib/scene-pass-mrt.d.ts +5 -2
- package/dist/lib/scene-pass-mrt.d.ts.map +1 -1
- package/dist/lib/scene-pass-mrt.js +7 -2
- package/dist/lib/scene-themes.d.ts +0 -2
- package/dist/lib/scene-themes.d.ts.map +1 -1
- package/dist/lib/scene-themes.js +0 -2
- package/dist/lib/scene-visibility.d.ts +2 -0
- package/dist/lib/scene-visibility.d.ts.map +1 -1
- package/dist/lib/scene-visibility.js +12 -3
- package/dist/lib/site-surfaces.d.ts +1 -8
- package/dist/lib/site-surfaces.d.ts.map +1 -1
- package/dist/lib/site-surfaces.js +0 -19
- package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
- package/dist/lib/snapshot-pipeline.js +24 -40
- package/dist/lib/texture-reference.d.ts.map +1 -1
- package/dist/lib/texture-reference.js +13 -10
- package/dist/lib/underground-ambiance.d.ts +0 -2
- package/dist/lib/underground-ambiance.d.ts.map +1 -1
- package/dist/lib/underground-ambiance.js +0 -2
- package/dist/lib/viewer-config.d.ts +21 -0
- package/dist/lib/viewer-config.d.ts.map +1 -0
- package/dist/lib/viewer-config.js +21 -0
- package/dist/lib/viewer-preference-storage.d.ts +7 -0
- package/dist/lib/viewer-preference-storage.d.ts.map +1 -0
- package/dist/lib/viewer-preference-storage.js +23 -0
- package/dist/store/use-item-light-pool.d.ts +3 -6
- package/dist/store/use-item-light-pool.d.ts.map +1 -1
- package/dist/store/use-item-light-pool.js +12 -0
- package/dist/store/use-viewer.d.ts +62 -59
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +106 -19
- package/dist/systems/ceiling/ceiling-system.d.ts +5 -0
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +11 -1
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +501 -145
- package/dist/systems/door/leaf-profile.d.ts +11 -0
- package/dist/systems/door/leaf-profile.d.ts.map +1 -0
- package/dist/systems/door/leaf-profile.js +140 -0
- package/dist/systems/elevator/elevator-interaction-system.d.ts +16 -2
- package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -1
- package/dist/systems/elevator/elevator-interaction-system.js +41 -8
- package/dist/systems/geometry/geometry-status.d.ts +8 -0
- package/dist/systems/geometry/geometry-status.d.ts.map +1 -0
- package/dist/systems/geometry/geometry-status.js +24 -0
- package/dist/systems/interactive/callout-actions.d.ts +72 -0
- package/dist/systems/interactive/callout-actions.d.ts.map +1 -0
- package/dist/systems/interactive/callout-actions.js +212 -0
- package/dist/systems/interactive/interactive-system.d.ts +5 -1
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +44 -121
- package/dist/systems/interactive/opening-settled.d.ts +16 -0
- package/dist/systems/interactive/opening-settled.d.ts.map +1 -0
- package/dist/systems/interactive/opening-settled.js +53 -0
- package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
- package/dist/systems/item-light/item-light-system.js +22 -214
- package/dist/systems/item-light/pooled-lights.d.ts +70 -0
- package/dist/systems/item-light/pooled-lights.d.ts.map +1 -0
- package/dist/systems/item-light/pooled-lights.js +449 -0
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +19 -1
- package/dist/systems/roof/roof-material-slots.d.ts +14 -0
- package/dist/systems/roof/roof-material-slots.d.ts.map +1 -0
- package/dist/systems/roof/roof-material-slots.js +13 -0
- package/dist/systems/roof/roof-materials.d.ts +25 -2
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +85 -26
- package/dist/systems/roof/roof-system.d.ts +34 -1
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +582 -168
- package/dist/systems/wall/opening-casing-geometry.d.ts +14 -0
- package/dist/systems/wall/opening-casing-geometry.d.ts.map +1 -0
- package/dist/systems/wall/opening-casing-geometry.js +102 -0
- package/dist/systems/wall/opening-cutout-geometry.d.ts +9 -0
- package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -1
- package/dist/systems/wall/opening-cutout-geometry.js +7 -3
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +28 -35
- package/dist/systems/wall/wall-system.d.ts +11 -0
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +86 -25
- package/dist/systems/wall/wall-t-junctions.d.ts +15 -0
- package/dist/systems/wall/wall-t-junctions.d.ts.map +1 -0
- package/dist/systems/wall/wall-t-junctions.js +149 -0
- package/dist/systems/window/window-animation-system.d.ts +3 -12
- package/dist/systems/window/window-animation-system.d.ts.map +1 -1
- package/dist/systems/window/window-animation-system.js +37 -122
- package/dist/systems/window/window-pose.d.ts +31 -0
- package/dist/systems/window/window-pose.d.ts.map +1 -0
- package/dist/systems/window/window-pose.js +152 -0
- package/dist/systems/window/window-roller-shutter.d.ts +8 -0
- package/dist/systems/window/window-roller-shutter.d.ts.map +1 -0
- package/dist/systems/window/window-roller-shutter.js +64 -0
- package/dist/systems/window/window-system.d.ts +2 -11
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +470 -303
- package/package.json +6 -6
- package/dist/systems/interactive/control-widget.d.ts +0 -10
- package/dist/systems/interactive/control-widget.d.ts.map +0 -1
- package/dist/systems/interactive/control-widget.js +0 -35
- package/dist/systems/interactive/procedural-controls.d.ts +0 -13
- package/dist/systems/interactive/procedural-controls.d.ts.map +0 -1
- package/dist/systems/interactive/procedural-controls.js +0 -17
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
import { Box3, Color, DataTexture, FloatType, Frustum, MathUtils, Matrix4, Mesh, NearestFilter, Raycaster, RGBAFormat, SRGBColorSpace, Vector2, Vector3, } from 'three';
|
|
2
|
+
import { abs, clamp, dot, Fn, float, If, int, ivec2, Loop, max, normalize, normalWorld, positionWorld, select, smoothstep, texture, uniform, vec3, vec4, } from 'three/tsl';
|
|
3
|
+
import { CELL, RoomMap, TILE } from './room-map';
|
|
4
|
+
/** Texels per portal: [center, halfW] [normal, halfH] [radiance, neighbour] [floorY, mainLamp, gain, kind]. */
|
|
5
|
+
const PSTRIDE = 4;
|
|
6
|
+
/** Target average lamp irradiance of a room at night (same units as the ~1.5 sky irradiance of day). */
|
|
7
|
+
const LAMP_E_TARGET = 1.1;
|
|
8
|
+
/** Lamps have a size (shade/diffuser): never evaluate the inverse square closer than ~0.8 m. */
|
|
9
|
+
const LAMP_MIN_D2 = 0.64;
|
|
10
|
+
const DATA_W = 256;
|
|
11
|
+
/** Texels per exterior lamp: [pos, range] [rgb, cosInner] [spot dir, cosOuter]. */
|
|
12
|
+
const EXT_STRIDE = 3;
|
|
13
|
+
const _viewProj = new Matrix4();
|
|
14
|
+
const _frustum = new Frustum();
|
|
15
|
+
const _toCamera = new Vector3();
|
|
16
|
+
const _roomsLight = new Color();
|
|
17
|
+
export class RoomLighting {
|
|
18
|
+
rooms = [];
|
|
19
|
+
/** Lamps outside every room (lanterns, garden lights), lit analytically on outside surfaces. */
|
|
20
|
+
exteriorLamps = [];
|
|
21
|
+
portals = [];
|
|
22
|
+
openings = [];
|
|
23
|
+
levels = [];
|
|
24
|
+
/** Which room a point is in (tiled per level; see `room-map.ts`). */
|
|
25
|
+
map = new RoomMap();
|
|
26
|
+
data = new Float32Array(4);
|
|
27
|
+
lampTexel = new Map();
|
|
28
|
+
lastSky = null;
|
|
29
|
+
// ---- runtime controls (the enhanced viewer's daylight preset) -------------------------------
|
|
30
|
+
portalGain = uniform(2.5);
|
|
31
|
+
bounceGain = uniform(1.5);
|
|
32
|
+
lampGain = uniform(1);
|
|
33
|
+
/**
|
|
34
|
+
* Lamp light as drawn, not as metered: eased below 1 while the camera is outside at night, so
|
|
35
|
+
* lit rooms seen from out there keep an indoor-like exposure (the meters ignore it).
|
|
36
|
+
*/
|
|
37
|
+
lampView = uniform(1);
|
|
38
|
+
/** Sky-IBL multiplier in enclosed rooms. */
|
|
39
|
+
indoorEnv = uniform(0.1);
|
|
40
|
+
/** Window spill outside at night. */
|
|
41
|
+
spillGain = uniform(1);
|
|
42
|
+
/** Ceiling for lamp irradiance on any surface (soft): ≈3× the room-average target. */
|
|
43
|
+
lampMaxE = uniform(3.2);
|
|
44
|
+
/** Sky patch on the floor (0 on the Low tier). */
|
|
45
|
+
skyPatch = uniform(3);
|
|
46
|
+
/** Light through interior openings + threshold crossfade (off on the Low tier). */
|
|
47
|
+
softRooms = uniform(1);
|
|
48
|
+
/** Camera world Y (live). */
|
|
49
|
+
cameraY = uniform(0);
|
|
50
|
+
// ---- shader inputs that change with the scene ----------------------------------------------
|
|
51
|
+
u = {
|
|
52
|
+
portalCount: uniform(0, 'int'),
|
|
53
|
+
portalBase: uniform(0, 'int'),
|
|
54
|
+
extLampStart: uniform(0, 'int'),
|
|
55
|
+
extLampCount: uniform(0, 'int'),
|
|
56
|
+
// Compact lists of what can light the outside right now (one texel per entry: its index).
|
|
57
|
+
activeBase: uniform(0, 'int'),
|
|
58
|
+
spillCount: uniform(0, 'int'),
|
|
59
|
+
extActiveCount: uniform(0, 'int'),
|
|
60
|
+
data: texture(emptyTexture()),
|
|
61
|
+
};
|
|
62
|
+
/** Rebuild from a scene: rooms, lamps, portals, openings, room-id map. */
|
|
63
|
+
build(input) {
|
|
64
|
+
this.rooms = [];
|
|
65
|
+
this.exteriorLamps = [];
|
|
66
|
+
this.portals = [];
|
|
67
|
+
this.openings = [];
|
|
68
|
+
this.lampTexel.clear();
|
|
69
|
+
this.levels = input.levels.length ? input.levels : [{ y: 0, building: 0 }];
|
|
70
|
+
// ---- rooms ---------------------------------------------------------------------------
|
|
71
|
+
const raycaster = new Raycaster();
|
|
72
|
+
input.configureRaycaster?.(raycaster);
|
|
73
|
+
const coverGrid = new MeshGrid(input.covers.map((c) => c.object));
|
|
74
|
+
const coverKind = new Map();
|
|
75
|
+
for (const c of input.covers)
|
|
76
|
+
c.object.traverse((o) => coverKind.set(o, c.kind));
|
|
77
|
+
const kindOf = (o) => {
|
|
78
|
+
for (let x = o; x; x = x.parent) {
|
|
79
|
+
const k = coverKind.get(x);
|
|
80
|
+
if (k)
|
|
81
|
+
return k;
|
|
82
|
+
}
|
|
83
|
+
return '';
|
|
84
|
+
};
|
|
85
|
+
// Level order inside each building (for the ceiling fallback) before the map is built.
|
|
86
|
+
const above = this.levels.map((level, i) => {
|
|
87
|
+
let next = -1;
|
|
88
|
+
this.levels.forEach((other, j) => {
|
|
89
|
+
if (j === i || other.building !== level.building || other.y <= level.y)
|
|
90
|
+
return;
|
|
91
|
+
if (next < 0 || other.y < this.levels[next].y)
|
|
92
|
+
next = j;
|
|
93
|
+
});
|
|
94
|
+
return next;
|
|
95
|
+
});
|
|
96
|
+
for (const zone of input.zones) {
|
|
97
|
+
const levelIndex = MathUtils.clamp(zone.levelIndex, 0, this.levels.length - 1);
|
|
98
|
+
const floorY = this.levels[levelIndex].y;
|
|
99
|
+
const nextY = above[levelIndex] >= 0 ? this.levels[above[levelIndex]].y : undefined;
|
|
100
|
+
const holes = zone.holes ?? [];
|
|
101
|
+
const area = Math.max(0, Math.abs(polygonArea(zone.polygon)) -
|
|
102
|
+
holes.reduce((sum, hole) => sum + Math.abs(polygonArea(hole)), 0));
|
|
103
|
+
const c = interiorPoint(zone.polygon, holes);
|
|
104
|
+
// Enclosed = something overhead (ceiling / roof / slab above). Open terraces get sky light.
|
|
105
|
+
raycaster.set(new Vector3(c.x, floorY + 1.2, c.y), new Vector3(0, 1, 0));
|
|
106
|
+
raycaster.far = 30;
|
|
107
|
+
const hits = intersectGeometry(raycaster, coverGrid.near(c.x, c.y, c.x, c.y), false);
|
|
108
|
+
const hit = hits[0];
|
|
109
|
+
const top = hits[hits.length - 1];
|
|
110
|
+
const topKind = top ? kindOf(top.object) : '';
|
|
111
|
+
const ceilingY = hit ? hit.point.y : nextY !== undefined ? nextY : floorY + 2.7;
|
|
112
|
+
this.rooms.push({
|
|
113
|
+
zone,
|
|
114
|
+
levelIndex,
|
|
115
|
+
enclosed: !!hit,
|
|
116
|
+
area,
|
|
117
|
+
centroid: new Vector3(c.x, floorY, c.y),
|
|
118
|
+
ceilingY,
|
|
119
|
+
coverY: top ? top.point.y : ceilingY,
|
|
120
|
+
coverIsRoof: topKind === 'roof' || topKind === 'slab',
|
|
121
|
+
surfaces: 2 * Math.max(area, 2) + polygonPerimeter(zone.polygon) * Math.max(2.2, ceilingY - floorY),
|
|
122
|
+
lampE: 0,
|
|
123
|
+
dayE: 0,
|
|
124
|
+
lamps: [],
|
|
125
|
+
portals: [],
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// ---- room map + openings between rooms (walked on each level's grid) ---------------------
|
|
129
|
+
const openings = this.openingSearch(input, raycaster);
|
|
130
|
+
this.map.build(this.levels, this.rooms.map((room) => ({
|
|
131
|
+
level: room.levelIndex,
|
|
132
|
+
polygon: room.zone.polygon,
|
|
133
|
+
holes: room.zone.holes,
|
|
134
|
+
})), openings.walk);
|
|
135
|
+
this.openings = openings.finish();
|
|
136
|
+
// ---- lamps ---------------------------------------------------------------------------
|
|
137
|
+
for (const lamp of input.lamps) {
|
|
138
|
+
const ri = this.roomAt(lamp.position.clone().add(new Vector3(0, -0.3, 0)));
|
|
139
|
+
const room = ri >= 0 ? this.rooms[ri] : undefined;
|
|
140
|
+
if (room?.enclosed)
|
|
141
|
+
room.lamps.push(lamp);
|
|
142
|
+
else
|
|
143
|
+
this.exteriorLamps.push(lamp);
|
|
144
|
+
}
|
|
145
|
+
for (const room of this.rooms) {
|
|
146
|
+
if (room.lamps.length || !room.enclosed)
|
|
147
|
+
continue;
|
|
148
|
+
// Virtual lamp: one warm source just under the ceiling, sized to the room (follows the
|
|
149
|
+
// time-of-day lamp default, like lamps nobody switched).
|
|
150
|
+
const diag = Math.sqrt(room.area) * 1.41;
|
|
151
|
+
room.lamps.push({
|
|
152
|
+
position: room.centroid.clone().setY(room.ceilingY - 0.35),
|
|
153
|
+
color: kelvin(2900),
|
|
154
|
+
intensity: Math.min(14, 4 + room.area * 0.35),
|
|
155
|
+
range: Math.max(3.5, diag * 0.9),
|
|
156
|
+
on: input.virtualLampsOn,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// Per-room soft cap: average lamp irradiance ≈ ρ·Σ(4πI)/A_surfaces (+ multi-bounce), compressed
|
|
160
|
+
// with E_t·(1 − e^(−E/E_t)) so a hall with ten chandeliers can't flood while dim rooms stay dim.
|
|
161
|
+
for (const room of this.rooms) {
|
|
162
|
+
const raw = (room.lamps.reduce((s, l) => s + 4 * Math.PI * l.intensity, 0) / room.surfaces) * 0.6;
|
|
163
|
+
if (raw <= 0)
|
|
164
|
+
continue;
|
|
165
|
+
const soft = LAMP_E_TARGET * (1 - Math.exp(-raw / LAMP_E_TARGET));
|
|
166
|
+
const k = soft / raw;
|
|
167
|
+
for (const l of room.lamps)
|
|
168
|
+
l.intensity *= k;
|
|
169
|
+
room.lampE = soft;
|
|
170
|
+
}
|
|
171
|
+
// ---- window portals (glass of windows and glazed doors) ---------------------------------
|
|
172
|
+
for (const root of input.openings) {
|
|
173
|
+
const glass = [];
|
|
174
|
+
root.object.traverse((o) => {
|
|
175
|
+
if (o.isMesh && o.userData.slotId === 'glass')
|
|
176
|
+
glass.push(o);
|
|
177
|
+
});
|
|
178
|
+
if (!glass.length)
|
|
179
|
+
continue;
|
|
180
|
+
// Glass box in the opening's local frame → width/height/normal independent of wall angle.
|
|
181
|
+
root.object.updateWorldMatrix(true, true);
|
|
182
|
+
const inv = root.object.matrixWorld.clone().invert();
|
|
183
|
+
const box = new Box3();
|
|
184
|
+
for (const g of glass) {
|
|
185
|
+
const geometry = g.geometry;
|
|
186
|
+
if (!geometry.boundingBox)
|
|
187
|
+
geometry.computeBoundingBox();
|
|
188
|
+
box.union(geometry.boundingBox.clone().applyMatrix4(inv.clone().multiply(g.matrixWorld)));
|
|
189
|
+
}
|
|
190
|
+
if (box.isEmpty())
|
|
191
|
+
continue;
|
|
192
|
+
const size = box.getSize(new Vector3());
|
|
193
|
+
const thinAxis = size.x < size.z ? 'x' : 'z';
|
|
194
|
+
const localN = new Vector3(thinAxis === 'x' ? 1 : 0, 0, thinAxis === 'z' ? 1 : 0);
|
|
195
|
+
const center = box.getCenter(new Vector3()).applyMatrix4(root.object.matrixWorld);
|
|
196
|
+
const n = localN.transformDirection(root.object.matrixWorld).setY(0);
|
|
197
|
+
if (n.lengthSq() < 1e-6)
|
|
198
|
+
continue;
|
|
199
|
+
n.normalize();
|
|
200
|
+
const width = thinAxis === 'x' ? size.z : size.x;
|
|
201
|
+
const height = size.y;
|
|
202
|
+
if (width * height < 0.08)
|
|
203
|
+
continue;
|
|
204
|
+
const a = this.roomAt(center.clone().addScaledVector(n, 0.45));
|
|
205
|
+
const bRoom = this.roomAt(center.clone().addScaledVector(n, -0.45));
|
|
206
|
+
const add = (room, normal) => {
|
|
207
|
+
const r = this.rooms[room];
|
|
208
|
+
if (!r?.enclosed)
|
|
209
|
+
return;
|
|
210
|
+
const p = { center, normal, width, height, room, kind: 0, neighbor: -1, gain: 1 };
|
|
211
|
+
r.portals.push(p);
|
|
212
|
+
this.portals.push(p);
|
|
213
|
+
};
|
|
214
|
+
if (a >= 0 && bRoom < 0)
|
|
215
|
+
add(a, n);
|
|
216
|
+
else if (bRoom >= 0 && a < 0)
|
|
217
|
+
add(bRoom, n.clone().negate());
|
|
218
|
+
else if (a >= 0 && bRoom >= 0 && a !== bRoom) {
|
|
219
|
+
// Interior glazing: borrow light from an unenclosed neighbour only.
|
|
220
|
+
if (!this.rooms[bRoom].enclosed)
|
|
221
|
+
add(a, n);
|
|
222
|
+
else if (!this.rooms[a].enclosed)
|
|
223
|
+
add(bRoom, n.clone().negate());
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// ---- opening portals ---------------------------------------------------------------------
|
|
227
|
+
const glassNear = (room, c) => this.rooms[room].portals.some((p) => p.kind === 0 && p.center.distanceTo(c) < 0.9);
|
|
228
|
+
const addOpening = (room, normal, op, kind, neighbor) => {
|
|
229
|
+
const r = this.rooms[room];
|
|
230
|
+
if (!r.enclosed)
|
|
231
|
+
return;
|
|
232
|
+
if (kind === 2 && glassNear(room, op.center))
|
|
233
|
+
return; // glazed door: already a window portal
|
|
234
|
+
const p = {
|
|
235
|
+
center: op.center.clone(),
|
|
236
|
+
normal,
|
|
237
|
+
width: op.width,
|
|
238
|
+
height: op.height,
|
|
239
|
+
room,
|
|
240
|
+
kind,
|
|
241
|
+
neighbor,
|
|
242
|
+
doorId: op.doorId,
|
|
243
|
+
gain: 1,
|
|
244
|
+
};
|
|
245
|
+
r.portals.push(p);
|
|
246
|
+
this.portals.push(p);
|
|
247
|
+
};
|
|
248
|
+
for (const op of this.openings) {
|
|
249
|
+
const A = this.rooms[op.a];
|
|
250
|
+
const B = op.b >= 0 ? this.rooms[op.b] : null;
|
|
251
|
+
if (B && A.enclosed && B.enclosed) {
|
|
252
|
+
addOpening(op.a, op.normal.clone().negate(), op, 1, op.b);
|
|
253
|
+
addOpening(op.b, op.normal.clone(), op, 1, op.a);
|
|
254
|
+
}
|
|
255
|
+
else if (B && A.enclosed && !B.enclosed)
|
|
256
|
+
addOpening(op.a, op.normal.clone().negate(), op, 2, -1);
|
|
257
|
+
else if (B?.enclosed && !A.enclosed)
|
|
258
|
+
addOpening(op.b, op.normal.clone(), op, 2, -1);
|
|
259
|
+
else if (!B)
|
|
260
|
+
addOpening(op.a, op.normal.clone().negate(), op, 2, -1);
|
|
261
|
+
}
|
|
262
|
+
this.allocateData();
|
|
263
|
+
if (this.lastSky)
|
|
264
|
+
this.updateSky(...this.lastSky);
|
|
265
|
+
}
|
|
266
|
+
/** CPU-side room lookup in assembled space (same rule as the shader, without the normal offset). */
|
|
267
|
+
roomAt(p) {
|
|
268
|
+
return this.map.roomAt(p.x, p.y, p.z);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether each level is on screen. A camera over a level whose upper floors are hidden stands
|
|
272
|
+
* in those floors' rooms by height alone, but it is outdoors: metered and white-balanced as
|
|
273
|
+
* such, not as a lamplit room it cannot see.
|
|
274
|
+
*/
|
|
275
|
+
levelShown = [];
|
|
276
|
+
/** Per level: focused (its rooms open from above when the camera is over their cover). */
|
|
277
|
+
levelFocused = [];
|
|
278
|
+
/** Per level: the highest one shown in its building (its rooms seen from above). */
|
|
279
|
+
levelTopShown = [];
|
|
280
|
+
/**
|
|
281
|
+
* What the lit rooms in view add to an outside camera's meter: a blend of the exterior reading
|
|
282
|
+
* and the rooms' own irradiance (their indoor meter), weighted by the share of the frame each
|
|
283
|
+
* fills — fully where it is open from above (its level the top one shown), a little where only
|
|
284
|
+
* its windows show — counted twice over, as the eye goes to the lit rooms. Rooms only ever
|
|
285
|
+
* brighten the reading, so daytime views are unchanged. `roomsE` is the rooms' own reading (0
|
|
286
|
+
* with none in view): what they meter at from inside. Given the outdoor illuminant, `illuminant`
|
|
287
|
+
* is the light the eye adapts to: the outdoors shifted towards the rooms' light by their pull.
|
|
288
|
+
*/
|
|
289
|
+
viewedMeter(camera, exteriorE, skyE, outdoor, skyLum = 0) {
|
|
290
|
+
_viewProj.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
291
|
+
_frustum.setFromProjectionMatrix(_viewProj);
|
|
292
|
+
const ortho = camera.isOrthographicCamera;
|
|
293
|
+
const view = ortho
|
|
294
|
+
? (() => {
|
|
295
|
+
const c = camera;
|
|
296
|
+
return ((c.right - c.left) * (c.top - c.bottom)) / (c.zoom * c.zoom);
|
|
297
|
+
})()
|
|
298
|
+
: (() => {
|
|
299
|
+
const c = camera;
|
|
300
|
+
const t = Math.tan(MathUtils.degToRad(c.fov) / 2);
|
|
301
|
+
return 4 * t * t * c.aspect;
|
|
302
|
+
})();
|
|
303
|
+
let weight = 0;
|
|
304
|
+
let weighted = 0;
|
|
305
|
+
_roomsLight.setRGB(0, 0, 0);
|
|
306
|
+
this.rooms.forEach((r, index) => {
|
|
307
|
+
if (!r.enclosed || this.levelShown[r.levelIndex] === false)
|
|
308
|
+
return;
|
|
309
|
+
if (!_frustum.containsPoint(r.centroid))
|
|
310
|
+
return;
|
|
311
|
+
_toCamera.subVectors(camera.position, r.centroid);
|
|
312
|
+
const facing = Math.max(0.2, _toCamera.y / Math.max(_toCamera.length(), 1e-4));
|
|
313
|
+
const reach = ortho ? 1 : Math.max(_toCamera.lengthSq(), 1);
|
|
314
|
+
const open = this.levelTopShown[r.levelIndex] !== false ? 1 : 0.15;
|
|
315
|
+
const w = (r.area * facing * open) / reach / view;
|
|
316
|
+
const E = this.meter(index, skyE);
|
|
317
|
+
weight += w;
|
|
318
|
+
weighted += w * E;
|
|
319
|
+
if (!outdoor)
|
|
320
|
+
return;
|
|
321
|
+
const light = this.roomIlluminant(index, outdoor, skyLum);
|
|
322
|
+
const L = light ? luminance(light) : 0;
|
|
323
|
+
// Each room's light colour (unit luminance), as much as it outshines the exterior.
|
|
324
|
+
if (light && L > 1e-6)
|
|
325
|
+
_roomsLight.add(light.multiplyScalar((w * Math.max(0, E - exteriorE)) / L));
|
|
326
|
+
});
|
|
327
|
+
if (weight <= 0)
|
|
328
|
+
return { sceneE: exteriorE, roomsE: 0, illuminant: null };
|
|
329
|
+
const roomsE = weighted / weight;
|
|
330
|
+
const share = Math.min(1, weight * 2);
|
|
331
|
+
const sceneE = Math.max(exteriorE, (1 - share) * exteriorE + share * roomsE);
|
|
332
|
+
let illuminant = null;
|
|
333
|
+
const roomsL = luminance(_roomsLight);
|
|
334
|
+
const outdoorL = outdoor ? luminance(outdoor) : 0;
|
|
335
|
+
if (outdoor && roomsL > 1e-6 && outdoorL > 1e-6) {
|
|
336
|
+
// The rooms' pull: their part of the reading above the exterior's.
|
|
337
|
+
const pull = MathUtils.clamp((sceneE - exteriorE) / Math.max(sceneE, 1e-6), 0, 1);
|
|
338
|
+
illuminant = outdoor
|
|
339
|
+
.clone()
|
|
340
|
+
.multiplyScalar(1 / outdoorL)
|
|
341
|
+
.lerp(_roomsLight.multiplyScalar(1 / roomsL), pull);
|
|
342
|
+
}
|
|
343
|
+
return { sceneE, roomsE, illuminant };
|
|
344
|
+
}
|
|
345
|
+
isEnclosedAt(p) {
|
|
346
|
+
const room = this.roomAt(p);
|
|
347
|
+
const r = room >= 0 ? this.rooms[room] : undefined;
|
|
348
|
+
if (!r || this.levelShown[r.levelIndex] === false)
|
|
349
|
+
return { room: -1, inside: false };
|
|
350
|
+
return { room, inside: r.enclosed && p.y < r.ceilingY };
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Live level presentation, per level: explode offset (live world Y − assembled), whether it is
|
|
354
|
+
* shown, and whether it is the selected level of a cutaway (solo / levels above hidden). A shown
|
|
355
|
+
* level is focused — its rooms open from above — when selected so, or when every level over it
|
|
356
|
+
* in its building is hidden.
|
|
357
|
+
*/
|
|
358
|
+
setLevelPresentation(offsets, shown, selected) {
|
|
359
|
+
this.levelShown = shown;
|
|
360
|
+
const focused = this.levels.map((_, i) => {
|
|
361
|
+
if (shown[i] === false)
|
|
362
|
+
return false;
|
|
363
|
+
if (selected[i])
|
|
364
|
+
return true;
|
|
365
|
+
let up = this.map.levelAbove(i);
|
|
366
|
+
if (up < 0)
|
|
367
|
+
return false;
|
|
368
|
+
for (; up >= 0; up = this.map.levelAbove(up))
|
|
369
|
+
if (shown[up] !== false)
|
|
370
|
+
return false;
|
|
371
|
+
return true;
|
|
372
|
+
});
|
|
373
|
+
this.levelFocused = focused;
|
|
374
|
+
this.levelTopShown = this.levels.map((_, i) => {
|
|
375
|
+
if (shown[i] === false)
|
|
376
|
+
return false;
|
|
377
|
+
for (let up = this.map.levelAbove(i); up >= 0; up = this.map.levelAbove(up))
|
|
378
|
+
if (shown[up] !== false)
|
|
379
|
+
return false;
|
|
380
|
+
return true;
|
|
381
|
+
});
|
|
382
|
+
this.map.setPresentation(offsets, shown, focused);
|
|
383
|
+
}
|
|
384
|
+
/** Re-run the light transfer (after a quality-tier change of the soft room map). */
|
|
385
|
+
refresh() {
|
|
386
|
+
if (this.lastSky)
|
|
387
|
+
this.updateSky(...this.lastSky);
|
|
388
|
+
}
|
|
389
|
+
/** Lamp switches changed: rewrite lamp texels (+ the light carried through openings). */
|
|
390
|
+
setLampStates(isOn) {
|
|
391
|
+
let dirty = false;
|
|
392
|
+
for (const l of this.lampTexel.keys()) {
|
|
393
|
+
const on = isOn(l);
|
|
394
|
+
if (on === l.on)
|
|
395
|
+
continue;
|
|
396
|
+
l.on = on;
|
|
397
|
+
this.writeLamp(l);
|
|
398
|
+
dirty = true;
|
|
399
|
+
}
|
|
400
|
+
if (dirty) {
|
|
401
|
+
this.writeInteriorPortals();
|
|
402
|
+
this.writeActiveLists();
|
|
403
|
+
this.markData();
|
|
404
|
+
}
|
|
405
|
+
return dirty;
|
|
406
|
+
}
|
|
407
|
+
/** Door openness (0 closed … 1 open) → portal gain; a closed interior door leaves a crack. */
|
|
408
|
+
updateDoors(openness) {
|
|
409
|
+
let dirty = false;
|
|
410
|
+
this.portals.forEach((p, k) => {
|
|
411
|
+
if (!p.doorId)
|
|
412
|
+
return;
|
|
413
|
+
const open = MathUtils.clamp(openness(p.doorId), 0, 1);
|
|
414
|
+
const gain = p.kind === 1 ? Math.max(0.06, open) : open;
|
|
415
|
+
if (Math.abs(gain - p.gain) < 0.01)
|
|
416
|
+
return;
|
|
417
|
+
p.gain = gain;
|
|
418
|
+
this.data[(this.portalBase + k * PSTRIDE + 3) * 4 + 2] = gain;
|
|
419
|
+
dirty = true;
|
|
420
|
+
});
|
|
421
|
+
if (dirty) {
|
|
422
|
+
this.writeActiveLists();
|
|
423
|
+
this.markData();
|
|
424
|
+
if (this.lastSky)
|
|
425
|
+
this.updateSky(...this.lastSky); // exterior doors feed the bounce too
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** 0…1: share of the room's lamp power currently lit. */
|
|
429
|
+
lampFraction(room) {
|
|
430
|
+
const r = this.rooms[room];
|
|
431
|
+
if (!r)
|
|
432
|
+
return 0;
|
|
433
|
+
let all = 0;
|
|
434
|
+
let lit = 0;
|
|
435
|
+
for (const l of r.lamps) {
|
|
436
|
+
all += l.intensity;
|
|
437
|
+
lit += l.on ? l.intensity : 0;
|
|
438
|
+
}
|
|
439
|
+
return all > 0 ? lit / all : 0;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Analytic light meter: the average irradiance a camera standing in `room` would see —
|
|
443
|
+
* daylight (portals + bounce, scaled like the shader's gains) + lamps + leftover sky IBL.
|
|
444
|
+
*/
|
|
445
|
+
meter(room, skyE) {
|
|
446
|
+
const r = this.rooms[room];
|
|
447
|
+
if (!r)
|
|
448
|
+
return skyE;
|
|
449
|
+
const portalW = this.portalGain.value;
|
|
450
|
+
return (r.dayE * (0.5 * portalW + 0.5 * this.bounceGain.value) +
|
|
451
|
+
r.lampE * this.lampFraction(room) * this.lampGain.value +
|
|
452
|
+
skyE * this.indoorEnv.value);
|
|
453
|
+
}
|
|
454
|
+
/** The illuminant colour inside `room` (daylight through the windows + its lit lamps). */
|
|
455
|
+
roomIlluminant(room, outdoor, skyLum) {
|
|
456
|
+
const r = this.rooms[room];
|
|
457
|
+
if (!r)
|
|
458
|
+
return null;
|
|
459
|
+
const lampCol = new Color(0, 0, 0);
|
|
460
|
+
for (const l of r.lamps)
|
|
461
|
+
if (l.on)
|
|
462
|
+
lampCol.add(l.color.clone().multiplyScalar(l.intensity));
|
|
463
|
+
const lampLum = luminance(lampCol);
|
|
464
|
+
const lampE = r.lampE * this.lampFraction(room) * this.lampGain.value;
|
|
465
|
+
const dayE = r.dayE * this.portalGain.value;
|
|
466
|
+
const outLum = Math.max(luminance(outdoor), 1e-5);
|
|
467
|
+
return new Color(0, 0, 0)
|
|
468
|
+
.add(outdoor.clone().multiplyScalar((dayE + this.indoorEnv.value * skyLum) / outLum))
|
|
469
|
+
.add(lampLum > 0 ? lampCol.multiplyScalar(lampE / lampLum) : new Color(0, 0, 0));
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Per sun/sky change: radiance leaving each portal into the room, and the per-room sun
|
|
473
|
+
* bounce (sun patch on the floor re-radiating — the main thing screen-space GI misses).
|
|
474
|
+
* `skyRadiance(dir)` returns linear sky radiance seen *through* the window (outward dir).
|
|
475
|
+
*/
|
|
476
|
+
updateSky(skyRadiance, sunDir, sunColor, sunIrradiance) {
|
|
477
|
+
this.lastSky = [skyRadiance, sunDir.clone(), sunColor.clone(), sunIrradiance];
|
|
478
|
+
const groundAlbedo = 0.3;
|
|
479
|
+
const glassT = 0.8;
|
|
480
|
+
const skyUp = skyRadiance(new Vector3(0, 1, 0));
|
|
481
|
+
// Ground seen through the window: sunlit + skylit grass/paving, warm-ish.
|
|
482
|
+
const groundL = sunColor
|
|
483
|
+
.clone()
|
|
484
|
+
.multiplyScalar((sunIrradiance * Math.max(sunDir.y, 0)) / Math.PI)
|
|
485
|
+
.add(skyUp.clone().multiplyScalar(0.8))
|
|
486
|
+
.multiplyScalar(groundAlbedo)
|
|
487
|
+
.multiply(new Color(1.0, 0.95, 0.85));
|
|
488
|
+
const portalL = [];
|
|
489
|
+
this.portals.forEach((p, k) => {
|
|
490
|
+
if (p.kind === 1) {
|
|
491
|
+
portalL.push(new Color(0, 0, 0)); // filled below from the neighbour's meter
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const out = p.normal.clone().negate();
|
|
495
|
+
const up = skyRadiance(out.clone().setY(0.5).normalize());
|
|
496
|
+
const hz = skyRadiance(out.clone().setY(0.08).normalize());
|
|
497
|
+
// A vertical window sees ~half sky, ~half ground. Open doorways have no glass.
|
|
498
|
+
const rad = up
|
|
499
|
+
.multiplyScalar(0.3)
|
|
500
|
+
.add(hz.multiplyScalar(0.25))
|
|
501
|
+
.add(groundL.clone().multiplyScalar(0.45))
|
|
502
|
+
.multiplyScalar(p.kind === 0 ? glassT : 1);
|
|
503
|
+
portalL.push(rad);
|
|
504
|
+
this.set(this.portalBase + k * PSTRIDE + 2, rad.r, rad.g, rad.b, p.neighbor);
|
|
505
|
+
});
|
|
506
|
+
// BRE split-flux internally reflected component: flux entering through the room's
|
|
507
|
+
// windows (sky + direct sun), spread over all interior surfaces with multi-bounce gain:
|
|
508
|
+
// E_irc = Φ·ρ / (A_surfaces·(1 − ρ))
|
|
509
|
+
const rho = 0.5;
|
|
510
|
+
const interiorTint = new Color(1.0, 0.93, 0.84); // white walls + wood/textile floors
|
|
511
|
+
const soft = this.softRooms.value;
|
|
512
|
+
this.rooms.forEach((r, i) => {
|
|
513
|
+
const flux = new Color(0, 0, 0);
|
|
514
|
+
for (const p of r.portals) {
|
|
515
|
+
if (p.kind === 1)
|
|
516
|
+
continue;
|
|
517
|
+
const A = p.width * p.height * (p.kind === 2 ? p.gain * soft : 1);
|
|
518
|
+
const k = this.portals.indexOf(p);
|
|
519
|
+
flux.add(portalL[k].clone().multiplyScalar(Math.PI * A));
|
|
520
|
+
const cos = Math.max(0, -p.normal.dot(sunDir));
|
|
521
|
+
if (sunDir.y > 0)
|
|
522
|
+
flux.add(sunColor.clone().multiplyScalar(sunIrradiance * cos * A * (p.kind === 0 ? glassT : 1)));
|
|
523
|
+
}
|
|
524
|
+
// Daylight meter for this room: direct flux spread over its surfaces + the multi-bounce part.
|
|
525
|
+
r.dayE = ((flux.r + flux.g + flux.b) / 3 / r.surfaces) * (1 + rho / (1 - rho));
|
|
526
|
+
const e = flux.multiplyScalar(rho / (r.surfaces * (1 - rho))).multiply(interiorTint);
|
|
527
|
+
this.set(i * 3 + 1, r.portals.length, e.r, e.g, e.b);
|
|
528
|
+
});
|
|
529
|
+
this.writeInteriorPortals();
|
|
530
|
+
this.markData();
|
|
531
|
+
}
|
|
532
|
+
// ---- data texture --------------------------------------------------------------------------
|
|
533
|
+
// rooms: [enclosed, lampStart, lampCount, portalStart] [portalCount, bounce.rgb] [coverY, roof, level, 0]
|
|
534
|
+
// lamps: [pos.xyz, range] [rgb·intensity·on, cosInner] (+ exterior: [spot dir.xyz, cosOuter])
|
|
535
|
+
// portals: [center.xyz, halfW] [normal.xyz, halfH] [radiance.rgb, neighbour] [floorY, mainLamp, gain, kind]
|
|
536
|
+
// active: [portal index] per spilling window, then [exterior lamp index] per lit exterior lamp
|
|
537
|
+
portalBase = 0;
|
|
538
|
+
activeBase = 0;
|
|
539
|
+
allocateData() {
|
|
540
|
+
const nRoomLamps = this.rooms.reduce((s, r) => s + r.lamps.length, 0);
|
|
541
|
+
const nLamps = nRoomLamps + this.exteriorLamps.length;
|
|
542
|
+
const texels = this.rooms.length * 3 +
|
|
543
|
+
nLamps * 2 +
|
|
544
|
+
this.exteriorLamps.length +
|
|
545
|
+
this.portals.length * PSTRIDE +
|
|
546
|
+
this.portals.length +
|
|
547
|
+
this.exteriorLamps.length +
|
|
548
|
+
4;
|
|
549
|
+
const rows = Math.max(1, Math.ceil(texels / DATA_W));
|
|
550
|
+
this.data = new Float32Array(DATA_W * rows * 4);
|
|
551
|
+
const lampBase = this.rooms.length * 3;
|
|
552
|
+
this.portalBase = lampBase + nLamps * 2 + this.exteriorLamps.length;
|
|
553
|
+
let lamp = 0;
|
|
554
|
+
this.rooms.forEach((r, i) => {
|
|
555
|
+
this.set(i * 3, r.enclosed ? 1 : 0, lampBase + lamp * 2, r.lamps.length, 0);
|
|
556
|
+
this.set(i * 3 + 1, r.portals.length, 0, 0, 0);
|
|
557
|
+
this.set(i * 3 + 2, r.coverY, r.coverIsRoof ? 1 : 0, this.map.gpuLevel(r.levelIndex), 0);
|
|
558
|
+
for (const l of r.lamps) {
|
|
559
|
+
this.lampTexel.set(l, lampBase + lamp * 2);
|
|
560
|
+
this.set(lampBase + lamp * 2, l.position.x, l.position.y, l.position.z, l.range);
|
|
561
|
+
this.writeLamp(l);
|
|
562
|
+
lamp++;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
// Exterior lamps after the room lamps — evaluated analytically on outside surfaces.
|
|
566
|
+
const extLampStart = lampBase + lamp * 2;
|
|
567
|
+
this.exteriorLamps.forEach((l, i) => {
|
|
568
|
+
const texel = extLampStart + i * EXT_STRIDE;
|
|
569
|
+
this.lampTexel.set(l, texel);
|
|
570
|
+
this.set(texel, l.position.x, l.position.y, l.position.z, Math.max(6, l.range));
|
|
571
|
+
this.writeLamp(l);
|
|
572
|
+
// Omni lamps: a cone that takes everything (cos ≥ −1 > −2).
|
|
573
|
+
const spot = l.spot;
|
|
574
|
+
if (spot)
|
|
575
|
+
this.set(texel + 2, spot.direction.x, spot.direction.y, spot.direction.z, spot.cosOuter);
|
|
576
|
+
else
|
|
577
|
+
this.set(texel + 2, 0, -1, 0, -2);
|
|
578
|
+
});
|
|
579
|
+
// Every room's portals contiguous.
|
|
580
|
+
this.portals = this.rooms.flatMap((r) => r.portals);
|
|
581
|
+
let ps = 0;
|
|
582
|
+
this.rooms.forEach((r, i) => {
|
|
583
|
+
this.data[i * 3 * 4 + 3] = this.portalBase + ps * PSTRIDE;
|
|
584
|
+
ps += r.portals.length;
|
|
585
|
+
});
|
|
586
|
+
this.portals.forEach((p, k) => {
|
|
587
|
+
const t = this.portalBase + k * PSTRIDE;
|
|
588
|
+
this.set(t, p.center.x, p.center.y, p.center.z, p.width / 2);
|
|
589
|
+
this.set(t + 1, p.normal.x, p.normal.y, p.normal.z, p.height / 2);
|
|
590
|
+
const room = this.rooms[p.room];
|
|
591
|
+
const main = room.lamps.slice().sort((a, b) => b.intensity - a.intensity)[0];
|
|
592
|
+
this.set(t + 2, 0, 0, 0, p.neighbor);
|
|
593
|
+
this.set(t + 3, this.levels[room.levelIndex]?.y ?? 0, main ? this.lampTexel.get(main) : -1, p.gain, p.kind);
|
|
594
|
+
});
|
|
595
|
+
const previous = this.u.data.value;
|
|
596
|
+
const tex = new DataTexture(this.data, DATA_W, rows, RGBAFormat, FloatType);
|
|
597
|
+
tex.magFilter = tex.minFilter = NearestFilter;
|
|
598
|
+
tex.needsUpdate = true;
|
|
599
|
+
this.u.data.value = tex;
|
|
600
|
+
previous.dispose();
|
|
601
|
+
this.u.portalCount.value = this.portals.length;
|
|
602
|
+
this.u.portalBase.value = this.portalBase;
|
|
603
|
+
this.u.extLampStart.value = extLampStart;
|
|
604
|
+
this.u.extLampCount.value = this.exteriorLamps.length;
|
|
605
|
+
this.activeBase = this.portalBase + this.portals.length * PSTRIDE;
|
|
606
|
+
this.u.activeBase.value = this.activeBase;
|
|
607
|
+
this.writeActiveLists();
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The outside is lit only by windows of rooms whose lamp is on and by exterior lamps that are
|
|
611
|
+
* on: list just those, so the per-pixel spill and lantern loops run over nothing by day and
|
|
612
|
+
* over the lit few at night, not over every window of the building.
|
|
613
|
+
*/
|
|
614
|
+
writeActiveLists() {
|
|
615
|
+
let spill = 0;
|
|
616
|
+
this.portals.forEach((p, k) => {
|
|
617
|
+
if (p.kind === 1 || p.gain <= 0.01)
|
|
618
|
+
return;
|
|
619
|
+
const room = this.rooms[p.room];
|
|
620
|
+
if (!room?.lamps.some((lamp) => lamp.on && lamp.intensity > 0))
|
|
621
|
+
return;
|
|
622
|
+
this.set(this.activeBase + spill, k, 0, 0, 0);
|
|
623
|
+
spill++;
|
|
624
|
+
});
|
|
625
|
+
let lit = 0;
|
|
626
|
+
this.exteriorLamps.forEach((lamp, i) => {
|
|
627
|
+
if (!lamp.on || lamp.intensity <= 0)
|
|
628
|
+
return;
|
|
629
|
+
this.set(this.activeBase + this.portals.length + lit, i, 0, 0, 0);
|
|
630
|
+
lit++;
|
|
631
|
+
});
|
|
632
|
+
this.u.spillCount.value = spill;
|
|
633
|
+
this.u.extActiveCount.value = lit;
|
|
634
|
+
}
|
|
635
|
+
markData() {
|
|
636
|
+
;
|
|
637
|
+
this.u.data.value.needsUpdate = true;
|
|
638
|
+
}
|
|
639
|
+
writeLamp(l) {
|
|
640
|
+
const exterior = l.candela !== undefined && this.exteriorLamps.includes(l);
|
|
641
|
+
const k = (l.on ? 1 : 0) * (exterior ? Math.min(l.candela, l.intensity) : l.intensity);
|
|
642
|
+
const cosInner = l.spot ? l.spot.cosInner : -1;
|
|
643
|
+
this.set(this.lampTexel.get(l) + 1, l.color.r * k, l.color.g * k, l.color.b * k, cosInner);
|
|
644
|
+
}
|
|
645
|
+
/** Interior portals emit the neighbour room's average radiance: L = E_avg·ρ/π (lamps + daylight). */
|
|
646
|
+
writeInteriorPortals() {
|
|
647
|
+
const lampGain = this.lampGain.value;
|
|
648
|
+
this.portals.forEach((p, k) => {
|
|
649
|
+
if (p.kind !== 1)
|
|
650
|
+
return;
|
|
651
|
+
const nb = this.rooms[p.neighbor];
|
|
652
|
+
const lampCol = new Color(0, 0, 0);
|
|
653
|
+
let full = 0;
|
|
654
|
+
for (const l of nb.lamps) {
|
|
655
|
+
lampCol.add(l.color.clone().multiplyScalar(l.on ? l.intensity : 0));
|
|
656
|
+
full += l.intensity * ((l.color.r + l.color.g + l.color.b) / 3);
|
|
657
|
+
}
|
|
658
|
+
const L = lampCol.multiplyScalar((nb.lampE * lampGain) / Math.max(1e-4, full));
|
|
659
|
+
L.add(new Color(1.0, 0.95, 0.88).multiplyScalar(nb.dayE));
|
|
660
|
+
L.multiplyScalar(0.5 / Math.PI);
|
|
661
|
+
this.set(this.portalBase + k * PSTRIDE + 2, L.r, L.g, L.b, p.neighbor);
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
set(texel, a, b, c, d) {
|
|
665
|
+
this.data.set([a, b, c, d], texel * 4);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Walk every boundary between two room cells (or a room and outside) on each level's grid and
|
|
669
|
+
* cast short horizontal rays against the walls at 0.35 m and 1.6 m: open at both heights = a
|
|
670
|
+
* walkable opening (doorway, arch, open-plan edge). Windows don't count (sills are above 0.35 m).
|
|
671
|
+
* `walk` runs per level while its grid exists; `finish` clusters the samples into openings.
|
|
672
|
+
*/
|
|
673
|
+
openingSearch(input, ray) {
|
|
674
|
+
const doors = input.openings
|
|
675
|
+
.filter((o) => o.kind === 'door')
|
|
676
|
+
.map((o) => ({ id: o.id, box: new Box3().setFromObject(o.object) }));
|
|
677
|
+
const doorHoles = doors.map((door) => door.box.clone().expandByScalar(0.05));
|
|
678
|
+
ray.far = 0.9;
|
|
679
|
+
const wallGrid = new MeshGrid(input.walls);
|
|
680
|
+
const samples = new Map();
|
|
681
|
+
const v = new Vector3();
|
|
682
|
+
const dirV = new Vector3();
|
|
683
|
+
const walk = (tile) => {
|
|
684
|
+
const { cells, stride, i0, j0, x0, z0, ci0, cj0, level: l } = tile;
|
|
685
|
+
const floorY = this.levels[l]?.y ?? 0;
|
|
686
|
+
const visit = (i, j, axis) => {
|
|
687
|
+
const di = axis === 'x' ? 1 : 0;
|
|
688
|
+
const dj = axis === 'z' ? 1 : 0;
|
|
689
|
+
if ((axis === 'x' ? cj0 + j : ci0 + i) % 2)
|
|
690
|
+
return; // every 2nd cell along the edge (0.2 m)
|
|
691
|
+
const A = cells[j * stride + i];
|
|
692
|
+
const B = cells[(j + dj) * stride + i + di];
|
|
693
|
+
if (A === B)
|
|
694
|
+
return;
|
|
695
|
+
const a = A ? A - 1 : -1;
|
|
696
|
+
const b = B ? B - 1 : -1;
|
|
697
|
+
// Outside openings only from enclosed rooms.
|
|
698
|
+
if (a < 0 && !this.rooms[b].enclosed)
|
|
699
|
+
return;
|
|
700
|
+
if (b < 0 && !this.rooms[a].enclosed)
|
|
701
|
+
return;
|
|
702
|
+
const x = x0 + (i + 0.5 + di * 0.5) * CELL;
|
|
703
|
+
const z = z0 + (j + 0.5 + dj * 0.5) * CELL;
|
|
704
|
+
dirV.set(di, 0, dj);
|
|
705
|
+
for (const h of [0.35, 1.6]) {
|
|
706
|
+
v.set(x, floorY + h, z).addScaledVector(dirV, -0.45);
|
|
707
|
+
ray.set(v, dirV);
|
|
708
|
+
// A doorway is open whatever wall-attached geometry crosses it (finishes, trims).
|
|
709
|
+
const near = wallGrid.near(Math.min(v.x, v.x + dirV.x * 0.9), Math.min(v.z, v.z + dirV.z * 0.9), Math.max(v.x, v.x + dirV.x * 0.9), Math.max(v.z, v.z + dirV.z * 0.9));
|
|
710
|
+
const blocked = intersectGeometry(ray, near, false).some((hit) => !doorHoles.some((hole) => hole.containsPoint(hit.point)));
|
|
711
|
+
if (blocked)
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
// Normalise so key/direction are from the lower room id (outside = last).
|
|
715
|
+
const lo = a < 0 ? b : b < 0 ? a : Math.min(a, b);
|
|
716
|
+
const hi = lo === a ? b : a;
|
|
717
|
+
const dir = lo === a ? 1 : -1;
|
|
718
|
+
const key = `${l}|${lo}|${hi}`;
|
|
719
|
+
let s = samples.get(key);
|
|
720
|
+
if (!s) {
|
|
721
|
+
s = { a: lo, b: hi, pts: [], facing: [], level: l };
|
|
722
|
+
samples.set(key, s);
|
|
723
|
+
}
|
|
724
|
+
s.pts.push(new Vector3(x, floorY, z));
|
|
725
|
+
s.facing.push(new Vector3(di * dir, 0, dj * dir));
|
|
726
|
+
};
|
|
727
|
+
// Each edge belongs to the tile of the cell before it. A uniform tile has none inside: only
|
|
728
|
+
// its borders with the next tiles can hold one.
|
|
729
|
+
const iEnd = i0 + TILE;
|
|
730
|
+
const jEnd = j0 + TILE;
|
|
731
|
+
if (tile.uniform) {
|
|
732
|
+
for (let j = j0; j < jEnd; j++)
|
|
733
|
+
visit(iEnd - 1, j, 'x');
|
|
734
|
+
for (let i = i0; i < iEnd; i++)
|
|
735
|
+
visit(i, jEnd - 1, 'z');
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
for (let j = j0; j < jEnd; j++)
|
|
739
|
+
for (let i = i0; i < iEnd; i++) {
|
|
740
|
+
visit(i, j, 'x');
|
|
741
|
+
visit(i, j, 'z');
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
const finish = () => {
|
|
745
|
+
const openings = [];
|
|
746
|
+
// Boundary samples a step apart along an edge belong to one opening. Off the grid axes (a
|
|
747
|
+
// rotated building) the edge is a staircase of cells, so adjacency is by distance.
|
|
748
|
+
const gap = Math.max(0.45, CELL * 3);
|
|
749
|
+
for (const s of samples.values()) {
|
|
750
|
+
const order = s.pts.map((_, k) => k).sort((p, q) => s.pts[p].x - s.pts[q].x);
|
|
751
|
+
const seen = new Uint8Array(order.length);
|
|
752
|
+
for (let seed = 0; seed < order.length; seed++) {
|
|
753
|
+
if (seen[seed])
|
|
754
|
+
continue;
|
|
755
|
+
seen[seed] = 1;
|
|
756
|
+
const members = [];
|
|
757
|
+
const queue = [seed];
|
|
758
|
+
while (queue.length) {
|
|
759
|
+
const k = queue.pop();
|
|
760
|
+
const p = s.pts[order[k]];
|
|
761
|
+
members.push(order[k]);
|
|
762
|
+
for (const step of [-1, 1]) {
|
|
763
|
+
for (let m = k + step; m >= 0 && m < order.length; m += step) {
|
|
764
|
+
const q = s.pts[order[m]];
|
|
765
|
+
if (Math.abs(q.x - p.x) > gap)
|
|
766
|
+
break;
|
|
767
|
+
if (seen[m] || Math.hypot(q.x - p.x, q.z - p.z) > gap)
|
|
768
|
+
continue;
|
|
769
|
+
seen[m] = 1;
|
|
770
|
+
queue.push(m);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (members.length < 3)
|
|
775
|
+
continue; // < 0.5 m
|
|
776
|
+
openings.push(this.openingFromRun(members, s, doors));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return openings;
|
|
780
|
+
};
|
|
781
|
+
return { walk, finish };
|
|
782
|
+
}
|
|
783
|
+
/** One opening from its boundary samples: measured along their own line, facing room b. */
|
|
784
|
+
openingFromRun(members, s, doors) {
|
|
785
|
+
const run = members.map((k) => s.pts[k]);
|
|
786
|
+
const c = run.reduce((acc, p) => acc.add(p), new Vector3()).multiplyScalar(1 / run.length);
|
|
787
|
+
// Principal direction of the samples (2×2 covariance in XZ).
|
|
788
|
+
let xx = 0;
|
|
789
|
+
let xz = 0;
|
|
790
|
+
let zz = 0;
|
|
791
|
+
for (const p of run) {
|
|
792
|
+
const dx = p.x - c.x;
|
|
793
|
+
const dz = p.z - c.z;
|
|
794
|
+
xx += dx * dx;
|
|
795
|
+
xz += dx * dz;
|
|
796
|
+
zz += dz * dz;
|
|
797
|
+
}
|
|
798
|
+
const angle = 0.5 * Math.atan2(2 * xz, xx - zz);
|
|
799
|
+
const tangent = new Vector3(Math.cos(angle), 0, Math.sin(angle));
|
|
800
|
+
let lo = Number.POSITIVE_INFINITY;
|
|
801
|
+
let hi = Number.NEGATIVE_INFINITY;
|
|
802
|
+
for (const p of run) {
|
|
803
|
+
const t = (p.x - c.x) * tangent.x + (p.z - c.z) * tangent.z;
|
|
804
|
+
lo = Math.min(lo, t);
|
|
805
|
+
hi = Math.max(hi, t);
|
|
806
|
+
}
|
|
807
|
+
c.addScaledVector(tangent, (lo + hi) / 2);
|
|
808
|
+
const width = hi - lo + 2 * CELL;
|
|
809
|
+
const facing = members.reduce((acc, k) => acc.add(s.facing[k]), new Vector3());
|
|
810
|
+
const normal = new Vector3(-tangent.z, 0, tangent.x);
|
|
811
|
+
if (normal.dot(facing) < 0)
|
|
812
|
+
normal.negate();
|
|
813
|
+
const roomA = this.rooms[s.a];
|
|
814
|
+
const roomB = s.b >= 0 ? this.rooms[s.b] : undefined;
|
|
815
|
+
const floorY = this.levels[s.level]?.y ?? 0;
|
|
816
|
+
// Door in this opening? → its height + state. Otherwise floor to the lower ceiling.
|
|
817
|
+
const door = doors.find((d) => d.box
|
|
818
|
+
.clone()
|
|
819
|
+
.expandByScalar(0.25)
|
|
820
|
+
.containsPoint(c.clone().setY(floorY + 1)));
|
|
821
|
+
const ceil = Math.min(roomA.ceilingY, roomB?.ceilingY ?? roomA.ceilingY);
|
|
822
|
+
const height = door
|
|
823
|
+
? Math.min(door.box.max.y - floorY, ceil - floorY)
|
|
824
|
+
: Math.max(1.9, ceil - floorY);
|
|
825
|
+
return {
|
|
826
|
+
a: s.a,
|
|
827
|
+
b: s.b,
|
|
828
|
+
center: c.setY(floorY + height / 2),
|
|
829
|
+
normal,
|
|
830
|
+
width: door ? Math.min(width, door.box.getSize(new Vector3()).length()) : width,
|
|
831
|
+
height,
|
|
832
|
+
doorId: door?.id,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
// ---- TSL ---------------------------------------------------------------------------------
|
|
836
|
+
/** Room index (−1 outside) at a world-space position (live heights). */
|
|
837
|
+
roomAtNode = Fn(([p]) => this.map.roomAtNode(p));
|
|
838
|
+
/**
|
|
839
|
+
* The room a surface faces (probed 18 cm off it along its normal). One node per material: its
|
|
840
|
+
* lighting and its exterior mask share the lookup.
|
|
841
|
+
*/
|
|
842
|
+
probeRoomNode() {
|
|
843
|
+
return this.roomAtNode(positionWorld.add(normalWorld.mul(0.18)));
|
|
844
|
+
}
|
|
845
|
+
/** E' = Emax·(1 − e^(−E/Emax)), on luminance, hue kept — near-field hot spots compress, never blow out. */
|
|
846
|
+
softClampLamps(e) {
|
|
847
|
+
const L = dot(e, vec3(0.2126, 0.7152, 0.0722)).max(1e-5);
|
|
848
|
+
const Lc = this.lampMaxE.mul(float(1).sub(L.div(this.lampMaxE).negate().exp()));
|
|
849
|
+
return e.mul(Lc.div(L));
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Dollhouse / roofless views: an enclosed room is open to the sky when the camera is above its
|
|
853
|
+
* topmost cover and that cover is hidden from there: a ceiling (hidden from above), or anything
|
|
854
|
+
* once its level is focused (the levels over it hidden). From inside, under the cover, the room
|
|
855
|
+
* stays enclosed whatever is focused: it must not take the full sky on top of its windows.
|
|
856
|
+
* `h2` is the room's third header texel: [coverY, roof, level, 0]; `level` its level's
|
|
857
|
+
* [th, entryOffset, offset, flags] (flag 1: focused).
|
|
858
|
+
*/
|
|
859
|
+
openToSky(h2, level) {
|
|
860
|
+
const focused = level.w.mod(2).greaterThan(0.5);
|
|
861
|
+
return this.cameraY.sub(level.z).greaterThan(h2.x).and(h2.y.lessThan(0.5).or(focused));
|
|
862
|
+
}
|
|
863
|
+
texel(i) {
|
|
864
|
+
const idx = int(i);
|
|
865
|
+
return this.u.data.load(ivec2(idx.mod(DATA_W), idx.div(DATA_W)));
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* vec4(irradiance, enclosure):
|
|
869
|
+
* irradiance — lamp + portal + bounce diffuse irradiance (linear)
|
|
870
|
+
* enclosure — multiplier for the sky IBL (1 outdoors, `indoorEnv` in enclosed rooms)
|
|
871
|
+
*/
|
|
872
|
+
lightingNode = Fn(([probeRoom]) => {
|
|
873
|
+
const room = int(probeRoom).toVar();
|
|
874
|
+
const irr = vec3(0).toVar();
|
|
875
|
+
const enclosure = float(1).toVar();
|
|
876
|
+
If(room.greaterThanEqual(0), () => {
|
|
877
|
+
const h0 = this.texel(room.mul(3));
|
|
878
|
+
const h1 = this.texel(room.mul(3).add(1));
|
|
879
|
+
const h2 = this.texel(room.mul(3).add(2));
|
|
880
|
+
const level = this.map.levelTexel1(h2.z);
|
|
881
|
+
If(h0.x.greaterThan(0.5).and(this.openToSky(h2, level).not()), () => {
|
|
882
|
+
enclosure.assign(this.indoorEnv);
|
|
883
|
+
});
|
|
884
|
+
// Shade in assembled space so exploded floors stay lit exactly as when stacked.
|
|
885
|
+
const P = positionWorld.sub(vec3(0, level.z, 0));
|
|
886
|
+
const Nw = normalWorld;
|
|
887
|
+
// Lamps: smooth windowed inverse-square, wrapped diffuse so the room reads as filled
|
|
888
|
+
// rather than spotlit — stands in for the bounce we don't compute.
|
|
889
|
+
const lampIrr = vec3(0).toVar();
|
|
890
|
+
Loop({ start: int(0), end: int(h0.z), type: 'int', condition: '<' }, ({ i }) => {
|
|
891
|
+
const a = this.texel(int(h0.y).add(i.mul(2)));
|
|
892
|
+
const c = this.texel(int(h0.y).add(i.mul(2)).add(1));
|
|
893
|
+
const L = a.xyz.sub(P);
|
|
894
|
+
const d2 = dot(L, L);
|
|
895
|
+
const d = d2.sqrt();
|
|
896
|
+
const win = clamp(float(1).sub(d.div(a.w).pow(4)), 0, 1).pow(2);
|
|
897
|
+
const atten = win.div(max(d2, LAMP_MIN_D2));
|
|
898
|
+
const wrap = max(dot(Nw, L.div(d)).mul(0.6).add(0.4), 0);
|
|
899
|
+
lampIrr.addAssign(c.xyz.mul(atten).mul(wrap));
|
|
900
|
+
});
|
|
901
|
+
lampIrr.assign(this.softClampLamps(lampIrr));
|
|
902
|
+
irr.addAssign(lampIrr.mul(this.lampGain).mul(this.lampView));
|
|
903
|
+
// Window portals: Lambertian rectangle, approximated as a disk of equal area
|
|
904
|
+
// E = L·A·cosθe·cosθr / (π·d² + A) — finite at the glass, physically-shaped falloff.
|
|
905
|
+
const portalIrr = vec3(0).toVar();
|
|
906
|
+
const patchIrr = vec3(0).toVar();
|
|
907
|
+
const fade = vec3(0).toVar();
|
|
908
|
+
const fadeEnc = float(0).toVar();
|
|
909
|
+
Loop({ start: int(0), end: int(h1.x), type: 'int', condition: '<' }, ({ i }) => {
|
|
910
|
+
const base = int(h0.w).add(i.mul(PSTRIDE));
|
|
911
|
+
const a = this.texel(base);
|
|
912
|
+
const b = this.texel(base.add(1));
|
|
913
|
+
const c = this.texel(base.add(2));
|
|
914
|
+
const m = this.texel(base.add(3));
|
|
915
|
+
const isWindow = m.w.lessThan(0.5);
|
|
916
|
+
const isInterior = m.w.greaterThan(0.5).and(m.w.lessThan(1.5));
|
|
917
|
+
// Openings (kind 1/2) belong to the soft room map; door state lives in m.z.
|
|
918
|
+
const gain = m.z.mul(select(isWindow, float(1), this.softRooms));
|
|
919
|
+
const toP = P.sub(a.xyz);
|
|
920
|
+
// Clamp the sample point onto the window rectangle → wide windows light wide areas.
|
|
921
|
+
const right = normalize(vec3(b.z.negate(), 0, b.x));
|
|
922
|
+
const along = clamp(dot(toP, right), a.w.negate(), a.w);
|
|
923
|
+
const up = clamp(toP.y, b.w.negate(), b.w);
|
|
924
|
+
const q = a.xyz.add(right.mul(along)).add(vec3(0, up, 0));
|
|
925
|
+
const v = P.sub(q);
|
|
926
|
+
const d2 = max(dot(v, v), 1e-4);
|
|
927
|
+
const dir = v.div(d2.sqrt());
|
|
928
|
+
const cosE = max(dot(b.xyz, dir), 0);
|
|
929
|
+
const cosR = max(dot(Nw, dir.negate()).mul(0.8).add(0.2), 0);
|
|
930
|
+
const A = a.w.mul(b.w).mul(4);
|
|
931
|
+
portalIrr.addAssign(c.xyz.mul(A).mul(cosE).mul(cosR).div(d2.mul(Math.PI).add(A)).mul(gain));
|
|
932
|
+
// Threshold crossfade: within 0.6 m of an interior opening, room-wide terms fade towards
|
|
933
|
+
// the neighbour's — w = ½·(1−t)/(1+t).
|
|
934
|
+
const dp = dot(P.sub(a.xyz), b.xyz);
|
|
935
|
+
const lateral = abs(dot(P.sub(a.xyz), right)).sub(a.w);
|
|
936
|
+
const vertical = abs(P.y.sub(a.y)).sub(b.w);
|
|
937
|
+
const t = clamp(dp.div(0.6), 0, 1);
|
|
938
|
+
const w = float(0.5)
|
|
939
|
+
.mul(float(1).sub(t))
|
|
940
|
+
.div(float(1).add(t))
|
|
941
|
+
.mul(smoothstep(0.3, 0.0, lateral))
|
|
942
|
+
.mul(smoothstep(0.3, 0.0, vertical))
|
|
943
|
+
.mul(float(1).sub(abs(dot(Nw, b.xyz)).mul(0.5)))
|
|
944
|
+
.mul(select(isInterior.and(dp.greaterThan(-0.05)).and(dp.lessThan(0.6)), m.z.mul(this.softRooms), float(0)));
|
|
945
|
+
If(w.greaterThan(0.001), () => {
|
|
946
|
+
const nb = int(c.w);
|
|
947
|
+
const g0 = this.texel(nb.mul(3));
|
|
948
|
+
const g1 = this.texel(nb.mul(3).add(1));
|
|
949
|
+
const nbLamps = vec3(0).toVar();
|
|
950
|
+
Loop({ start: int(0), end: int(g0.z), type: 'int', condition: '<' }, ({ i: j }) => {
|
|
951
|
+
const la = this.texel(int(g0.y).add(j.mul(2)));
|
|
952
|
+
const lc = this.texel(int(g0.y).add(j.mul(2)).add(1));
|
|
953
|
+
const L = la.xyz.sub(P);
|
|
954
|
+
const ld2 = dot(L, L);
|
|
955
|
+
const ld = ld2.sqrt();
|
|
956
|
+
const lwin = clamp(float(1).sub(ld.div(la.w).pow(4)), 0, 1).pow(2);
|
|
957
|
+
nbLamps.addAssign(lc.xyz
|
|
958
|
+
.mul(lwin.div(max(ld2, LAMP_MIN_D2)))
|
|
959
|
+
.mul(max(dot(Nw, L.div(ld)).mul(0.6).add(0.4), 0)));
|
|
960
|
+
});
|
|
961
|
+
const lamps = this.lampGain.mul(this.lampView);
|
|
962
|
+
const mine = lampIrr.mul(lamps).add(h1.yzw.mul(this.bounceGain));
|
|
963
|
+
const theirs = nbLamps.mul(lamps).add(g1.yzw.mul(this.bounceGain));
|
|
964
|
+
fade.addAssign(theirs.sub(mine).mul(w));
|
|
965
|
+
fadeEnc.addAssign(select(g0.x.greaterThan(0.5), this.indoorEnv, float(1)).sub(enclosure).mul(w));
|
|
966
|
+
});
|
|
967
|
+
// Sky patch: the window shape projected onto the room from a virtual sky source up and
|
|
968
|
+
// out from the window (wide penumbra) — the soft light rectangle on the floor/wall that
|
|
969
|
+
// shaded façades still get on a bright day.
|
|
970
|
+
const S = a.xyz.sub(b.xyz.mul(2.2)).add(vec3(0, b.w.mul(2).add(1.6), 0));
|
|
971
|
+
const dS = P.sub(S);
|
|
972
|
+
const den = dot(dS, b.xyz);
|
|
973
|
+
const tS = dot(a.xyz.sub(S), b.xyz).div(den);
|
|
974
|
+
const X = S.add(dS.mul(tS));
|
|
975
|
+
const softS = float(0.22);
|
|
976
|
+
const inS = smoothstep(a.w.add(softS), a.w.sub(softS), abs(dot(X.sub(a.xyz), right))).mul(smoothstep(b.w.add(softS), b.w.sub(softS), abs(X.y.sub(a.y))));
|
|
977
|
+
const okS = den.greaterThan(0).and(tS.lessThan(1)).and(tS.greaterThan(0));
|
|
978
|
+
const ds2 = max(dot(dS, dS), 0.25);
|
|
979
|
+
const cosS = max(dot(Nw, dS.negate().div(ds2.sqrt())), 0);
|
|
980
|
+
patchIrr.addAssign(select(okS.and(isWindow), c.xyz.mul(inS).mul(cosS).mul(A).div(ds2), vec3(0)));
|
|
981
|
+
});
|
|
982
|
+
irr.addAssign(fade);
|
|
983
|
+
enclosure.addAssign(fadeEnc);
|
|
984
|
+
irr.addAssign(patchIrr.mul(this.skyPatch).mul(this.portalGain).mul(Math.PI));
|
|
985
|
+
irr.addAssign(portalIrr.mul(this.portalGain).mul(Math.PI));
|
|
986
|
+
// Internally reflected component: near-uniform fill; ceilings catch a bit more (light
|
|
987
|
+
// arrives mostly from the sunlit floor), floors a bit less.
|
|
988
|
+
irr.addAssign(h1.yzw.mul(this.bounceGain).mul(Nw.y.mul(-0.2).add(1)));
|
|
989
|
+
});
|
|
990
|
+
return vec4(irr, enclosure);
|
|
991
|
+
});
|
|
992
|
+
/**
|
|
993
|
+
* Light thrown *out* of lit rooms through their windows onto exterior surfaces (ground,
|
|
994
|
+
* terraces, balconies, reveals). Two terms per exterior portal:
|
|
995
|
+
* - gobo: the room's main lamp projected through the window rectangle — the crisp,
|
|
996
|
+
* window-shaped pool on a lawn at night (exact for an unoccluded point lamp)
|
|
997
|
+
* - glow: the pane as a soft area emitter (lamp-lit room radiance), for the halo
|
|
998
|
+
* Receivers below the window's storey floor − 0.18 m get nothing, so upper windows can't
|
|
999
|
+
* project through slabs onto the ground.
|
|
1000
|
+
*/
|
|
1001
|
+
exteriorSpillNode = Fn(([P, Nw]) => {
|
|
1002
|
+
const total = vec3(0).toVar();
|
|
1003
|
+
Loop({ start: int(0), end: this.u.spillCount, type: 'int', condition: '<' }, ({ i }) => {
|
|
1004
|
+
const portal = int(this.texel(this.u.activeBase.add(i)).x);
|
|
1005
|
+
const base = this.u.portalBase.add(portal.mul(PSTRIDE));
|
|
1006
|
+
const a = this.texel(base);
|
|
1007
|
+
const b = this.texel(base.add(1));
|
|
1008
|
+
const m = this.texel(base.add(3));
|
|
1009
|
+
const isInterior = m.w.greaterThan(0.5).and(m.w.lessThan(1.5));
|
|
1010
|
+
const gain = m.z.mul(select(m.w.lessThan(0.5), float(1), this.softRooms));
|
|
1011
|
+
If(P.y
|
|
1012
|
+
.greaterThan(m.x.sub(0.18))
|
|
1013
|
+
.and(m.y.greaterThanEqual(0))
|
|
1014
|
+
.and(isInterior.not())
|
|
1015
|
+
.and(gain.greaterThan(0.01)), () => {
|
|
1016
|
+
const lampPos = this.texel(int(m.y)).xyz;
|
|
1017
|
+
const lampCol = this.texel(int(m.y).add(1)).xyz;
|
|
1018
|
+
const out = b.xyz.negate();
|
|
1019
|
+
const right = normalize(vec3(b.z.negate(), 0, b.x));
|
|
1020
|
+
// gobo: intersect lamp→P with the window plane
|
|
1021
|
+
const d = P.sub(lampPos);
|
|
1022
|
+
const denom = dot(d, out);
|
|
1023
|
+
const t = dot(a.xyz.sub(lampPos), out).div(denom);
|
|
1024
|
+
const X = lampPos.add(d.mul(t));
|
|
1025
|
+
const lx = abs(dot(X.sub(a.xyz), right));
|
|
1026
|
+
const ly = abs(X.y.sub(a.y));
|
|
1027
|
+
const soft = float(0.06);
|
|
1028
|
+
const inRect = smoothstep(a.w.add(soft), a.w.sub(soft), lx).mul(smoothstep(b.w.add(soft), b.w.sub(soft), ly));
|
|
1029
|
+
const valid = denom.greaterThan(0).and(t.lessThan(1)).and(t.greaterThan(0));
|
|
1030
|
+
const d2 = max(dot(d, d), 0.04);
|
|
1031
|
+
const cosR = max(dot(Nw, d.negate().div(d2.sqrt())), 0);
|
|
1032
|
+
const gobo = select(valid, inRect.mul(cosR).div(d2), float(0));
|
|
1033
|
+
// glow: pane as a soft emitter
|
|
1034
|
+
const toP = P.sub(a.xyz);
|
|
1035
|
+
const q = a.xyz
|
|
1036
|
+
.add(right.mul(clamp(dot(toP, right), a.w.negate(), a.w)))
|
|
1037
|
+
.add(vec3(0, clamp(toP.y, b.w.negate(), b.w), 0));
|
|
1038
|
+
const v = P.sub(q);
|
|
1039
|
+
const e2 = max(dot(v, v), 1e-3);
|
|
1040
|
+
const dir = v.div(e2.sqrt());
|
|
1041
|
+
const A = a.w.mul(b.w).mul(4);
|
|
1042
|
+
const glow = max(dot(out, dir), 0)
|
|
1043
|
+
.mul(max(dot(Nw, dir.negate()), 0))
|
|
1044
|
+
.mul(A)
|
|
1045
|
+
.div(e2.mul(Math.PI).add(A));
|
|
1046
|
+
total.addAssign(lampCol.mul(gobo.mul(1.4).add(glow.mul(0.35))).mul(gain));
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
return total.mul(this.lampGain).mul(this.lampView).mul(this.spillGain);
|
|
1050
|
+
});
|
|
1051
|
+
/**
|
|
1052
|
+
* Exterior lamps (lanterns, bollards, garden lights) as sized sources with the same falloff and
|
|
1053
|
+
* soft clamp as room lamps — no hot spots a bollard's height from the paving. Applied to
|
|
1054
|
+
* outside surfaces only.
|
|
1055
|
+
*/
|
|
1056
|
+
exteriorLampsNode = Fn(([P, Nw]) => {
|
|
1057
|
+
const out = vec3(0).toVar();
|
|
1058
|
+
Loop({ start: int(0), end: this.u.extActiveCount, type: 'int', condition: '<' }, ({ i }) => {
|
|
1059
|
+
const lamp = int(this.texel(this.u.activeBase.add(this.u.portalCount).add(i)).x);
|
|
1060
|
+
const base = this.u.extLampStart.add(lamp.mul(EXT_STRIDE));
|
|
1061
|
+
const a = this.texel(base);
|
|
1062
|
+
const L = a.xyz.sub(P);
|
|
1063
|
+
const d2 = dot(L, L);
|
|
1064
|
+
// Beyond its reach a lamp adds nothing (its window falls to zero there): skip the rest.
|
|
1065
|
+
If(d2.lessThan(a.w.mul(a.w)), () => {
|
|
1066
|
+
const c = this.texel(base.add(1));
|
|
1067
|
+
const s = this.texel(base.add(2));
|
|
1068
|
+
const d = d2.sqrt();
|
|
1069
|
+
const win = clamp(float(1).sub(d.div(a.w).pow(4)), 0, 1).pow(2);
|
|
1070
|
+
// Spotlights keep their cone (soft edge = the penumbra); omni lamps pass everywhere.
|
|
1071
|
+
const cone = smoothstep(s.w, c.w, dot(L.div(d).negate(), s.xyz));
|
|
1072
|
+
// Spots shade plainly (N·L), as the scene's lights do: a sconce's own wall, edge-on and
|
|
1073
|
+
// close, stays nearly dark. Omni lamps keep a little wrap for the bounce they'd bring.
|
|
1074
|
+
const wrap = select(s.w.lessThan(-1.5), float(0.25), float(0));
|
|
1075
|
+
const facing = dot(Nw, L.div(d)).mul(float(1).sub(wrap)).add(wrap);
|
|
1076
|
+
out.addAssign(c.xyz
|
|
1077
|
+
.mul(win.div(max(d2, LAMP_MIN_D2)))
|
|
1078
|
+
.mul(max(facing, 0))
|
|
1079
|
+
.mul(cone));
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
return this.softClampLamps(out).mul(this.lampGain).mul(this.lampView);
|
|
1083
|
+
});
|
|
1084
|
+
/** 1 where exterior lights may shine (outside, or rooms open to the sky), 0 inside. */
|
|
1085
|
+
exteriorMaskNode = Fn(([probeRoom]) => {
|
|
1086
|
+
const room = int(probeRoom).toVar();
|
|
1087
|
+
const mask = float(1).toVar();
|
|
1088
|
+
If(room.greaterThanEqual(0), () => {
|
|
1089
|
+
const h0 = this.texel(room.mul(3));
|
|
1090
|
+
const h2 = this.texel(room.mul(3).add(2));
|
|
1091
|
+
If(h0.x.greaterThan(0.5).and(this.openToSky(h2, this.map.levelTexel1(h2.z)).not()), () => {
|
|
1092
|
+
mask.assign(0);
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
return mask;
|
|
1096
|
+
});
|
|
1097
|
+
/** Linear lamp radiance of a room (its lit lamps) — the night tint of its window panes. */
|
|
1098
|
+
roomGlowNode = Fn(([room]) => {
|
|
1099
|
+
const glow = vec3(0).toVar();
|
|
1100
|
+
If(room.greaterThanEqual(0), () => {
|
|
1101
|
+
const h0 = this.texel(room.mul(3));
|
|
1102
|
+
Loop({ start: int(0), end: int(h0.z), type: 'int', condition: '<' }, ({ i }) => {
|
|
1103
|
+
glow.addAssign(this.texel(int(h0.y).add(i.mul(2)).add(1)).xyz);
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
return glow.div(glow.dot(vec3(0.33)).max(1));
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
// ---- helpers -------------------------------------------------------------------------------
|
|
1110
|
+
/**
|
|
1111
|
+
* Ray hits against the meshes' geometry itself, nearest first. Picking overrides (walls hidden
|
|
1112
|
+
* by the cutaway, ceiling caps, batched originals) mustn't open rooms to the sky or invent
|
|
1113
|
+
* doorways, so the meshes' own `raycast` is bypassed.
|
|
1114
|
+
*/
|
|
1115
|
+
function intersectGeometry(ray, objects, recursive) {
|
|
1116
|
+
const hits = [];
|
|
1117
|
+
const visit = (object) => {
|
|
1118
|
+
if (object.isMesh)
|
|
1119
|
+
Mesh.prototype.raycast.call(object, ray, hits);
|
|
1120
|
+
if (recursive)
|
|
1121
|
+
for (const child of object.children)
|
|
1122
|
+
visit(child);
|
|
1123
|
+
};
|
|
1124
|
+
for (const object of objects)
|
|
1125
|
+
visit(object);
|
|
1126
|
+
return hits.sort((a, b) => a.distance - b.distance);
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Meshes (and the meshes under them) bucketed by their world XZ bounds, so a short doorway ray or
|
|
1130
|
+
* a vertical cover ray tests its neighbours, not every wall and slab of the scene.
|
|
1131
|
+
*/
|
|
1132
|
+
class MeshGrid {
|
|
1133
|
+
static SIZE = 2;
|
|
1134
|
+
cells = new Map();
|
|
1135
|
+
seen = new Set();
|
|
1136
|
+
constructor(roots) {
|
|
1137
|
+
const box = new Box3();
|
|
1138
|
+
for (const root of roots)
|
|
1139
|
+
root.traverse((object) => {
|
|
1140
|
+
const mesh = object;
|
|
1141
|
+
if (!mesh.isMesh)
|
|
1142
|
+
return;
|
|
1143
|
+
// From the vertices as they are now: a cached bounding box can predate geometry that was
|
|
1144
|
+
// rebuilt in place (walls finishing while a large scene loads).
|
|
1145
|
+
const position = mesh.geometry?.getAttribute('position');
|
|
1146
|
+
if (!position || position.count === 0)
|
|
1147
|
+
return;
|
|
1148
|
+
box.setFromBufferAttribute(position).applyMatrix4(mesh.matrixWorld);
|
|
1149
|
+
if (box.isEmpty())
|
|
1150
|
+
return;
|
|
1151
|
+
const s = MeshGrid.SIZE;
|
|
1152
|
+
for (let iz = Math.floor(box.min.z / s); iz <= Math.floor(box.max.z / s); iz++)
|
|
1153
|
+
for (let ix = Math.floor(box.min.x / s); ix <= Math.floor(box.max.x / s); ix++) {
|
|
1154
|
+
const key = cellKey(ix, iz);
|
|
1155
|
+
const list = this.cells.get(key);
|
|
1156
|
+
if (list)
|
|
1157
|
+
list.push(mesh);
|
|
1158
|
+
else
|
|
1159
|
+
this.cells.set(key, [mesh]);
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
/** Meshes whose bounds may reach the XZ rect. */
|
|
1164
|
+
near(minX, minZ, maxX, maxZ) {
|
|
1165
|
+
const s = MeshGrid.SIZE;
|
|
1166
|
+
const out = [];
|
|
1167
|
+
this.seen.clear();
|
|
1168
|
+
for (let iz = Math.floor(minZ / s); iz <= Math.floor(maxZ / s); iz++)
|
|
1169
|
+
for (let ix = Math.floor(minX / s); ix <= Math.floor(maxX / s); ix++)
|
|
1170
|
+
for (const mesh of this.cells.get(cellKey(ix, iz)) ?? [])
|
|
1171
|
+
if (!this.seen.has(mesh)) {
|
|
1172
|
+
this.seen.add(mesh);
|
|
1173
|
+
out.push(mesh);
|
|
1174
|
+
}
|
|
1175
|
+
return out;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const cellKey = (ix, iz) => (ix + 1_000_000) * 2_000_003 + (iz + 1_000_000);
|
|
1179
|
+
function emptyTexture() {
|
|
1180
|
+
const tex = new DataTexture(new Float32Array(4), 1, 1, RGBAFormat, FloatType);
|
|
1181
|
+
tex.magFilter = tex.minFilter = NearestFilter;
|
|
1182
|
+
tex.needsUpdate = true;
|
|
1183
|
+
return tex;
|
|
1184
|
+
}
|
|
1185
|
+
const luminance = (c) => 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
|
|
1186
|
+
export function pointInPolygon(x, z, poly) {
|
|
1187
|
+
let inside = false;
|
|
1188
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
1189
|
+
const [xi, zi] = poly[i];
|
|
1190
|
+
const [xj, zj] = poly[j];
|
|
1191
|
+
if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi)
|
|
1192
|
+
inside = !inside;
|
|
1193
|
+
}
|
|
1194
|
+
return inside;
|
|
1195
|
+
}
|
|
1196
|
+
export function polygonPerimeter(poly) {
|
|
1197
|
+
let p = 0;
|
|
1198
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++)
|
|
1199
|
+
p += Math.hypot(poly[i][0] - poly[j][0], poly[i][1] - poly[j][1]);
|
|
1200
|
+
return p;
|
|
1201
|
+
}
|
|
1202
|
+
export function polygonArea(poly) {
|
|
1203
|
+
let a = 0;
|
|
1204
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++)
|
|
1205
|
+
a += (poly[j][0] + poly[i][0]) * (poly[j][1] - poly[i][1]);
|
|
1206
|
+
return a / 2;
|
|
1207
|
+
}
|
|
1208
|
+
export function polygonCentroid(poly) {
|
|
1209
|
+
let cx = 0;
|
|
1210
|
+
let cz = 0;
|
|
1211
|
+
let a = 0;
|
|
1212
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
1213
|
+
const f = poly[j][0] * poly[i][1] - poly[i][0] * poly[j][1];
|
|
1214
|
+
cx += (poly[j][0] + poly[i][0]) * f;
|
|
1215
|
+
cz += (poly[j][1] + poly[i][1]) * f;
|
|
1216
|
+
a += f;
|
|
1217
|
+
}
|
|
1218
|
+
if (Math.abs(a) < 1e-9)
|
|
1219
|
+
return new Vector2(poly[0]?.[0] ?? 0, poly[0]?.[1] ?? 0);
|
|
1220
|
+
return new Vector2(cx / (3 * a), cz / (3 * a));
|
|
1221
|
+
}
|
|
1222
|
+
/** Tanner Helland blackbody fit → linear RGB. */
|
|
1223
|
+
/** A point inside the polygon and outside its holes: the centroid when it is, else a scan. */
|
|
1224
|
+
export function interiorPoint(poly, holes = []) {
|
|
1225
|
+
const inside = (x, z) => pointInPolygon(x, z, poly) && !holes.some((hole) => pointInPolygon(x, z, hole));
|
|
1226
|
+
const c = polygonCentroid(poly);
|
|
1227
|
+
if (inside(c.x, c.y))
|
|
1228
|
+
return c;
|
|
1229
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
1230
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
1231
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
1232
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
1233
|
+
for (const [x, z] of poly) {
|
|
1234
|
+
minX = Math.min(minX, x);
|
|
1235
|
+
maxX = Math.max(maxX, x);
|
|
1236
|
+
minZ = Math.min(minZ, z);
|
|
1237
|
+
maxZ = Math.max(maxZ, z);
|
|
1238
|
+
}
|
|
1239
|
+
for (let n = 4; n <= 32; n *= 2)
|
|
1240
|
+
for (let j = 1; j < n; j++)
|
|
1241
|
+
for (let i = 1; i < n; i++) {
|
|
1242
|
+
const x = minX + ((maxX - minX) * i) / n;
|
|
1243
|
+
const z = minZ + ((maxZ - minZ) * j) / n;
|
|
1244
|
+
if (inside(x, z))
|
|
1245
|
+
return new Vector2(x, z);
|
|
1246
|
+
}
|
|
1247
|
+
return c;
|
|
1248
|
+
}
|
|
1249
|
+
export function kelvin(k) {
|
|
1250
|
+
const t = k / 100;
|
|
1251
|
+
let r;
|
|
1252
|
+
let g;
|
|
1253
|
+
let b;
|
|
1254
|
+
if (t <= 66) {
|
|
1255
|
+
r = 255;
|
|
1256
|
+
g = 99.4708025861 * Math.log(t) - 161.1195681661;
|
|
1257
|
+
b = t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
|
|
1258
|
+
}
|
|
1259
|
+
else {
|
|
1260
|
+
r = 329.698727446 * (t - 60) ** -0.1332047592;
|
|
1261
|
+
g = 288.1221695283 * (t - 60) ** -0.0755148492;
|
|
1262
|
+
b = 255;
|
|
1263
|
+
}
|
|
1264
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
1265
|
+
return new Color().setRGB(clamp01(r / 255), clamp01(g / 255), clamp01(b / 255), SRGBColorSpace);
|
|
1266
|
+
}
|
|
1267
|
+
/** The daylight look's room system (one viewer canvas at a time). */
|
|
1268
|
+
export const daylightRooms = new RoomLighting();
|