Adjustments pass: shadow bias, polygon editor UX, space-detection load fix (#502)
* fix(viewer): tune shadow biases to stop acne without detaching shadows normalBias 0.02 was too small a texel offset for the building-fit 1024 shadow map and brought back self-shadowing acne. Settle on normalBias 0.08 (0.07 and below acnes, 0.1 reads detached) plus depth bias -0.0005 to suppress the residual acne that a normal bias alone couldn't clear. Also adds a `?debug=shadowcamera` diagnostic that draws a CameraHelper for each shadow camera so the fitted frustum can be inspected while tuning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): polygon editor handle UX — focus, cursors, visibility - vertex/midpoint cylinders ignore scene depth like the edge arrows so they stay visible through walls and slabs; vertex radius 0.1 -> 0.08, midpoint 0.06 -> 0.05, midpoints use the brighter arrow shade at rest - during any drag only the active handle stays mounted (other arrows, vertices, edge bars and the cross disappear) so the gesture reads clearly - handles set pointer cursors: move for vertices/midpoints/cross, and a screen-space direction-aware resize cursor for the edge arrows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): keep ceiling affordances quiet during any interaction The ceiling corner brackets' hit boxes caught drag-time hover (spatial events keep firing during host drags by design), set hoveredId to the ceiling and flashed the ceiling grid mid-gesture — e.g. while dragging a slab polygon vertex. Unmount the brackets while ANY interaction scope is active, and gate CeilingSystem's hover-driven grid reveal on idle scope + no inputDragging as a second layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): stop room detection resurrecting deleted slabs on load Two holes in the wall-driven auto slab sync: - initSpaceDetectionSync started with an empty baseline, so scene hydration (one atomic setScene) read as 'every wall changed' and ran a full detection pass on every load, recreating auto slabs the user had deleted. Seed the baseline from the store at init and treat a level's first snapshot as baseline — detection now only reacts to in-session wall edits. - matchesManualFootprint required mutual coverage, so a single manual slab spanning multiple rooms never suppressed those rooms' auto slabs (only a fraction of it lies inside each room). Suppression now only asks whether the ROOM is substantially covered by the union of manual slabs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9ca3eaa7fe
commit
22c9472066
@@ -252,15 +252,14 @@ function polygonCoverageRatio(subject: Point2D[], covers: Point2D[][]) {
|
||||
}
|
||||
|
||||
// Demoted auto surfaces keep their polygon untouched, so a re-closed room
|
||||
// usually hits the exact-signature manual check. Coverage also handles a room
|
||||
// deliberately split across multiple manual surfaces: their union suppresses
|
||||
// a replacement auto surface as long as the pieces substantially belong to
|
||||
// and cover the room.
|
||||
// usually hits the exact-signature manual check. Coverage handles the rest:
|
||||
// a room split across multiple manual surfaces AND a single manual surface
|
||||
// spanning multiple rooms both suppress a replacement auto surface — what
|
||||
// matters is that the ROOM is already substantially covered, not that any
|
||||
// one manual surface belongs to it (a per-surface "mostly inside the room"
|
||||
// filter dropped multi-room slabs and resurrected deleted auto slabs).
|
||||
function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) {
|
||||
const roomManualPolygons = manualPolygons.filter(
|
||||
(manual) => polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD,
|
||||
)
|
||||
return polygonCoverageRatio(roomPolygon, roomManualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD
|
||||
return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD
|
||||
}
|
||||
|
||||
function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) {
|
||||
@@ -1357,7 +1356,11 @@ export function isSpaceDetectionPaused(): boolean {
|
||||
}
|
||||
|
||||
export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => void {
|
||||
const previousSnapshots = new Map<string, string>()
|
||||
// Baseline from whatever is already in the store. Detection reacts to wall
|
||||
// edits made IN-SESSION (create / move / delete); it must not re-litigate a
|
||||
// scene that merely loaded — rerunning on hydration resurrected auto slabs
|
||||
// the user had deleted in an earlier session.
|
||||
const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes)
|
||||
let isProcessing = false
|
||||
|
||||
const unsubscribe = sceneStore.subscribe((state: any) => {
|
||||
@@ -1380,7 +1383,13 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
|
||||
|
||||
const levelsToUpdate = new Set<string>()
|
||||
for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
|
||||
if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) {
|
||||
// First sight of a level is a hydration baseline, not a wall edit —
|
||||
// `setScene` delivers a loaded scene as one atomic update, and a level's
|
||||
// first wall can't close a room anyway. Record it (below) and only
|
||||
// react to subsequent changes.
|
||||
const previous = previousSnapshots.get(levelId)
|
||||
if (previous === undefined) continue
|
||||
if (previous !== (currentSnapshots.get(levelId) ?? '')) {
|
||||
levelsToUpdate.add(levelId)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -20,10 +20,7 @@ import {
|
||||
} from '../../../lib/ceiling-plan-snap'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor, { isGridSnapActive } from '../../../store/use-editor'
|
||||
import useInteractionScope, {
|
||||
useIsCurveReshape,
|
||||
useMovingNode,
|
||||
} from '../../../store/use-interaction-scope'
|
||||
import useInteractionScope from '../../../store/use-interaction-scope'
|
||||
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
||||
|
||||
const BRACKET_THICKNESS = 0.04
|
||||
@@ -99,8 +96,13 @@ export const CeilingSelectionAffordanceSystem = () => {
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
const movingNode = useMovingNode()
|
||||
const isCurveReshape = useIsCurveReshape()
|
||||
// ANY active interaction (moving/placing a node, reshaping a boundary or
|
||||
// curve, dragging a handle) unmounts the brackets: their ceiling-height hit
|
||||
// boxes would otherwise catch drag-time hover, set `hoveredId` to the
|
||||
// ceiling, and flash the ceiling grid mid-gesture (e.g. while dragging a
|
||||
// slab polygon vertex). The brackets' own corner drag doesn't begin a
|
||||
// scope, so it can't unmount itself.
|
||||
const scopeIdle = useInteractionScope((state) => state.scope.kind === 'idle')
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
|
||||
const ceilings = useScene(
|
||||
@@ -120,8 +122,7 @@ export const CeilingSelectionAffordanceSystem = () => {
|
||||
phase === 'structure' &&
|
||||
mode === 'select' &&
|
||||
structureLayer === 'elements' &&
|
||||
!movingNode &&
|
||||
!isCurveReshape &&
|
||||
scopeIdle &&
|
||||
currentLevelId !== null
|
||||
|
||||
if (!shouldRender) return null
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { Color, type Material, type Mesh } from 'three'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { useMovingNode } from '../../../store/use-interaction-scope'
|
||||
import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope'
|
||||
|
||||
const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff'
|
||||
const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial'
|
||||
@@ -80,11 +80,18 @@ export const CeilingSystem = () => {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||
const hoveredId = useViewer((state) => state.hoveredId)
|
||||
// A pending gesture (polygon/handle drag, reshape, move) must not flash the
|
||||
// ceiling grid when the pointer strays over a ceiling — spatial hover events
|
||||
// keep firing during host drags by design (see use-node-events), so the
|
||||
// reveal is gated here, on the consumer.
|
||||
const inputDragging = useViewer((state) => state.inputDragging)
|
||||
const scopeIdle = useInteractionScope((state) => state.scope.kind === 'idle')
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
const hoveredNode = hoveredId ? nodes[hoveredId as AnyNodeId] : null
|
||||
const hoveredCeilingId = hoveredNode?.type === 'ceiling' ? hoveredNode.id : null
|
||||
const hoveredCeilingId =
|
||||
hoveredNode?.type === 'ceiling' && scopeIdle && !inputDragging ? hoveredNode.id : null
|
||||
|
||||
const levelsToShowCeilings = new Set<string>()
|
||||
|
||||
@@ -153,6 +160,15 @@ export const CeilingSystem = () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId, hoveredId])
|
||||
}, [
|
||||
tool,
|
||||
selectedItem,
|
||||
movingNode,
|
||||
selectedIds,
|
||||
activeLevelId,
|
||||
hoveredId,
|
||||
scopeIdle,
|
||||
inputDragging,
|
||||
])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BoxGeometry,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type Line,
|
||||
type Object3D,
|
||||
Shape,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
@@ -41,6 +42,19 @@ const EDGE_ARROW_OFFSET = 0.34
|
||||
// the `NO_RAYCAST` sentinel in node-arrow-handles.tsx.
|
||||
const NO_RAYCAST = () => null
|
||||
|
||||
// Maps the screen-space direction of an edge's outward normal to the closest
|
||||
// directional resize cursor, so an edge arrow advertises the axis it will
|
||||
// actually drag along regardless of camera orbit. Input is an NDC delta
|
||||
// (y up); the angle is folded to [0°, 180°) since resize cursors are
|
||||
// bidirectional.
|
||||
function resizeCursorForScreenDirection(dx: number, dy: number): string {
|
||||
const angle = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 180) + 180) % 180
|
||||
if (angle < 22.5 || angle >= 157.5) return 'ew-resize'
|
||||
if (angle < 67.5) return 'nesw-resize'
|
||||
if (angle < 112.5) return 'ns-resize'
|
||||
return 'nwse-resize'
|
||||
}
|
||||
|
||||
function createEdgeArrowGeometry() {
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0.22, 0)
|
||||
@@ -201,7 +215,11 @@ export type PolygonMidpointHandleRenderer = (
|
||||
props: PolygonMidpointHandleRenderProps,
|
||||
) => React.ReactNode
|
||||
|
||||
function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMaterial {
|
||||
function usePolygonNodeMaterial(
|
||||
color: string,
|
||||
opacity = 1,
|
||||
depthTest = true,
|
||||
): MeshBasicNodeMaterial {
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
@@ -217,7 +235,8 @@ function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMateri
|
||||
useEffect(() => {
|
||||
material.color.set(color)
|
||||
material.opacity = opacity
|
||||
}, [color, material, opacity])
|
||||
material.depthTest = depthTest
|
||||
}, [color, depthTest, material, opacity])
|
||||
useEffect(() => () => material.dispose(), [material])
|
||||
|
||||
return material
|
||||
@@ -239,7 +258,8 @@ function usePolygonArrowMaterial(): MeshBasicNodeMaterial {
|
||||
}
|
||||
|
||||
// One mesh per handle: lives on SCENE_LAYER with a node material so the
|
||||
// post-processing ink-edge pass outlines it.
|
||||
// post-processing ink-edge pass outlines it. Ignores scene depth like the
|
||||
// edge arrows so the handle stays visible through walls and slabs.
|
||||
function OutlinedCylinderHandle({
|
||||
radius,
|
||||
height,
|
||||
@@ -255,7 +275,7 @@ function OutlinedCylinderHandle({
|
||||
position: [number, number, number]
|
||||
} & PolygonHandleHandlers) {
|
||||
const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius])
|
||||
const material = usePolygonNodeMaterial(color, opacity)
|
||||
const material = usePolygonNodeMaterial(color, opacity, false)
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
return (
|
||||
@@ -452,6 +472,39 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
// When not using portal, edit at world origin
|
||||
const editY = levelNode ? Y_OFFSET : 0
|
||||
|
||||
const camera = useThree((state) => state.camera)
|
||||
|
||||
// Handle-hover cursor, applied to document.body like the registry arrow
|
||||
// handles (see handle-arrow.tsx). Tracked in a ref so we only ever clear a
|
||||
// cursor we set ourselves.
|
||||
const activeBodyCursorRef = useRef<string | null>(null)
|
||||
const setBodyCursor = useCallback((cursor: string) => {
|
||||
activeBodyCursorRef.current = cursor
|
||||
document.body.style.cursor = cursor
|
||||
}, [])
|
||||
const clearBodyCursor = useCallback(() => {
|
||||
if (activeBodyCursorRef.current && document.body.style.cursor === activeBodyCursorRef.current) {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
activeBodyCursorRef.current = null
|
||||
}, [])
|
||||
useEffect(() => () => clearBodyCursor(), [clearBodyCursor])
|
||||
|
||||
const edgeResizeCursor = useCallback(
|
||||
(midpoint: [number, number], outwardNormal: [number, number]) => {
|
||||
const from = new Vector3(midpoint[0], editY, midpoint[1])
|
||||
const to = new Vector3(midpoint[0] + outwardNormal[0], editY, midpoint[1] + outwardNormal[1])
|
||||
if (levelNode) {
|
||||
levelNode.localToWorld(from)
|
||||
levelNode.localToWorld(to)
|
||||
}
|
||||
from.project(camera)
|
||||
to.project(camera)
|
||||
return resizeCursorForScreenDirection(to.x - from.x, to.y - from.y)
|
||||
},
|
||||
[camera, editY, levelNode],
|
||||
)
|
||||
|
||||
// Local state for dragging
|
||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
||||
@@ -706,7 +759,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
onDragCommitRef.current?.()
|
||||
updatePreviewPolygon(null)
|
||||
setDragState(null)
|
||||
}, [onPolygonChange, updatePreviewPolygon])
|
||||
clearBodyCursor()
|
||||
}, [clearBodyCursor, onPolygonChange, updatePreviewPolygon])
|
||||
|
||||
// Handle adding a new vertex at midpoint
|
||||
const handleAddVertex = useCallback(
|
||||
@@ -930,13 +984,16 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Vertex handles - blue cylinders that match surface height */}
|
||||
{/* Vertex handles - blue cylinders that match surface height. During a
|
||||
drag only the active handle stays mounted so the gesture reads
|
||||
clearly (same rule for the cross / edge handles below). */}
|
||||
{displayPolygon.map(([x, z], index) => {
|
||||
const isHovered = hoveredVertex === index
|
||||
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
|
||||
if (dragState && !isDragging) return null
|
||||
const isLinkedHighlighted = isVertexLinkedHighlighted(index)
|
||||
const isHighlighted = isDragging || isHovered || isLinkedHighlighted
|
||||
const radius = 0.1
|
||||
const radius = 0.08
|
||||
const height = handleHeight
|
||||
const point: [number, number] = [x!, z!]
|
||||
const position: [number, number, number] = [x!, editY + height / 2, z!]
|
||||
@@ -969,10 +1026,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
onPointerEnter: (e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(index)
|
||||
setBodyCursor('move')
|
||||
},
|
||||
onPointerLeave: (e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(null)
|
||||
// Keep the cursor for the whole gesture when the pointer slips
|
||||
// off the handle mid-drag; commit clears it.
|
||||
if (!dragState?.isDragging) clearBodyCursor()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1006,7 +1067,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
)
|
||||
})}
|
||||
|
||||
{allowPolygonMove && (
|
||||
{allowPolygonMove && (!dragState || dragState.mode === 'polygon') && (
|
||||
<OutlinedCrossHandle
|
||||
color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
onClick={(e) => {
|
||||
@@ -1026,14 +1087,22 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setBodyCursor('move')
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!dragState?.isDragging) clearBodyCursor()
|
||||
}}
|
||||
position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowEdgeMove &&
|
||||
edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
|
||||
const isHovered = hoveredEdge === index
|
||||
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
|
||||
if (dragState && !isDragging) return null
|
||||
const isLinkedHighlighted = highlightedEdgeIndices.has(index)
|
||||
const isHighlighted = isDragging || isHovered || isLinkedHighlighted
|
||||
const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
|
||||
@@ -1100,10 +1169,12 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredEdge(index)
|
||||
setBodyCursor(edgeResizeCursor(midpoint, outwardNormal))
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredEdge(null)
|
||||
if (!dragState?.isDragging) clearBodyCursor()
|
||||
}}
|
||||
position={[arrowX, edgeHandleY, arrowZ]}
|
||||
rotationY={outwardAngle}
|
||||
@@ -1120,7 +1191,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
const isHovered = hoveredMidpoint === index
|
||||
const isLinkedHighlighted = highlightedEdgeIndices.has(index)
|
||||
const isHighlighted = isHovered || isLinkedHighlighted
|
||||
const radius = 0.06
|
||||
const radius = 0.05
|
||||
const height = handleHeight
|
||||
const point: [number, number] = [x!, z!]
|
||||
const position: [number, number, number] = [x!, editY + height / 2, z!]
|
||||
@@ -1149,10 +1220,12 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
onPointerEnter: (e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(index)
|
||||
setBodyCursor('move')
|
||||
},
|
||||
onPointerLeave: (e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(null)
|
||||
clearBodyCursor()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1174,7 +1247,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<OutlinedCylinderHandle
|
||||
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
// Midpoints read as secondary add-points: the brighter hover
|
||||
// shade at rest distinguishes them from the vertex handles.
|
||||
color={EDGE_ARROW_HOVER_COLOR}
|
||||
height={height}
|
||||
key={`midpoint-${index}`}
|
||||
{...handleProps}
|
||||
|
||||
@@ -23,6 +23,17 @@ const SHADOWS_DISABLED =
|
||||
.map((s) => s.trim()),
|
||||
).has('shadows')
|
||||
|
||||
// Diagnostic toggle: `?debug=shadowcamera` draws a CameraHelper for each
|
||||
// shadow camera so the building-fit frustum (and thus shadow texel density)
|
||||
// can be inspected while tuning margins/bias.
|
||||
const SHADOW_CAMERA_DEBUG =
|
||||
typeof window !== 'undefined' &&
|
||||
new Set(
|
||||
(new URLSearchParams(window.location.search).get('debug') ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim()),
|
||||
).has('shadowcamera')
|
||||
|
||||
// Shadow darkness for the bright key lights (themes drive most lights past
|
||||
// intensity 1). Runs high so shadowed areas actually lose the sun's
|
||||
// contribution — the ambient/hemisphere/IBL stack provides the fill. The old
|
||||
@@ -32,9 +43,12 @@ const MAX_SHADOW_INTENSITY = 0.75
|
||||
|
||||
// `normalBias` is measured in world units. The previous 0.3 moved shadow
|
||||
// lookups 30 cm off their surfaces, visibly detaching wall shadows at the
|
||||
// floor. Keep only a small offset for acne, with a tiny depth bias alongside it.
|
||||
const SHADOW_DEPTH_BIAS = -0.0001
|
||||
const SHADOW_NORMAL_BIAS = 0.02
|
||||
// floor; 0.07 and below still acnes on the building-fit 1024 map (large
|
||||
// texels), so 0.08 is the smallest acne-free value at that resolution — even
|
||||
// with the depth bias at -0.0005 (which is itself needed: 0.08 alone still
|
||||
// showed faint acne at -0.0001).
|
||||
const SHADOW_DEPTH_BIAS = -0.0005
|
||||
const SHADOW_NORMAL_BIAS = 0.08
|
||||
|
||||
// Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the
|
||||
// camera): we union the bounds of all registered scene nodes, fit a sphere, and
|
||||
@@ -77,6 +91,7 @@ export function Lights() {
|
||||
const boundsBox = useRef(new THREE.Box3()) // scratch: union AABB
|
||||
const boundsSphere = useRef(new THREE.Sphere()) // scratch: fitted sphere
|
||||
const lastBoundsTime = useRef(-1) // last refresh timestamp (-1 = never)
|
||||
const shadowHelpers = useRef<Array<THREE.CameraHelper | null>>([])
|
||||
|
||||
const hemiRef = useRef<HemisphereLight>(null)
|
||||
const ambientRef = useRef<AmbientLight>(null)
|
||||
@@ -170,6 +185,15 @@ export function Lights() {
|
||||
cam.near = near
|
||||
cam.far = far
|
||||
cam.updateProjectionMatrix()
|
||||
if (SHADOW_CAMERA_DEBUG) {
|
||||
let helper = shadowHelpers.current[index]
|
||||
if (!helper) {
|
||||
helper = new THREE.CameraHelper(cam)
|
||||
shadowHelpers.current[index] = helper
|
||||
state.scene.add(helper)
|
||||
}
|
||||
helper.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user