diff --git a/src/components/debug/DebugOctreeVisualization.tsx b/src/components/debug/DebugOctreeVisualization.tsx index 871d492..8ff8ae7 100644 --- a/src/components/debug/DebugOctreeVisualization.tsx +++ b/src/components/debug/DebugOctreeVisualization.tsx @@ -1,6 +1,10 @@ import { useMemo } from "react"; import { Box3, BufferAttribute, BufferGeometry } from "three"; import type { Octree } from "three-stdlib"; +import { + LA_FABRIK_CENTER, + isInsideLaFabrikFootprint, +} from "@/data/world/laFabrikConfig"; import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore"; interface DebugOctreeVisualizationProps { @@ -18,8 +22,12 @@ interface CollectOptions { minDepth: number; maxDepth: number; leavesOnly: boolean; + fabrikOnly: boolean; } +const FABRIK_FILTER_PADDING = 1.5; +const FABRIK_FILTER_VERTICAL = 8; + const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray = [ [0, 1], [1, 3], @@ -35,6 +43,24 @@ const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray = [ [3, 7], ]; +function boxIntersectsFabrik(box: Box3): boolean { + if (box.max.y < LA_FABRIK_CENTER[1] - FABRIK_FILTER_VERTICAL) return false; + if (box.min.y > LA_FABRIK_CENTER[1] + FABRIK_FILTER_VERTICAL) return false; + + // Sample box corners + center on XZ plane against the rotated fabrik footprint. + const samples: ReadonlyArray = [ + [box.min.x, box.min.z], + [box.min.x, box.max.z], + [box.max.x, box.min.z], + [box.max.x, box.max.z], + [(box.min.x + box.max.x) * 0.5, (box.min.z + box.max.z) * 0.5], + ]; + for (const [x, z] of samples) { + if (isInsideLaFabrikFootprint(x, z, FABRIK_FILTER_PADDING)) return true; + } + return false; +} + function collectOctreeBoxes( node: Octree, options: CollectOptions, @@ -47,8 +73,10 @@ function collectOctreeBoxes( const passesDepth = depth >= options.minDepth; const passesLeafFilter = !options.leavesOnly || isLeaf; const hasTriangles = node.triangles.length > 0; + const passesFabrikFilter = + !options.fabrikOnly || boxIntersectsFabrik(node.box); - if (passesDepth && passesLeafFilter && hasTriangles) { + if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) { acc.push({ box: node.box, depth, @@ -114,6 +142,7 @@ export function DebugOctreeVisualization({ const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth); const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly); const opacity = useDebugVisualsStore((state) => state.octreeOpacity); + const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly); const geometry = useMemo(() => { if (!octree || !showOctree) return null; @@ -121,10 +150,11 @@ export function DebugOctreeVisualization({ minDepth, maxDepth, leavesOnly, + fabrikOnly, }); if (boxes.length === 0) return null; return buildOctreeLineGeometry(boxes); - }, [leavesOnly, maxDepth, minDepth, octree, showOctree]); + }, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]); if (!geometry) return null; diff --git a/src/data/world/octreeCollisionConfig.ts b/src/data/world/octreeCollisionConfig.ts index 88e6076..3af672a 100644 --- a/src/data/world/octreeCollisionConfig.ts +++ b/src/data/world/octreeCollisionConfig.ts @@ -23,10 +23,14 @@ export const MAP_OCTREE_COLLISION_BOXES = { } as const satisfies Record; export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [ - { - center: [-6.9351, 2.278, -0.0001], - size: [0.2, 1.94, 3.711], - }, + // NOTE: removed — this thin wall (size [0.2, 1.94, 3.71]) sat at x≈-6.93 and + // sealed the doorway despite the geometry having a hole there. The fabrik + // mesh octree already provides the surrounding wall collision, so this + // proxy was both redundant and bug-causing. + // { + // center: [-6.9351, 2.278, -0.0001], + // size: [0.2, 1.94, 3.711], + // }, { center: [0.8026, 0.719, -3.639], size: [4.346, 1.108, 1.181], diff --git a/src/hooks/debug/useDebugVisualsDebug.ts b/src/hooks/debug/useDebugVisualsDebug.ts index 7cc46b9..be1538c 100644 --- a/src/hooks/debug/useDebugVisualsDebug.ts +++ b/src/hooks/debug/useDebugVisualsDebug.ts @@ -11,6 +11,7 @@ export function useDebugVisualsDebug(): void { octreeMaxDepth: state.octreeMaxDepth, octreeLeavesOnly: state.octreeLeavesOnly, octreeOpacity: state.octreeOpacity, + octreeFabrikOnly: state.octreeFabrikOnly, }; folder @@ -54,5 +55,12 @@ export function useDebugVisualsDebug(): void { .onChange((value: number) => { useDebugVisualsStore.getState().setOctreeOpacity(value); }); + + folder + .add(controls, "octreeFabrikOnly") + .name("Octree Fabrik Only") + .onChange((value: boolean) => { + useDebugVisualsStore.getState().setOctreeFabrikOnly(value); + }); }); } diff --git a/src/managers/stores/useDebugVisualsStore.ts b/src/managers/stores/useDebugVisualsStore.ts index a8a9496..030e5c2 100644 --- a/src/managers/stores/useDebugVisualsStore.ts +++ b/src/managers/stores/useDebugVisualsStore.ts @@ -13,6 +13,8 @@ interface DebugVisualsStore { setOctreeLeavesOnly: (value: boolean) => void; octreeOpacity: number; setOctreeOpacity: (value: number) => void; + octreeFabrikOnly: boolean; + setOctreeFabrikOnly: (value: boolean) => void; } export const useDebugVisualsStore = create((set) => ({ @@ -28,4 +30,6 @@ export const useDebugVisualsStore = create((set) => ({ setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }), octreeOpacity: 0.35, setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }), + octreeFabrikOnly: false, + setOctreeFabrikOnly: (octreeFabrikOnly) => set({ octreeFabrikOnly }), })); diff --git a/src/world/Lighting.tsx b/src/world/Lighting.tsx index 547d6df..23ae7b2 100644 --- a/src/world/Lighting.tsx +++ b/src/world/Lighting.tsx @@ -53,6 +53,18 @@ function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void { sun.shadow.camera.updateProjectionMatrix(); } +function forceShadowPass( + gl: WebGLRenderer, + scene: Scene, + sun: DirectionalLight, +): void { + scene.updateMatrixWorld(true); + sun.updateMatrixWorld(true); + sun.shadow.camera.updateProjectionMatrix(); + sun.shadow.needsUpdate = true; + gl.shadowMap.needsUpdate = true; +} + // [diag] temporary helper: count shadow-casting/receiving meshes in the scene function snapshotShadowMeshes(scene: Scene): { meshCount: number; @@ -76,6 +88,7 @@ export function Lighting(): React.JSX.Element { const camera = useThree((state) => state.camera); const gl = useThree((state) => state.gl); const scene = useThree((state) => state.scene); + const invalidate = useThree((state) => state.invalidate); const ambient = useRef(null); const sun = useRef(null); const sunTarget = useRef(null); @@ -86,7 +99,25 @@ export function Lighting(): React.JSX.Element { configureSunShadow(sun.current, sunTarget.current); configureRendererShadows(gl); - sun.current.shadow.needsUpdate = true; + + // Multi-frame shadow warmup: forces the shadow pass over 3 consecutive + // frames so newly mounted meshes (loaded asynchronously by Suspense) get + // their world matrices and shadow map properly allocated. Without this, + // shadows can fail to render after a Physics Suspense remount. + let raf1 = 0; + let raf2 = 0; + forceShadowPass(gl, scene, sun.current); + invalidate(); + raf1 = window.requestAnimationFrame(() => { + if (!sun.current) return; + forceShadowPass(gl, scene, sun.current); + invalidate(); + raf2 = window.requestAnimationFrame(() => { + if (!sun.current) return; + forceShadowPass(gl, scene, sun.current); + invalidate(); + }); + }); // [diag] one-shot scene snapshot to count shadow casters/receivers const counts = snapshotShadowMeshes(scene); @@ -110,16 +141,19 @@ export function Lighting(): React.JSX.Element { timestamp: performance.now().toFixed(0), }); if (sun.current) { - sun.current.shadow.needsUpdate = true; + forceShadowPass(gl, scene, sun.current); + invalidate(); } }; canvas.addEventListener("webglcontextlost", handleContextLost); canvas.addEventListener("webglcontextrestored", handleContextRestored); return () => { + window.cancelAnimationFrame(raf1); + window.cancelAnimationFrame(raf2); canvas.removeEventListener("webglcontextlost", handleContextLost); canvas.removeEventListener("webglcontextrestored", handleContextRestored); }; - }, [gl, scene]); + }, [gl, invalidate, scene]); useDebugFolder("Lighting", (folder) => { folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");