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