fix(world): restore multi-frame shadow warmup and unblock fabrik doorway
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

- Lighting: replace single-frame needsUpdate with a 3-rAF warmup that forces
  scene.updateMatrixWorld + sun.shadow.needsUpdate + gl.shadowMap.needsUpdate.
  This restores the SceneShadowWarmup behaviour (deleted in 777e51e) inline,
  so shadows survive Physics Suspense remounts and webglcontextrestored.
- octreeCollisionConfig: remove (comment out) the thin LA_FABRIK interior box
  at x=-6.93 that was sealing the doorway despite the mesh hole; fabrik mesh
  octree already provides surrounding wall collision.
- DebugOctreeVisualization: add Fabrik-only filter to inspect interior
  collisions/non-collisions in isolation.
This commit is contained in:
Tom Boullay
2026-06-01 22:41:45 +02:00
parent 1b57a25e5f
commit 69c720b86b
5 changed files with 89 additions and 9 deletions
@@ -1,6 +1,10 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Box3, BufferAttribute, BufferGeometry } from "three"; import { Box3, BufferAttribute, BufferGeometry } from "three";
import type { Octree } from "three-stdlib"; import type { Octree } from "three-stdlib";
import {
LA_FABRIK_CENTER,
isInsideLaFabrikFootprint,
} from "@/data/world/laFabrikConfig";
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore"; import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
interface DebugOctreeVisualizationProps { interface DebugOctreeVisualizationProps {
@@ -18,8 +22,12 @@ interface CollectOptions {
minDepth: number; minDepth: number;
maxDepth: number; maxDepth: number;
leavesOnly: boolean; leavesOnly: boolean;
fabrikOnly: boolean;
} }
const FABRIK_FILTER_PADDING = 1.5;
const FABRIK_FILTER_VERTICAL = 8;
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [ const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
[0, 1], [0, 1],
[1, 3], [1, 3],
@@ -35,6 +43,24 @@ const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
[3, 7], [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<readonly [number, number]> = [
[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( function collectOctreeBoxes(
node: Octree, node: Octree,
options: CollectOptions, options: CollectOptions,
@@ -47,8 +73,10 @@ function collectOctreeBoxes(
const passesDepth = depth >= options.minDepth; const passesDepth = depth >= options.minDepth;
const passesLeafFilter = !options.leavesOnly || isLeaf; const passesLeafFilter = !options.leavesOnly || isLeaf;
const hasTriangles = node.triangles.length > 0; const hasTriangles = node.triangles.length > 0;
const passesFabrikFilter =
!options.fabrikOnly || boxIntersectsFabrik(node.box);
if (passesDepth && passesLeafFilter && hasTriangles) { if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) {
acc.push({ acc.push({
box: node.box, box: node.box,
depth, depth,
@@ -114,6 +142,7 @@ export function DebugOctreeVisualization({
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth); const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly); const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
const opacity = useDebugVisualsStore((state) => state.octreeOpacity); const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly);
const geometry = useMemo(() => { const geometry = useMemo(() => {
if (!octree || !showOctree) return null; if (!octree || !showOctree) return null;
@@ -121,10 +150,11 @@ export function DebugOctreeVisualization({
minDepth, minDepth,
maxDepth, maxDepth,
leavesOnly, leavesOnly,
fabrikOnly,
}); });
if (boxes.length === 0) return null; if (boxes.length === 0) return null;
return buildOctreeLineGeometry(boxes); return buildOctreeLineGeometry(boxes);
}, [leavesOnly, maxDepth, minDepth, octree, showOctree]); }, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]);
if (!geometry) return null; if (!geometry) return null;
+8 -4
View File
@@ -23,10 +23,14 @@ export const MAP_OCTREE_COLLISION_BOXES = {
} as const satisfies Record<string, MapOctreeCollisionBox>; } as const satisfies Record<string, MapOctreeCollisionBox>;
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [ export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
{ // NOTE: removed — this thin wall (size [0.2, 1.94, 3.71]) sat at x≈-6.93 and
center: [-6.9351, 2.278, -0.0001], // sealed the doorway despite the geometry having a hole there. The fabrik
size: [0.2, 1.94, 3.711], // 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], center: [0.8026, 0.719, -3.639],
size: [4.346, 1.108, 1.181], size: [4.346, 1.108, 1.181],
+8
View File
@@ -11,6 +11,7 @@ export function useDebugVisualsDebug(): void {
octreeMaxDepth: state.octreeMaxDepth, octreeMaxDepth: state.octreeMaxDepth,
octreeLeavesOnly: state.octreeLeavesOnly, octreeLeavesOnly: state.octreeLeavesOnly,
octreeOpacity: state.octreeOpacity, octreeOpacity: state.octreeOpacity,
octreeFabrikOnly: state.octreeFabrikOnly,
}; };
folder folder
@@ -54,5 +55,12 @@ export function useDebugVisualsDebug(): void {
.onChange((value: number) => { .onChange((value: number) => {
useDebugVisualsStore.getState().setOctreeOpacity(value); useDebugVisualsStore.getState().setOctreeOpacity(value);
}); });
folder
.add(controls, "octreeFabrikOnly")
.name("Octree Fabrik Only")
.onChange((value: boolean) => {
useDebugVisualsStore.getState().setOctreeFabrikOnly(value);
});
}); });
} }
@@ -13,6 +13,8 @@ interface DebugVisualsStore {
setOctreeLeavesOnly: (value: boolean) => void; setOctreeLeavesOnly: (value: boolean) => void;
octreeOpacity: number; octreeOpacity: number;
setOctreeOpacity: (value: number) => void; setOctreeOpacity: (value: number) => void;
octreeFabrikOnly: boolean;
setOctreeFabrikOnly: (value: boolean) => void;
} }
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({ export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
@@ -28,4 +30,6 @@ export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }), setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }),
octreeOpacity: 0.35, octreeOpacity: 0.35,
setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }), setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }),
octreeFabrikOnly: false,
setOctreeFabrikOnly: (octreeFabrikOnly) => set({ octreeFabrikOnly }),
})); }));
+37 -3
View File
@@ -53,6 +53,18 @@ function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
sun.shadow.camera.updateProjectionMatrix(); 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 // [diag] temporary helper: count shadow-casting/receiving meshes in the scene
function snapshotShadowMeshes(scene: Scene): { function snapshotShadowMeshes(scene: Scene): {
meshCount: number; meshCount: number;
@@ -76,6 +88,7 @@ export function Lighting(): React.JSX.Element {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl); const gl = useThree((state) => state.gl);
const scene = useThree((state) => state.scene); const scene = useThree((state) => state.scene);
const invalidate = useThree((state) => state.invalidate);
const ambient = useRef<AmbientLight>(null); const ambient = useRef<AmbientLight>(null);
const sun = useRef<DirectionalLight>(null); const sun = useRef<DirectionalLight>(null);
const sunTarget = useRef<Object3D>(null); const sunTarget = useRef<Object3D>(null);
@@ -86,7 +99,25 @@ export function Lighting(): React.JSX.Element {
configureSunShadow(sun.current, sunTarget.current); configureSunShadow(sun.current, sunTarget.current);
configureRendererShadows(gl); 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 // [diag] one-shot scene snapshot to count shadow casters/receivers
const counts = snapshotShadowMeshes(scene); const counts = snapshotShadowMeshes(scene);
@@ -110,16 +141,19 @@ export function Lighting(): React.JSX.Element {
timestamp: performance.now().toFixed(0), timestamp: performance.now().toFixed(0),
}); });
if (sun.current) { if (sun.current) {
sun.current.shadow.needsUpdate = true; forceShadowPass(gl, scene, sun.current);
invalidate();
} }
}; };
canvas.addEventListener("webglcontextlost", handleContextLost); canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener("webglcontextrestored", handleContextRestored); canvas.addEventListener("webglcontextrestored", handleContextRestored);
return () => { return () => {
window.cancelAnimationFrame(raf1);
window.cancelAnimationFrame(raf2);
canvas.removeEventListener("webglcontextlost", handleContextLost); canvas.removeEventListener("webglcontextlost", handleContextLost);
canvas.removeEventListener("webglcontextrestored", handleContextRestored); canvas.removeEventListener("webglcontextrestored", handleContextRestored);
}; };
}, [gl, scene]); }, [gl, invalidate, scene]);
useDebugFolder("Lighting", (folder) => { useDebugFolder("Lighting", (folder) => {
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color"); folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");