Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39b996eb31 | |||
| 134c0aecb7 | |||
| b144dc1c18 | |||
| 69c720b86b |
@@ -44,30 +44,39 @@ through opaque geometry.
|
|||||||
## Shadow rendering intermittence
|
## Shadow rendering intermittence
|
||||||
|
|
||||||
Shadows occasionally failed to render on initial load and could disappear
|
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
|
### Per-frame refresh (steady state)
|
||||||
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.
|
|
||||||
|
|
||||||
Fix in `src/world/Lighting.tsx`: explicit `sun.shadow.needsUpdate = true` in
|
The sun follows the camera, so its world matrix is dirty every frame. With
|
||||||
two places, restoring the belt-and-suspenders pattern from `develop`:
|
`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`.
|
### Mount-time shadow map reallocation (`useShadowMapWarmup`)
|
||||||
- At the end of the `useFrame` block, right after `sun.updateMatrixWorld()`.
|
|
||||||
|
|
||||||
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`
|
`src/hooks/three/useShadowMapWarmup.ts` replays that cycle programmatically
|
||||||
(`bias=0`, `normalBias=0`, `cameraSize=95`).
|
without the cost of a full context loss. It runs a `useFrame` watchdog that
|
||||||
- Late-suspension Suspense boundaries in `World.tsx` to prevent global scene
|
samples the scene mesh count every 6 frames; once the count has been stable
|
||||||
remounts that would re-run shadow setup mid-load.
|
for ~1 s (or after a 5 s safety cap), it:
|
||||||
- `gl.shadowMap.needsUpdate = true` on `onCreated` and on
|
|
||||||
`webglcontextrestored` in `src/pages/page.tsx`.
|
|
||||||
|
|
||||||
If the issue reproduces, capture `[diag]`-style logs from `useOctreeGraphNode`,
|
1. Disposes the directional light shadow map and nulls it. three.js
|
||||||
`Lighting`, and `GameMapCollision` to confirm there is no extra configuration
|
reallocates the render target on the next render at the configured
|
||||||
pass (which would indicate a remaining suspending hook outside the existing
|
`mapSize`.
|
||||||
Suspense boundaries).
|
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.
|
||||||
|
|||||||
Binary file not shown.
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { Mesh, type Object3D } from "three";
|
import { type Object3D } from "three";
|
||||||
import { Octree } from "three-stdlib";
|
import { Octree } from "three-stdlib";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
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(
|
export function useOctreeGraphNode(
|
||||||
graphNodeRef: RefObject<Object3D | null>,
|
graphNodeRef: RefObject<Object3D | null>,
|
||||||
onOctreeReady: OctreeReadyHandler,
|
onOctreeReady: OctreeReadyHandler,
|
||||||
@@ -48,15 +28,6 @@ export function useOctreeGraphNode(
|
|||||||
const octree = new Octree();
|
const octree = new Octree();
|
||||||
octree.fromGraphNode(graphNode);
|
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);
|
onOctreeReady(octree);
|
||||||
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -272,37 +272,20 @@ function CollisionModelInstance({
|
|||||||
});
|
});
|
||||||
const sceneInstance = useClonedObject(scene);
|
const sceneInstance = useClonedObject(scene);
|
||||||
useEffect(() => {
|
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.
|
|
||||||
if (node.name !== "lafabrik") return;
|
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 =>
|
const isDoorSlab = (name: string): boolean =>
|
||||||
name === "porte" || /^porte[._]\d+$/i.test(name);
|
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 doorMeshes: THREE.Object3D[] = [];
|
||||||
const candidates: string[] = [];
|
|
||||||
const removed: THREE.Object3D[] = [];
|
|
||||||
sceneInstance.traverse((child) => {
|
sceneInstance.traverse((child) => {
|
||||||
if (/porte/i.test(child.name)) {
|
if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) {
|
||||||
candidates.push(child.name);
|
doorMeshes.push(child);
|
||||||
}
|
|
||||||
if (isDoorSlab(child.name)) {
|
|
||||||
removed.push(child);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log("[lafabrik:porte-strip]", {
|
for (const child of doorMeshes) {
|
||||||
candidates,
|
|
||||||
strippedCount: removed.length,
|
|
||||||
strippedNames: removed.map((c) => c.name),
|
|
||||||
});
|
|
||||||
for (const child of removed) {
|
|
||||||
child.removeFromParent();
|
child.removeFromParent();
|
||||||
}
|
}
|
||||||
}, [node.name, sceneInstance]);
|
}, [node.name, sceneInstance]);
|
||||||
@@ -350,7 +333,6 @@ function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
|
|||||||
<boxGeometry args={box.size} />
|
<boxGeometry args={box.size} />
|
||||||
<meshBasicMaterial />
|
<meshBasicMaterial />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Octree ignores material.side, so rotate a second shell for X/Z collisions. */}
|
|
||||||
<mesh rotation={[0, Math.PI, 0]}>
|
<mesh rotation={[0, Math.PI, 0]}>
|
||||||
<boxGeometry args={box.size} />
|
<boxGeometry args={box.size} />
|
||||||
<meshBasicMaterial />
|
<meshBasicMaterial />
|
||||||
|
|||||||
+25
-81
@@ -1,12 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import {
|
import {
|
||||||
Mesh,
|
|
||||||
PCFShadowMap,
|
PCFShadowMap,
|
||||||
type AmbientLight,
|
type AmbientLight,
|
||||||
type DirectionalLight,
|
type DirectionalLight,
|
||||||
type Object3D,
|
type Object3D,
|
||||||
type Scene,
|
|
||||||
type WebGLRenderer,
|
type WebGLRenderer,
|
||||||
} from "three";
|
} from "three";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +27,7 @@ import {
|
|||||||
} from "@/data/world/lightingConfig";
|
} from "@/data/world/lightingConfig";
|
||||||
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import { useShadowMapWarmup } from "@/hooks/three/useShadowMapWarmup";
|
||||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||||
|
|
||||||
function configureRendererShadows(gl: WebGLRenderer): void {
|
function configureRendererShadows(gl: WebGLRenderer): void {
|
||||||
@@ -53,73 +52,41 @@ function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
|
|||||||
sun.shadow.camera.updateProjectionMatrix();
|
sun.shadow.camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
// [diag] temporary helper: count shadow-casting/receiving meshes in the scene
|
function placeSunRelativeToCamera(
|
||||||
function snapshotShadowMeshes(scene: Scene): {
|
sun: DirectionalLight,
|
||||||
meshCount: number;
|
sunTarget: Object3D,
|
||||||
castShadowCount: number;
|
cameraPosition: { x: number; z: number },
|
||||||
receiveShadowCount: number;
|
): void {
|
||||||
} {
|
sunTarget.position.set(cameraPosition.x, 0, cameraPosition.z);
|
||||||
let meshCount = 0;
|
sun.position.set(
|
||||||
let castShadowCount = 0;
|
cameraPosition.x + LIGHTING_STATE.sunX,
|
||||||
let receiveShadowCount = 0;
|
LIGHTING_STATE.sunY,
|
||||||
scene.traverse((obj) => {
|
cameraPosition.z + LIGHTING_STATE.sunZ,
|
||||||
if (obj instanceof Mesh) {
|
);
|
||||||
meshCount += 1;
|
|
||||||
if (obj.castShadow) castShadowCount += 1;
|
|
||||||
if (obj.receiveShadow) receiveShadowCount += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { meshCount, castShadowCount, receiveShadowCount };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Lighting(): React.JSX.Element {
|
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);
|
||||||
const lastDiagAtRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sun.current || !sunTarget.current) return;
|
if (!sun.current || !sunTarget.current) return;
|
||||||
|
|
||||||
configureSunShadow(sun.current, sunTarget.current);
|
|
||||||
configureRendererShadows(gl);
|
configureRendererShadows(gl);
|
||||||
sun.current.shadow.needsUpdate = true;
|
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]);
|
||||||
|
|
||||||
// [diag] one-shot scene snapshot to count shadow casters/receivers
|
useShadowMapWarmup({ light: sun, scene, gl, invalidate });
|
||||||
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) {
|
|
||||||
sun.current.shadow.needsUpdate = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
canvas.addEventListener("webglcontextlost", handleContextLost);
|
|
||||||
canvas.addEventListener("webglcontextrestored", handleContextRestored);
|
|
||||||
return () => {
|
|
||||||
canvas.removeEventListener("webglcontextlost", handleContextLost);
|
|
||||||
canvas.removeEventListener("webglcontextrestored", handleContextRestored);
|
|
||||||
};
|
|
||||||
}, [gl, scene]);
|
|
||||||
|
|
||||||
useDebugFolder("Lighting", (folder) => {
|
useDebugFolder("Lighting", (folder) => {
|
||||||
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
||||||
@@ -153,43 +120,20 @@ export function Lighting(): React.JSX.Element {
|
|||||||
.name("Sun Z");
|
.name("Sun Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
useFrame(() => {
|
||||||
if (ambient.current) {
|
if (ambient.current) {
|
||||||
ambient.current.color.set(LIGHTING_STATE.ambientColor);
|
ambient.current.color.set(LIGHTING_STATE.ambientColor);
|
||||||
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sun.current && sunTarget.current) {
|
if (!sun.current || !sunTarget.current) return;
|
||||||
sunTarget.current.position.set(camera.position.x, 0, camera.position.z);
|
|
||||||
|
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||||
sunTarget.current.updateMatrixWorld();
|
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.color.set(LIGHTING_STATE.sunColor);
|
||||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||||
sun.current.updateMatrixWorld();
|
sun.current.updateMatrixWorld();
|
||||||
sun.current.shadow.needsUpdate = true;
|
sun.current.shadow.needsUpdate = true;
|
||||||
}
|
|
||||||
|
|
||||||
// [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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user