feat(world): add map lod graphics presets
This commit is contained in:
@@ -3,13 +3,25 @@ import {
|
||||
MergedStaticMapModel,
|
||||
type MergedStaticMapModelProps,
|
||||
} from "@/components/three/world/MergedStaticMapModel";
|
||||
import { getMapLodModelPath } from "@/data/world/mapLodConfig";
|
||||
import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
|
||||
|
||||
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
|
||||
const ECOLE_LOD_MODEL_PATH = getMapLodModelPath("ecole");
|
||||
|
||||
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||
|
||||
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
|
||||
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
|
||||
const modelPath = useMapLodModelPath({
|
||||
modelName: "ecole",
|
||||
modelPath: ECOLE_MODEL_PATH,
|
||||
position: props.position,
|
||||
});
|
||||
|
||||
return <MergedStaticMapModel modelPath={modelPath} {...props} />;
|
||||
}
|
||||
|
||||
useGLTF.preload(ECOLE_MODEL_PATH);
|
||||
if (ECOLE_LOD_MODEL_PATH) {
|
||||
useGLTF.preload(ECOLE_LOD_MODEL_PATH);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { useEffect } from "react";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Captions,
|
||||
Gauge,
|
||||
LogOut,
|
||||
Music2,
|
||||
RotateCcw,
|
||||
Volume2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
GRAPHICS_PRESET_KEYS,
|
||||
GRAPHICS_PRESETS,
|
||||
type GraphicsPreset,
|
||||
} from "@/data/world/graphicsConfig";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
import type { SubtitleLanguage } from "@/types/settings/settings";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
|
||||
@@ -21,6 +36,7 @@ function clearCookies(): void {
|
||||
interface VolumeSliderProps {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
@@ -28,13 +44,17 @@ interface VolumeSliderProps {
|
||||
function VolumeSlider({
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
onChange,
|
||||
}: VolumeSliderProps): React.JSX.Element {
|
||||
return (
|
||||
<label className="game-settings-menu__slider" htmlFor={id}>
|
||||
<span>
|
||||
{label}
|
||||
<em>
|
||||
{icon}
|
||||
{label}
|
||||
</em>
|
||||
<strong>{formatPercent(value)}</strong>
|
||||
</span>
|
||||
<input
|
||||
@@ -50,8 +70,50 @@ function VolumeSlider({
|
||||
);
|
||||
}
|
||||
|
||||
function formatChunkDistance(distance: number): string {
|
||||
return `${distance}m`;
|
||||
}
|
||||
|
||||
interface GraphicsPresetButtonProps {
|
||||
active: boolean;
|
||||
preset: GraphicsPreset;
|
||||
onSelect: (preset: GraphicsPreset) => void;
|
||||
}
|
||||
|
||||
function GraphicsPresetButton({
|
||||
active,
|
||||
preset,
|
||||
onSelect,
|
||||
}: GraphicsPresetButtonProps): React.JSX.Element {
|
||||
const config = GRAPHICS_PRESETS[preset];
|
||||
const lodLabel = config.forceLodModels
|
||||
? "LOD forcé"
|
||||
: `HD ${config.lodHighDetailDistance}m`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={active ? "active" : undefined}
|
||||
onClick={() => onSelect(preset)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span>{config.label}</span>
|
||||
<small>
|
||||
{formatChunkDistance(config.chunkLoadRadius)} · {lodLabel} ·{" "}
|
||||
{config.fogEnabled ? "Fog" : "Clear"}
|
||||
</small>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameSettingsMenu(): React.JSX.Element | null {
|
||||
const resetGame = useGameStore((state) => state.resetGame);
|
||||
const graphicsPreset = useWorldSettingsStore(
|
||||
(state) => state.graphics.preset,
|
||||
);
|
||||
const setGraphicsPreset = useWorldSettingsStore(
|
||||
(state) => state.setGraphicsPreset,
|
||||
);
|
||||
const {
|
||||
isSettingsMenuOpen,
|
||||
musicVolume,
|
||||
@@ -103,8 +165,8 @@ export function GameSettingsMenu(): React.JSX.Element | null {
|
||||
<div className="game-settings-menu__panel">
|
||||
<header className="game-settings-menu__header">
|
||||
<div>
|
||||
<span>Pause</span>
|
||||
<h2>Options</h2>
|
||||
<span>La Fabrik</span>
|
||||
<h2>Pause</h2>
|
||||
</div>
|
||||
<button
|
||||
className="game-settings-menu__close"
|
||||
@@ -116,62 +178,98 @@ export function GameSettingsMenu(): React.JSX.Element | null {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="audio-settings-heading"
|
||||
>
|
||||
<h3 id="audio-settings-heading">Audio</h3>
|
||||
<VolumeSlider
|
||||
id="music-volume"
|
||||
label="Musique"
|
||||
value={musicVolume}
|
||||
onChange={setMusicVolume}
|
||||
/>
|
||||
<VolumeSlider
|
||||
id="sfx-volume"
|
||||
label="Sound effects"
|
||||
value={sfxVolume}
|
||||
onChange={setSfxVolume}
|
||||
/>
|
||||
<VolumeSlider
|
||||
id="dialogue-volume"
|
||||
label="Dialogue"
|
||||
value={dialogueVolume}
|
||||
onChange={setDialogueVolume}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="subtitle-settings-heading"
|
||||
>
|
||||
<h3 id="subtitle-settings-heading">Sous-titres</h3>
|
||||
<label className="game-settings-menu__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitlesEnabled}
|
||||
onChange={(event) => setSubtitlesEnabled(event.target.checked)}
|
||||
/>
|
||||
Afficher sous-titres
|
||||
</label>
|
||||
|
||||
<div
|
||||
className="game-settings-menu__choice-group"
|
||||
aria-label="Langue des sous-titres"
|
||||
<div className="game-settings-menu__grid">
|
||||
<section
|
||||
className="game-settings-menu__section game-settings-menu__section--wide"
|
||||
aria-labelledby="graphics-settings-heading"
|
||||
>
|
||||
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
|
||||
<button
|
||||
key={language}
|
||||
type="button"
|
||||
className={subtitleLanguage === language ? "active" : undefined}
|
||||
onClick={() => setSubtitleLanguage(language)}
|
||||
aria-pressed={subtitleLanguage === language}
|
||||
>
|
||||
{language === "fr" ? "Francais" : "English"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="game-settings-menu__section-title">
|
||||
<Gauge size={16} aria-hidden="true" />
|
||||
<h3 id="graphics-settings-heading">Performance</h3>
|
||||
</div>
|
||||
<div
|
||||
className="game-settings-menu__choice-group game-settings-menu__choice-group--presets"
|
||||
aria-label="Preset graphique"
|
||||
>
|
||||
{GRAPHICS_PRESET_KEYS.map((preset) => (
|
||||
<GraphicsPresetButton
|
||||
key={preset}
|
||||
preset={preset}
|
||||
active={graphicsPreset === preset}
|
||||
onSelect={setGraphicsPreset}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="audio-settings-heading"
|
||||
>
|
||||
<div className="game-settings-menu__section-title">
|
||||
<Volume2 size={16} aria-hidden="true" />
|
||||
<h3 id="audio-settings-heading">Audio</h3>
|
||||
</div>
|
||||
<VolumeSlider
|
||||
id="music-volume"
|
||||
icon={<Music2 size={14} aria-hidden="true" />}
|
||||
label="Musique"
|
||||
value={musicVolume}
|
||||
onChange={setMusicVolume}
|
||||
/>
|
||||
<VolumeSlider
|
||||
id="sfx-volume"
|
||||
icon={<Volume2 size={14} aria-hidden="true" />}
|
||||
label="Effets"
|
||||
value={sfxVolume}
|
||||
onChange={setSfxVolume}
|
||||
/>
|
||||
<VolumeSlider
|
||||
id="dialogue-volume"
|
||||
icon={<Captions size={14} aria-hidden="true" />}
|
||||
label="Dialogue"
|
||||
value={dialogueVolume}
|
||||
onChange={setDialogueVolume}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="subtitle-settings-heading"
|
||||
>
|
||||
<div className="game-settings-menu__section-title">
|
||||
<Captions size={16} aria-hidden="true" />
|
||||
<h3 id="subtitle-settings-heading">Sous-titres</h3>
|
||||
</div>
|
||||
<label className="game-settings-menu__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitlesEnabled}
|
||||
onChange={(event) => setSubtitlesEnabled(event.target.checked)}
|
||||
/>
|
||||
Afficher sous-titres
|
||||
</label>
|
||||
|
||||
<div
|
||||
className="game-settings-menu__choice-group"
|
||||
aria-label="Langue des sous-titres"
|
||||
>
|
||||
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
|
||||
<button
|
||||
key={language}
|
||||
type="button"
|
||||
className={
|
||||
subtitleLanguage === language ? "active" : undefined
|
||||
}
|
||||
onClick={() => setSubtitleLanguage(language)}
|
||||
aria-pressed={subtitleLanguage === language}
|
||||
>
|
||||
<span>{language === "fr" ? "Français" : "English"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{showDebugRestart ? (
|
||||
<button
|
||||
@@ -189,6 +287,7 @@ export function GameSettingsMenu(): React.JSX.Element | null {
|
||||
type="button"
|
||||
onClick={handleQuit}
|
||||
>
|
||||
<LogOut size={14} aria-hidden="true" />
|
||||
Quitter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,55 @@
|
||||
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
|
||||
|
||||
export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
|
||||
|
||||
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
|
||||
|
||||
export interface GraphicsPresetConfig {
|
||||
chunkLoadRadius: number;
|
||||
chunkUnloadRadius: number;
|
||||
fogEnabled: boolean;
|
||||
forceLodModels: boolean;
|
||||
label: string;
|
||||
lodHighDetailDistance: number;
|
||||
}
|
||||
|
||||
export const GRAPHICS_PRESETS = {
|
||||
low: {
|
||||
label: "Basse",
|
||||
chunkLoadRadius: 10,
|
||||
chunkUnloadRadius: 18,
|
||||
fogEnabled: true,
|
||||
forceLodModels: true,
|
||||
lodHighDetailDistance: 0,
|
||||
},
|
||||
medium: {
|
||||
label: "Moyenne",
|
||||
chunkLoadRadius: 20,
|
||||
chunkUnloadRadius: 30,
|
||||
fogEnabled: true,
|
||||
forceLodModels: true,
|
||||
lodHighDetailDistance: 0,
|
||||
},
|
||||
high: {
|
||||
label: "High",
|
||||
chunkLoadRadius: CHUNK_CONFIG.loadRadius,
|
||||
chunkUnloadRadius: CHUNK_CONFIG.unloadRadius,
|
||||
fogEnabled: false,
|
||||
forceLodModels: false,
|
||||
lodHighDetailDistance: 10,
|
||||
},
|
||||
ultra: {
|
||||
label: "Ultra",
|
||||
chunkLoadRadius: 50,
|
||||
chunkUnloadRadius: 65,
|
||||
fogEnabled: false,
|
||||
forceLodModels: false,
|
||||
lodHighDetailDistance: 20,
|
||||
},
|
||||
} as const satisfies Record<GraphicsPreset, GraphicsPresetConfig>;
|
||||
|
||||
export const GRAPHICS_DEFAULTS = {
|
||||
preset: "high" as GraphicsPreset,
|
||||
dynamicGrass: true,
|
||||
dynamicTrees: true,
|
||||
dynamicClouds: true,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
GRAPHICS_PRESETS,
|
||||
type GraphicsPreset,
|
||||
} from "@/data/world/graphicsConfig";
|
||||
|
||||
export const MAP_LOD_MODEL_PATHS = {
|
||||
ebike: "/models/ebike-LOD/model.gltf",
|
||||
eolienne: "/models/eolienne-LOD/model.gltf",
|
||||
pylone: "/models/pylone-LOD/model.gltf",
|
||||
boiteimmeuble: "/models/boiteimmeuble-LOD/model.gltf",
|
||||
ecole: "/models/ecole-LOD/model.gltf",
|
||||
immeuble1: "/models/immeuble1-LOD/model.gltf",
|
||||
maison1: "/models/maison1-LOD/model.gltf",
|
||||
panneauaffichage: "/models/panneauaffichage-LOD/model.gltf",
|
||||
talkie: "/models/talkie-LOD/model.gltf",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
export function getMapLodModelPath(modelName: string): string | null {
|
||||
return (
|
||||
MAP_LOD_MODEL_PATHS[modelName as keyof typeof MAP_LOD_MODEL_PATHS] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function selectMapModelPathByDistance({
|
||||
distance,
|
||||
modelName,
|
||||
modelPath,
|
||||
preset,
|
||||
}: {
|
||||
distance: number;
|
||||
modelName: string;
|
||||
modelPath: string;
|
||||
preset: GraphicsPreset;
|
||||
}): string {
|
||||
const lodModelPath = getMapLodModelPath(modelName);
|
||||
if (!lodModelPath) return modelPath;
|
||||
|
||||
const presetConfig = GRAPHICS_PRESETS[preset];
|
||||
if (presetConfig.forceLodModels) return lodModelPath;
|
||||
|
||||
return distance <= presetConfig.lodHighDetailDistance
|
||||
? modelPath
|
||||
: lodModelPath;
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
import { GRAPHICS_PRESETS } from "@/data/world/graphicsConfig";
|
||||
import type {
|
||||
GraphicsPreset,
|
||||
GraphicsPresetConfig,
|
||||
} from "@/data/world/graphicsConfig";
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
|
||||
export function useGraphicsPreset(): GraphicsPreset {
|
||||
return useWorldSettingsStore((state) => state.graphics.preset);
|
||||
}
|
||||
|
||||
export function useGraphicsPresetConfig(): GraphicsPresetConfig {
|
||||
return useWorldSettingsStore(
|
||||
(state) => GRAPHICS_PRESETS[state.graphics.preset],
|
||||
);
|
||||
}
|
||||
|
||||
export function useDynamicGrass(): boolean {
|
||||
return useWorldSettingsStore((state) => state.graphics.dynamicGrass);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, 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 { useGraphicsPreset } from "@/hooks/world/useGraphicsSettings";
|
||||
|
||||
interface UseMapLodModelPathArgs {
|
||||
modelName: string;
|
||||
modelPath: string;
|
||||
position: readonly [number, number, number];
|
||||
}
|
||||
|
||||
export function useMapLodModelPath({
|
||||
modelName,
|
||||
modelPath,
|
||||
position,
|
||||
}: UseMapLodModelPathArgs): string {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const graphicsPreset = useGraphicsPreset();
|
||||
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
|
||||
const initialModelPath = selectMapModelPathByDistance({
|
||||
distance: Math.hypot(
|
||||
position[0] - camera.position.x,
|
||||
position[2] - camera.position.z,
|
||||
),
|
||||
modelName,
|
||||
modelPath,
|
||||
preset: graphicsPreset,
|
||||
});
|
||||
const activeModelPathRef = useRef(initialModelPath);
|
||||
const [activeModelPath, setActiveModelPath] = useState(initialModelPath);
|
||||
|
||||
const updateModelPath = useCallback(() => {
|
||||
const distance = Math.hypot(
|
||||
position[0] - camera.position.x,
|
||||
position[2] - camera.position.z,
|
||||
);
|
||||
const nextModelPath = selectMapModelPathByDistance({
|
||||
distance,
|
||||
modelName,
|
||||
modelPath,
|
||||
preset: graphicsPreset,
|
||||
});
|
||||
|
||||
if (nextModelPath === activeModelPathRef.current) return;
|
||||
|
||||
activeModelPathRef.current = nextModelPath;
|
||||
setActiveModelPath(nextModelPath);
|
||||
}, [camera, graphicsPreset, modelName, modelPath, position]);
|
||||
|
||||
useEffect(() => {
|
||||
updateModelPath();
|
||||
}, [updateModelPath]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const now = clock.elapsedTime * 1000;
|
||||
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
|
||||
lastUpdateRef.current = now;
|
||||
|
||||
updateModelPath();
|
||||
});
|
||||
|
||||
return activeModelPath;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface WorldChunkLike {
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface WorldChunkVisibilityConfig {
|
||||
loadRadius: number;
|
||||
unloadRadius: number;
|
||||
}
|
||||
|
||||
function areSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
return a.size === b.size && [...a].every((key) => b.has(key));
|
||||
}
|
||||
@@ -15,6 +20,7 @@ function areSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
|
||||
chunks: readonly TChunk[],
|
||||
streamingEnabled: boolean,
|
||||
visibilityConfig: WorldChunkVisibilityConfig = CHUNK_CONFIG,
|
||||
): readonly TChunk[] {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
|
||||
@@ -35,8 +41,8 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
|
||||
);
|
||||
const wasActive = activeChunkKeysRef.current.has(chunk.key);
|
||||
const radius = wasActive
|
||||
? CHUNK_CONFIG.unloadRadius
|
||||
: CHUNK_CONFIG.loadRadius;
|
||||
? visibilityConfig.unloadRadius
|
||||
: visibilityConfig.loadRadius;
|
||||
|
||||
if (distance <= radius) {
|
||||
nextKeys.add(chunk.key);
|
||||
@@ -47,7 +53,18 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
|
||||
|
||||
activeChunkKeysRef.current = nextKeys;
|
||||
setActiveChunkKeys(nextKeys);
|
||||
}, [camera, chunks]);
|
||||
}, [
|
||||
camera,
|
||||
chunks,
|
||||
visibilityConfig.loadRadius,
|
||||
visibilityConfig.unloadRadius,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamingEnabled) return;
|
||||
|
||||
updateActiveChunks();
|
||||
}, [streamingEnabled, updateActiveChunks]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!streamingEnabled) return;
|
||||
@@ -71,7 +88,7 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
|
||||
Math.hypot(
|
||||
chunk.centerX - camera.position.x,
|
||||
chunk.centerZ - camera.position.z,
|
||||
) <= CHUNK_CONFIG.loadRadius
|
||||
) <= visibilityConfig.loadRadius
|
||||
);
|
||||
});
|
||||
}, [
|
||||
@@ -80,5 +97,6 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
|
||||
camera.position.z,
|
||||
chunks,
|
||||
streamingEnabled,
|
||||
visibilityConfig.loadRadius,
|
||||
]);
|
||||
}
|
||||
|
||||
+194
-49
@@ -1218,22 +1218,49 @@ canvas {
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: clamp(18px, 4vw, 42px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(4, 10, 18, 0.64), rgba(2, 6, 12, 0.86)),
|
||||
rgba(0, 0, 0, 0.72);
|
||||
color: #ffffff;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.game-settings-menu__panel {
|
||||
width: min(460px, 100%);
|
||||
max-height: calc(100vh - 40px);
|
||||
position: relative;
|
||||
width: min(760px, 100%);
|
||||
max-height: calc(100vh - clamp(36px, 8vw, 84px));
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 24px;
|
||||
background: rgba(8, 8, 8, 0.94);
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
|
||||
padding: clamp(16px, 2.4vw, 22px);
|
||||
border: 1px solid rgba(125, 211, 252, 0.38);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 20, 34, 0.94), rgba(3, 8, 14, 0.96)),
|
||||
rgba(4, 8, 12, 0.96);
|
||||
box-shadow:
|
||||
0 28px 90px rgba(0, 0, 0, 0.58),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
0 0 34px rgba(56, 189, 248, 0.16);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(125, 211, 252, 0.55) transparent;
|
||||
}
|
||||
|
||||
.game-settings-menu__panel::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: 8px;
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
rgba(125, 211, 252, 0.08) 0,
|
||||
rgba(125, 211, 252, 0.08) 1px,
|
||||
transparent 1px,
|
||||
transparent 8px
|
||||
);
|
||||
content: "";
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-settings-menu__header {
|
||||
@@ -1241,21 +1268,26 @@ canvas {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 4px 16px;
|
||||
padding: 4px 2px 18px;
|
||||
}
|
||||
|
||||
.game-settings-menu__header span {
|
||||
color: #8f8f8f;
|
||||
font-size: 0.7rem;
|
||||
color: #7dd3fc;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-settings-menu__header h2 {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.06em;
|
||||
font-family: "Nersans One", var(--font-primary);
|
||||
font-size: clamp(2rem, 4vw, 3.2rem);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
line-height: 0.92;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-settings-menu__close {
|
||||
@@ -1263,26 +1295,59 @@ canvas {
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
background: #111111;
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(125, 211, 252, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 22, 34, 0.84);
|
||||
color: #dff7ff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.game-settings-menu__close:hover,
|
||||
.game-settings-menu__close:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.78);
|
||||
background: rgba(125, 211, 252, 0.18);
|
||||
color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.game-settings-menu__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.game-settings-menu__section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px 4px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
align-content: start;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(5, 14, 24, 0.64);
|
||||
}
|
||||
|
||||
.game-settings-menu__section--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.game-settings-menu__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.game-settings-menu__section h3 {
|
||||
margin: 0;
|
||||
color: #d7d7d7;
|
||||
color: #dff7ff;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1297,19 +1362,28 @@ canvas {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #f2f2f2;
|
||||
color: #edfaff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.game-settings-menu__slider em {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.game-settings-menu__slider strong {
|
||||
color: #8f8f8f;
|
||||
color: #7dd3fc;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-settings-menu__slider input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: #ffffff;
|
||||
accent-color: #7dd3fc;
|
||||
}
|
||||
|
||||
.game-settings-menu__checkbox {
|
||||
@@ -1320,7 +1394,7 @@ canvas {
|
||||
.game-settings-menu__checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #ffffff;
|
||||
accent-color: #7dd3fc;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group {
|
||||
@@ -1329,36 +1403,91 @@ canvas {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group--stacked {
|
||||
grid-template-columns: 1fr;
|
||||
.game-settings-menu__choice-group--presets {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button,
|
||||
.game-settings-menu__restart,
|
||||
.game-settings-menu__quit {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 14px;
|
||||
background: #101010;
|
||||
color: #f2f2f2;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button.active {
|
||||
border-color: #ffffff;
|
||||
background: #ffffff;
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
.game-settings-menu__restart {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(6, 18, 30, 0.72);
|
||||
color: #edfaff;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 680;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group--presets button {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 4px;
|
||||
min-height: 72px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button small {
|
||||
color: rgba(223, 247, 255, 0.62);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button:hover,
|
||||
.game-settings-menu__choice-group button:focus-visible,
|
||||
.game-settings-menu__restart:hover,
|
||||
.game-settings-menu__restart:focus-visible,
|
||||
.game-settings-menu__quit:hover,
|
||||
.game-settings-menu__quit:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.64);
|
||||
background: rgba(125, 211, 252, 0.16);
|
||||
color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button:active,
|
||||
.game-settings-menu__restart:active,
|
||||
.game-settings-menu__quit:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button.active {
|
||||
border-color: rgba(125, 211, 252, 0.92);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(125, 211, 252, 0.28),
|
||||
rgba(14, 116, 144, 0.22)
|
||||
);
|
||||
color: #ffffff;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
0 0 22px rgba(56, 189, 248, 0.22);
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button.active small {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.game-settings-menu__restart {
|
||||
margin-top: 12px;
|
||||
border-color: rgba(96, 165, 250, 0.35);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
@@ -1369,6 +1498,22 @@ canvas {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.game-settings-menu {
|
||||
align-items: stretch;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.game-settings-menu__panel {
|
||||
max-height: calc(100vh - 28px);
|
||||
}
|
||||
|
||||
.game-settings-menu__grid,
|
||||
.game-settings-menu__choice-group--presets {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Debug overlay panels */
|
||||
.debug-overlay-layout {
|
||||
position: fixed;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
|
||||
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
||||
import {
|
||||
GRAPHICS_DEFAULTS,
|
||||
type GraphicsPreset,
|
||||
type GraphicsState,
|
||||
} from "@/data/world/graphicsConfig";
|
||||
|
||||
@@ -21,6 +22,7 @@ interface WorldSettingsActions {
|
||||
setWindSpeed: (speed: number) => void;
|
||||
setWindDirection: (direction: number) => void;
|
||||
setWindStrength: (strength: number) => void;
|
||||
setGraphicsPreset: (preset: GraphicsPreset) => void;
|
||||
setGraphics: (graphics: Partial<GraphicsState>) => void;
|
||||
setDynamicGrass: (enabled: boolean) => void;
|
||||
setDynamicTrees: (enabled: boolean) => void;
|
||||
@@ -82,6 +84,11 @@ export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
||||
graphics: { ...state.graphics, ...graphicsUpdate },
|
||||
})),
|
||||
|
||||
setGraphicsPreset: (preset) =>
|
||||
set((state) => ({
|
||||
graphics: { ...state.graphics, preset },
|
||||
})),
|
||||
|
||||
setDynamicGrass: (dynamicGrass) =>
|
||||
set((state) => ({
|
||||
graphics: { ...state.graphics, dynamicGrass },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user