Feat/polish-mission1 #12

Merged
math-pixel merged 42 commits from feat/polish-mission1 into develop 2026-06-01 21:51:09 +00:00
5 changed files with 178 additions and 190 deletions
Showing only changes of commit 134c0aecb7 - Show all commits
+30 -21
View File
@@ -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.
+1 -30
View File
@@ -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<Object3D | null>,
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]);
}
+105
View File
@@ -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<DirectionalLight | null>;
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<Material>();
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;
}
+16 -23
View File
@@ -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]);
+26 -116
View File
@@ -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<AmbientLight>(null);
const sun = useRef<DirectionalLight>(null);
const sunTarget = useRef<Object3D>(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 (