Merge branch 'develop' into feat/e-bike
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component, useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { SkeletonUtils } from "three-stdlib";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
@@ -362,6 +361,3 @@ export function HandTrackingGlove({
|
||||
</HandTrackingGloveErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(GLOVE_CONFIGS.left.modelPath);
|
||||
useGLTF.preload(GLOVE_CONFIGS.right.modelPath);
|
||||
|
||||
@@ -105,6 +105,9 @@ function GraphicsPresetButton({
|
||||
const lodLabel = config.forceLodModels
|
||||
? "LOD forcé"
|
||||
: `HD ${config.lodHighDetailDistance}m`;
|
||||
const chunkLabel = config.chunkStreamingEnabled
|
||||
? formatChunkDistance(config.chunkLoadRadius)
|
||||
: "All";
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -115,8 +118,7 @@ function GraphicsPresetButton({
|
||||
>
|
||||
<span>{config.label}</span>
|
||||
<small>
|
||||
{formatChunkDistance(config.chunkLoadRadius)} · {lodLabel} ·{" "}
|
||||
{config.fogEnabled ? "Fog" : "Clear"}
|
||||
{chunkLabel} · {lodLabel} · {config.fogEnabled ? "Fog" : "Clear"}
|
||||
</small>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as THREE from "three";
|
||||
import gsap from "gsap";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
|
||||
const TALKIE_MODEL_PATH = "/models/talkie/model.glb";
|
||||
const TALKIE_REST_Y = -1.55;
|
||||
const TALKIE_ACTIVE_Y = -0.38;
|
||||
const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04];
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
|
||||
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
|
||||
export const TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS: Vector3Tuple = [
|
||||
100, 0.5, 100,
|
||||
];
|
||||
|
||||
export const TEST_SCENE_GRABBABLE_POSITION: Vector3Tuple = [0, 1, -3];
|
||||
export const TEST_SCENE_GRABBABLE_POSITION: Vector3Tuple = [0, 0.25, -3];
|
||||
export const TEST_SCENE_GRABBABLE_BOX_SIZE: Vector3Tuple = [0.5, 0.5, 0.5];
|
||||
export const TEST_SCENE_GRABBABLE_COLOR = "#e07b39";
|
||||
export const TEST_SCENE_GRABBABLE_ROUGHNESS = 0.6;
|
||||
@@ -23,6 +25,12 @@ export const TEST_SCENE_TRIGGER_METALNESS = 0.5;
|
||||
|
||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65;
|
||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
|
||||
export const TEST_SCENE_GPS_PREVIEW_POSITION: Vector3Tuple = [0, 5, -4.8];
|
||||
export const TEST_SCENE_GPS_PREVIEW_ROTATION: Vector3Tuple = [
|
||||
-33 * DEG_TO_RAD,
|
||||
0,
|
||||
0,
|
||||
];
|
||||
|
||||
export const GAME_REPAIR_ZONES = [
|
||||
{
|
||||
|
||||
@@ -143,7 +143,7 @@ export const galleryModels: GalleryModel[] = [
|
||||
},
|
||||
{ id: "sapin", name: "Sapin", path: "/models/sapin/model.gltf" },
|
||||
{ id: "skybox", name: "Skybox", path: "/models/skybox/skybox.gltf" },
|
||||
{ id: "talkie", name: "Talkie", path: "/models/talkie/model.gltf" },
|
||||
{ id: "talkie", name: "Talkie", path: "/models/talkie/model.glb" },
|
||||
{ id: "terrain", name: "Terrain", path: "/models/terrain/model.gltf" },
|
||||
{
|
||||
id: "tuyauxlac",
|
||||
|
||||
@@ -41,11 +41,6 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
label: "Replacement cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "ebike-radio-distractor",
|
||||
label: "Radio module",
|
||||
modelPath: "/models/talkie/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "ebike-glove-distractor",
|
||||
label: "Insulation glove",
|
||||
@@ -134,11 +129,6 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
label: "Tree sensor",
|
||||
modelPath: "/models/sapin/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "farm-radio-distractor",
|
||||
label: "Radio module",
|
||||
modelPath: "/models/talkie/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,3 +8,9 @@ export const HAND_TRACKING_BROWSER_WASM_URL =
|
||||
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
|
||||
export const HAND_TRACKING_BROWSER_MODEL_URL =
|
||||
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
|
||||
export const HAND_TRACKING_BROWSER_DELEGATE: "CPU" | "GPU" = "CPU";
|
||||
|
||||
// Delay before the runtime actually starts after `enabled` flips to true.
|
||||
// Absorbs React StrictMode's mount/unmount/mount cycle in dev and rapid
|
||||
// `nearby` toggles at trigger borders. Invisible to the user (~5 frames).
|
||||
export const HAND_TRACKING_RUNTIME_START_DELAY_MS = 80;
|
||||
|
||||
@@ -20,4 +20,8 @@ export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [
|
||||
LA_FABRIK_PLAYER_SPAWN[1],
|
||||
LA_FABRIK_PLAYER_SPAWN[2] - 1,
|
||||
];
|
||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [
|
||||
0,
|
||||
PLAYER_EYE_HEIGHT,
|
||||
0,
|
||||
];
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
|
||||
export const GRAPHICS_PRESET_KEYS = [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"ultra",
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
|
||||
|
||||
export interface GraphicsPresetConfig {
|
||||
chunkLoadRadius: number;
|
||||
chunkStreamingEnabled: boolean;
|
||||
chunkUnloadRadius: number;
|
||||
fogEnabled: boolean;
|
||||
forceLodModels: boolean;
|
||||
@@ -16,6 +23,7 @@ export const GRAPHICS_PRESETS = {
|
||||
label: "Basse",
|
||||
chunkLoadRadius: 10,
|
||||
chunkUnloadRadius: 18,
|
||||
chunkStreamingEnabled: true,
|
||||
fogEnabled: true,
|
||||
forceLodModels: true,
|
||||
lodHighDetailDistance: 0,
|
||||
@@ -24,25 +32,37 @@ export const GRAPHICS_PRESETS = {
|
||||
label: "Moyenne",
|
||||
chunkLoadRadius: 20,
|
||||
chunkUnloadRadius: 30,
|
||||
chunkStreamingEnabled: true,
|
||||
fogEnabled: true,
|
||||
forceLodModels: true,
|
||||
lodHighDetailDistance: 0,
|
||||
},
|
||||
high: {
|
||||
label: "High",
|
||||
chunkLoadRadius: 35,
|
||||
chunkUnloadRadius: 45,
|
||||
chunkLoadRadius: 30,
|
||||
chunkUnloadRadius: 40,
|
||||
chunkStreamingEnabled: true,
|
||||
fogEnabled: false,
|
||||
forceLodModels: false,
|
||||
lodHighDetailDistance: 10,
|
||||
lodHighDetailDistance: 20,
|
||||
},
|
||||
ultra: {
|
||||
label: "Ultra",
|
||||
chunkLoadRadius: 50,
|
||||
chunkUnloadRadius: 65,
|
||||
chunkStreamingEnabled: true,
|
||||
fogEnabled: false,
|
||||
forceLodModels: false,
|
||||
lodHighDetailDistance: 20,
|
||||
lodHighDetailDistance: 30,
|
||||
},
|
||||
max: {
|
||||
label: "Max",
|
||||
chunkLoadRadius: 50,
|
||||
chunkUnloadRadius: 65,
|
||||
chunkStreamingEnabled: false,
|
||||
fogEnabled: false,
|
||||
forceLodModels: false,
|
||||
lodHighDetailDistance: 50,
|
||||
},
|
||||
} as const satisfies Record<GraphicsPreset, GraphicsPresetConfig>;
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ const SUN_LIGHT_COLOR = "#ffe2bf";
|
||||
|
||||
export const LIGHTING_DEFAULTS = {
|
||||
ambientColor: AMBIENT_LIGHT_COLOR,
|
||||
ambientIntensity: 0.9,
|
||||
ambientIntensity: 0.7,
|
||||
sunColor: SUN_LIGHT_COLOR,
|
||||
sunIntensity: 2.2,
|
||||
sunIntensity: 1.9,
|
||||
sunX: 70,
|
||||
sunY: 45,
|
||||
sunZ: 35,
|
||||
|
||||
@@ -13,7 +13,9 @@ export const MAP_LOD_MODEL_PATHS = {
|
||||
lafabrik: "/models/lafabrik-LOD/model.gltf",
|
||||
maison1: "/models/maison1-LOD/model.gltf",
|
||||
panneauaffichage: "/models/panneauaffichage-LOD/model.gltf",
|
||||
talkie: "/models/talkie-LOD/model.gltf",
|
||||
arbre: "/models/arbre-LOD/model.glb",
|
||||
buisson: "/models/buisson-LOD/model.glb",
|
||||
sapin: "/models/sapin-LOD/model.glb",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
export function getMapLodModelPath(modelName: string): string | null {
|
||||
@@ -22,6 +24,19 @@ export function getMapLodModelPath(modelName: string): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
export const MAP_LOD_SCALE_MULTIPLIERS = {
|
||||
sapin: 0.35,
|
||||
buisson: 0.7,
|
||||
} as const satisfies Partial<Record<keyof typeof MAP_LOD_MODEL_PATHS, number>>;
|
||||
|
||||
export function getMapLodScaleMultiplier(modelName: string): number {
|
||||
return (
|
||||
MAP_LOD_SCALE_MULTIPLIERS[
|
||||
modelName as keyof typeof MAP_LOD_SCALE_MULTIPLIERS
|
||||
] ?? 1
|
||||
);
|
||||
}
|
||||
|
||||
export function selectMapModelPathByDistance({
|
||||
distance,
|
||||
modelName,
|
||||
|
||||
@@ -2,17 +2,20 @@ import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
HAND_TRACKING_FRAME_HEIGHT,
|
||||
HAND_TRACKING_FRAME_WIDTH,
|
||||
HAND_TRACKING_RUNTIME_START_DELAY_MS,
|
||||
HAND_TRACKING_TARGET_FPS,
|
||||
} from "@/data/handTrackingConfig";
|
||||
import {
|
||||
convertBrowserHandResult,
|
||||
getBrowserHandLandmarker,
|
||||
releaseBrowserHandLandmarker,
|
||||
} from "@/lib/handTracking/browserHandTracking";
|
||||
import {
|
||||
INITIAL_HAND_TRACKING_SNAPSHOT,
|
||||
getCameraStreamWithTimeout,
|
||||
} from "@/lib/handTracking/handTrackingSession";
|
||||
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
interface UseBrowserHandTrackingOptions {
|
||||
enabled: boolean;
|
||||
@@ -34,8 +37,12 @@ export function useBrowserHandTracking({
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let cleanedUp = false;
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
|
||||
if (intervalRef.current !== null) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
@@ -44,6 +51,7 @@ export function useBrowserHandTracking({
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
videoRef.current = null;
|
||||
releaseBrowserHandLandmarker();
|
||||
};
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
@@ -111,24 +119,44 @@ export function useBrowserHandTracking({
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
|
||||
|
||||
const result = handLandmarker.detectForVideo(
|
||||
video,
|
||||
performance.now(),
|
||||
);
|
||||
const hands = convertBrowserHandResult(result);
|
||||
try {
|
||||
const result = handLandmarker.detectForVideo(
|
||||
video,
|
||||
performance.now(),
|
||||
);
|
||||
const hands = convertBrowserHandResult(result);
|
||||
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
hands,
|
||||
usageStatus: hands.some((hand) => hand.isFist)
|
||||
? "active"
|
||||
: "available",
|
||||
error: null,
|
||||
}));
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
hands,
|
||||
usageStatus: hands.some((hand) => hand.isFist)
|
||||
? "active"
|
||||
: "available",
|
||||
error: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error("HandTracking", "Browser JS runtime error", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
cleanup();
|
||||
setSnapshot({
|
||||
hands: [],
|
||||
status: "error",
|
||||
usageStatus: "inactive",
|
||||
serverStatus: "Browser JS",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Browser hand tracking failed",
|
||||
});
|
||||
}
|
||||
}, 1_000 / HAND_TRACKING_TARGET_FPS);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
|
||||
logger.error("HandTracking", "Browser JS runtime failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
setSnapshot({
|
||||
hands: [],
|
||||
status: "error",
|
||||
@@ -142,10 +170,17 @@ export function useBrowserHandTracking({
|
||||
}
|
||||
};
|
||||
|
||||
void start();
|
||||
// Delay the actual start so that a StrictMode mount/unmount/mount
|
||||
// cycle, or a rapid `enabled` toggle at a trigger border, does not
|
||||
// spin up the camera + MediaPipe twice in a few milliseconds.
|
||||
const startTimer = window.setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
void start();
|
||||
}, HAND_TRACKING_RUNTIME_START_DELAY_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(startTimer);
|
||||
cleanup();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HAND_TRACKING_FRAME_WIDTH,
|
||||
HAND_TRACKING_JPEG_QUALITY,
|
||||
HAND_TRACKING_RESPONSE_TIMEOUT_MS,
|
||||
HAND_TRACKING_RUNTIME_START_DELAY_MS,
|
||||
HAND_TRACKING_TARGET_FPS,
|
||||
} from "@/data/handTrackingConfig";
|
||||
import { getHandTrackingWsUrl } from "@/utils/handTracking/handTrackingEndpoint";
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
HandTrackingServerMessage,
|
||||
HandTrackingSnapshot,
|
||||
} from "@/types/handTracking/handTracking";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
interface UseRemoteHandTrackingOptions {
|
||||
enabled: boolean;
|
||||
@@ -100,6 +102,7 @@ export function useRemoteHandTracking({
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let cleanedUp = false;
|
||||
|
||||
const clearResponseTimeout = (): void => {
|
||||
if (responseTimeoutRef.current === null) return;
|
||||
@@ -108,6 +111,9 @@ export function useRemoteHandTracking({
|
||||
};
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
|
||||
if (sendIntervalRef.current !== null) {
|
||||
window.clearInterval(sendIntervalRef.current);
|
||||
sendIntervalRef.current = null;
|
||||
@@ -283,6 +289,9 @@ export function useRemoteHandTracking({
|
||||
};
|
||||
ws.onerror = () => {
|
||||
markResponseReceived();
|
||||
logger.error("HandTracking", "Backend WebSocket error", {
|
||||
websocketUrl,
|
||||
});
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
status: "error",
|
||||
@@ -307,6 +316,10 @@ export function useRemoteHandTracking({
|
||||
);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
logger.error("HandTracking", "Backend runtime failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
websocketUrl,
|
||||
});
|
||||
setSnapshot({
|
||||
hands: [],
|
||||
status: "error",
|
||||
@@ -318,10 +331,17 @@ export function useRemoteHandTracking({
|
||||
}
|
||||
};
|
||||
|
||||
void start();
|
||||
// Delay the actual start so that a StrictMode mount/unmount/mount
|
||||
// cycle, or a rapid `enabled` toggle at a trigger border, does not
|
||||
// open the camera + WebSocket twice in a few milliseconds.
|
||||
const startTimer = window.setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
void start();
|
||||
}, HAND_TRACKING_RUNTIME_START_DELAY_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(startTimer);
|
||||
cleanup();
|
||||
};
|
||||
}, [enabled, websocketUrl]);
|
||||
|
||||
+1
-1
@@ -1544,7 +1544,7 @@ canvas {
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group--presets {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
HAND_TRACKING_BROWSER_DELEGATE,
|
||||
HAND_TRACKING_BROWSER_MODEL_URL,
|
||||
HAND_TRACKING_BROWSER_WASM_URL,
|
||||
} from "@/data/handTrackingConfig";
|
||||
@@ -6,6 +7,7 @@ import type {
|
||||
HandTrackingHand,
|
||||
HandTrackingLandmark,
|
||||
} from "@/types/handTracking/handTracking";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
type HandLandmarkerModule = typeof import("@mediapipe/tasks-vision");
|
||||
type HandLandmarker = Awaited<
|
||||
@@ -14,6 +16,7 @@ type HandLandmarker = Awaited<
|
||||
type HandLandmarkerResult = ReturnType<HandLandmarker["detectForVideo"]>;
|
||||
|
||||
let handLandmarkerPromise: Promise<HandLandmarker> | null = null;
|
||||
let handLandmarkerInstance: HandLandmarker | null = null;
|
||||
|
||||
function averageLandmarks(
|
||||
landmarks: HandTrackingLandmark[],
|
||||
@@ -78,20 +81,46 @@ export async function getBrowserHandLandmarker(): Promise<HandLandmarker> {
|
||||
HAND_TRACKING_BROWSER_WASM_URL,
|
||||
);
|
||||
|
||||
return HandLandmarker.createFromOptions(vision, {
|
||||
const handLandmarker = await HandLandmarker.createFromOptions(vision, {
|
||||
baseOptions: {
|
||||
modelAssetPath: HAND_TRACKING_BROWSER_MODEL_URL,
|
||||
delegate: "GPU",
|
||||
delegate: HAND_TRACKING_BROWSER_DELEGATE,
|
||||
},
|
||||
numHands: 2,
|
||||
runningMode: "VIDEO",
|
||||
});
|
||||
|
||||
handLandmarkerInstance = handLandmarker;
|
||||
return handLandmarker;
|
||||
},
|
||||
);
|
||||
|
||||
return handLandmarkerPromise;
|
||||
}
|
||||
|
||||
export function releaseBrowserHandLandmarker(): void {
|
||||
const activeLandmarker = handLandmarkerInstance;
|
||||
const pendingLandmarker = handLandmarkerPromise;
|
||||
|
||||
handLandmarkerInstance = null;
|
||||
handLandmarkerPromise = null;
|
||||
|
||||
if (activeLandmarker) {
|
||||
activeLandmarker.close();
|
||||
return;
|
||||
}
|
||||
|
||||
void pendingLandmarker
|
||||
?.then((landmarker) => {
|
||||
landmarker.close();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.warn("HandTracking", "Browser JS landmarker release failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function convertBrowserHandResult(
|
||||
result: HandLandmarkerResult,
|
||||
): HandTrackingHand[] {
|
||||
|
||||
+43
-4
@@ -15,7 +15,9 @@ import {
|
||||
} from "@/components/ui/intro";
|
||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
||||
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
|
||||
import { releaseBrowserHandLandmarker } from "@/lib/handTracking/browserHandTracking";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
@@ -26,6 +28,9 @@ import { logger } from "@/utils/core/Logger";
|
||||
import { World } from "@/world/World";
|
||||
|
||||
const LOADING_TO_VIDEO_FADE_MS = 500;
|
||||
const WEBGL_CONTEXT_RESTORE_DELAY_MS = 500;
|
||||
const CANVAS_DPR: [number, number] = [1, 1];
|
||||
const registeredWebglContextCanvases = new WeakSet<HTMLCanvasElement>();
|
||||
|
||||
export function HomePage(): React.JSX.Element | null {
|
||||
const navigate = useNavigate();
|
||||
@@ -38,6 +43,11 @@ export function HomePage(): React.JSX.Element | null {
|
||||
const graphicsPreset = useWorldSettingsStore(
|
||||
(state) => state.graphics.preset,
|
||||
);
|
||||
const cameraMode = useDebugStore((debug) => debug.getCameraMode());
|
||||
const handTrackingSource = useDebugStore((debug) =>
|
||||
debug.getHandTrackingSource(),
|
||||
);
|
||||
const sceneMode = useDebugStore((debug) => debug.getSceneMode());
|
||||
const dialogMessage = useGameStore(
|
||||
(state) => state.missionFlow.dialogMessage,
|
||||
);
|
||||
@@ -48,9 +58,18 @@ export function HomePage(): React.JSX.Element | null {
|
||||
INITIAL_SCENE_LOADING_STATE,
|
||||
);
|
||||
const sceneReadyRef = useRef(false);
|
||||
const cameraModeRef = useRef(cameraMode);
|
||||
const handTrackingSourceRef = useRef(handTrackingSource);
|
||||
const sceneModeRef = useRef(sceneMode);
|
||||
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`;
|
||||
const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal);
|
||||
|
||||
useEffect(() => {
|
||||
cameraModeRef.current = cameraMode;
|
||||
handTrackingSourceRef.current = handTrackingSource;
|
||||
sceneModeRef.current = sceneMode;
|
||||
}, [cameraMode, handTrackingSource, sceneMode]);
|
||||
|
||||
useEffect(() => {
|
||||
sceneReadyRef.current = sceneLoadingState.status === "ready";
|
||||
}, [sceneLoadingState.status]);
|
||||
@@ -138,11 +157,25 @@ export function HomePage(): React.JSX.Element | null {
|
||||
// page stays frozen on a black canvas until the user reloads.
|
||||
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
|
||||
|
||||
if (registeredWebglContextCanvases.has(canvas)) return;
|
||||
registeredWebglContextCanvases.add(canvas);
|
||||
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
logger.error("WebGL", "Context lost - attempting auto-restore");
|
||||
releaseBrowserHandLandmarker();
|
||||
|
||||
logger.error("WebGL", "Context lost - attempting auto-restore", {
|
||||
cameraMode: cameraModeRef.current,
|
||||
geometries: gl.info.memory.geometries,
|
||||
handTrackingSource: handTrackingSourceRef.current,
|
||||
sceneMode: sceneModeRef.current,
|
||||
textures: gl.info.memory.textures,
|
||||
});
|
||||
// Give the GPU a moment to free resources before asking it back.
|
||||
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
|
||||
window.setTimeout(
|
||||
() => loseContextExt?.restoreContext(),
|
||||
WEBGL_CONTEXT_RESTORE_DELAY_MS,
|
||||
);
|
||||
};
|
||||
|
||||
const handleContextRestored = () => {
|
||||
@@ -150,7 +183,11 @@ export function HomePage(): React.JSX.Element | null {
|
||||
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||
gl.shadowMap.autoUpdate = true;
|
||||
gl.shadowMap.needsUpdate = true;
|
||||
logger.info("WebGL", "Context restored");
|
||||
logger.info("WebGL", "Context restored", {
|
||||
cameraMode: cameraModeRef.current,
|
||||
handTrackingSource: handTrackingSourceRef.current,
|
||||
sceneMode: sceneModeRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||
@@ -191,10 +228,12 @@ export function HomePage(): React.JSX.Element | null {
|
||||
<HandTrackingProvider>
|
||||
<Canvas
|
||||
camera={{ position: [85, 60, 85], fov: 42 }}
|
||||
dpr={CANVAS_DPR}
|
||||
id="game-canvas"
|
||||
shadows={{ type: THREE.PCFShadowMap }}
|
||||
gl={{
|
||||
powerPreference: "high-performance",
|
||||
antialias: true,
|
||||
antialias: false,
|
||||
stencil: false,
|
||||
}}
|
||||
onCreated={handleCanvasCreated}
|
||||
|
||||
+25
-10
@@ -1,9 +1,11 @@
|
||||
import GUI from "lil-gui";
|
||||
import type { Controller } from "lil-gui";
|
||||
import type { CameraMode, SceneMode } from "@/types/debug/debug";
|
||||
import type { HandTrackingSource } from "@/types/handTracking/handTracking";
|
||||
import { FOG_CONFIG } from "@/data/world/fogConfig";
|
||||
import { EventEmitter } from "@/utils/core/EventEmitter";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls";
|
||||
|
||||
@@ -77,6 +79,7 @@ export class Debug {
|
||||
private readonly events = new EventEmitter<DebugEvents>();
|
||||
private readonly folders = new Map<string, GUI>();
|
||||
private readonly folderRefCounts = new Map<string, number>();
|
||||
private handTrackingSourceController: Controller | null = null;
|
||||
private readonly controls: {
|
||||
cameraMode: CameraMode;
|
||||
fogEnabled: boolean;
|
||||
@@ -160,16 +163,22 @@ export class Debug {
|
||||
this.emit();
|
||||
});
|
||||
|
||||
handTrackingFolder
|
||||
?.add(this.controls, "handTrackingSource", {
|
||||
"Browser JS": "browser",
|
||||
Backend: "backend",
|
||||
})
|
||||
.name("Source")
|
||||
.onChange((value: HandTrackingSource) => {
|
||||
this.controls.handTrackingSource = value;
|
||||
this.saveAndEmit();
|
||||
});
|
||||
this.handTrackingSourceController =
|
||||
handTrackingFolder
|
||||
?.add(this.controls, "handTrackingSource", {
|
||||
"Browser JS": "browser",
|
||||
Backend: "backend",
|
||||
})
|
||||
.name("Source")
|
||||
.onChange((value: HandTrackingSource) => {
|
||||
const previousSource = this.controls.handTrackingSource;
|
||||
this.controls.handTrackingSource = value;
|
||||
logger.info("HandTracking", "Debug source changed", {
|
||||
from: previousSource,
|
||||
to: value,
|
||||
});
|
||||
this.saveAndEmit();
|
||||
}) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +263,13 @@ export class Debug {
|
||||
}
|
||||
|
||||
setHandTrackingSource(value: HandTrackingSource): void {
|
||||
const previousSource = this.controls.handTrackingSource;
|
||||
this.controls.handTrackingSource = value;
|
||||
this.handTrackingSourceController?.updateDisplay();
|
||||
logger.info("HandTracking", "Settings source changed", {
|
||||
from: previousSource,
|
||||
to: value,
|
||||
});
|
||||
this.saveAndEmit();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,20 +19,21 @@ type TexturedMaterial = THREE.Material &
|
||||
Partial<Record<TextureKey, THREE.Texture>>;
|
||||
|
||||
const optimizedTextures = new WeakSet<THREE.Texture>();
|
||||
const MAX_GLTF_TEXTURE_ANISOTROPY = 2;
|
||||
|
||||
function optimizeTexture(texture: THREE.Texture, maxAnisotropy: number): void {
|
||||
if (optimizedTextures.has(texture)) return;
|
||||
|
||||
optimizedTextures.add(texture);
|
||||
texture.anisotropy = Math.min(4, Math.max(1, maxAnisotropy));
|
||||
const nextAnisotropy = Math.min(
|
||||
MAX_GLTF_TEXTURE_ANISOTROPY,
|
||||
Math.max(1, maxAnisotropy),
|
||||
);
|
||||
|
||||
if (!(texture instanceof THREE.CompressedTexture)) {
|
||||
texture.generateMipmaps = true;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
if (texture.anisotropy > nextAnisotropy) {
|
||||
texture.anisotropy = nextAnisotropy;
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
function optimizeMaterialTextures(
|
||||
|
||||
+21
-5
@@ -31,11 +31,20 @@ import { CharacterSystem } from "@/world/characters/CharacterSystem";
|
||||
import { Player } from "@/world/player/Player";
|
||||
import { TestMap } from "@/world/debug/TestMap";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
import type { HandTrackingGloveHandedness } from "@/hooks/handTracking/useHandTrackingGloveStatus";
|
||||
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
|
||||
|
||||
interface WorldProps {
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
}
|
||||
|
||||
function hasTrackedHand(
|
||||
hands: HandTrackingHand[],
|
||||
handedness: HandTrackingGloveHandedness,
|
||||
): boolean {
|
||||
return hands.some((hand) => hand.handedness.toLowerCase() === handedness);
|
||||
}
|
||||
|
||||
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
useEnvironmentDebug();
|
||||
useMapPerformanceDebug();
|
||||
@@ -49,7 +58,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
(state) => state.showPlayerModel,
|
||||
);
|
||||
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||
const { status, usageStatus } = useHandTrackingSnapshot();
|
||||
const { hands, status, usageStatus } = useHandTrackingSnapshot();
|
||||
const {
|
||||
octree,
|
||||
gameplayReady,
|
||||
@@ -63,8 +72,11 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
? PLAYER_SPAWN_POSITION_GAME
|
||||
: PLAYER_SPAWN_POSITION_PHYSICS;
|
||||
const showHandTrackingGloves =
|
||||
sceneMode === "physics" ||
|
||||
(status !== "idle" && usageStatus !== "inactive");
|
||||
status === "connected" && usageStatus !== "inactive" && hands.length > 0;
|
||||
const showLeftHandTrackingGlove =
|
||||
showHandTrackingGloves && hasTrackedHand(hands, "left");
|
||||
const showRightHandTrackingGlove =
|
||||
showHandTrackingGloves && hasTrackedHand(hands, "right");
|
||||
const spawnPlayer =
|
||||
cameraMode !== "debug" &&
|
||||
(sceneMode === "game" ? gameplayReady : octree !== null);
|
||||
@@ -82,8 +94,12 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
) : null}
|
||||
{showHandTrackingGloves ? (
|
||||
<Suspense fallback={null}>
|
||||
<HandTrackingGlove handedness="left" />
|
||||
<HandTrackingGlove handedness="right" />
|
||||
{showLeftHandTrackingGlove ? (
|
||||
<HandTrackingGlove handedness="left" />
|
||||
) : null}
|
||||
{showRightHandTrackingGlove ? (
|
||||
<HandTrackingGlove handedness="right" />
|
||||
) : null}
|
||||
</Suspense>
|
||||
) : null}
|
||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||
|
||||
+10
-15
@@ -17,6 +17,8 @@ import {
|
||||
TEST_SCENE_GRABBABLE_METALNESS,
|
||||
TEST_SCENE_GRABBABLE_POSITION,
|
||||
TEST_SCENE_GRABBABLE_ROUGHNESS,
|
||||
TEST_SCENE_GPS_PREVIEW_POSITION,
|
||||
TEST_SCENE_GPS_PREVIEW_ROTATION,
|
||||
GAME_REPAIR_ZONES,
|
||||
TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS,
|
||||
TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS,
|
||||
@@ -110,24 +112,17 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
console.log(
|
||||
`[TestMap] ${parsed.length} waypoints chargés depuis localStorage.`,
|
||||
);
|
||||
// Schedule state update to avoid synchronous setState in effect
|
||||
queueMicrotask(() => {
|
||||
if (!cancelled) setWaypoints(parsed);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse local storage waypoints", e);
|
||||
} catch {
|
||||
// Ignore parse errors — fall through to fetch fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try public/roadNetwork.json
|
||||
console.log(
|
||||
"[TestMap] Tentative de chargement depuis /roadNetwork.json...",
|
||||
);
|
||||
fetch("/roadNetwork.json")
|
||||
.then((res) => {
|
||||
if (res.ok) return res.json();
|
||||
@@ -136,14 +131,11 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
if (Array.isArray(data)) {
|
||||
console.log(
|
||||
`[TestMap] ${data.length} waypoints chargés depuis /roadNetwork.json.`,
|
||||
);
|
||||
setWaypoints(data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("[TestMap] Aucun point d'A* trouvé par défaut.", err);
|
||||
.catch(() => {
|
||||
// No A* waypoints available — silent fallback
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -253,7 +245,10 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
</Physics>
|
||||
|
||||
{/* Dynamic Futuristic 3D GPS Dashboard Preview */}
|
||||
<group position={[0, 2.8, -4.8]} rotation={[0, 0, 0]}>
|
||||
<group
|
||||
position={TEST_SCENE_GPS_PREVIEW_POSITION}
|
||||
rotation={TEST_SCENE_GPS_PREVIEW_ROTATION}
|
||||
>
|
||||
{/* Futuristic glowing screen frame (commented out to show true 3D transparency!) */}
|
||||
{/*
|
||||
<mesh>
|
||||
|
||||
@@ -149,6 +149,7 @@ export function MapInstancingSystem({
|
||||
const streamingEnabled =
|
||||
streaming &&
|
||||
CHUNK_CONFIG.enabled &&
|
||||
graphicsPresetConfig.chunkStreamingEnabled &&
|
||||
sceneMode === "game" &&
|
||||
cameraMode === "player";
|
||||
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { PointerLockControls } from "@react-three/drei";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import { setGlobalCamera } from "@/world/GameCinematics";
|
||||
|
||||
export function PlayerCamera(): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const isSettingsMenuOpen = useSettingsStore(
|
||||
(state) => state.isSettingsMenuOpen,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalCamera(camera);
|
||||
return () => {
|
||||
setGlobalCamera(null);
|
||||
document.exitPointerLock();
|
||||
if (document.pointerLockElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
};
|
||||
}, [camera]);
|
||||
|
||||
return <PointerLockControls />;
|
||||
return (
|
||||
<PointerLockControls
|
||||
enabled={!isSettingsMenuOpen}
|
||||
selector="#game-canvas"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
EBIKE_CAMERA_TRANSFORM,
|
||||
EBIKE_DECELERATION_DURATION_MS,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
|
||||
/** Global window properties used for ebike communication */
|
||||
interface EbikeGlobalState {
|
||||
@@ -152,6 +153,7 @@ export function PlayerController({
|
||||
spawnPosition,
|
||||
}: PlayerControllerProps): null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const sceneMode = useSceneMode();
|
||||
const movementLocked = useRepairMovementLocked();
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const movementLockedRef = useRef(movementLocked);
|
||||
@@ -483,19 +485,21 @@ export function PlayerController({
|
||||
}
|
||||
}
|
||||
|
||||
const groundHeight = terrainHeight.getHeight(
|
||||
capsule.current.end.x,
|
||||
capsule.current.end.z,
|
||||
);
|
||||
if (groundHeight !== null && velocity.current.y <= 0) {
|
||||
const groundOffset = getCapsuleFootY(capsule.current) - groundHeight;
|
||||
if (sceneMode === "game") {
|
||||
const groundHeight = terrainHeight.getHeight(
|
||||
capsule.current.end.x,
|
||||
capsule.current.end.z,
|
||||
);
|
||||
if (groundHeight !== null && velocity.current.y <= 0) {
|
||||
const groundOffset = getCapsuleFootY(capsule.current) - groundHeight;
|
||||
|
||||
if (groundOffset <= PLAYER_GROUND_SNAP_DISTANCE) {
|
||||
capsule.current.translate(
|
||||
_collisionCorrection.set(0, -groundOffset, 0),
|
||||
);
|
||||
velocity.current.y = 0;
|
||||
onFloor.current = true;
|
||||
if (groundOffset <= PLAYER_GROUND_SNAP_DISTANCE) {
|
||||
capsule.current.translate(
|
||||
_collisionCorrection.set(0, -groundOffset, 0),
|
||||
);
|
||||
velocity.current.y = 0;
|
||||
onFloor.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { Suspense, useMemo } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
|
||||
import {
|
||||
getMapLodModelPath,
|
||||
getMapLodScaleMultiplier,
|
||||
selectMapModelPathByDistance,
|
||||
} from "@/data/world/mapLodConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useGraphicsPresetConfig } from "@/hooks/world/useGraphicsSettings";
|
||||
import {
|
||||
useGraphicsPreset,
|
||||
useGraphicsPresetConfig,
|
||||
} from "@/hooks/world/useGraphicsSettings";
|
||||
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
|
||||
import {
|
||||
isMapModelVisible,
|
||||
@@ -18,6 +34,7 @@ import {
|
||||
} from "@/data/world/vegetationConfig";
|
||||
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
|
||||
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
|
||||
import type { GraphicsPreset } from "@/data/world/graphicsConfig";
|
||||
|
||||
interface VegetationSystemProps {
|
||||
onlyMapName?: string | null;
|
||||
@@ -70,12 +87,78 @@ function removeLaFabrikVegetation(
|
||||
});
|
||||
}
|
||||
|
||||
function areChunkModelPathsEqual(
|
||||
a: ReadonlyMap<string, string>,
|
||||
b: ReadonlyMap<string, string>,
|
||||
): boolean {
|
||||
return (
|
||||
a.size === b.size && [...a].every(([key, value]) => b.get(key) === value)
|
||||
);
|
||||
}
|
||||
|
||||
function useVegetationChunkModelPaths(
|
||||
chunks: readonly VegetationChunk[],
|
||||
preset: GraphicsPreset,
|
||||
): ReadonlyMap<string, string> {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
|
||||
const modelPathsRef = useRef<Map<string, string>>(new Map());
|
||||
const [modelPaths, setModelPaths] = useState<ReadonlyMap<string, string>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const updateModelPaths = useCallback(() => {
|
||||
const cameraX = camera.position.x;
|
||||
const cameraZ = camera.position.z;
|
||||
const next = new Map<string, string>();
|
||||
|
||||
for (const chunk of chunks) {
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const instance of chunk.instances) {
|
||||
const distance = Math.hypot(
|
||||
instance.position[0] - cameraX,
|
||||
instance.position[2] - cameraZ,
|
||||
);
|
||||
if (distance < nearestDistance) nearestDistance = distance;
|
||||
}
|
||||
|
||||
const modelPath = selectMapModelPathByDistance({
|
||||
distance: nearestDistance,
|
||||
modelName: VEGETATION_TYPES[chunk.type].mapName,
|
||||
modelPath: chunk.modelPath,
|
||||
preset,
|
||||
});
|
||||
next.set(chunk.key, modelPath);
|
||||
}
|
||||
|
||||
if (areChunkModelPathsEqual(next, modelPathsRef.current)) return;
|
||||
|
||||
modelPathsRef.current = next;
|
||||
setModelPaths(next);
|
||||
}, [camera, chunks, preset]);
|
||||
|
||||
useEffect(() => {
|
||||
updateModelPaths();
|
||||
}, [updateModelPaths]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const now = clock.elapsedTime * 1000;
|
||||
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
|
||||
lastUpdateRef.current = now;
|
||||
|
||||
updateModelPaths();
|
||||
});
|
||||
|
||||
return modelPaths;
|
||||
}
|
||||
|
||||
export function VegetationSystem({
|
||||
onlyMapName = null,
|
||||
streaming = true,
|
||||
}: VegetationSystemProps): React.JSX.Element | null {
|
||||
const cameraMode = useCameraMode();
|
||||
const sceneMode = useSceneMode();
|
||||
const graphicsPresetKey = useGraphicsPreset();
|
||||
const graphicsPreset = useGraphicsPresetConfig();
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
@@ -83,6 +166,7 @@ export function VegetationSystem({
|
||||
const streamingEnabled =
|
||||
streaming &&
|
||||
CHUNK_CONFIG.enabled &&
|
||||
graphicsPreset.chunkStreamingEnabled &&
|
||||
sceneMode === "game" &&
|
||||
cameraMode === "player";
|
||||
|
||||
@@ -112,25 +196,38 @@ export function VegetationSystem({
|
||||
unloadRadius: graphicsPreset.chunkUnloadRadius,
|
||||
});
|
||||
|
||||
const chunkModelPaths = useVegetationChunkModelPaths(
|
||||
visibleChunks,
|
||||
graphicsPresetKey,
|
||||
);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<group name="vegetation-system">
|
||||
{visibleChunks.map((chunk) => (
|
||||
<Suspense key={chunk.key} fallback={null}>
|
||||
<InstancedVegetation
|
||||
modelPath={chunk.modelPath}
|
||||
instances={chunk.instances}
|
||||
scaleMultiplier={chunk.scaleMultiplier}
|
||||
castShadow={chunk.castShadow}
|
||||
receiveShadow={chunk.receiveShadow}
|
||||
windStrength={chunk.windStrength}
|
||||
rotationOffset={chunk.rotationOffset}
|
||||
/>
|
||||
</Suspense>
|
||||
))}
|
||||
{visibleChunks.map((chunk) => {
|
||||
const modelPath = chunkModelPaths.get(chunk.key) ?? chunk.modelPath;
|
||||
const mapName = VEGETATION_TYPES[chunk.type].mapName;
|
||||
const isLod = modelPath === getMapLodModelPath(mapName);
|
||||
const scaleMultiplier =
|
||||
chunk.scaleMultiplier *
|
||||
(isLod ? getMapLodScaleMultiplier(mapName) : 1);
|
||||
return (
|
||||
<Suspense key={`${chunk.key}:${modelPath}`} fallback={null}>
|
||||
<InstancedVegetation
|
||||
modelPath={modelPath}
|
||||
instances={chunk.instances}
|
||||
scaleMultiplier={scaleMultiplier}
|
||||
castShadow={chunk.castShadow}
|
||||
receiveShadow={chunk.receiveShadow}
|
||||
windStrength={chunk.windStrength}
|
||||
rotationOffset={chunk.rotationOffset}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user