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
🔍 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:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user