Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/editor/src/components/editor/thumbnail-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
GRID_LAYER,
heroCameraPose,
holdLiveFrame,
itemLightsForCapture,
pendingSceneBuildCount,
presentLevelsForCapture,
refreshIsolation,
Expand Down Expand Up @@ -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
Expand Down
61 changes: 12 additions & 49 deletions packages/viewer/src/components/viewer/glb-interactive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,17 +255,13 @@ export function GlbInteractive({
identity,
zones,
actions,
levelOrder,
}: {
items: GlbInteractiveItem[]
identity: Map<string, Object3D>
zones: GlbZoneRef[]
/** Baked animation actions keyed by clip name — ambient item loops play from
* `<pascalId>: loop`. */
actions: Record<string, AnimationAction | null>
/** 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(() => {
Expand Down Expand Up @@ -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.
Expand All @@ -324,7 +316,7 @@ export function GlbInteractive({

return (
<>
<GlbItemLights levelIndexById={levelIndexById} regs={lightRegs} />
<GlbItemLights regs={lightRegs} />
{animationItems.map((item) =>
item.scripted ? (
<GlbScriptedClips actions={actions} item={item} key={item.pascalId} />
Expand Down Expand Up @@ -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. */
Expand All @@ -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<string, number>,
): 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<string, number>
}) {
function GlbItemLights({ regs }: { regs: GlbLightReg[] }) {
return (
<PooledLights<GlbLightReg>
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)
}}
/>
)
Expand Down
3 changes: 0 additions & 3 deletions packages/viewer/src/components/viewer/glb-scene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -1013,7 +1011,6 @@ export function GlbScene({
actions={actions}
identity={identity}
items={interactiveItems}
levelOrder={levelOrder}
zones={zoneEntries}
/>
) : null}
Expand Down
1 change: 1 addition & 0 deletions packages/viewer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
138 changes: 131 additions & 7 deletions packages/viewer/src/systems/item-light/item-light-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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<ReturnType<typeof create>>, 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()
}
})
Loading
Loading