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 { 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<readonly [number, number]> = [
[0, 1],
[1, 3],
@@ -35,6 +43,24 @@ const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
[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(
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;
+8 -4
View File
@@ -23,10 +23,14 @@ export const MAP_OCTREE_COLLISION_BOXES = {
} as const satisfies Record<string, MapOctreeCollisionBox>;
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],
+8
View File
@@ -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);
});
});
}
@@ -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<DebugVisualsStore>((set) => ({
@@ -28,4 +30,6 @@ export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }),
octreeOpacity: 0.35,
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();
}
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<AmbientLight>(null);
const sun = useRef<DirectionalLight>(null);
const sunTarget = useRef<Object3D>(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");