8 Commits

Author SHA1 Message Date
Tom Boullay 7c35090dbd fix(ui): animate scene loading logo
🔍 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
2026-06-01 14:28:48 +02:00
Tom Boullay a766784ce8 docs: update scene runtime and debug toggles
- Drop SceneShadowWarmup section, document centralized shadow config.
- Document the localized Suspense boundaries in World.tsx.
- Document the new player model and octree debug visualizations.
- Open note about intermittent first-load shadow rendering.
2026-06-01 14:16:01 +02:00
Tom Boullay 63952912b5 fix(world): wrap stage and player in suspense to prevent scene remount
Late asset loads inside GameStageContent (e.g. EbikeSpeedometer's
useTexture) and the spawn-player block were bubbling Suspense up to the
root boundary in pages/page.tsx, which unmounted World mid-load and
triggered a redundant octree rebuild + shadow re-config. Localize the
suspension by wrapping each block in its own Suspense fallback.

Also mount DebugOctreeVisualization conditionally on the new debug
toggle.
2026-06-01 14:14:27 +02:00
Tom Boullay fd0b9e2749 feat(debug): add player model and octree visualization toggles
- Show Player Model: render the main character GLTF in camera-local
  space so it stays visible at any pitch.
- Show Octree: overlay the collision octree as colored line segments,
  one wireframe per spatial cell, colored by depth.
- Octree Max Depth: cap recursion to keep the scene readable.
2026-06-01 14:14:20 +02:00
Tom Boullay 777e51efeb fix(world): centralize shadow config and remove warmup
- Extract SHADOW_CONFIG into lightingConfig.ts (bias=0, normalBias=0,
  cameraSize=95) matching the historically working values from develop.
- Drop SceneShadowWarmup; rely on sun.shadow.autoUpdate=true for
  steady-state refresh.
