5 Commits

Author SHA1 Message Date
Tom Boullay 4f1b3b4ff3 fix(graphics): tune presets, single-line ui, vegetation LOD by nearest instance
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
- Bump high to 30m chunk / HD<20m and ultra to HD<30m so HD models
  persist further before swapping.
- Render the 5 graphics preset cards on a single row.
- Vegetation LOD selection now uses the distance to the nearest instance
  in each chunk instead of the chunk centre, matching MapInstancingSystem
  and avoiding premature LOD swaps when the camera enters a chunk.
2026-06-02 14:33:16 +02:00
Tom Boullay 627c8d4eb9 feat(vegetation): wire arbre/sapin/buisson into LOD system
Register the three new vegetation LOD models in MAP_LOD_MODEL_PATHS and
extend VegetationSystem with per-chunk distance-based LOD selection
(mirroring MapInstancingSystem). Chunk model paths are re-evaluated on
the existing CHUNK_CONFIG.updateInterval cadence and Suspense keys
include the resolved path so a swap unmounts the previous instanced mesh
cleanly.
2026-06-02 14:17:19 +02:00
Tom Boullay 0b801888f0 update: fix name assets + add LOD 2026-06-02 14:13:55 +02:00
Tom Boullay a180b89ee6 fix(pylone): convert lampe BLEND to MASK in GLTFs
Switching alphaMode from BLEND to MASK with a 0.5 cutoff lets the lampe
material participate in instanced shadow rendering correctly. BLEND is
known to interact badly with InstancedMesh shadow casting, producing
incorrect or missing shadows on the pylone lampe.
2026-06-02 13:52:08 +02:00
Tom Boullay 3e66e31117 feat(graphics): add max preset (no chunk streaming, LOD@50m)
Restore ultra to its original behaviour (50m chunk streaming, HD within
20m, no fog) and introduce a new max preset that disables chunk streaming
entirely (loads all chunks unconditionally) and pushes the HD/LOD swap
distance to 50m. Add chunkStreamingEnabled flag to GraphicsPresetConfig
so the streaming gate honours the preset. The settings card label shows
'All' when streaming is off.
2026-06-02 13:51:33 +02:00
14 changed files with 150 additions and 27 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -2
View File
@@ -105,6 +105,9 @@ function GraphicsPresetButton({
const lodLabel = config.forceLodModels const lodLabel = config.forceLodModels
? "LOD forcé" ? "LOD forcé"
: `HD ${config.lodHighDetailDistance}m`; : `HD ${config.lodHighDetailDistance}m`;
const chunkLabel = config.chunkStreamingEnabled
? formatChunkDistance(config.chunkLoadRadius)
: "All";
return ( return (
<button <button
@@ -115,8 +118,7 @@ function GraphicsPresetButton({
> >
<span>{config.label}</span> <span>{config.label}</span>
<small> <small>
{formatChunkDistance(config.chunkLoadRadius)} · {lodLabel} ·{" "} {chunkLabel} · {lodLabel} · {config.fogEnabled ? "Fog" : "Clear"}
{config.fogEnabled ? "Fog" : "Clear"}
</small> </small>
</button> </button>
); );
+25 -5
View File
@@ -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 type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
export interface GraphicsPresetConfig { export interface GraphicsPresetConfig {
chunkLoadRadius: number; chunkLoadRadius: number;
chunkStreamingEnabled: boolean;
chunkUnloadRadius: number; chunkUnloadRadius: number;
fogEnabled: boolean; fogEnabled: boolean;
forceLodModels: boolean; forceLodModels: boolean;
@@ -16,6 +23,7 @@ export const GRAPHICS_PRESETS = {
label: "Basse", label: "Basse",
chunkLoadRadius: 10, chunkLoadRadius: 10,
chunkUnloadRadius: 18, chunkUnloadRadius: 18,
chunkStreamingEnabled: true,
fogEnabled: true, fogEnabled: true,
forceLodModels: true, forceLodModels: true,
lodHighDetailDistance: 0, lodHighDetailDistance: 0,
@@ -24,25 +32,37 @@ export const GRAPHICS_PRESETS = {
label: "Moyenne", label: "Moyenne",
chunkLoadRadius: 20, chunkLoadRadius: 20,
chunkUnloadRadius: 30, chunkUnloadRadius: 30,
chunkStreamingEnabled: true,
fogEnabled: true, fogEnabled: true,
forceLodModels: true, forceLodModels: true,
lodHighDetailDistance: 0, lodHighDetailDistance: 0,
}, },
high: { high: {
label: "High", label: "High",
chunkLoadRadius: 35, chunkLoadRadius: 30,
chunkUnloadRadius: 45, chunkUnloadRadius: 40,
chunkStreamingEnabled: true,
fogEnabled: false, fogEnabled: false,
forceLodModels: false, forceLodModels: false,
lodHighDetailDistance: 10, lodHighDetailDistance: 20,
}, },
ultra: { ultra: {
label: "Ultra", label: "Ultra",
chunkLoadRadius: 50, chunkLoadRadius: 50,
chunkUnloadRadius: 65, chunkUnloadRadius: 65,
chunkStreamingEnabled: true,
fogEnabled: false, fogEnabled: false,
forceLodModels: 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>; } as const satisfies Record<GraphicsPreset, GraphicsPresetConfig>;
+3
View File
@@ -14,6 +14,9 @@ export const MAP_LOD_MODEL_PATHS = {
maison1: "/models/maison1-LOD/model.gltf", maison1: "/models/maison1-LOD/model.gltf",
panneauaffichage: "/models/panneauaffichage-LOD/model.gltf", panneauaffichage: "/models/panneauaffichage-LOD/model.gltf",
talkie: "/models/talkie-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>; } as const satisfies Record<string, string>;
export function getMapLodModelPath(modelName: string): string | null { export function getMapLodModelPath(modelName: string): string | null {
+1 -1
View File
@@ -1544,7 +1544,7 @@ canvas {
} }
.game-settings-menu__choice-group--presets { .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, .game-settings-menu__choice-group button,
@@ -149,6 +149,7 @@ export function MapInstancingSystem({
const streamingEnabled = const streamingEnabled =
streaming && streaming &&
CHUNK_CONFIG.enabled && CHUNK_CONFIG.enabled &&
graphicsPresetConfig.chunkStreamingEnabled &&
sceneMode === "game" && sceneMode === "game" &&
cameraMode === "player"; cameraMode === "player";
+103 -15
View File
@@ -1,8 +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 { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import { selectMapModelPathByDistance } from "@/data/world/mapLodConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode"; 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 { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import { import {
isMapModelVisible, isMapModelVisible,
@@ -18,6 +30,7 @@ import {
} from "@/data/world/vegetationConfig"; } from "@/data/world/vegetationConfig";
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig"; import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances"; import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
import type { GraphicsPreset } from "@/data/world/graphicsConfig";
interface VegetationSystemProps { interface VegetationSystemProps {
onlyMapName?: string | null; onlyMapName?: string | null;
@@ -70,12 +83,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({ export function VegetationSystem({
onlyMapName = null, onlyMapName = null,
streaming = true, streaming = true,
}: VegetationSystemProps): React.JSX.Element | null { }: VegetationSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode(); const cameraMode = useCameraMode();
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
const graphicsPresetKey = useGraphicsPreset();
const graphicsPreset = useGraphicsPresetConfig(); const graphicsPreset = useGraphicsPresetConfig();
const groups = useMapPerformanceStore((state) => state.groups); const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models); const models = useMapPerformanceStore((state) => state.models);
@@ -83,6 +162,7 @@ export function VegetationSystem({
const streamingEnabled = const streamingEnabled =
streaming && streaming &&
CHUNK_CONFIG.enabled && CHUNK_CONFIG.enabled &&
graphicsPreset.chunkStreamingEnabled &&
sceneMode === "game" && sceneMode === "game" &&
cameraMode === "player"; cameraMode === "player";
@@ -112,25 +192,33 @@ export function VegetationSystem({
unloadRadius: graphicsPreset.chunkUnloadRadius, unloadRadius: graphicsPreset.chunkUnloadRadius,
}); });
const chunkModelPaths = useVegetationChunkModelPaths(
visibleChunks,
graphicsPresetKey,
);
if (isLoading || !data) { if (isLoading || !data) {
return null; return null;
} }
return ( return (
<group name="vegetation-system"> <group name="vegetation-system">
{visibleChunks.map((chunk) => ( {visibleChunks.map((chunk) => {
<Suspense key={chunk.key} fallback={null}> const modelPath = chunkModelPaths.get(chunk.key) ?? chunk.modelPath;
<InstancedVegetation return (
modelPath={chunk.modelPath} <Suspense key={`${chunk.key}:${modelPath}`} fallback={null}>
instances={chunk.instances} <InstancedVegetation
scaleMultiplier={chunk.scaleMultiplier} modelPath={modelPath}
castShadow={chunk.castShadow} instances={chunk.instances}
receiveShadow={chunk.receiveShadow} scaleMultiplier={chunk.scaleMultiplier}
windStrength={chunk.windStrength} castShadow={chunk.castShadow}
rotationOffset={chunk.rotationOffset} receiveShadow={chunk.receiveShadow}
/> windStrength={chunk.windStrength}
</Suspense> rotationOffset={chunk.rotationOffset}
))} />
</Suspense>
);
})}
</group> </group>
); );
} }