3 Commits

Author SHA1 Message Date
Tom Boullay a2a491bd5c chore(world): add temporary shadow pipeline diagnostics
🔍 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
Shadows still go missing intermittently despite the per-frame
needsUpdate fix. Add temporary console logs to narrow down the cause:

- [shadow:mount]: one-shot snapshot of renderer shadow flags and a
  scene.traverse count of meshes with castShadow / receiveShadow at
  Lighting mount time.
- [shadow:tick]: every 2s during useFrame, log shadow map enabled flag,
  autoUpdate, sun.castShadow, sun intensity, shadow map texture
  presence, sun and target world positions, and renderer draw calls.

To be removed once the root cause is identified.
2026-06-01 16:50:21 +02:00
Tom Boullay da7d66e1fd feat(debug): add filters to octree visualization
Default visualization was unreadable because every node from depth 0 to
maxDepth was rendered with rainbow-coloured edges. Add three filters
exposed in the Debug folder:

- Octree Leaves Only (default true): skip internal nodes
- Octree Min Depth (default 4): hide the largest enclosing boxes
- Octree Opacity (default 0.35): tone down line density

Also skip nodes without triangles, drop the per-depth HSL palette in
favour of a uniform cyan, and bump default Octree Max Depth to 8.
2026-06-01 16:50:08 +02:00
Tom Boullay 5faf4b4197 fix(ui): keep talkie overlay visible after reveal step
Regression introduced by the narrator-video revert (1ad0c4d): the talkie
overlay was hidden whenever no narrator subtitle was active. Restore the
prior behaviour where the talkie stays visible from the reveal step
onward and only the --raised modifier and signal lines depend on the
active narrator dialogue.
2026-06-01 16:49:58 +02:00
5 changed files with 133 additions and 38 deletions
@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Box3, BufferAttribute, BufferGeometry, Color } from "three"; import { Box3, BufferAttribute, BufferGeometry } from "three";
import type { Octree } from "three-stdlib"; import type { Octree } from "three-stdlib";
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore"; import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
@@ -11,6 +11,13 @@ interface OctreeNodeBox {
box: Box3; box: Box3;
depth: number; depth: number;
triangleCount: number; triangleCount: number;
isLeaf: boolean;
}
interface CollectOptions {
minDepth: number;
maxDepth: number;
leavesOnly: boolean;
} }
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [ const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
@@ -30,20 +37,28 @@ const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
function collectOctreeBoxes( function collectOctreeBoxes(
node: Octree, node: Octree,
maxDepth: number, options: CollectOptions,
depth = 0, depth = 0,
acc: OctreeNodeBox[] = [], acc: OctreeNodeBox[] = [],
): OctreeNodeBox[] { ): OctreeNodeBox[] {
if (depth > maxDepth) return acc; if (depth > options.maxDepth) return acc;
const isLeaf = node.subTrees.length === 0;
const passesDepth = depth >= options.minDepth;
const passesLeafFilter = !options.leavesOnly || isLeaf;
const hasTriangles = node.triangles.length > 0;
if (passesDepth && passesLeafFilter && hasTriangles) {
acc.push({ acc.push({
box: node.box, box: node.box,
depth, depth,
triangleCount: node.triangles.length, triangleCount: node.triangles.length,
isLeaf,
}); });
}
for (const sub of node.subTrees) { for (const sub of node.subTrees) {
collectOctreeBoxes(sub, maxDepth, depth + 1, acc); collectOctreeBoxes(sub, options, depth + 1, acc);
} }
return acc; return acc;
@@ -55,17 +70,12 @@ function buildOctreeLineGeometry(
const positionsBuffer = new Float32Array( const positionsBuffer = new Float32Array(
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3, nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
); );
const colorsBuffer = new Float32Array(
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
);
const corners: [number, number, number][] = Array.from({ length: 8 }, () => [ const corners: [number, number, number][] = Array.from({ length: 8 }, () => [
0, 0, 0, 0, 0, 0,
]); ]);
let positionsOffset = 0; let positionsOffset = 0;
let colorsOffset = 0;
const colorHelper = new Color();
for (const node of nodes) { for (const node of nodes) {
const { min, max } = node.box; const { min, max } = node.box;
@@ -79,9 +89,6 @@ function buildOctreeLineGeometry(
corners[6] = [min.x, max.y, max.z]; corners[6] = [min.x, max.y, max.z];
corners[7] = [max.x, max.y, max.z]; corners[7] = [max.x, max.y, max.z];
const hue = (node.depth * 0.13) % 1;
colorHelper.setHSL(hue, 0.85, 0.55);
for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) { for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) {
const ca = corners[a]!; const ca = corners[a]!;
const cb = corners[b]!; const cb = corners[b]!;
@@ -91,19 +98,11 @@ function buildOctreeLineGeometry(
positionsBuffer[positionsOffset++] = cb[0]; positionsBuffer[positionsOffset++] = cb[0];
positionsBuffer[positionsOffset++] = cb[1]; positionsBuffer[positionsOffset++] = cb[1];
positionsBuffer[positionsOffset++] = cb[2]; positionsBuffer[positionsOffset++] = cb[2];
colorsBuffer[colorsOffset++] = colorHelper.r;
colorsBuffer[colorsOffset++] = colorHelper.g;
colorsBuffer[colorsOffset++] = colorHelper.b;
colorsBuffer[colorsOffset++] = colorHelper.r;
colorsBuffer[colorsOffset++] = colorHelper.g;
colorsBuffer[colorsOffset++] = colorHelper.b;
} }
} }
const geometry = new BufferGeometry(); const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3)); geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
geometry.setAttribute("color", new BufferAttribute(colorsBuffer, 3));
return geometry; return geometry;
} }
@@ -111,14 +110,21 @@ export function DebugOctreeVisualization({
octree, octree,
}: DebugOctreeVisualizationProps): React.JSX.Element | null { }: DebugOctreeVisualizationProps): React.JSX.Element | null {
const showOctree = useDebugVisualsStore((state) => state.showOctree); const showOctree = useDebugVisualsStore((state) => state.showOctree);
const minDepth = useDebugVisualsStore((state) => state.octreeMinDepth);
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth); const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
const geometry = useMemo(() => { const geometry = useMemo(() => {
if (!octree || !showOctree) return null; if (!octree || !showOctree) return null;
const boxes = collectOctreeBoxes(octree, maxDepth); const boxes = collectOctreeBoxes(octree, {
minDepth,
maxDepth,
leavesOnly,
});
if (boxes.length === 0) return null; if (boxes.length === 0) return null;
return buildOctreeLineGeometry(boxes); return buildOctreeLineGeometry(boxes);
}, [maxDepth, octree, showOctree]); }, [leavesOnly, maxDepth, minDepth, octree, showOctree]);
if (!geometry) return null; if (!geometry) return null;
@@ -126,11 +132,11 @@ export function DebugOctreeVisualization({
<lineSegments frustumCulled={false} renderOrder={999}> <lineSegments frustumCulled={false} renderOrder={999}>
<primitive object={geometry} attach="geometry" /> <primitive object={geometry} attach="geometry" />
<lineBasicMaterial <lineBasicMaterial
vertexColors color="#22d3ee"
depthTest={false} depthTest={false}
depthWrite={false} depthWrite={false}
transparent transparent
opacity={0.85} opacity={opacity}
/> />
</lineSegments> </lineSegments>
); );
+3 -3
View File
@@ -71,14 +71,14 @@ export function TalkieDialogueOverlay(): React.JSX.Element | null {
mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep); mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep);
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur"; const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
if (!isAfterReveal || !isNarratorDialogue) return null; if (!isAfterReveal) return null;
return ( return (
<aside <aside
className="talkie-dialogue-overlay talkie-dialogue-overlay--raised" className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--raised" : ""}`}
aria-hidden="true" aria-hidden="true"
> >
<TalkieSignalLines /> {isNarratorDialogue ? <TalkieSignalLines /> : null}
<div className="talkie-dialogue-overlay__model-frame"> <div className="talkie-dialogue-overlay__model-frame">
<Canvas <Canvas
camera={{ position: [0, 0, 4.2], zoom: 78 }} camera={{ position: [0, 0, 4.2], zoom: 78 }}
+28 -3
View File
@@ -3,10 +3,14 @@ import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
export function useDebugVisualsDebug(): void { export function useDebugVisualsDebug(): void {
useDebugFolder("Debug", (folder) => { useDebugFolder("Debug", (folder) => {
const state = useDebugVisualsStore.getState();
const controls = { const controls = {
showPlayerModel: useDebugVisualsStore.getState().showPlayerModel, showPlayerModel: state.showPlayerModel,
showOctree: useDebugVisualsStore.getState().showOctree, showOctree: state.showOctree,
octreeMaxDepth: useDebugVisualsStore.getState().octreeMaxDepth, octreeMinDepth: state.octreeMinDepth,
octreeMaxDepth: state.octreeMaxDepth,
octreeLeavesOnly: state.octreeLeavesOnly,
octreeOpacity: state.octreeOpacity,
}; };
folder folder
@@ -23,11 +27,32 @@ export function useDebugVisualsDebug(): void {
useDebugVisualsStore.getState().setShowOctree(value); useDebugVisualsStore.getState().setShowOctree(value);
}); });
folder
.add(controls, "octreeLeavesOnly")
.name("Octree Leaves Only")
.onChange((value: boolean) => {
useDebugVisualsStore.getState().setOctreeLeavesOnly(value);
});
folder
.add(controls, "octreeMinDepth", 0, 10, 1)
.name("Octree Min Depth")
.onChange((value: number) => {
useDebugVisualsStore.getState().setOctreeMinDepth(value);
});
folder folder
.add(controls, "octreeMaxDepth", 0, 10, 1) .add(controls, "octreeMaxDepth", 0, 10, 1)
.name("Octree Max Depth") .name("Octree Max Depth")
.onChange((value: number) => { .onChange((value: number) => {
useDebugVisualsStore.getState().setOctreeMaxDepth(value); useDebugVisualsStore.getState().setOctreeMaxDepth(value);
}); });
folder
.add(controls, "octreeOpacity", 0.05, 1, 0.05)
.name("Octree Opacity")
.onChange((value: number) => {
useDebugVisualsStore.getState().setOctreeOpacity(value);
});
}); });
} }
+13 -1
View File
@@ -7,6 +7,12 @@ interface DebugVisualsStore {
setShowOctree: (value: boolean) => void; setShowOctree: (value: boolean) => void;
octreeMaxDepth: number; octreeMaxDepth: number;
setOctreeMaxDepth: (value: number) => void; setOctreeMaxDepth: (value: number) => void;
octreeMinDepth: number;
setOctreeMinDepth: (value: number) => void;
octreeLeavesOnly: boolean;
setOctreeLeavesOnly: (value: boolean) => void;
octreeOpacity: number;
setOctreeOpacity: (value: number) => void;
} }
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({ export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
@@ -14,6 +20,12 @@ export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }), setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }),
showOctree: false, showOctree: false,
setShowOctree: (showOctree) => set({ showOctree }), setShowOctree: (showOctree) => set({ showOctree }),
octreeMaxDepth: 6, octreeMaxDepth: 8,
setOctreeMaxDepth: (octreeMaxDepth) => set({ octreeMaxDepth }), setOctreeMaxDepth: (octreeMaxDepth) => set({ octreeMaxDepth }),
octreeMinDepth: 4,
setOctreeMinDepth: (octreeMinDepth) => set({ octreeMinDepth }),
octreeLeavesOnly: true,
setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }),
octreeOpacity: 0.35,
setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }),
})); }));
+54 -2
View File
@@ -1,10 +1,12 @@
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 {
@@ -51,12 +53,33 @@ 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 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 };
}
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 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;
@@ -64,7 +87,18 @@ 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; sun.current.shadow.needsUpdate = true;
}, [gl]);
// [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,
});
}, [gl, scene]);
useDebugFolder("Lighting", (folder) => { useDebugFolder("Lighting", (folder) => {
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color"); folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
@@ -98,7 +132,7 @@ export function Lighting(): React.JSX.Element {
.name("Sun Z"); .name("Sun Z");
}); });
useFrame(() => { useFrame(({ clock }) => {
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;
@@ -117,6 +151,24 @@ export function Lighting(): React.JSX.Element {
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 (