diff --git a/backend/hand_landmarker.task b/backend/hand_landmarker.task index 0d53faf..5ecab74 100644 Binary files a/backend/hand_landmarker.task and b/backend/hand_landmarker.task differ diff --git a/docs/technical/scene-runtime.md b/docs/technical/scene-runtime.md index 9c9bba7..fcbe033 100644 --- a/docs/technical/scene-runtime.md +++ b/docs/technical/scene-runtime.md @@ -72,14 +72,23 @@ 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 -- `gameplayReady`: true when map, stage, and octree are all ready +- `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 -The final game-scene readiness condition is: +The base game-scene readiness condition before the shadow warmup is: ```ts showGameStage && gameStageLoaded && octree !== null; ``` +After that condition is met, `SceneShadowWarmup` runs one final loading step: + +```txt +Activation des ombres -> Ombres prêtes -> Gameplay prêt +``` + +This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed. + The debug physics scene is ready when: ```ts diff --git a/src/hooks/world/useWorldSceneLoading.ts b/src/hooks/world/useWorldSceneLoading.ts index e82e92b..c629fa7 100644 --- a/src/hooks/world/useWorldSceneLoading.ts +++ b/src/hooks/world/useWorldSceneLoading.ts @@ -11,10 +11,13 @@ 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({ @@ -24,13 +27,19 @@ export function useWorldSceneLoading({ const [octree, setOctree] = useState(null); const [gameMapLoaded, setGameMapLoaded] = useState(false); const [gameStageLoaded, setGameStageLoaded] = useState(false); + const [shadowsReady, setShadowsReady] = useState(false); const showGameStage = sceneMode === "game" && gameMapLoaded; - const gameplayReady = showGameStage && gameStageLoaded && octree !== null; + const gameSceneReadyForShadows = + showGameStage && gameStageLoaded && octree !== null; + const shadowWarmupReady = sceneMode === "game" && gameSceneReadyForShadows; + const shouldWarmUpShadows = shadowWarmupReady && !shadowsReady; + const gameplayReady = gameSceneReadyForShadows && shadowsReady; const sceneReady = (sceneMode === "game" && gameplayReady) || (sceneMode === "physics" && octree !== null); const handleGameMapLoaded = useCallback(() => { + setShadowsReady(false); setGameMapLoaded(true); }, []); @@ -45,6 +54,7 @@ export function useWorldSceneLoading({ const handleOctreeReady = useCallback( (nextOctree: Octree) => { + setShadowsReady(false); setOctree(nextOctree); onLoadingStateChange?.({ currentStep: "Collision prête", @@ -55,6 +65,23 @@ 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", @@ -88,9 +115,12 @@ export function useWorldSceneLoading({ return { octree, gameplayReady, + shouldWarmUpShadows, showGameStage, handleGameStageLoaded, handleGameMapLoaded, handleOctreeReady, + handleShadowWarmupReady, + handleShadowWarmupStarted, }; } diff --git a/src/world/Environment.tsx b/src/world/Environment.tsx index 430bc66..7333d0f 100644 --- a/src/world/Environment.tsx +++ b/src/world/Environment.tsx @@ -15,11 +15,24 @@ 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"; -export function Environment(): React.JSX.Element { +interface ShadowWarmupConfig { + active: boolean; + onReady: () => void; + onStarted: () => void; +} + +interface EnvironmentProps { + shadowWarmup?: ShadowWarmupConfig; +} + +export function Environment({ + shadowWarmup, +}: EnvironmentProps): React.JSX.Element { const sceneMode = useSceneMode(); const groups = useMapPerformanceStore((state) => state.groups); const models = useMapPerformanceStore((state) => state.models); @@ -34,6 +47,13 @@ export function Environment(): React.JSX.Element { return ( <> + {shadowWarmup ? ( + + ) : null} {showSky ? ( 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; +} diff --git a/src/world/World.tsx b/src/world/World.tsx index 87778d5..f8eadb5 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -47,6 +47,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { handleGameStageLoaded, handleGameMapLoaded, handleOctreeReady, + handleShadowWarmupReady, + handleShadowWarmupStarted, + shouldWarmUpShadows, } = useWorldSceneLoading({ sceneMode, onLoadingStateChange }); const playerSpawnPosition = sceneMode === "game" @@ -61,7 +64,13 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { return ( <> - + {showHandTrackingGloves ? (