- Enable cloud castShadow and traverse Ebike meshes for cast/receive.
2026-06-01 14:14:14 +02:00
Tom Boullay 1ad0c4de37 revert(ui): remove narrator video on talkie 2026-06-01 14:14:03 +02:00
Tom Boullay 7a378afad3 feat(world): add octree collision proxies 2026-06-01 14:11:23 +02:00
Tom Boullay d52ec7e5a9 fix(model): clean lafabrik props and materials 2026-06-01 13:50:16 +02:00
31 changed files with 704 additions and 409 deletions
+18 -14
View File
@@ -74,28 +74,32 @@ It tracks:
- `gameMapLoaded`: map data and visible map nodes settled
- `gameStageLoaded`: Rapier gameplay stage mounted
- `showGameStage`: true when the map is ready enough to mount gameplay content
- `shadowsReady`: renderer, shadow lights, and scene matrices have been forced once after the scene is mounted
- `gameplayReady`: true when map, stage, octree, and the shadow warmup are all ready
- `gameplayReady`: true when map, stage, and octree are all ready
The base game-scene readiness condition before the shadow warmup is:
The game-scene readiness condition is:
```ts
showGameStage && gameStageLoaded && octree !== null;
```
After that condition is met, `SceneShadowWarmup` runs one final loading step:
Shadows are configured once when `Lighting` mounts (renderer `shadowMap.enabled`, sun
`shadow.autoUpdate = true`, bias and frustum from `SHADOW_CONFIG` in
`src/data/world/lightingConfig.ts`). The shadow map then refreshes every frame and
follows the player camera through the sun's `target`. The earlier `SceneShadowWarmup`
step has been removed — the visible loading overlay no longer waits for a forced
shadow refresh because `autoUpdate` covers steady-state rendering.
```txt
Activation des ombres -> Ombres prêtes -> Gameplay prêt
```
### Avoiding global scene remounts
This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed.
After the warmup, shadow maps switch back to manual refreshes driven by `Lighting`.
The sun still follows the player camera, but the shadow map is only marked dirty
when the camera has moved enough and a short refresh interval has elapsed. This
keeps shadows present after loading without paying for a full shadow render every
frame across the dense vegetation chunks.
Heavy stage components (`GameStageContent`, `Player`, dialogues) load assets via
`useGLTF`/`useTexture` without preload (e.g. `EbikeSpeedometer` calls `useTexture`
when the bike mounts). To prevent any late suspension from bubbling up to the
root `<Suspense>` boundary in `src/pages/page.tsx` and unmounting the entire
world (which would trigger a redundant octree rebuild and shadow re-config), the
game stage block and the spawn-player block are wrapped in their own
`<Suspense fallback={null}>` boundaries inside `src/world/World.tsx`. Any new
sibling that suspends late should be added inside one of these boundaries or get
its own.
The debug physics scene is ready when:
+47
View File
@@ -20,3 +20,50 @@ If DevTools still opens a bundled file, stop the dev server, clear Vite's cached
rm -rf node_modules/.vite
npm run dev:three-debug
```
## Visual debug toggles
The `Debug` folder of the runtime debug GUI exposes inspection toggles backed by
`src/managers/stores/useDebugVisualsStore.ts`:
- **Show Player Model** — renders the main character GLTF in front of the
current camera (`src/components/debug/DebugPlayerModel.tsx`). The model is
positioned in camera-local space so it stays visible regardless of pitch.
- **Show Octree** — overlays the collision octree as colored line segments,
one wireframe per spatial cell (`src/components/debug/DebugOctreeVisualization.tsx`).
Cells are colored by depth. Use it to inspect collision precision around
doorways or passages.
- **Octree Max Depth** — caps how deep the octree visualization recurses
(default 6). Increase to see leaf-level subdivisions; decrease to keep the
scene readable when the tree is large.
The octree visualization reads the live `Octree` instance from `World`. The
mesh uses `depthTest: false` and a high `renderOrder`, so cells stay visible
through opaque geometry.
## Shadow rendering intermittence (open investigation)
Shadows occasionally fail to render on initial load even though the
`Lighting` configuration runs to completion (verified through diagnostic logs).
The issue is not deterministic across runs with identical config. Suspected
contributors:
- WebGL context restoration timing (`webglcontextrestored` rebinds shadow map
state in `src/pages/page.tsx`).
- First-frame shadow map being rendered before any mesh has its
`castShadow`/`receiveShadow` flag set; `autoUpdate=true` should fix it on the
next frame, but a single dropped frame is still visible at very first paint.
- HMR/state interactions in dev mode that do not occur in production builds.
Mitigations already applied:
- Shadow config centralized in `src/data/world/lightingConfig.ts`
(`bias=0`, `normalBias=0`, `cameraSize=95`, matching the historically working
values from `develop`).
- Late-suspension Suspense boundaries in `World.tsx` to prevent global scene
remounts that would re-run shadow setup mid-load.
If the issue reproduces in production, capture a screenshot plus the
`[diag]`-style logs from `useOctreeGraphNode`, `Lighting`, and `GameMapCollision`
to confirm whether the third configuration pass is happening (which would
indicate a remaining suspending hook outside the existing Suspense boundaries).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,137 @@
import { useMemo } from "react";
import { Box3, BufferAttribute, BufferGeometry, Color } from "three";
import type { Octree } from "three-stdlib";
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
interface DebugOctreeVisualizationProps {
octree: Octree | null;
}
interface OctreeNodeBox {
box: Box3;
depth: number;
triangleCount: number;
}
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
[0, 1],
[1, 3],
[3, 2],
[2, 0],
[4, 5],
[5, 7],
[7, 6],
[6, 4],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
];
function collectOctreeBoxes(
node: Octree,
maxDepth: number,
depth = 0,
acc: OctreeNodeBox[] = [],
): OctreeNodeBox[] {
if (depth > maxDepth) return acc;
acc.push({
box: node.box,
depth,
triangleCount: node.triangles.length,
});
for (const sub of node.subTrees) {
collectOctreeBoxes(sub, maxDepth, depth + 1, acc);
}
return acc;
}
function buildOctreeLineGeometry(
nodes: readonly OctreeNodeBox[],
): BufferGeometry {
const positionsBuffer = new Float32Array(
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 }, () => [
0, 0, 0,
]);
let positionsOffset = 0;
let colorsOffset = 0;
const colorHelper = new Color();
for (const node of nodes) {
const { min, max } = node.box;
corners[0] = [min.x, min.y, min.z];
corners[1] = [max.x, min.y, min.z];
corners[2] = [min.x, max.y, min.z];
corners[3] = [max.x, max.y, min.z];
corners[4] = [min.x, min.y, max.z];
corners[5] = [max.x, min.y, max.z];
corners[6] = [min.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) {
const ca = corners[a]!;
const cb = corners[b]!;
positionsBuffer[positionsOffset++] = ca[0];
positionsBuffer[positionsOffset++] = ca[1];
positionsBuffer[positionsOffset++] = ca[2];
positionsBuffer[positionsOffset++] = cb[0];
positionsBuffer[positionsOffset++] = cb[1];
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();
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
geometry.setAttribute("color", new BufferAttribute(colorsBuffer, 3));
return geometry;
}
export function DebugOctreeVisualization({
octree,
}: DebugOctreeVisualizationProps): React.JSX.Element | null {
const showOctree = useDebugVisualsStore((state) => state.showOctree);
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
const geometry = useMemo(() => {
if (!octree || !showOctree) return null;
const boxes = collectOctreeBoxes(octree, maxDepth);
if (boxes.length === 0) return null;
return buildOctreeLineGeometry(boxes);
}, [maxDepth, octree, showOctree]);
if (!geometry) return null;
return (
<lineSegments frustumCulled={false} renderOrder={999}>
<primitive object={geometry} attach="geometry" />
<lineBasicMaterial
vertexColors
depthTest={false}
depthWrite={false}
transparent
opacity={0.85}
/>
</lineSegments>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
const MODEL_PATH = "/models/persoprincipal/model.gltf";
// Offset expressed in the camera's local space:
// - x: horizontal (0 = centered)
// - y: vertical relative to camera eye (negative = below)
// - z: forward (negative = in front of the camera)
const LOCAL_OFFSET = new THREE.Vector3(0, -1, -2.5);
const eulerHelper = new THREE.Euler();
export function DebugPlayerModel(): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(MODEL_PATH);
const model = useMemo(() => {
const cloned = scene.clone(true);
cloned.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
child.frustumCulled = false;
}
});
return cloned;
}, [scene]);
useEffect(
() => () => {
model.clear();
},
[model],
);
useFrame(({ camera }) => {
const group = groupRef.current;
if (!group) return;
// Place the model in front of the camera using its local space so it stays
// visible regardless of the camera pitch (top-down ebike view, etc.).
group.position.copy(LOCAL_OFFSET).applyMatrix4(camera.matrixWorld);
// Keep the model upright and aligned with the camera yaw only.
eulerHelper.setFromQuaternion(camera.quaternion, "YXZ");
group.rotation.set(0, eulerHelper.y, 0);
});
return (
<group ref={groupRef} frustumCulled={false}>
<primitive object={model} />
</group>
);
}
useGLTF.preload(MODEL_PATH);
+11
View File
@@ -131,6 +131,17 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
}
}, [model]);
useEffect(() => {
if (!model) return;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
}, [model]);
useEffect(() => {
window.ebikeVisualGroup = groupRef;
window.ebikeParkedPosition = restingPositionRef.current;
+29 -4
View File
@@ -1,10 +1,18 @@
import { useEffect, useState } from "react";
import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
const LOADING_LOGO_PATH = "/assets/logo.png";
const LOADING_BACKGROUND_PATH = "/assets/bg-site.webp";
const LOADING_FRAME_RATE = 12;
const LOADING_FRAME_INTERVAL_MS = 1000 / LOADING_FRAME_RATE;
const LOADING_LOGO_FRAMES = [
"/assets/loader/Loader-1.png",
"/assets/loader/Loader-2.png",
"/assets/loader/Loader-3.png",
"/assets/loader/Loader-4.png",
] as const;
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
for (const path of [LOADING_BACKGROUND_PATH, ...LOADING_LOGO_FRAMES]) {
const image = new Image();
image.src = path;
}
@@ -16,8 +24,25 @@ interface SceneLoadingOverlayProps {
export function SceneLoadingOverlay({
state,
}: SceneLoadingOverlayProps): React.JSX.Element | null {
const [logoFrameIndex, setLogoFrameIndex] = useState(0);
const isReady = state.status === "ready";
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
const logoFramePath =
LOADING_LOGO_FRAMES[logoFrameIndex] ?? LOADING_LOGO_FRAMES[0];
useEffect(() => {
if (isReady) return undefined;
const intervalId = window.setInterval(() => {
setLogoFrameIndex(
(currentIndex) => (currentIndex + 1) % LOADING_LOGO_FRAMES.length,
);
}, LOADING_FRAME_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [isReady]);
return (
<div
@@ -33,7 +58,7 @@ export function SceneLoadingOverlay({
<img
alt="La Fabrik Durable"
className="scene-loading-overlay__logo"
src={LOADING_LOGO_PATH}
src={logoFramePath}
/>
<div className="scene-loading-overlay__footer">
<div className="scene-loading-overlay__meta">
+27 -171
View File
@@ -4,81 +4,20 @@ import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import { GAME_STEPS } from "@/data/game/gameStateConfig";
import type { Vector3Tuple } from "@/types/three/three";
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
const TALKIE_VIDEO_PATH = "/assets/world/UI/talkie-video.mp4";
const TALKIE_FIRST_VISIBLE_STEP = "reveal";
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf(
TALKIE_FIRST_VISIBLE_STEP,
);
const TALKIE_REVEAL_STEPS = new Set([
"reveal",
"await-ebike-mount",
"ebike-intro-ride",
"ebike-breakdown",
"completed",
]);
const TALKIE_REST_Y = -1.55;
const TALKIE_ACTIVE_Y = -0.92;
const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04];
const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(2.2);
const TALKIE_FLOAT_Y_AMPLITUDE = 0.055;
const TALKIE_SCREEN_TEXTURE_SIZE = 512;
interface TalkieModelProps {
active: boolean;
}
interface TalkieVideoResources {
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D | null;
material: THREE.MeshBasicMaterial;
texture: THREE.CanvasTexture;
video: HTMLVideoElement;
}
function createTalkieVideoResources(): TalkieVideoResources {
const video = document.createElement("video");
video.src = TALKIE_VIDEO_PATH;
video.crossOrigin = "anonymous";
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = "auto";
const canvas = document.createElement("canvas");
canvas.width = TALKIE_SCREEN_TEXTURE_SIZE;
canvas.height = TALKIE_SCREEN_TEXTURE_SIZE;
const context = canvas.getContext("2d");
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.flipY = false;
texture.needsUpdate = true;
const material = new THREE.MeshBasicMaterial({
map: texture,
toneMapped: false,
});
return { canvas, context, material, texture, video };
}
function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
function TalkieModel(): React.JSX.Element {
const { scene } = useGLTF(TALKIE_MODEL_PATH);
const model = useMemo(() => scene.clone(true), [scene]);
const groupRef = useRef<THREE.Group>(null);
const screenRef = useRef<THREE.Mesh | null>(null);
const originalScreenMaterialRef = useRef<THREE.Material | null>(null);
const videoResourcesRef = useRef<TalkieVideoResources | null>(null);
useEffect(() => {
const videoResources = createTalkieVideoResources();
videoResourcesRef.current = videoResources;
return () => {
videoResources.video.pause();
videoResources.video.removeAttribute("src");
videoResources.video.load();
videoResources.texture.dispose();
videoResources.material.dispose();
videoResourcesRef.current = null;
};
}, []);
useEffect(() => {
model.traverse((child) => {
@@ -88,119 +27,38 @@ function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
child.frustumCulled = false;
}
});
const screen = model.getObjectByName("écran");
if (screen instanceof THREE.Mesh) {
screenRef.current = screen;
originalScreenMaterialRef.current = Array.isArray(screen.material)
? (screen.material[0] ?? null)
: screen.material;
}
}, [model]);
useEffect(() => {
const screen = screenRef.current;
const originalMaterial = originalScreenMaterialRef.current;
const videoResources = videoResourcesRef.current;
if (!videoResources) return;
if (screen) {
screen.material = active
? videoResources.material
: (originalMaterial ?? videoResources.material);
}
if (active) {
void videoResources.video.play();
return;
}
videoResources.video.pause();
}, [active]);
useFrame(({ clock }) => {
if (!groupRef.current) return;
const t = clock.getElapsedTime();
const floatY = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE;
const targetY = (active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y) + floatY;
groupRef.current.position.y = THREE.MathUtils.lerp(
groupRef.current.position.y,
targetY,
0.14,
);
groupRef.current.rotation.x =
TALKIE_BASE_ROTATION[0] +
Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
groupRef.current.rotation.y =
TALKIE_BASE_ROTATION[1] +
Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
groupRef.current.rotation.z =
TALKIE_BASE_ROTATION[2] +
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
const videoResources = videoResourcesRef.current;
if (active && videoResources?.context) {
const { canvas, context, texture, video } = videoResources;
context.fillStyle = "#02040a";
context.fillRect(0, 0, canvas.width, canvas.height);
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
const videoAspect = video.videoWidth / video.videoHeight;
const canvasAspect = canvas.width / canvas.height;
const drawWidth =
videoAspect > canvasAspect
? canvas.width
: canvas.height * videoAspect;
const drawHeight =
videoAspect > canvasAspect
? canvas.width / videoAspect
: canvas.height;
const drawX = (canvas.width - drawWidth) / 2;
const drawY = (canvas.height - drawHeight) / 2;
context.drawImage(video, drawX, drawY, drawWidth, drawHeight);
}
texture.needsUpdate = true;
}
groupRef.current.rotation.z = Math.sin(t * 22) * 0.025;
groupRef.current.position.y = Math.sin(t * 6) * 0.012;
});
return (
<group
ref={groupRef}
position={[0, TALKIE_REST_Y, 0]}
rotation={TALKIE_BASE_ROTATION}
>
<group ref={groupRef}>
<primitive
object={model}
position={[0, -3.25, 0]}
rotation={[0, -1, 0]}
scale={1.5}
position={[0, -0.18, 0]}
rotation={[0.18, Math.PI, -0.08]}
scale={1.45}
/>
</group>
);
}
interface TalkieSignalLinesProps {
side: "left" | "right";
}
function TalkieSignalLines({
side,
}: TalkieSignalLinesProps): React.JSX.Element {
function TalkieSignalLines(): React.JSX.Element {
return (
<svg
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
viewBox="0 0 90 120"
className="talkie-dialogue-overlay__signals"
viewBox="0 0 120 160"
aria-hidden="true"
>
<path d="M18 48 C30 58 30 72 18 82" />
<path d="M34 34 C56 52 56 78 34 96" />
<path d="M52 20 C84 46 84 84 52 110" />
<path d="M34 20 C52 44 16 66 34 92 C48 112 22 128 30 146" />
<path d="M68 12 C92 44 50 70 70 104 C84 130 48 142 52 154" />
<path d="M100 8 C124 42 82 76 100 112 C112 136 74 150 78 158" />
</svg>
);
}
@@ -209,23 +67,21 @@ export function TalkieDialogueOverlay(): React.JSX.Element | null {
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep);
const introStepIndex = GAME_STEPS.indexOf(introStep);
const hasTalkieBeenRevealed =
mainState !== "intro" || introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX;
const isAfterReveal =
mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep);
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
if (!hasTalkieBeenRevealed) return null;
if (!isAfterReveal || !isNarratorDialogue) return null;
return (
<aside
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active talkie-dialogue-overlay--raised" : ""}`}
className="talkie-dialogue-overlay talkie-dialogue-overlay--raised"
aria-hidden="true"
>
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null}
{isNarratorDialogue ? <TalkieSignalLines side="right" /> : null}
<TalkieSignalLines />
<div className="talkie-dialogue-overlay__model-frame">
<Canvas
camera={{ position: [0, 0, 4.2], zoom: 62 }}
camera={{ position: [0, 0, 4.2], zoom: 78 }}
dpr={[1, 1.5]}
gl={{ alpha: true, antialias: true }}
orthographic
@@ -233,7 +89,7 @@ export function TalkieDialogueOverlay(): React.JSX.Element | null {
<ambientLight intensity={2.5} />
<directionalLight position={[2, 3, 4]} intensity={2.8} />
<Suspense fallback={null}>
<TalkieModel active={isNarratorDialogue} />
<TalkieModel />
</Suspense>
</Canvas>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
const BACKGROUND_IMAGE = "/assets/bg-site.png";
const BACKGROUND_IMAGE = "/assets/bg-site.webp";
export const SITE_CONFIG = {
backgroundImage: BACKGROUND_IMAGE,
+1 -1
View File
@@ -19,7 +19,7 @@ export const CLOUD_DEFAULTS = {
maxRotation: Math.PI * 2,
minSpeedMultiplier: 0.4,
maxSpeedMultiplier: 1,
castShadow: false,
castShadow: true,
receiveShadow: false,
};
+9
View File
@@ -30,3 +30,12 @@ export const SUN_Y_STEP = 1;
export const SUN_Z_MIN = -100;
export const SUN_Z_MAX = 100;
export const SUN_Z_STEP = 1;
export const SHADOW_CONFIG = {
mapSize: 2048,
cameraSize: 95,
cameraNear: 0.5,
cameraFar: 300,
bias: 0,
normalBias: 0,
} as const;
+57
View File
@@ -0,0 +1,57 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface OctreeCollisionBox {
center: Vector3Tuple;
size: Vector3Tuple;
}
export interface MapOctreeCollisionBox extends OctreeCollisionBox {
bottomY: number;
}
export const MAP_OCTREE_COLLISION_BOXES = {
immeuble1: {
center: [-0.0308, 5.8389, 0],
size: [17.2522, 11.6098, 9.2668],
bottomY: 0.034,
},
maison1: {
center: [0, 1.3638, 0.0536],
size: [2.7813, 3.022, 2.8609],
bottomY: -0.1472,
},
} as const satisfies Record<string, MapOctreeCollisionBox>;
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
{
center: [-6.9351, 2.278, -0.0001],
size: [0.2, 1.94, 3.711],
},
{
center: [0.8026, 0.719, -3.639],
size: [4.346, 1.108, 1.181],
},
{
center: [-5.8519, 0.9362, 2.5742],
size: [1.67, 1.551, 2.566],
},
{
center: [-2.0627, 1.4875, -1.2243],
size: [0.691, 0.723, 0.687],
},
{
center: [-3.5502, 1.4378, -1.2485],
size: [1.055, 0.657, 0.563],
},
] as const satisfies readonly OctreeCollisionBox[];
export const CHARACTER_OCTREE_COLLISION_BOX = {
center: [0, 0.875, 0],
size: [0.62, 1.75, 0.62],
} as const satisfies OctreeCollisionBox;
export function hasMapOctreeCollisionBox(
name: string,
): name is keyof typeof MAP_OCTREE_COLLISION_BOXES {
return name in MAP_OCTREE_COLLISION_BOXES;
}
+33
View File
@@ -0,0 +1,33 @@
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
export function useDebugVisualsDebug(): void {
useDebugFolder("Debug", (folder) => {
const controls = {
showPlayerModel: useDebugVisualsStore.getState().showPlayerModel,
showOctree: useDebugVisualsStore.getState().showOctree,
octreeMaxDepth: useDebugVisualsStore.getState().octreeMaxDepth,
};
folder
.add(controls, "showPlayerModel")
.name("Show Player Model")
.onChange((value: boolean) => {
useDebugVisualsStore.getState().setShowPlayerModel(value);
});
folder
.add(controls, "showOctree")
.name("Show Octree")
.onChange((value: boolean) => {
useDebugVisualsStore.getState().setShowOctree(value);
});
folder
.add(controls, "octreeMaxDepth", 0, 10, 1)
.name("Octree Max Depth")
.onChange((value: number) => {
useDebugVisualsStore.getState().setOctreeMaxDepth(value);
});
});
}
+1
View File
@@ -27,6 +27,7 @@ export function useOctreeGraphNode(
const octree = new Octree();
octree.fromGraphNode(graphNode);
onOctreeReady(octree);
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
}
+1 -31
View File
@@ -11,13 +11,10 @@ interface UseWorldSceneLoadingOptions {
interface UseWorldSceneLoadingResult {
octree: Octree | null;
gameplayReady: boolean;
shouldWarmUpShadows: boolean;
showGameStage: boolean;
handleGameStageLoaded: () => void;
handleGameMapLoaded: () => void;
handleOctreeReady: (octree: Octree) => void;
handleShadowWarmupReady: () => void;
handleShadowWarmupStarted: () => void;
}
export function useWorldSceneLoading({
@@ -27,19 +24,13 @@ export function useWorldSceneLoading({
const [octree, setOctree] = useState<Octree | null>(null);
const [gameMapLoaded, setGameMapLoaded] = useState(false);
const [gameStageLoaded, setGameStageLoaded] = useState(false);
const [shadowsReady, setShadowsReady] = useState(false);
const showGameStage = sceneMode === "game" && gameMapLoaded;
const gameSceneReadyForShadows =
showGameStage && gameStageLoaded && octree !== null;
const shadowWarmupReady = sceneMode === "game" && gameSceneReadyForShadows;
const shouldWarmUpShadows = shadowWarmupReady && !shadowsReady;
const gameplayReady = gameSceneReadyForShadows && shadowsReady;
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
const sceneReady =
(sceneMode === "game" && gameplayReady) ||
(sceneMode === "physics" && octree !== null);
const handleGameMapLoaded = useCallback(() => {
setShadowsReady(false);
setGameMapLoaded(true);
}, []);
@@ -54,7 +45,6 @@ export function useWorldSceneLoading({
const handleOctreeReady = useCallback(
(nextOctree: Octree) => {
setShadowsReady(false);
setOctree(nextOctree);
onLoadingStateChange?.({
currentStep: "Collision prête",
@@ -65,23 +55,6 @@ export function useWorldSceneLoading({
[onLoadingStateChange],
);
const handleShadowWarmupStarted = useCallback(() => {
onLoadingStateChange?.({
currentStep: "Activation des ombres",
progress: 0.97,
status: "loading",
});
}, [onLoadingStateChange]);
const handleShadowWarmupReady = useCallback(() => {
setShadowsReady(true);
onLoadingStateChange?.({
currentStep: "Ombres prêtes",
progress: 0.99,
status: "loading",
});
}, [onLoadingStateChange]);
useEffect(() => {
onLoadingStateChange?.({
currentStep: "Initialisation du jeu",
@@ -115,12 +88,9 @@ export function useWorldSceneLoading({
return {
octree,
gameplayReady,
shouldWarmUpShadows,
showGameStage,
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
handleShadowWarmupReady,
handleShadowWarmupStarted,
};
}
+21 -27
View File
@@ -942,9 +942,11 @@ canvas {
.scene-loading-overlay__logo {
position: relative;
z-index: 1;
width: clamp(207px, 32.2vw, 368px);
max-height: min(43.7vh, 368px);
object-fit: contain;
width: clamp(180px, 28vw, 320px);
max-height: min(38vh, 320px);
border-radius: 16px;
object-fit: cover;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
}
.scene-loading-overlay__footer {
@@ -1238,24 +1240,24 @@ canvas {
/* Dialogue talkie */
.talkie-dialogue-overlay {
position: fixed;
left: 0;
bottom: 0;
left: clamp(12px, 2.2vw, 28px);
bottom: clamp(24px, 7vh, 76px);
z-index: 16;
width: clamp(190px, 18vw, 310px);
aspect-ratio: 1.05;
overflow: visible;
width: clamp(120px, 13vw, 190px);
aspect-ratio: 1;
pointer-events: none;
transform: translateY(0);
transition: transform 180ms ease;
}
.talkie-dialogue-overlay--raised {
transform: translateY(-8px);
transform: translateY(-10px);
}
.talkie-dialogue-overlay__model-frame {
position: absolute;
inset: -18% -12% -6% -12%;
inset: 0;
animation: talkie-radio-shake 1s ease-in-out infinite;
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
}
@@ -1266,30 +1268,22 @@ canvas {
.talkie-dialogue-overlay__signals {
position: absolute;
bottom: 38%;
right: -26%;
bottom: 34%;
z-index: 2;
width: 34%;
height: 50%;
width: 58%;
height: 78%;
overflow: visible;
opacity: 0.72;
opacity: 0.8;
animation: talkie-signal-pulse 1s ease-in-out infinite;
}
.talkie-dialogue-overlay__signals--left {
left: 7%;
scale: -1 1;
}
.talkie-dialogue-overlay__signals--right {
right: 7%;
}
.talkie-dialogue-overlay__signals path {
fill: none;
stroke: rgba(162, 210, 255, 0.92);
stroke: rgba(235, 244, 255, 0.9);
stroke-linecap: round;
stroke-width: 4;
filter: drop-shadow(0 0 5px rgba(125, 211, 252, 0.58));
stroke-width: 5;
filter: drop-shadow(0 0 7px rgba(125, 211, 252, 0.72));
}
.talkie-dialogue-overlay__signals path:nth-child(2) {
@@ -1299,7 +1293,7 @@ canvas {
.talkie-dialogue-overlay__signals path:nth-child(3) {
animation-delay: 180ms;
opacity: 0.45;
opacity: 0.55;
}
@keyframes talkie-radio-shake {
@@ -0,0 +1,19 @@
import { create } from "zustand";
interface DebugVisualsStore {
showPlayerModel: boolean;
setShowPlayerModel: (value: boolean) => void;
showOctree: boolean;
setShowOctree: (value: boolean) => void;
octreeMaxDepth: number;
setOctreeMaxDepth: (value: number) => void;
}
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
showPlayerModel: false,
setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }),
showOctree: false,
setShowOctree: (showOctree) => set({ showOctree }),
octreeMaxDepth: 6,
setOctreeMaxDepth: (octreeMaxDepth) => set({ octreeMaxDepth }),
}));
+1
View File
@@ -26,6 +26,7 @@ const DEBUG_FOLDER_ORDER = [
"Hand Tracking",
"Map",
"Personnages",
"Debug",
] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
+1 -21
View File
@@ -15,24 +15,11 @@ import { SkyModel } from "@/components/three/world/SkyModel";
import { CloudSystem } from "@/world/clouds/CloudSystem";
import { FogSystem } from "@/world/fog/FogSystem";
import { GrassSystem } from "@/world/grass/GrassSystem";
import { SceneShadowWarmup } from "@/world/SceneShadowWarmup";
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
import { WaterSystem } from "@/world/water/WaterSystem";
import { WorldPlane } from "@/world/WorldPlane";
interface ShadowWarmupConfig {
active: boolean;
onReady: () => void;
onStarted: () => void;
}
interface EnvironmentProps {
shadowWarmup?: ShadowWarmupConfig;
}
export function Environment({
shadowWarmup,
}: EnvironmentProps): React.JSX.Element {
export function Environment(): React.JSX.Element {
const sceneMode = useSceneMode();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
@@ -47,13 +34,6 @@ export function Environment({
return (
<>
<FogSystem />
{shadowWarmup ? (
<SceneShadowWarmup
active={shadowWarmup.active}
onReady={shadowWarmup.onReady}
onStarted={shadowWarmup.onStarted}
/>
) : null}
{showSky ? (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
+11
View File
@@ -27,6 +27,7 @@ import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
import { GameMapCollision } from "@/world/GameMapCollision";
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
import { hasMapOctreeCollisionBox } from "@/data/world/octreeCollisionConfig";
import { getMapSingleModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -115,6 +116,9 @@ export function GameMap({
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
[],
);
const [proxyCollisionMapNodes, setProxyCollisionMapNodes] = useState<
MapNode[]
>([]);
const [terrainNode, setTerrainNode] = useState<MapNode | null>(null);
const [mapLoaded, setMapLoaded] = useState(false);
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
@@ -134,6 +138,7 @@ export function GameMap({
(currentStep: string) => {
setRenderMapNodes([]);
setCollisionMapNodes([]);
setProxyCollisionMapNodes([]);
setTerrainNode(null);
setMapLoaded(true);
settledMapNodesRef.current.clear();
@@ -191,6 +196,10 @@ export function GameMap({
const modelUrl = sceneData.models.get(node.name);
return { node, modelUrl: modelUrl ?? null };
});
const loadedProxyCollisionNodes = sceneData.mapNodes.filter(
(node) =>
node.type === "Object3D" && hasMapOctreeCollisionBox(node.name),
);
const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes);
const repairMissionAnchors = getRepairMissionMapAnchors(
sceneData.mapNodes,
@@ -211,6 +220,7 @@ export function GameMap({
setRenderMapNodes(loadedMapNodes);
setCollisionMapNodes(loadedCollisionNodes);
setProxyCollisionMapNodes(loadedProxyCollisionNodes);
setTerrainNode(loadedTerrainNode);
setRepairMissionAnchors(repairMissionAnchors);
setMapLoaded(true);
@@ -285,6 +295,7 @@ export function GameMap({
buildOctree={buildOctree}
mapReady={mapReady}
nodes={collisionMapNodes}
proxyNodes={proxyCollisionMapNodes}
onLoaded={onLoaded}
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={onOctreeReady}
+172 -10
View File
@@ -17,9 +17,24 @@ import {
normalizeMapScale,
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import {
CHARACTER_CONFIGS,
CHARACTER_IDS,
type CharacterId,
} from "@/data/world/characters/characterConfig";
import {
CHARACTER_OCTREE_COLLISION_BOX,
LA_FABRIK_INTERIOR_COLLISION_BOXES,
MAP_OCTREE_COLLISION_BOXES,
hasMapOctreeCollisionBox,
type OctreeCollisionBox,
} from "@/data/world/octreeCollisionConfig";
import { getMapModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
import { useGameStore } from "@/managers/stores/useGameStore";
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
import type { MapNode } from "@/types/map/mapScene";
import type { OctreeReadyHandler } from "@/types/three/three";
import type { OctreeReadyHandler, Vector3Tuple } from "@/types/three/three";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
@@ -39,6 +54,7 @@ interface GameMapCollisionProps {
buildOctree?: boolean;
mapReady: boolean;
nodes: readonly GameMapCollisionNode[];
proxyNodes: readonly MapNode[];
onLoaded?: (() => void) | undefined;
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
onOctreeReady: OctreeReadyHandler;
@@ -101,6 +117,7 @@ export function GameMapCollision({
buildOctree = true,
mapReady,
nodes,
proxyNodes,
onLoaded,
onLoadingStateChange,
onOctreeReady,
@@ -109,10 +126,28 @@ export function GameMapCollision({
const settledCollisionNodesRef = useRef(new Set<number>());
const loadedNotifiedRef = useRef(false);
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
const mainState = useGameStore((state) => state.mainState);
const terrainHeight = useTerrainHeightSampler();
const collisionNodes = nodes.filter(isCollisionNode);
const includeCharacterCollisions = mainState !== "ebike";
const characterCollisionCount = includeCharacterCollisions
? CHARACTER_IDS.length
: 0;
const collisionSourceCount =
collisionNodes.length + proxyNodes.length + characterCollisionCount;
const collisionReady =
mapReady && settledCollisionNodeCount >= collisionNodes.length;
const characterCollisionSignature = useCharacterDebugStore((state) =>
includeCharacterCollisions
? CHARACTER_IDS.map((id) => {
const character = state.characters[id];
return [...character.position, ...character.rotation].join(",");
}).join("|")
: "characters-hidden",
);
const collisionRebuildKey = collisionReady
? `${collisionNodes.length}:${collisionSourceCount}:${characterCollisionSignature}`
: "pending";
const notifyLoaded = useCallback(() => {
if (loadedNotifiedRef.current) return;
@@ -144,14 +179,14 @@ export function GameMapCollision({
useOctreeGraphNode(
groupRef,
handleOctreeReady,
collisionReady ? collisionNodes.length : 0,
buildOctree && collisionReady && collisionNodes.length > 0,
collisionRebuildKey,
buildOctree && collisionReady && collisionSourceCount > 0,
);
useEffect(() => {
if (!mapReady) return;
if (collisionNodes.length === 0) {
if (collisionSourceCount === 0) {
notifyLoaded();
return;
}
@@ -171,6 +206,7 @@ export function GameMapCollision({
}, [
buildOctree,
collisionNodes.length,
collisionSourceCount,
collisionReady,
mapReady,
notifyLoaded,
@@ -180,6 +216,18 @@ export function GameMapCollision({
return (
<group ref={groupRef} visible={false}>
{mapReady ? <WorldBoundsCollision /> : null}
{mapReady
? proxyNodes.map((node, index) => (
<MapCollisionBoxProxy
key={`proxy-collision-${index}`}
node={node}
terrainHeight={terrainHeight}
/>
))
: null}
{mapReady && includeCharacterCollisions ? (
<CharacterCollisionProxies terrainHeight={terrainHeight} />
) : null}
{mapReady
? collisionNodes.map((mapNode, index) => (
<CollisionErrorBoundary
@@ -253,11 +301,125 @@ function CollisionModelInstance({
}, [onLoaded]);
return (
<primitive
object={sceneInstance}
position={collisionPosition}
rotation={rotation}
scale={normalizedScale}
/>
<>
<primitive
object={sceneInstance}
position={collisionPosition}
rotation={rotation}
scale={normalizedScale}
/>
{node.name === "lafabrik" ? (
<group
name="lafabrik-interior-collision-proxies"
position={collisionPosition}
rotation={rotation}
scale={normalizedScale}
>
{LA_FABRIK_INTERIOR_COLLISION_BOXES.map((box, index) => (
<CollisionBox key={`lafabrik-interior-${index}`} box={box} />
))}
</group>
) : null}
</>
);
}
function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
return (
<mesh position={box.center}>
<boxGeometry args={box.size} />
<meshBasicMaterial />
</mesh>
);
}
function createScaledMapNodeScale(node: MapNode): Vector3Tuple {
const baseScale = normalizeMapScale(node.scale);
const scaleMultiplier = getMapModelScaleMultiplier(node.name);
return [
baseScale[0] * scaleMultiplier,
baseScale[1] * scaleMultiplier,
baseScale[2] * scaleMultiplier,
];
}
function MapCollisionBoxProxy({
node,
terrainHeight,
}: {
node: MapNode;
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element | null {
const collisionBox = hasMapOctreeCollisionBox(node.name)
? MAP_OCTREE_COLLISION_BOXES[node.name]
: null;
const normalizedScale = useMemo(() => createScaledMapNodeScale(node), [node]);
const position = useMemo(() => {
const [x, y, z] = node.position;
if (!collisionBox) return [x, y, z] satisfies Vector3Tuple;
const height = terrainHeight.getHeight(x, z);
const bottomOffset = -collisionBox.bottomY * normalizedScale[1];
return [x, (height ?? y) + bottomOffset, z] satisfies Vector3Tuple;
}, [collisionBox, node.position, normalizedScale, terrainHeight]);
if (!collisionBox) return null;
return (
<group
name={`${node.name}-octree-collision-proxy`}
position={position}
rotation={node.rotation}
scale={normalizedScale}
>
<CollisionBox box={collisionBox} />
</group>
);
}
function CharacterCollisionProxies({
terrainHeight,
}: {
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element {
return (
<>
{CHARACTER_IDS.map((id) => (
<CharacterCollisionProxy
key={`character-collision-${id}`}
id={id}
terrainHeight={terrainHeight}
/>
))}
</>
);
}
function CharacterCollisionProxy({
id,
terrainHeight,
}: {
id: CharacterId;
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element {
const config = CHARACTER_CONFIGS[id];
const state = useCharacterDebugStore((store) => store.characters[id]);
const position = useMemo(() => {
const [x, y, z] = state.position;
const height = terrainHeight.getHeight(x, z);
return [x, height ?? y, z] satisfies Vector3Tuple;
}, [state.position, terrainHeight]);
return (
<group
name={`${config.id}-octree-collision-proxy`}
position={position}
rotation={state.rotation}
>
<CollisionBox box={CHARACTER_OCTREE_COLLISION_BOX} />
</group>
);
}
+11 -16
View File
@@ -11,6 +11,7 @@ import {
AMBIENT_INTENSITY_MAX,
AMBIENT_INTENSITY_MIN,
AMBIENT_INTENSITY_STEP,
SHADOW_CONFIG,
SUN_INTENSITY_MAX,
SUN_INTENSITY_MIN,
SUN_INTENSITY_STEP,
@@ -28,30 +29,25 @@ import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { LIGHTING_STATE } from "@/world/lightingState";
const SHADOW_MAP_SIZE = 2048;
const SHADOW_CAMERA_SIZE = 95;
const SHADOW_CAMERA_NEAR = 0.5;
const SHADOW_CAMERA_FAR = 300;
function configureRendererShadows(gl: WebGLRenderer): void {
gl.shadowMap.enabled = true;
gl.shadowMap.type = PCFShadowMap;
gl.shadowMap.autoUpdate = true;
gl.shadowMap.needsUpdate = true;
}
function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
sun.target = sunTarget;
sun.shadow.autoUpdate = true;
sun.shadow.needsUpdate = true;
sun.shadow.mapSize.width = SHADOW_MAP_SIZE;
sun.shadow.mapSize.height = SHADOW_MAP_SIZE;
sun.shadow.camera.left = -SHADOW_CAMERA_SIZE;
sun.shadow.camera.right = SHADOW_CAMERA_SIZE;
sun.shadow.camera.top = SHADOW_CAMERA_SIZE;
sun.shadow.camera.bottom = -SHADOW_CAMERA_SIZE;
sun.shadow.camera.near = SHADOW_CAMERA_NEAR;
sun.shadow.camera.far = SHADOW_CAMERA_FAR;
sun.shadow.bias = SHADOW_CONFIG.bias;
sun.shadow.normalBias = SHADOW_CONFIG.normalBias;
sun.shadow.mapSize.width = SHADOW_CONFIG.mapSize;
sun.shadow.mapSize.height = SHADOW_CONFIG.mapSize;
sun.shadow.camera.left = -SHADOW_CONFIG.cameraSize;
sun.shadow.camera.right = SHADOW_CONFIG.cameraSize;
sun.shadow.camera.top = SHADOW_CONFIG.cameraSize;
sun.shadow.camera.bottom = -SHADOW_CONFIG.cameraSize;
sun.shadow.camera.near = SHADOW_CONFIG.cameraNear;
sun.shadow.camera.far = SHADOW_CONFIG.cameraFar;
sun.shadow.camera.updateProjectionMatrix();
}
@@ -118,7 +114,6 @@ export function Lighting(): React.JSX.Element {
sun.current.color.set(LIGHTING_STATE.sunColor);
sun.current.intensity = LIGHTING_STATE.sunIntensity;
sun.current.updateMatrixWorld();
sun.current.shadow.needsUpdate = true;
}
});
-92
View File
@@ -1,92 +0,0 @@
import { useEffect, useRef } from "react";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
interface SceneShadowWarmupProps {
active: boolean;
onReady: () => void;
onStarted: () => void;
}
function markShadowLightForUpdate(object: THREE.Object3D): void {
if (
!(
object instanceof THREE.DirectionalLight ||
object instanceof THREE.PointLight ||
object instanceof THREE.SpotLight
)
) {
return;
}
if (!object.castShadow) return;
object.updateMatrixWorld(true);
object.shadow.camera.updateProjectionMatrix();
object.shadow.needsUpdate = true;
}
function forceSceneShadowPass(
gl: THREE.WebGLRenderer,
scene: THREE.Scene,
): void {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
gl.shadowMap.needsUpdate = true;
scene.updateMatrixWorld(true);
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.updateMatrixWorld(true);
}
markShadowLightForUpdate(object);
});
}
export function SceneShadowWarmup({
active,
onReady,
onStarted,
}: SceneShadowWarmupProps): null {
const gl = useThree((state) => state.gl);
const scene = useThree((state) => state.scene);
const invalidate = useThree((state) => state.invalidate);
const isRunningRef = useRef(false);
useEffect(() => {
if (!active) {
isRunningRef.current = false;
return undefined;
}
if (isRunningRef.current) return undefined;
isRunningRef.current = true;
onStarted();
forceSceneShadowPass(gl, scene);
invalidate();
let firstFrame = 0;
let secondFrame = 0;
firstFrame = window.requestAnimationFrame(() => {
forceSceneShadowPass(gl, scene);
invalidate();
secondFrame = window.requestAnimationFrame(() => {
forceSceneShadowPass(gl, scene);
invalidate();
onReady();
});
});
return () => {
window.cancelAnimationFrame(firstFrame);
window.cancelAnimationFrame(secondFrame);
};
}, [active, gl, invalidate, onReady, onStarted, scene]);
return null;
}
+21 -13
View File
@@ -9,12 +9,16 @@ import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
import { DebugOctreeVisualization } from "@/components/debug/DebugOctreeVisualization";
import { DebugPlayerModel } from "@/components/debug/DebugPlayerModel";
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
import { Environment } from "@/world/Environment";
import { GameCinematics } from "@/world/GameCinematics";
@@ -36,10 +40,15 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
useEnvironmentDebug();
useMapPerformanceDebug();
useCharacterDebug();
useDebugVisualsDebug();
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const mainState = useGameStore((state) => state.mainState);
const showDebugPlayerModel = useDebugVisualsStore(
(state) => state.showPlayerModel,
);
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
const { status, usageStatus } = useHandTrackingSnapshot();
const {
octree,
@@ -48,9 +57,6 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
handleShadowWarmupReady,
handleShadowWarmupStarted,
shouldWarmUpShadows,
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const playerSpawnPosition =
sceneMode === "game"
@@ -65,15 +71,15 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
return (
<>
<Environment
shadowWarmup={{
active: shouldWarmUpShadows,
onReady: handleShadowWarmupReady,
onStarted: handleShadowWarmupStarted,
}}
/>
<Environment />
<Lighting />
<DebugHelpers />
{showDebugOctree ? <DebugOctreeVisualization octree={octree} /> : null}
{showDebugPlayerModel ? (
<Suspense fallback={null}>
<DebugPlayerModel />
</Suspense>
) : null}
{showHandTrackingGloves ? (
<Suspense fallback={null}>
<HandTrackingGlove handedness="left" />
@@ -92,11 +98,13 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
{showGameStage ? (
<Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} />
<GameStageContent />
<Suspense fallback={null}>
<GameStageContent />
</Suspense>
</Physics>
) : null}
{spawnPlayer ? (
<>
<Suspense fallback={null}>
<GameMusic />
{mainState === "outro" ? <GameCinematics /> : null}
{mainState !== "intro" ? <GameDialogues /> : null}
@@ -105,7 +113,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
octree={octree}
spawnPosition={playerSpawnPosition}
/>
</>
</Suspense>
) : null}
</>
) : (