diff --git a/docs/technical/three-debugging.md b/docs/technical/three-debugging.md index a79cf50..467239c 100644 --- a/docs/technical/three-debugging.md +++ b/docs/technical/three-debugging.md @@ -44,30 +44,39 @@ through opaque geometry. ## Shadow rendering intermittence Shadows occasionally failed to render on initial load and could disappear -mid-session even though the `Lighting` configuration ran to completion. +mid-session even though the `Lighting` configuration ran to completion. The +fix has two layers: -Root cause: the sun follows the camera (its world matrix is dirty every frame -via `updateMatrixWorld()` inside `Lighting.useFrame`). With `shadow.autoUpdate` -alone, three.js can skip the shadow map re-render on a frame where the matrix -update has happened but the renderer's internal dirty tracking does not pick -it up, leaving the shadow map stale or unrendered. +### Per-frame refresh (steady state) -Fix in `src/world/Lighting.tsx`: explicit `sun.shadow.needsUpdate = true` in -two places, restoring the belt-and-suspenders pattern from `develop`: +The sun follows the camera, so its world matrix is dirty every frame. With +`shadow.autoUpdate` alone, three.js can skip the shadow map re-render on a +frame where the matrix update has happened but the renderer's internal dirty +tracking does not pick it up. To prevent that, `Lighting.useFrame` sets +`sun.shadow.needsUpdate = true` after the per-frame matrix updates. Shadow +config is centralized in `src/data/world/lightingConfig.ts` (`bias=0`, +`normalBias=0`, `cameraSize=95`). -- After `configureSunShadow(...)` in the mount `useEffect`. -- At the end of the `useFrame` block, right after `sun.updateMatrixWorld()`. +### Mount-time shadow map reallocation (`useShadowMapWarmup`) -Mitigations also in place: +The merged static map and other GLTFs mount imperatively after `Lighting`, +so the shadow render target ends up linked to a renderer state that pre-dates +the final scene. Materials compiled at that point bake a "no shadow map" +permutation into their shader program and silently fail to render shadows +until a WebGL context-restore cycle (the kind triggered by Chrome DevTools +in `?debug` runs) reallocates everything. -- Shadow config centralized in `src/data/world/lightingConfig.ts` - (`bias=0`, `normalBias=0`, `cameraSize=95`). -- Late-suspension Suspense boundaries in `World.tsx` to prevent global scene - remounts that would re-run shadow setup mid-load. -- `gl.shadowMap.needsUpdate = true` on `onCreated` and on - `webglcontextrestored` in `src/pages/page.tsx`. +`src/hooks/three/useShadowMapWarmup.ts` replays that cycle programmatically +without the cost of a full context loss. It runs a `useFrame` watchdog that +samples the scene mesh count every 6 frames; once the count has been stable +for ~1 s (or after a 5 s safety cap), it: -If the issue reproduces, capture `[diag]`-style logs from `useOctreeGraphNode`, -`Lighting`, and `GameMapCollision` to confirm there is no extra configuration -pass (which would indicate a remaining suspending hook outside the existing -Suspense boundaries). +1. Disposes the directional light shadow map and nulls it. three.js + reallocates the render target on the next render at the configured + `mapSize`. +2. Marks every material's `needsUpdate = true`, forcing a shader recompile + that rebinds every program to the freshly created shadow sampler. +3. Forces a single shadow pass and invalidates the renderer. + +The watchdog runs once per mount and adds a single traversal every 6 frames +during the warmup window, after which it self-terminates. diff --git a/src/hooks/three/useOctreeGraphNode.ts b/src/hooks/three/useOctreeGraphNode.ts index 1cdc44b..994ba0b 100644 --- a/src/hooks/three/useOctreeGraphNode.ts +++ b/src/hooks/three/useOctreeGraphNode.ts @@ -1,29 +1,9 @@ import { useEffect, useRef } from "react"; import type { RefObject } from "react"; -import { Mesh, type Object3D } from "three"; +import { type Object3D } from "three"; import { Octree } from "three-stdlib"; import type { OctreeReadyHandler } from "@/types/three/three"; -// [diag] temporary — count meshes/triangles captured in the octree graph node -function snapshotGraphNode(node: Object3D): { - meshCount: number; - triCount: number; -} { - let meshCount = 0; - let triCount = 0; - node.traverse((obj) => { - if (obj instanceof Mesh) { - meshCount += 1; - const geom = obj.geometry; - const idx = geom.index; - triCount += idx - ? idx.count / 3 - : (geom.attributes.position?.count ?? 0) / 3; - } - }); - return { meshCount, triCount }; -} - export function useOctreeGraphNode( graphNodeRef: RefObject, onOctreeReady: OctreeReadyHandler, @@ -48,15 +28,6 @@ export function useOctreeGraphNode( const octree = new Octree(); octree.fromGraphNode(graphNode); - // [diag] temporary — log octree contents to detect partial builds - const snapshot = snapshotGraphNode(graphNode); - console.log("[octree:build]", { - rebuildKey, - meshCount: snapshot.meshCount, - triCount: Math.round(snapshot.triCount), - timestamp: performance.now().toFixed(0), - }); - onOctreeReady(octree); }, [enabled, graphNodeRef, onOctreeReady, rebuildKey]); } diff --git a/src/hooks/three/useShadowMapWarmup.ts b/src/hooks/three/useShadowMapWarmup.ts new file mode 100644 index 0000000..5d9b16e --- /dev/null +++ b/src/hooks/three/useShadowMapWarmup.ts @@ -0,0 +1,105 @@ +import { useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import { + Material, + Mesh, + type DirectionalLight, + type Scene, + type WebGLRenderer, +} from "three"; + +interface UseShadowMapWarmupOptions { + /** Light whose shadow map should be reallocated once the scene stabilizes. */ + light: React.RefObject; + scene: Scene; + gl: WebGLRenderer; + invalidate: () => void; + /** Frames the mesh count must remain unchanged to consider the scene stable. */ + stableFramesThreshold?: number; + /** Hard cap on how long we keep watching, in frames (~5s @60fps). */ + safetyCapFrames?: number; + /** Sample mesh count every N frames to keep the traversal cost negligible. */ + sampleEveryFrames?: number; +} + +export function useShadowMapWarmup({ + light, + scene, + gl, + invalidate, + stableFramesThreshold = 60, + safetyCapFrames = 300, + sampleEveryFrames = 6, +}: UseShadowMapWarmupOptions): void { + const meshCountRef = useRef(0); + const stableFramesRef = useRef(0); + const watchFramesRef = useRef(0); + const doneRef = useRef(false); + + useFrame(() => { + if (doneRef.current || !light.current) return; + + watchFramesRef.current += 1; + + if (watchFramesRef.current % sampleEveryFrames === 0) { + let meshCount = 0; + scene.traverse((object) => { + if (object instanceof Mesh) meshCount += 1; + }); + + if (meshCount !== meshCountRef.current) { + meshCountRef.current = meshCount; + stableFramesRef.current = 0; + } else { + stableFramesRef.current += sampleEveryFrames; + } + } + + const stableEnough = stableFramesRef.current >= stableFramesThreshold; + const safetyCapReached = watchFramesRef.current >= safetyCapFrames; + if (!stableEnough && !safetyCapReached) return; + + doneRef.current = true; + reallocateShadowMap(light.current); + invalidateAllMaterials(scene); + forceShadowPass(gl, scene, light.current); + invalidate(); + }); +} + +function reallocateShadowMap(light: DirectionalLight): void { + const shadowMap = light.shadow.map; + if (!shadowMap) return; + + shadowMap.dispose(); + light.shadow.map = null; +} + +function invalidateAllMaterials(scene: Scene): void { + const seen = new Set(); + scene.traverse((object) => { + if (!(object instanceof Mesh)) return; + const materials = Array.isArray(object.material) + ? object.material + : [object.material]; + for (const material of materials) { + if (!material || seen.has(material)) continue; + seen.add(material); + material.needsUpdate = true; + } + }); +} + +function forceShadowPass( + gl: WebGLRenderer, + scene: Scene, + light: DirectionalLight, +): void { + scene.updateMatrixWorld(true); + light.target.updateMatrixWorld(true); + light.updateMatrixWorld(true); + light.shadow.camera.updateMatrixWorld(true); + light.shadow.camera.updateProjectionMatrix(); + light.shadow.needsUpdate = true; + gl.shadowMap.needsUpdate = true; +} diff --git a/src/world/GameMapCollision.tsx b/src/world/GameMapCollision.tsx index 633f3d5..55e4446 100644 --- a/src/world/GameMapCollision.tsx +++ b/src/world/GameMapCollision.tsx @@ -272,37 +272,30 @@ function CollisionModelInstance({ }); const sceneInstance = useClonedObject(scene); useEffect(() => { - // Strip the door slab from the la fabrik collision octree so the player - // can walk through the doorway. The visual model is rendered separately - // by MergedStaticMapModel and is unaffected. + // Strip the door slab AND its Solidify-modifier frame from the la fabrik + // collision octree so the player can walk through the doorway. The visual + // model is rendered separately by `MergedStaticMapModel` and is unaffected. + // + // - `porte` (+ Blender suffixes `porte.001` / `porte_001`): the door slab + // itself. We exclude unrelated names like `porte stock` (a shelf of + // stocked doors) by requiring an exact match or a numeric suffix only. + // - Children of a `Thicken` parent: the doorway frame produced by + // Blender's Solidify modifier. Its world AABBs sit right inside the + // doorway and otherwise prevent the player from entering / exiting. if (node.name !== "lafabrik") return; - // Strip the door slab (and any Blender-suffixed variant like `porte.001`, - // `porte_001`) from the la fabrik collision octree so the player can walk - // through the doorway. The visual model is rendered separately by - // MergedStaticMapModel and is unaffected. We exclude unrelated names like - // `porte stock` (a shelf of stocked doors) by requiring an exact match or - // a numeric suffix only. const isDoorSlab = (name: string): boolean => name === "porte" || /^porte[._]\d+$/i.test(name); + const isDoorFrameThickenChild = (child: THREE.Object3D): boolean => + child.parent?.name === "Thicken"; - // [diag] temporary — collect all door-like candidate names to debug stripping - const candidates: string[] = []; - const removed: THREE.Object3D[] = []; + const doorMeshes: THREE.Object3D[] = []; sceneInstance.traverse((child) => { - if (/porte/i.test(child.name)) { - candidates.push(child.name); - } - if (isDoorSlab(child.name)) { - removed.push(child); + if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) { + doorMeshes.push(child); } }); - console.log("[lafabrik:porte-strip]", { - candidates, - strippedCount: removed.length, - strippedNames: removed.map((c) => c.name), - }); - for (const child of removed) { + for (const child of doorMeshes) { child.removeFromParent(); } }, [node.name, sceneInstance]); diff --git a/src/world/Lighting.tsx b/src/world/Lighting.tsx index 23ae7b2..714f44c 100644 --- a/src/world/Lighting.tsx +++ b/src/world/Lighting.tsx @@ -1,12 +1,10 @@ import { useEffect, useRef } from "react"; import { useFrame, useThree } from "@react-three/fiber"; import { - Mesh, PCFShadowMap, type AmbientLight, type DirectionalLight, type Object3D, - type Scene, type WebGLRenderer, } from "three"; import { @@ -29,6 +27,7 @@ import { } from "@/data/world/lightingConfig"; import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig"; import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; +import { useShadowMapWarmup } from "@/hooks/three/useShadowMapWarmup"; import { LIGHTING_STATE } from "@/world/lightingState"; function configureRendererShadows(gl: WebGLRenderer): void { @@ -53,35 +52,17 @@ function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void { sun.shadow.camera.updateProjectionMatrix(); } -function forceShadowPass( - gl: WebGLRenderer, - scene: Scene, +function placeSunRelativeToCamera( sun: DirectionalLight, + sunTarget: Object3D, + cameraPosition: { x: number; z: number }, ): 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; - castShadowCount: number; - receiveShadowCount: number; -} { - let meshCount = 0; - let castShadowCount = 0; - let receiveShadowCount = 0; - scene.traverse((obj) => { - if (obj instanceof Mesh) { - meshCount += 1; - if (obj.castShadow) castShadowCount += 1; - if (obj.receiveShadow) receiveShadowCount += 1; - } - }); - return { meshCount, castShadowCount, receiveShadowCount }; + sunTarget.position.set(cameraPosition.x, 0, cameraPosition.z); + sun.position.set( + cameraPosition.x + LIGHTING_STATE.sunX, + LIGHTING_STATE.sunY, + cameraPosition.z + LIGHTING_STATE.sunZ, + ); } export function Lighting(): React.JSX.Element { @@ -92,68 +73,20 @@ export function Lighting(): React.JSX.Element { const ambient = useRef(null); const sun = useRef(null); const sunTarget = useRef(null); - const lastDiagAtRef = useRef(0); useEffect(() => { if (!sun.current || !sunTarget.current) return; - configureSunShadow(sun.current, sunTarget.current); configureRendererShadows(gl); + configureSunShadow(sun.current, sunTarget.current); + // Prime the sun + target onto the camera before the first shadow pass so + // the initial shadow frustum already covers the visible scene; without + // this, the first frame is rendered with the default (origin-centered) + // frustum and shadows can appear absent until the player moves. + placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position); + }, [camera, gl]); - // 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); - console.log("[shadow:mount]", { - shadowMapEnabled: gl.shadowMap.enabled, - shadowMapType: gl.shadowMap.type, - shadowAutoUpdate: gl.shadowMap.autoUpdate, - sunCastShadow: sun.current.castShadow, - hasShadowMap: !!sun.current.shadow.map, - ...counts, - }); - - // [diag] temporary — track WebGL context loss/restore to correlate with shadow drops - const canvas = gl.domElement; - const handleContextLost = (event: Event) => { - event.preventDefault(); - console.log("[ctx:lost]", { timestamp: performance.now().toFixed(0) }); - }; - const handleContextRestored = () => { - console.log("[ctx:restored]", { - timestamp: performance.now().toFixed(0), - }); - if (sun.current) { - 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, invalidate, scene]); + useShadowMapWarmup({ light: sun, scene, gl, invalidate }); useDebugFolder("Lighting", (folder) => { folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color"); @@ -187,43 +120,20 @@ export function Lighting(): React.JSX.Element { .name("Sun Z"); }); - useFrame(({ clock }) => { + useFrame(() => { if (ambient.current) { ambient.current.color.set(LIGHTING_STATE.ambientColor); ambient.current.intensity = LIGHTING_STATE.ambientIntensity; } - if (sun.current && sunTarget.current) { - sunTarget.current.position.set(camera.position.x, 0, camera.position.z); - sunTarget.current.updateMatrixWorld(); - sun.current.position.set( - camera.position.x + LIGHTING_STATE.sunX, - LIGHTING_STATE.sunY, - camera.position.z + LIGHTING_STATE.sunZ, - ); - sun.current.color.set(LIGHTING_STATE.sunColor); - sun.current.intensity = LIGHTING_STATE.sunIntensity; - sun.current.updateMatrixWorld(); - sun.current.shadow.needsUpdate = true; - } + if (!sun.current || !sunTarget.current) return; - // [diag] periodic shadow pipeline check (every 2s) - const now = clock.getElapsedTime(); - if (now - lastDiagAtRef.current > 2 && sun.current) { - lastDiagAtRef.current = now; - console.log("[shadow:tick]", { - shadowMapEnabled: gl.shadowMap.enabled, - shadowAutoUpdate: gl.shadowMap.autoUpdate, - sunCastShadow: sun.current.castShadow, - sunIntensity: sun.current.intensity, - hasShadowMapTexture: !!sun.current.shadow.map?.texture, - sunPos: sun.current.position.toArray().map((n) => Number(n.toFixed(2))), - targetPos: sunTarget.current?.position - .toArray() - .map((n) => Number(n.toFixed(2))), - renderCalls: gl.info.render.calls, - }); - } + placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position); + sunTarget.current.updateMatrixWorld(); + sun.current.color.set(LIGHTING_STATE.sunColor); + sun.current.intensity = LIGHTING_STATE.sunIntensity; + sun.current.updateMatrixWorld(); + sun.current.shadow.needsUpdate = true; }); return (