feat(world): add map lod graphics presets

This commit is contained in:
Tom Boullay
2026-05-31 19:03:55 +02:00
parent 564a455520
commit 34c198ebfd
13 changed files with 717 additions and 131 deletions
+8 -2
View File
@@ -2,11 +2,11 @@ import type { ReactNode } from "react";
import {
Component,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
@@ -23,6 +23,7 @@ import {
} from "@/managers/stores/useMapPerformanceStore";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
import { GameMapCollision } from "@/world/GameMapCollision";
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
@@ -362,6 +363,11 @@ function ModelInstance({
const { position, rotation, scale } = node;
const scaleMultiplier = getMapSingleModelScaleMultiplier(node.name);
const baseScale = normalizeMapScale(scale);
const activeModelUrl = useMapLodModelPath({
modelName: node.name,
modelPath: modelUrl,
position: node.position,
});
const normalizedScale = useMemo(
() =>
[
@@ -372,7 +378,7 @@ function ModelInstance({
[baseScale, scaleMultiplier],
);
const terrainHeight = useTerrainHeightSampler();
const { scene } = useLoggedGLTF(modelUrl, {
const { scene } = useLoggedGLTF(activeModelUrl, {
scope: "GameMap.ModelInstance",
position,
rotation,
+5 -1
View File
@@ -6,6 +6,7 @@ import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useFogSettings } from "@/hooks/world/useFogSettings";
import { useGraphicsPresetConfig } from "@/hooks/world/useGraphicsSettings";
import { LIGHTING_STATE } from "@/world/lightingState";
const tempSunFogColor = new THREE.Color();
@@ -23,11 +24,14 @@ export function FogSystem(): React.JSX.Element | null {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const fog = useFogSettings();
const graphicsPreset = useGraphicsPresetConfig();
const fogEnabled = useDebugStore((debug) => debug.getFogEnabled());
const scene = useThree((state) => state.scene);
const fogColor = useMemo(() => getLightingFogColor(new THREE.Color()), []);
const shouldShowFog =
fogEnabled && sceneMode === "game" && cameraMode === "player";
(fogEnabled || graphicsPreset.fogEnabled) &&
sceneMode === "game" &&
cameraMode === "player";
useFrame(() => {
if (!scene.fog) return;
+129 -13
View File
@@ -1,7 +1,20 @@
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 { selectMapModelPathByDistance } from "@/data/world/mapLodConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import {
useGraphicsPreset,
useGraphicsPresetConfig,
} from "@/hooks/world/useGraphicsSettings";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import {
isMapModelVisible,
@@ -16,6 +29,7 @@ import {
} from "@/data/world/mapInstancingConfig";
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
import type { MapAssetInstance } from "@/types/map/mapScene";
import type { GraphicsPreset } from "@/data/world/graphicsConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
interface MapInstancingSystemProps {
@@ -47,12 +61,88 @@ function createMapAssetChunks(
});
}
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 getNearestChunkInstanceDistance(
chunk: MapAssetChunk,
cameraX: number,
cameraZ: number,
): number {
return chunk.instances.reduce((nearestDistance, instance) => {
const distance = Math.hypot(
instance.position[0] - cameraX,
instance.position[2] - cameraZ,
);
return Math.min(nearestDistance, distance);
}, Number.POSITIVE_INFINITY);
}
function useChunkModelPaths(
chunks: readonly MapAssetChunk[],
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 nextModelPaths = new Map<string, string>();
for (const chunk of chunks) {
const distance = getNearestChunkInstanceDistance(chunk, cameraX, cameraZ);
const modelPath = selectMapModelPathByDistance({
distance,
modelName: chunk.config.mapName,
modelPath: chunk.config.modelPath,
preset,
});
nextModelPaths.set(chunk.key, modelPath);
}
if (areChunkModelPathsEqual(nextModelPaths, modelPathsRef.current)) return;
modelPathsRef.current = nextModelPaths;
setModelPaths(nextModelPaths);
}, [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 MapInstancingSystem({
onlyMapName = null,
streaming = true,
}: MapInstancingSystemProps): React.JSX.Element | null {
const camera = useThree((state) => state.camera);
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const graphicsPreset = useGraphicsPreset();
const graphicsPresetConfig = useGraphicsPresetConfig();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useMapInstancingData();
@@ -84,7 +174,29 @@ export function MapInstancingSystem({
});
}, [data, groups, models, onlyMapName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled, {
loadRadius: graphicsPresetConfig.chunkLoadRadius,
unloadRadius: graphicsPresetConfig.chunkUnloadRadius,
});
const chunkModelPaths = useChunkModelPaths(visibleChunks, graphicsPreset);
const getChunkModelPath = useCallback(
(chunk: MapAssetChunk): string => {
const cachedModelPath = chunkModelPaths.get(chunk.key);
if (cachedModelPath) return cachedModelPath;
return selectMapModelPathByDistance({
distance: getNearestChunkInstanceDistance(
chunk,
camera.position.x,
camera.position.z,
),
modelName: chunk.config.mapName,
modelPath: chunk.config.modelPath,
preset: graphicsPreset,
});
},
[camera, chunkModelPaths, graphicsPreset],
);
if (isLoading || !data) {
return null;
@@ -92,17 +204,21 @@ export function MapInstancingSystem({
return (
<group name="map-instancing-system">
{visibleChunks.map((chunk) => (
<Suspense key={chunk.key} fallback={null}>
<InstancedMapAsset
modelPath={chunk.config.modelPath}
instances={chunk.instances}
scaleMultiplier={chunk.config.scaleMultiplier}
castShadow={chunk.config.castShadow}
receiveShadow={chunk.config.receiveShadow}
/>
</Suspense>
))}
{visibleChunks.map((chunk) => {
const modelPath = getChunkModelPath(chunk);
return (
<Suspense key={`${chunk.key}:${modelPath}`} fallback={null}>
<InstancedMapAsset
modelPath={modelPath}
instances={chunk.instances}
scaleMultiplier={chunk.config.scaleMultiplier}
castShadow={chunk.config.castShadow}
receiveShadow={chunk.config.receiveShadow}
/>
</Suspense>
);
})}
</group>
);
}
+6 -1
View File
@@ -2,6 +2,7 @@ import { Suspense, useMemo } from "react";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useGraphicsPresetConfig } from "@/hooks/world/useGraphicsSettings";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import {
isMapModelVisible,
@@ -65,6 +66,7 @@ export function VegetationSystem({
}: VegetationSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const graphicsPreset = useGraphicsPresetConfig();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useVegetationData();
@@ -92,7 +94,10 @@ export function VegetationSystem({
});
}, [data, groups, models, onlyMapName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled, {
loadRadius: graphicsPreset.chunkLoadRadius,
unloadRadius: graphicsPreset.chunkUnloadRadius,
});
if (isLoading || !data) {
return null;