Skip to content

Commit 63b81b4

Browse files
wass08claude
andauthored
viewer: sparse tiled room map for daylight; rooms built from settled geometry (#1245)
* Daylight rooms: a sparse tiled room map, per level and per building The room-id map was one texture with every level stacked along Y at 10 cm cells, levels found by height across the whole site and capped at 8. A 40-storey tower lit floors 8-39 with whichever room was rasterized last, a campus fell to ~80 cm cells and still mixed its buildings' floors, a datacenter took 48 MB at 20 cm. Each level now keeps its own map of 10 cm cells cut into 1.6 m tiles: a tile inside one room (or outside all of them) is one value, only tiles a room edge crosses keep their cells, packed into a shared two-byte atlas. A point finds its buildings in a coarse directory and its level by a binary search over that building's live floor heights, which stay sorted when levels explode apart. Zone holes are rasterized. "Open to the sky" and the meters read the real presentation per level (shown, not shadow-only; focused when the level over it is hidden), not an index order. Texture sizes come from the device. The lighting and the exterior mask share one lookup. CPU bench, every room on every floor: tower 96/480 -> 480/480 rooms right, campus 708/1770 -> 1770/1770 at 10 cm (35 -> 17 MB), datacenter 48 MB at 20 cm -> 14 MB at 10 cm, street block 21 MB -> 0.4 MB; houses resolve the same rooms (differences only inside the 2-cell edge band). Rebuilds 2-90x faster. Main-pass GPU time within run-to-run noise on houses, tower and datacenter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Daylight rooms: build tile rows, cluster rooms, index rays; smallest room wins Review and browser findings on the tiled room map: - Build by rows of tiles (with a 3-cell margin) and group each level's rooms into clusters with their own rects: no dense per-level grid, so a level with rooms kilometres apart neither runs out of memory nor pays for the ground between them. Tile rects are computed in whole tiles (a rounded origin could start a level at tile -1 and crash the directory). - Doorway and cover rays test only the meshes near them (a 2 m grid of mesh bounds) instead of every wall and slab: room builds drop from 37 s to 0.6 s on a 2,560-wall datacenter and from 6.7 s to 1.2 s on a 40-storey tower. - Where zones overlap, the smallest room wins (largest rasterized first): an automatic floor-wide room or a unit around its rooms no longer swallows them. - A level opens from above only when every level over it in its building is hidden; the index texture is as wide as the device allows; room ids are capped at 65,535 with a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Daylight rooms: build from settled geometry; mesh bounds from current vertices On a large scene the rooms were built while walls and windows were still being built, and never again: walls and openings finish without a geometry revision. Rays then passed through unbuilt walls, so doorways read as whole walls and an L of exterior wall became one diagonal opening across a corner room, pouring outdoor light in (the bright wedges); most windows had no portal yet. - Rooms wait for the scene to settle (no dirty nodes) and build again each time it does after walls, slabs, openings or zones changed (not furniture). - A scene still loading after 10 s builds anyway, then again every 10 s while its dirty count keeps falling, so the rooms catch up with the geometry. - The ray index takes each mesh's bounds from its current vertices: a cached bounding box can predate a wall rebuilt in place, filing it where no ray looks. Campus: corner room 8-1 goes from one 17.6 m diagonal exterior opening and no windows to its four windows and two 1.2 m doorways; interior rooms from four 12 m "openings" to their four 1.2 m doorways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Daylight rooms: overlapping clusters keep searching; no rebuild while levels move Bugbot review on #1245: - A level's cluster rects can overlap (a C-shaped wing's rect covers a courtyard pavilion in its own cluster). The lookup stopped at the first rect holding the point, empty there, so the pavilion got no room light. It now keeps going until a cluster has a room at the point (CPU and shader). - Cover and doorway rays are cast at stacked floor heights against meshes where they are; a rebuild while levels are exploded (or still moving) read doorways as open and covers at exploded heights. Rebuilds wait until levels are stacked and still. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 55f7d51 commit 63b81b4

6 files changed

Lines changed: 1378 additions & 421 deletions

File tree

‎packages/viewer/src/components/viewer/use-daylight-rooms.ts‎

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
44
import { useFrame, useThree } from '@react-three/fiber'
55
import { useEffect, useRef } from 'react'
66
import { Box3, type Mesh, type Object3D, Vector3 } from 'three'
7+
import { DEFAULT_TEXTURE_LIMIT } from '../../lib/daylight/room-map'
78
import {
89
bakedLevelObjects,
910
bakedRoomScene,
@@ -15,11 +16,14 @@ import {
1516
syncLampEmitters,
1617
} from '../../lib/daylight/room-scene'
1718
import { daylightRooms } from '../../lib/daylight/rooms'
19+
import { heldReasons } from '../../lib/scene-visibility'
1820
import useViewer from '../../store/use-viewer'
1921

2022
/** Seconds between scene-change checks, and the quiet time before a rebuild. */
2123
const CHECK_INTERVAL = 0.5
2224
const REBUILD_DELAY = 0.4
25+
/** Longest wait for the scene to settle before building anyway (seconds). */
26+
const SETTLE_WAIT = 10
2327
/**
2428
* Furniture: what the rooms are built from (walls, slabs, ceilings, roofs, doors, windows,
2529
* zones) never includes it, so moving or placing it doesn't rebuild them (a rebuild casts
@@ -42,14 +46,24 @@ const LAMP_POSITION_STEP = 0.05
4246
*/
4347
export function useDaylightRooms() {
4448
const scene = useThree((s) => s.scene)
49+
const gl = useThree((s) => s.gl)
4550
const nodesVersion = useRef(0)
4651
const state = useRef({
4752
signature: '',
4853
pendingSince: -1,
4954
sinceCheck: CHECK_INTERVAL,
55+
/** Times the scene finished rebuilding geometry (no dirty nodes left), and whether it is now. */
56+
settles: 0,
57+
dirty: false,
58+
dirtyRooms: false,
59+
/** Dirty nodes left when rooms were last built mid-load (they rebuild as the count falls). */
60+
builtDirty: Number.POSITIVE_INFINITY,
61+
/** Level offsets last frame: rays are cast at stacked heights, so no build while levels move. */
62+
offsets: [] as number[],
63+
levelsMoved: false,
5064
live: true,
5165
bakedRoot: null as Object3D | null,
52-
levelObjects: [] as Object3D[][],
66+
levelObjects: [] as (Object3D | undefined)[],
5367
})
5468

5569
useEffect(
@@ -65,6 +79,23 @@ export function useDaylightRooms() {
6579
const s = state.current
6680
const lampDefault = useInteractive.getState().lampDefault
6781
s.sinceCheck += delta
82+
// Rooms are built from finished walls, windows and covers: wait for the scene to settle, and
83+
// build again each time it does (walls and openings finish without a geometry revision).
84+
const { dirtyNodes, nodes } = useScene.getState()
85+
const dirty = dirtyNodes.size > 0
86+
if (dirty && !s.dirtyRooms)
87+
for (const id of dirtyNodes) {
88+
const type = nodes[id]?.type
89+
if (type && !FURNITURE_TYPES.has(type)) {
90+
s.dirtyRooms = true
91+
break
92+
}
93+
}
94+
if (s.dirty && !dirty && s.dirtyRooms) {
95+
s.settles++
96+
s.dirtyRooms = false
97+
}
98+
s.dirty = dirty
6899
if (s.sinceCheck >= CHECK_INTERVAL) {
69100
s.sinceCheck = 0
70101
// Items can swap their materials (edits, loads): keep the lamps' own emitters installed.
@@ -73,7 +104,7 @@ export function useDaylightRooms() {
73104
const baked = live ? null : findBakedRoot(scene)
74105
const feedKeys = feedSignature()
75106
const signature = live
76-
? `live|${roomObjectsSignature()}|${nodesVersion.current}|${useViewer.getState().geometryRevision}|${feedKeys}`
107+
? `live|${roomObjectsSignature()}|${nodesVersion.current}|${useViewer.getState().geometryRevision}|${s.settles}|${feedKeys}`
77108
: `baked|${baked?.uuid ?? ''}|${baked?.children.length ?? 0}|${feedKeys}`
78109
if (signature !== s.signature) {
79110
s.signature = signature
@@ -84,17 +115,23 @@ export function useDaylightRooms() {
84115
}
85116
if (s.pendingSince >= 0) {
86117
s.pendingSince += delta
87-
if (s.pendingSince >= REBUILD_DELAY) {
118+
const exploded = useViewer.getState().levelMode === 'exploded' || s.levelsMoved
119+
if (
120+
s.pendingSince >= REBUILD_DELAY &&
121+
!exploded &&
122+
(!(s.live && dirty) || s.pendingSince > SETTLE_WAIT)
123+
) {
88124
s.pendingSince = -1
89125
const input = s.live
90126
? liveRoomScene(lampDefault)
91127
: s.bakedRoot
92128
? bakedRoomScene(s.bakedRoot, lampDefault)
93129
: null
130+
daylightRooms.map.textureLimit = textureLimit(gl)
94131
daylightRooms.build(
95132
input ?? {
96133
bounds: emptyBounds,
97-
levelY: [0],
134+
levels: [],
98135
zones: [],
99136
covers: [],
100137
walls: [],
@@ -104,22 +141,31 @@ export function useDaylightRooms() {
104141
},
105142
)
106143
s.levelObjects = s.live ? liveLevelObjects() : bakedLevelObjects(s.bakedRoot)
144+
// Built before the scene settled (a large scene still building walls and windows): build
145+
// again after the next wait while geometry keeps arriving, so the rooms catch up.
146+
if (s.live && dirty && dirtyNodes.size < s.builtDirty) {
147+
s.builtDirty = dirtyNodes.size
148+
s.pendingSince = 0
149+
} else if (!dirty) s.builtDirty = Number.POSITIVE_INFINITY
107150
flagGlassMaterials(scene)
108151
syncLampEmitters()
109152
}
110153
}
111154

112-
// Live level heights (stacked / exploded motion) and the camera, in assembled space.
113-
const offsets = s.levelObjects.map((objects, index) =>
114-
objects[0] ? objects[0].getWorldPosition(scratch).y - (daylightRooms.levelY[index] ?? 0) : 0,
155+
// Live level heights (stacked / exploded motion), what is shown, and the camera.
156+
const levels = daylightRooms.levels
157+
const offsets = s.levelObjects.map((object, index) =>
158+
object ? object.getWorldPosition(scratch).y - (levels[index]?.y ?? 0) : 0,
115159
)
116-
daylightRooms.setLevelOffsets(offsets)
117-
const focused = focusedLevelIndex(s.levelObjects)
118-
daylightRooms.levelShown = s.levelObjects.map(
119-
(objects, index) => (focused < 0 || index <= focused) && objects.some(shownInScene),
160+
s.levelsMoved = offsets.some(
161+
(offset, index) => Math.abs(offset - (s.offsets[index] ?? offset)) > 1e-4,
120162
)
121-
daylightRooms.cameraY.value = camera.position.y - (offsets[0] ?? 0)
122-
daylightRooms.focusedLevel.value = focused
163+
s.offsets = offsets
164+
const shown = s.levelObjects.map((object) => !!object && presented(object))
165+
const focus = selectedFocusLevel()
166+
const selected = s.levelObjects.map((object) => !!object && object === focus)
167+
daylightRooms.setLevelPresentation(offsets, shown, selected)
168+
daylightRooms.cameraY.value = camera.position.y
123169

124170
// Lamp switches (fed lamps by key; virtual lamps follow the time-of-day default) and doors.
125171
const lit = litFeedKeys()
@@ -227,21 +273,29 @@ function findBakedRoot(scene: Object3D): Object3D | null {
227273
return found
228274
}
229275

230-
/** Visible itself and through every ancestor (hidden levels are switched off on their group). */
231-
function shownInScene(object: Object3D): boolean {
276+
/**
277+
* On screen: visible through every ancestor (hidden levels are switched off on their group) and
278+
* not drawn for shadows only (levels hidden above the selection keep shadowing it).
279+
*/
280+
function presented(object: Object3D): boolean {
232281
for (let x: Object3D | null = object; x; x = x.parent) if (!x.visible) return false
233-
return true
282+
return !heldReasons(object)?.has('shadow-only')
234283
}
235284

236-
/**
237-
* The level whose rooms open to the sky because the levels above it are hidden (solo level, or
238-
* levels above the selection hidden) — the enhanced viewer's "focus level". −1 otherwise.
239-
*/
240-
function focusedLevelIndex(levelObjects: Object3D[][]): number {
285+
/** The selected level when the levels over it are hidden (solo, or hide-above), else null. */
286+
function selectedFocusLevel(): Object3D | null {
241287
const viewer = useViewer.getState()
242288
const hidesAbove = viewer.levelMode === 'solo' || viewer.hideLevelsAboveSelection
243289
const levelId = viewer.selection.levelId
244-
if (!hidesAbove || !levelId) return -1
245-
const object = sceneRegistry.nodes.get(levelId)
246-
return object ? levelObjects.findIndex((objects) => objects.includes(object)) : -1
290+
if (!hidesAbove || !levelId) return null
291+
return sceneRegistry.nodes.get(levelId) ?? null
292+
}
293+
294+
/** The device's largest 2D texture side (WebGPU device limits, or the WebGL context's). */
295+
function textureLimit(gl: unknown): number {
296+
const backend = (gl as { backend?: { device?: GPUDevice; gl?: WebGL2RenderingContext } }).backend
297+
const limit =
298+
backend?.device?.limits?.maxTextureDimension2D ??
299+
(backend?.gl?.getParameter(backend.gl.MAX_TEXTURE_SIZE) as number | undefined)
300+
return limit && limit > 0 ? limit : DEFAULT_TEXTURE_LIMIT
247301
}

‎packages/viewer/src/lib/daylight/room-context.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ const spillAlbedoFloor = uniform(0.35)
2626
*/
2727
export function daylightRoomContext(): N {
2828
const rooms = daylightRooms
29-
const roomNode = rooms.lightingNode()
29+
const room = rooms.probeRoomNode()
30+
const roomNode = rooms.lightingNode(room)
3031
const enclosure = roomNode.w
31-
const outsideMask = rooms.exteriorMaskNode()
32+
const outsideMask = rooms.exteriorMaskNode(room)
3233
// GI is later × albedo, so near-black paving would swallow the pools: lift dark receivers for
3334
// the spill term only (like a light decal).
3435
const lum = diffuseColor.rgb.dot(vec3(0.2126, 0.7152, 0.0722)).max(1e-3)

0 commit comments

Comments
 (0)