diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index a339676563..c68e126ab3 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -16,6 +16,7 @@ import { GRID_LAYER, heroCameraPose, holdLiveFrame, + itemLightsForCapture, pendingSceneBuildCount, presentLevelsForCapture, refreshIsolation, @@ -995,6 +996,9 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro } else if (snapLevels || pose || cameraPose) { exteriorGroundForCapture(restore, Boolean(cameraPose || pose), thumbnailCamera) } + // Last, with the camera where the picture is taken from: the item lights that + // matter to it, at full strength. + restore(itemLightsForCapture(scene, thumbnailCamera)) } // The scene pass is a FRAME-updated node: it renders once per node // frame, and only the animation loop advances that frame. With the diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index 05af985a2c..2f68d1c4cd 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -255,7 +255,6 @@ export function GlbInteractive({ identity, zones, actions, - levelOrder, }: { items: GlbInteractiveItem[] identity: Map @@ -263,9 +262,6 @@ export function GlbInteractive({ /** Baked animation actions keyed by clip name — ambient item loops play from * `: loop`. */ actions: Record - /** Level pascalIds bottom-to-top, so the light pool can prefer ground-floor - * lights when nothing is focused (mirrors the parametric level factor). */ - levelOrder: string[] }) { const scene = useThree((state) => state.scene) useLayoutEffect(() => { @@ -300,10 +296,6 @@ export function GlbInteractive({ ) const lightRegs = useMemo(() => buildGlbLightRegs(items, identity), [items, identity]) - const levelIndexById = useMemo( - () => new Map(levelOrder.map((id, i) => [id, i] as const)), - [levelOrder], - ) // Project the zone's baked-local polygon into world space so focused zone // membership still works after level stacking moves its parent. @@ -324,7 +316,7 @@ export function GlbInteractive({ return ( <> - + {animationItems.map((item) => item.scripted ? ( @@ -402,8 +394,6 @@ export type GlbLightReg = PooledLight & { levelId: string | null } -const _dir = new Vector3() -const _lightWorld = new Vector3() const _quaternion = new Quaternion() /** The nearest level-identity ancestor's pascalId, for the level factor. */ @@ -426,51 +416,24 @@ function isRendered(object: Object3D): boolean { return true } -function scoreReg( - reg: GlbLightReg, - camPos: Vector3, - camFwd: Vector3, - selectedLevelId: string | null, - levelMode: string, - levelIndexById: Map, -): number { - if (!reg.isOn() || !isRendered(reg.object)) return Number.POSITIVE_INFINITY - if (selectedLevelId && reg.levelId !== selectedLevelId && levelMode === 'solo') - return Number.POSITIVE_INFINITY - reg.getWorldPosition(_lightWorld) - _dir.copy(_lightWorld).sub(camPos).normalize() - const angular = 1 - camFwd.dot(_dir) - const dist = camPos.distanceTo(_lightWorld) / 200 - let levelPenalty = 0 - if (selectedLevelId) { - if (reg.levelId !== selectedLevelId) levelPenalty = levelMode === 'solo' ? 100 : 0.8 - } else if (reg.levelId && (levelIndexById.get(reg.levelId) ?? 0) !== 0) { - levelPenalty = 0.3 - } - return angular * 0.7 + dist * 0.3 + levelPenalty +// A light on another storey than the focused one counts for this much of what it adds. +const OTHER_LEVEL_WEIGHT = 0.25 + +/** 0 while the light cannot be seen lit (off, hidden, on a hidden level), else its level factor. */ +function regWeight(reg: GlbLightReg, selectedLevelId: string | null, levelMode: string): number { + if (!reg.isOn() || !isRendered(reg.object)) return 0 + if (!selectedLevelId || reg.levelId === selectedLevelId) return 1 + return levelMode === 'solo' ? 0 : OTHER_LEVEL_WEIGHT } -function GlbItemLights({ - regs, - levelIndexById, -}: { - regs: GlbLightReg[] - levelIndexById: Map -}) { +function GlbItemLights({ regs }: { regs: GlbLightReg[] }) { return ( getLights={() => regs} intensity={(reg) => (reg.isOn() && isRendered(reg.object) ? reg.getIntensity() : 0)} - score={(reg, camPos, camFwd) => { + weight={(reg) => { const viewer = useViewer.getState() - return scoreReg( - reg, - camPos, - camFwd, - viewer.selection.levelId, - viewer.levelMode, - levelIndexById, - ) + return regWeight(reg, viewer.selection.levelId, viewer.levelMode) }} /> ) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 3303b4e498..2c7cfb5eaa 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -474,8 +474,6 @@ export function GlbScene({ levelsWithZones: new Set(zoneList.map((zone) => zone.levelId)), } }, [gltf.scene]) - // Level pascalIds bottom-to-top, for the interactive light pool's level factor. - const levelOrder = useMemo(() => levels.map((entry) => entry.id), [levels]) // Loop clips no other controller plays (a plugin kind's mechanism) run on // click and E. As in the editor they start stopped and never persist. const loopMechanisms = useMemo( @@ -1013,7 +1011,6 @@ export function GlbScene({ actions={actions} identity={identity} items={interactiveItems} - levelOrder={levelOrder} zones={zoneEntries} /> ) : null} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 3b79d2597e..0c7b3f2e57 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -386,6 +386,7 @@ export { // manages item-mounted light sources. export { ItemSystem } from './systems/item/item-system' export { ItemLightSystem } from './systems/item-light/item-light-system' +export { itemLightsForCapture } from './systems/item-light/pooled-lights' export { LevelSystem } from './systems/level/level-system' export { EXPLODED_GAP, diff --git a/packages/viewer/src/systems/item-light/item-light-system.test.ts b/packages/viewer/src/systems/item-light/item-light-system.test.ts index c7bf0f1fa9..11b67962ce 100644 --- a/packages/viewer/src/systems/item-light/item-light-system.test.ts +++ b/packages/viewer/src/systems/item-light/item-light-system.test.ts @@ -4,11 +4,20 @@ import { sceneRegistry, useScene } from '@pascal-app/core' import { useThree } from '@react-three/fiber' import { act, create } from '@react-three/test-renderer' import { createElement } from 'react' -import { Group, type PointLight, type SpotLight, Vector3 } from 'three' +import { + Frustum, + Group, + Matrix4, + PerspectiveCamera, + type PointLight, + type SpotLight, + Vector3, +} from 'three' +import type { SpriteNodeMaterial } from 'three/webgpu' import { useItemLightPool } from '../../store/use-item-light-pool' import useViewer from '../../store/use-viewer' import { ItemLightSystem } from './item-light-system' -import { glowDarkness } from './pooled-lights' +import { glowDarkness, itemLightsForCapture, lightContribution } from './pooled-lights' let previousViewerState = useViewer.getState() @@ -26,7 +35,7 @@ afterEach(() => { }) }) -test('batched ceiling lamps stay eligible; all twelve light objects remain visible across toggles and removal', async () => { +test('batched ceiling lamps stay eligible; all sixteen point lights remain visible across toggles and removal', async () => { const id = 'item_lamp' as AnyNodeId const ceiling = new Group() ceiling.layers.set(5) @@ -64,7 +73,7 @@ test('batched ceiling lamps stay eligible; all twelve light objects remain visib const lights = renderer.scene .findAllByType('PointLight') .map((entry) => entry.instance as PointLight) - expect(lights).toHaveLength(12) + expect(lights).toHaveLength(16) expect(lights.every((light) => light.visible)).toBe(true) await renderer.advanceFrames(5, 1 / 30) expect(lights.some((light) => light.intensity > 0)).toBe(true) @@ -113,7 +122,7 @@ test('batched ceiling lamps stay eligible; all twelve light objects remain visib color: '#ff0000', distance: 4, getWorldPosition: (out) => { - out.set(4, 5, 6) + out.set(4, 5, 0) return true }, getIntensity: () => 1, @@ -141,11 +150,11 @@ test('a baked owner suppresses the native pool on its canvas', async () => { } const renderer = await create(createElement(Fixture)) try { - expect(renderer.scene.findAllByType('PointLight')).toHaveLength(12) + expect(renderer.scene.findAllByType('PointLight')).toHaveLength(16) await act(async () => useItemLightPool.getState().setBakedCanvas(canvas!, true)) expect(renderer.scene.findAllByType('PointLight')).toHaveLength(0) await act(async () => useItemLightPool.getState().setBakedCanvas(canvas!, false)) - expect(renderer.scene.findAllByType('PointLight')).toHaveLength(12) + expect(renderer.scene.findAllByType('PointLight')).toHaveLength(16) } finally { await renderer.unmount() } @@ -219,3 +228,118 @@ test('a glow shows at night and nearly vanishes under a high sun', () => { expect(glowDarkness(new Vector3(0, -1, 0), 'light')).toBe(1) expect(glowDarkness(new Vector3(1, 1, 0), 'dark')).toBeLessThan(0.1) }) + +const lit = (renderer: Awaited>, type: 'SpotLight' | 'PointLight') => + renderer.scene + .findAllByType(type) + .map((entry) => entry.instance as PointLight | SpotLight) + .filter((light) => light.intensity > 0) + +function registerLamp( + key: string, + at: [number, number, number], + options: { spot?: boolean; intensity?: number; distance?: number; on?: () => boolean } = {}, +) { + const id = `item_${key}` as AnyNodeId + if (!sceneRegistry.nodes.has(id)) sceneRegistry.nodes.set(id, new Group()) + useItemLightPool.getState().register({ + key, + nodeId: id, + color: '#ffffff', + distance: options.distance ?? 0, + ...(options.spot ? { spot: { direction: [0, -1, 0], angle: 60, softness: 0.4 } } : {}), + getWorldPosition: (out) => { + out.set(...at) + return true + }, + getIntensity: () => options.intensity ?? 3, + isEligible: options.on ?? (() => true), + }) +} + +test('a house seen from the street: every exterior beam facing the camera gets a slot', async () => { + // The default camera stands at z = 5 looking down -z; the facade is 12 m away. + const exterior: [number, number, number][] = [] + for (const x of [-6, -3, 0, 3, 6]) exterior.push([x, 2.5, -7]) + for (const x of [-4.5, -1.5, 1.5, 4.5]) exterior.push([x, 0.3, -7.5]) + exterior.forEach((at, i) => { + registerLamp(`outside_${i}`, at, { spot: true, distance: 6 }) + }) + // Fifty lights inside, behind the facade, half of them downlights. + for (let i = 0; i < 50; i++) + registerLamp(`inside_${i}`, [-8 + (i % 10) * 1.8, 2.6, -9 - Math.floor(i / 10) * 2.5], { + spot: i % 2 === 0, + }) + const renderer = await create(createElement(ItemLightSystem)) + try { + await act(async () => renderer.advanceFrames(1, 1 / 30)) + await renderer.advanceFrames(30, 1 / 30) + const spots = lit(renderer, 'SpotLight').map((light) => light.position.toArray().join(',')) + for (const at of exterior) expect(spots).toContain(at.join(',')) + } finally { + await renderer.unmount() + } +}) + +test('a light counts by what it adds to the view, and not at all when its reach misses it', () => { + const camera = new PerspectiveCamera(60, 1, 0.1, 100) + camera.position.set(0, 1.6, 0) + camera.lookAt(0, 1.6, -1) + camera.updateMatrixWorld() + const frustum = new Frustum().setFromProjectionMatrix( + new Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse), + ) + const eye = camera.position + const can = lightContribution(new Vector3(0, 2.6, -15), 2, 0, eye, frustum) + const sconce = lightContribution(new Vector3(1.5, 1.8, -2.5), 2, 0, eye, frustum) + expect(sconce).toBeGreaterThan(can) + // Behind the eye, its 3 m reach never meets the view. + expect(lightContribution(new Vector3(0, 1.6, 4), 5, 3, eye, frustum)).toBe(0) + // Off, it adds nothing. + expect(lightContribution(new Vector3(0, 1.6, -2), 0, 0, eye, frustum)).toBe(0) +}) + +test('a capture re-ranks for its own camera, at full strength at once, and gives the live view back', async () => { + let scene: Group | undefined + function Fixture() { + scene = useThree((state) => state.scene) as Group + return createElement(ItemLightSystem) + } + // Behind the live camera (z = 5, looking down -z), with a reach that misses its view. + registerLamp('sconce', [0, 1.5, 9], { distance: 2 }) + const renderer = await create(createElement(Fixture)) + try { + await renderer.advanceFrames(10, 1 / 30) + expect(lit(renderer, 'PointLight')).toHaveLength(0) + const capture = new PerspectiveCamera(60, 1, 0.1, 100) + capture.position.set(0, 1.5, 12) + capture.lookAt(0, 1.5, 9) + capture.updateProjectionMatrix() + const restore = itemLightsForCapture(scene!, capture) + const [light] = lit(renderer, 'PointLight') + expect(light?.intensity).toBe(3) + expect(light?.position.toArray()).toEqual([0, 1.5, 9]) + restore() + expect(lit(renderer, 'PointLight')).toHaveLength(0) + } finally { + await renderer.unmount() + } +}) + +test('the glows write nothing into the normals and diffuse colour the ink reads', async () => { + registerLamp('lamp', [0, 1, 0]) + useItemLightPool.getState().registrations.get('lamp')!.glow = { size: 0.2 } + const renderer = await create(createElement(ItemLightSystem)) + try { + await act(async () => renderer.advanceFrames(1, 1 / 30)) + const [sprite] = renderer.scene.findAll((entry) => entry.instance.name === 'light-glows') + const material = (sprite!.instance as unknown as { material: SpriteNodeMaterial }).material + expect(Object.keys(material.mrtNode?.outputNodes ?? {}).sort()).toEqual([ + 'diffuseColor', + 'normal', + 'output', + ]) + } finally { + await renderer.unmount() + } +}) diff --git a/packages/viewer/src/systems/item-light/item-light-system.tsx b/packages/viewer/src/systems/item-light/item-light-system.tsx index 6b80c35376..63e2d5db84 100644 --- a/packages/viewer/src/systems/item-light/item-light-system.tsx +++ b/packages/viewer/src/systems/item-light/item-light-system.tsx @@ -1,16 +1,11 @@ import type { AnyNodeId, LevelNode } from '@pascal-app/core' import { findLevelAncestorId, sceneRegistry, useScene } from '@pascal-app/core' import { useThree } from '@react-three/fiber' -import { Vector3 } from 'three' import { SCENE_LAYER } from '../../lib/layers' import { type LightSource, useItemLightPool } from '../../store/use-item-light-pool' import useViewer from '../../store/use-viewer' import { PooledLights } from './pooled-lights' -// Module-level temp vectors reused every frame (avoids GC pressure) -const _dir = new Vector3() -const _itemPos = new Vector3() - type SceneNodes = ReturnType['nodes'] function isRendered(nodeId: AnyNodeId): boolean { @@ -23,55 +18,31 @@ function isRendered(nodeId: AnyNodeId): boolean { return true } -function scoreRegistration( +// A light on another storey than the one being edited counts for this much of what it adds. +const OTHER_LEVEL_WEIGHT = 0.25 + +/** 0 while the light cannot be seen lit (off, hidden, on a hidden level), else its level factor. */ +function registrationWeight( reg: LightSource, - camPos: Vector3, - camFwd: Vector3, nodes: SceneNodes, selectedLevelId: string | null, levelMode: string, hideAbove: boolean, ): number { - if (!reg.isEligible() || !isRendered(reg.nodeId) || !reg.getWorldPosition(_itemPos)) - return Number.POSITIVE_INFINITY + if (!reg.isEligible() || !isRendered(reg.nodeId)) return 0 const { nodeId } = reg let current = nodes[nodeId] while (current) { - if (current.type !== 'site' && current.visible === false) return Number.POSITIVE_INFINITY + if (current.type !== 'site' && current.visible === false) return 0 current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined } const itemLevelId = findLevelAncestorId(nodeId, nodes) - if (selectedLevelId && itemLevelId !== selectedLevelId && levelMode === 'solo') - return Number.POSITIVE_INFINITY - - _dir.copy(_itemPos).sub(camPos).normalize() - const dot = camFwd.dot(_dir) // 1 = ahead, -1 = behind - - // Angular component (0 = dead ahead, 2 = directly behind) - const angular = 1 - dot - // Normalised distance component (assumes scenes < 200 units) - const dist = camPos.distanceTo(_itemPos) / 200 - - // ── Level factor ────────────────────────────────────────────────────────── - let levelPenalty = 0 - if (selectedLevelId) { - if (itemLevelId !== selectedLevelId) { - // Items on hidden levels (every other one in solo, those above the - // selected one while editing) are invisible — deprioritize strongly - const itemIndex = (nodes[itemLevelId as AnyNodeId] as LevelNode | undefined)?.level ?? 0 - const selectedIndex = - (nodes[selectedLevelId as AnyNodeId] as LevelNode | undefined)?.level ?? 0 - const hidden = levelMode === 'solo' || (hideAbove && itemIndex > selectedIndex) - levelPenalty = hidden ? 100 : 0.8 - } - } else if (itemLevelId) { - // No level selected — lightly prefer items on level index 0 - const levelNode = nodes[itemLevelId as AnyNodeId] as LevelNode | undefined - const levelIndex = levelNode?.level ?? 0 - if (levelIndex !== 0) levelPenalty = 0.3 - } - - return angular * 0.7 + dist * 0.3 + levelPenalty + if (!selectedLevelId || itemLevelId === selectedLevelId) return 1 + if (levelMode === 'solo') return 0 + // Above the selected level while editing, a storey is hidden. + const itemIndex = (nodes[itemLevelId as AnyNodeId] as LevelNode | undefined)?.level ?? 0 + const selectedIndex = (nodes[selectedLevelId as AnyNodeId] as LevelNode | undefined)?.level ?? 0 + return hideAbove && itemIndex > selectedIndex ? 0 : OTHER_LEVEL_WEIGHT } export function ItemLightSystem() { @@ -82,12 +53,10 @@ export function ItemLightSystem() { getLights={() => useItemLightPool.getState().registrations.values()} intensity={(reg) => (reg.isEligible() && isRendered(reg.nodeId) ? reg.getIntensity() : 0)} - score={(reg, camPos, camFwd) => { + weight={(reg) => { const viewer = useViewer.getState() - return scoreRegistration( + return registrationWeight( reg, - camPos, - camFwd, useScene.getState().nodes, viewer.selection.levelId, viewer.levelMode, diff --git a/packages/viewer/src/systems/item-light/pooled-lights.tsx b/packages/viewer/src/systems/item-light/pooled-lights.tsx index cd3a9f10df..7c3a978fa7 100644 --- a/packages/viewer/src/systems/item-light/pooled-lights.tsx +++ b/packages/viewer/src/systems/item-light/pooled-lights.tsx @@ -1,16 +1,29 @@ 'use client' import type { LightSpot } from '@pascal-app/core' -import { useFrame } from '@react-three/fiber' -import { useMemo, useRef, useState } from 'react' -import { float, instancedDynamicBufferAttribute, smoothstep, uv } from 'three/tsl' +import { useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + float, + instancedDynamicBufferAttribute, + mrt, + output, + smoothstep, + uv, + vec4, +} from 'three/tsl' import { AdditiveBlending, + type Camera, Color, DynamicDrawUsage, + Frustum, InstancedBufferAttribute, MathUtils, + Matrix4, + type Object3D, type PointLight, + Sphere, type SpotLight, Sprite, SpriteNodeMaterial, @@ -38,43 +51,96 @@ export type PooledLight = { type Props = { getLights: () => Iterable - /** Lower is more wanted; infinite when it cannot be seen lit (off, hidden). */ - score: (light: T, camPos: Vector3, camFwd: Vector3) => number + /** + * 0 while it cannot be seen lit (off, hidden, on a hidden level); otherwise a factor on what it + * adds to the view (a light on another storey than the one being edited counts for less). + */ + weight: (light: T) => number /** What it shines at now: 0 while off or not drawn. */ intensity: (light: T) => number } -const POINT_POOL = 12 -const SPOT_POOL = 6 -const SHADOW_POOL = 2 +/** + * Unshadowed pool lights only lengthen the shaders' light loop (no pass, and no recompile while + * the count holds); a shadow slot adds a depth render each frame, so those stay few. Touch + * devices get smaller pools. + */ +function poolSizes() { + const touch = + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(pointer: coarse)').matches + return touch ? { point: 6, spot: 6, shadow: 2 } : { point: 16, spot: 16, shadow: 2 } +} + // A point light's shadow is six renders and a spot's one, so spots win the shadow slots. -const POINT_SHADOW_PENALTY = 0.5 +const POINT_SHADOW_FACTOR = 0.25 const GLOW_CAPACITY = 256 // How bright a glow stays in full daylight, against 1 at night. const DAYLIGHT_GLOW = 0.08 // Strength 1 is a lamp's plain halo at night, read against tone-mapped light colours. const GLOW_GAIN = 3 +// Where a light with no distance of its own stops counting: intensity / d² under this. +const REACH_CUTOFF = 0.01 +// Nearer than this (metres squared) a light counts as this near, so one at the eye cannot win alone. +const MIN_DISTANCE_SQ = 0.25 // How often (in seconds) to re-evaluate which lights hold slots (fallback timer) const REASSIGN_INTERVAL = 0.2 -// A held slot keeps its key unless a candidate beats it by at least this much (no flicker). +// A held slot keeps its key unless a newcomer adds this much more (no flicker at the boundary). const HYSTERESIS = 0.15 // Camera movement that triggers an early re-evaluation const CAM_MOVE_DIST = 0.5 const CAM_ROT_DOT = 0.995 type Slot = { key: string | null; pendingKey: string | null; isFadingOut: boolean } -type Candidate = { key: string; score: number } +type Candidate = { key: string; priority: number } const slotsOf = (size: number): Slot[] => Array.from({ length: size }, () => ({ key: null, pendingKey: null, isFadingOut: false })) +const _sphere = new Sphere() +const _frustum = new Frustum() +const _viewProjection = new Matrix4() + +/** The frustum `camera` sees, for a capture camera that never rendered as well as the live one. */ +function frustumOf(camera: Camera): Frustum { + camera.updateMatrixWorld() + _viewProjection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse) + return _frustum.setFromProjectionMatrix( + _viewProjection, + camera.coordinateSystem, + camera.reversedDepth, + ) +} + /** - * Gives the wanted keys (best first) the pool's slots: a key that holds a slot keeps it, and a + * Roughly what a light adds to a view: its intensity over its squared distance from the eye, or 0 + * when its reach (its distance, else where it falls under REACH_CUTOFF) misses the view. + */ +export function lightContribution( + position: Vector3, + intensity: number, + distance: number, + eye: Vector3, + frustum: Frustum, +): number { + if (intensity <= 0) return 0 + const reach = distance > 0 ? distance : Math.sqrt(intensity / REACH_CUTOFF) + if (!frustum.intersectsSphere(_sphere.set(position, reach))) return 0 + return intensity / Math.max(position.distanceToSquared(eye), MIN_DISTANCE_SQ) +} + +/** + * Gives the wanted keys (most first) the pool's slots: a key that holds a slot keeps it, and a * newcomer evicts a key no longer wanted only on a clear win. A slot changing hands fades out * first; an idle slot takes its key at once. */ -export function assignSlots(slots: Slot[], wanted: Candidate[], scoreByKey: Map) { +export function assignSlots( + slots: Slot[], + wanted: Candidate[], + priorityByKey: Map, +) { const desired = wanted.map((candidate) => candidate.key) const held = new Map() slots.forEach((slot, i) => { @@ -98,8 +164,8 @@ export function assignSlots(slots: Slot[], wanted: Candidate[], scoreByKey: Map< const slot = slots[free]! const current = slot.key ?? slot.pendingKey if (current && !desired.includes(current)) { - const currentScore = scoreByKey.get(current) ?? Number.POSITIVE_INFINITY - if (currentScore - (scoreByKey.get(key) ?? 0) < HYSTERESIS) { + const currentPriority = priorityByKey.get(current) ?? 0 + if ((priorityByKey.get(key) ?? 0) < currentPriority * (1 + HYSTERESIS)) { free++ continue } @@ -124,6 +190,15 @@ export function assignSlots(slots: Slot[], wanted: Candidate[], scoreByKey: Map< }) } +/** A pool handed over at once, for a picture: no fades, each slot on the key it is going to. */ +function settle(slots: Slot[]) { + for (const slot of slots) { + if (slot.isFadingOut) slot.key = slot.pendingKey + slot.pendingKey = null + slot.isFadingOut = false + } +} + /** How much a glow shows: 1 at night, nearly nothing in daylight. */ export function glowDarkness(sunDirection: Vector3 | null, appearance: 'light' | 'dark'): number { if (!sunDirection) return appearance === 'dark' ? 1 : DAYLIGHT_GLOW @@ -152,7 +227,7 @@ function place(light: PointLight | SpotLight, source: PooledLight) { } const fade = (light: PointLight | SpotLight, to: number, dt: number) => { - light.intensity = MathUtils.lerp(light.intensity, to, dt * 12) + light.intensity = MathUtils.lerp(light.intensity, to, Math.min(1, dt * 12)) if (to <= 0 && light.intensity < 0.01) light.intensity = 0 } @@ -168,6 +243,7 @@ function drivePool( intensity: (light: T) => number, dt: number, ) { + // dt Infinity: every light straight to its target, for a picture. slots.forEach((slot, i) => { const pair = lights[i] if (!pair?.every(Boolean)) return @@ -216,6 +292,9 @@ function createGlows() { // A soft disc: full at the centre, smoothly nothing at the rim. const falloff = smoothstep(float(1), float(0), uv().sub(0.5).length().mul(2)) material.opacityNode = falloff.mul(falloff) + // Nothing into the scene pass's normals and diffuse colour (alpha 0 under material blending): the + // ink reads the normals, and the whole quad, not just its disc, was an edge there. As the dust. + material.mrtNode = mrt({ output, diffuseColor: vec4(0), normal: vec4(0) }) material.fog = false const sprite = new Sprite(material) sprite.count = 0 @@ -225,106 +304,132 @@ function createGlows() { return { sprite, positions, colors, sizes } } +type CaptureHook = (camera: Camera) => () => void +const captureHooks = new WeakMap>() + /** - * The lights items hold, through fixed pools: twelve point lights, six spots and two - * shadow-casting slots, each handed each tick to the most-wanted lights (camera-proximity - * scored, with hysteresis) and faded across reassignment. Every pool light stays mounted and - * visible and only its intensity moves: changing the light count recompiles every material. - * The spot and shadow pools mount the first time a light needs them, and stay. Glows are one - * instanced sprite for every glowing lamp, pooled or not. + * Sets the item lights of `scene` for a picture taken through `camera`: the pools re-ranked for + * what that camera sees and every light straight at its strength, no fade. A capture camera is not + * the live view's, and a theme switched for the picture leaves no time for lamps to fade in. + * Returns what puts the live view's lights back. */ -export function PooledLights({ getLights, score, intensity }: Props) { +export function itemLightsForCapture(scene: Object3D, camera: Camera): () => void { + const restores = [...(captureHooks.get(scene) ?? [])].map((hook) => hook(camera)) + return () => { + for (const restore of restores) restore() + } +} + +/** + * The lights items hold, through fixed pools of point lights, spots and two shadow-casting slots, + * handed to the lights that add most to the view (intensity over squared distance, among those + * whose reach meets the view frustum), with hysteresis, and faded across reassignment. Every pool + * light stays mounted and visible and only its intensity moves: changing the light count + * recompiles every material. The spot and shadow pools mount the first time a light needs them, + * and stay. Glows are one instanced sprite for every glowing lamp, pooled or not. + */ +export function PooledLights(props: Props) { + const propsRef = useRef(props) + propsRef.current = props + const scene = useThree((state) => state.scene) const atmosphere = useSceneAtmosphere() + const atmosphereRef = useRef(atmosphere) + atmosphereRef.current = atmosphere + const [sizes] = useState(poolSizes) const [needs, setNeeds] = useState({ spots: false, shadows: false, glows: false }) const needsRef = useRef(needs) needsRef.current = needs const pools = useRef({ - point: slotsOf(POINT_POOL), - spot: slotsOf(SPOT_POOL), - shadow: slotsOf(SHADOW_POOL), + point: slotsOf(sizes.point), + spot: slotsOf(sizes.spot), + shadow: slotsOf(sizes.shadow), }) const pointRefs = useRef>>( - Array.from({ length: POINT_POOL }, () => [null]), + Array.from({ length: sizes.point }, () => [null]), ) const spotRefs = useRef>>( - Array.from({ length: SPOT_POOL }, () => [null]), + Array.from({ length: sizes.spot }, () => [null]), ) const shadowRefs = useRef>>( - Array.from({ length: SHADOW_POOL }, () => [null, null]), + Array.from({ length: sizes.shadow }, () => [null, null]), ) const glows = useMemo(createGlows, []) const reassignTimer = useRef(0) const prevCamPos = useRef(new Vector3()) const prevCamFwd = useRef(new Vector3(0, 0, -1)) - useFrame(({ camera }, delta) => { - const dt = Math.min(delta, 0.1) + const collect = () => { const byKey = new Map() - for (const light of getLights()) byKey.set(light.key, light) + for (const light of propsRef.current.getLights()) byKey.set(light.key, light) + return byKey + } + /** Hands the pools to the lights that add most to what `camera` sees. */ + const rank = (camera: Camera, byKey: Map) => { + const { weight, intensity } = propsRef.current + const frustum = frustumOf(camera) camera.getWorldPosition(_camPos) - camera.getWorldDirection(_camFwd) - const camMoved = - _camPos.distanceTo(prevCamPos.current) > CAM_MOVE_DIST || - _camFwd.dot(prevCamFwd.current) < CAM_ROT_DOT - reassignTimer.current -= delta - if (reassignTimer.current <= 0 || camMoved) { - reassignTimer.current = REASSIGN_INTERVAL - prevCamPos.current.copy(_camPos) - prevCamFwd.current.copy(_camFwd) - - const mounted = needsRef.current - const wants = { ...mounted } - const scored: Array = [] - for (const light of byKey.values()) { - wants.spots ||= Boolean(light.spot) - wants.shadows ||= Boolean(light.shadows) - wants.glows ||= Boolean(light.glow) - const value = score(light, _camPos, _camFwd) - if (Number.isFinite(value)) scored.push({ key: light.key, score: value, light }) - } - if ( - wants.spots !== mounted.spots || - wants.shadows !== mounted.shadows || - wants.glows !== mounted.glows - ) - setNeeds(wants) - scored.sort((a, b) => a.score - b.score) - const scoreByKey = new Map(scored.map((candidate) => [candidate.key, candidate.score])) - - const shadowed = mounted.shadows - ? scored - .filter((candidate) => candidate.light.shadows) - .map((candidate) => ({ - key: candidate.key, - score: candidate.score + (candidate.light.spot ? 0 : POINT_SHADOW_PENALTY), - })) - .sort((a, b) => a.score - b.score) - .slice(0, SHADOW_POOL) - : [] - const inShadowPool = new Set(shadowed.map((candidate) => candidate.key)) - const rest = scored.filter((candidate) => !inShadowPool.has(candidate.key)) - assignSlots(pools.current.shadow, shadowed, scoreByKey) - assignSlots( - pools.current.spot, - mounted.spots ? rest.filter((candidate) => candidate.light.spot).slice(0, SPOT_POOL) : [], - scoreByKey, - ) - // Until the spot pool mounts, a spot shines as a point light. - assignSlots( - pools.current.point, - rest.filter((candidate) => !(mounted.spots && candidate.light.spot)).slice(0, POINT_POOL), - scoreByKey, - ) + const mounted = needsRef.current + const wants = { ...mounted } + const ranked: Array = [] + for (const light of byKey.values()) { + wants.spots ||= Boolean(light.spot) + wants.shadows ||= Boolean(light.shadows) + wants.glows ||= Boolean(light.glow) + const factor = weight(light) + if (factor <= 0 || !light.getWorldPosition(_pos)) continue + const priority = + factor * lightContribution(_pos, intensity(light), light.distance, _camPos, frustum) + if (priority > 0) ranked.push({ key: light.key, priority, light }) } + if ( + wants.spots !== mounted.spots || + wants.shadows !== mounted.shadows || + wants.glows !== mounted.glows + ) + setNeeds(wants) + ranked.sort((a, b) => b.priority - a.priority) + const priorityByKey = new Map(ranked.map((candidate) => [candidate.key, candidate.priority])) + const shadowed = mounted.shadows + ? ranked + .filter((candidate) => candidate.light.shadows) + .map((candidate) => ({ + key: candidate.key, + priority: candidate.priority * (candidate.light.spot ? 1 : POINT_SHADOW_FACTOR), + })) + .sort((a, b) => b.priority - a.priority) + .slice(0, sizes.shadow) + : [] + const inShadowPool = new Set(shadowed.map((candidate) => candidate.key)) + const rest = ranked.filter((candidate) => !inShadowPool.has(candidate.key)) + assignSlots(pools.current.shadow, shadowed, priorityByKey) + assignSlots( + pools.current.spot, + mounted.spots ? rest.filter((candidate) => candidate.light.spot).slice(0, sizes.spot) : [], + priorityByKey, + ) + // Until the spot pool mounts, a spot shines as a point light. + assignSlots( + pools.current.point, + rest.filter((candidate) => !(mounted.spots && candidate.light.spot)).slice(0, sizes.point), + priorityByKey, + ) + } + + const drive = (byKey: Map, dt: number) => { + const { intensity } = propsRef.current drivePool(pools.current.point, pointRefs.current, byKey, intensity, dt) drivePool(pools.current.spot, spotRefs.current, byKey, intensity, dt) drivePool(pools.current.shadow, shadowRefs.current, byKey, intensity, dt) + } + /** The glows, each drawn its radius nearer `eye` so the fixture around the bulb does not hide it. */ + const placeGlows = (byKey: Map, eye: Vector3) => { if (!needsRef.current.glows) return + const { intensity } = propsRef.current const darkness = glowDarkness( - atmosphere?.sunDirection ?? null, + atmosphereRef.current?.sunDirection ?? null, useViewer.getState().sceneAppearance.appearance, ) let count = 0 @@ -332,8 +437,7 @@ export function PooledLights({ getLights, score, intensit if (!light.glow || count >= GLOW_CAPACITY) continue const now = intensity(light) if (now <= 0 || !light.getWorldPosition(_pos)) continue - // Drawn its radius nearer the eye, so the fixture around the bulb does not swallow it. - _dir.subVectors(_camPos, _pos) + _dir.subVectors(eye, _pos) _pos.addScaledVector( _dir, Math.min(light.glow.size / 2, _dir.length() / 2) / (_dir.length() || 1), @@ -352,6 +456,62 @@ export function PooledLights({ getLights, score, intensit glows.positions.needsUpdate = true glows.colors.needsUpdate = true glows.sizes.needsUpdate = true + } + + useEffect(() => { + const hook: CaptureHook = (camera) => { + const slots = Object.values(pools.current).map((pool) => pool.map((slot) => ({ ...slot }))) + const lights = [...pointRefs.current, ...spotRefs.current, ...shadowRefs.current] + .flat() + .filter((light): light is PointLight | SpotLight => Boolean(light)) + const intensities = lights.map((light) => light.intensity) + const byKey = collect() + rank(camera, byKey) + for (const pool of Object.values(pools.current)) settle(pool) + drive(byKey, Number.POSITIVE_INFINITY) + camera.getWorldPosition(_camPos) + placeGlows(byKey, _camPos) + return () => { + Object.values(pools.current).forEach((pool, i) => { + pool.forEach((slot, j) => { + Object.assign(slot, slots[i]![j]) + }) + }) + lights.forEach((light, i) => { + light.intensity = intensities[i]! + }) + reassignTimer.current = 0 + } + } + let hooks = captureHooks.get(scene) + if (!hooks) { + hooks = new Set() + captureHooks.set(scene, hooks) + } + hooks.add(hook) + return () => { + hooks.delete(hook) + } + // biome-ignore lint/correctness/useExhaustiveDependencies: the hook reads the latest props and pools through refs + }, [scene]) + + useFrame(({ camera }, delta) => { + const byKey = collect() + camera.getWorldPosition(_camPos) + camera.getWorldDirection(_camFwd) + const camMoved = + _camPos.distanceTo(prevCamPos.current) > CAM_MOVE_DIST || + _camFwd.dot(prevCamFwd.current) < CAM_ROT_DOT + reassignTimer.current -= delta + if (reassignTimer.current <= 0 || camMoved) { + reassignTimer.current = REASSIGN_INTERVAL + prevCamPos.current.copy(_camPos) + prevCamFwd.current.copy(_camFwd) + rank(camera, byKey) + } + drive(byKey, Math.min(delta, 0.1)) + camera.getWorldPosition(_camPos) + placeGlows(byKey, _camPos) }, 6) return (