Compare commits
7 Commits
f035195b56
...
a4383a7cec
| Author | SHA1 | Date | |
|---|---|---|---|
| a4383a7cec | |||
| d07ffc4662 | |||
| d17738eaf1 | |||
| 50fa94b3ad | |||
| 44f9d68ef1 | |||
| e4857135b1 | |||
| 1f6335092a |
@@ -137,6 +137,82 @@ Once the expensive model families are isolated, the real triangle fixes are:
|
||||
|
||||
Chunked instancing is especially important. A single `InstancedMesh` containing every bush has one global bounding sphere. If that bounding sphere is visible, Three.js may keep the whole batch visible. Splitting instances into grid chunks allows entire offscreen chunks to be skipped.
|
||||
|
||||
## Player-Only Vegetation Streaming
|
||||
|
||||
The first distance-streaming pass is intentionally limited to vegetation and crop instances:
|
||||
|
||||
- `arbre`
|
||||
- `sapin`
|
||||
- `buisson`
|
||||
- `champdeble`
|
||||
- `champdesoja`
|
||||
- `champsdetournesol`
|
||||
|
||||
The behavior is configured in:
|
||||
|
||||
```txt
|
||||
src/data/world/fogConfig.ts
|
||||
```
|
||||
|
||||
Current runtime values:
|
||||
|
||||
```txt
|
||||
chunkSize: 35
|
||||
loadRadius: 45
|
||||
unloadRadius: 45
|
||||
updateInterval: 350ms
|
||||
fog near: 30
|
||||
fog far: 45
|
||||
```
|
||||
|
||||
The streaming and fog are scoped to the production game scene with the player camera only:
|
||||
|
||||
```txt
|
||||
sceneMode === "game" && cameraMode === "player"
|
||||
```
|
||||
|
||||
This matters for debugging. In debug camera mode there is no fog and no distance streaming, so the developer can inspect the full map freely. In player mode, chunks mount and unmount around the camera to reduce visible triangle count while fog hides vegetation pop-in.
|
||||
|
||||
Chunk cleanup is handled through React unmounting. `VegetationSystem` removes chunks from the tree, and `InstancedVegetation` removes its `THREE.InstancedMesh` objects from the group while disposing the locally created merged geometries/material clones in its own cleanup path.
|
||||
|
||||
## Runtime Texture Filtering
|
||||
|
||||
Loaded GLTF textures are normalized in code through:
|
||||
|
||||
```txt
|
||||
src/utils/three/optimizeGLTFScene.ts
|
||||
```
|
||||
|
||||
The runtime pass applies conservative texture filtering:
|
||||
|
||||
1. Cap anisotropy to a small value.
|
||||
2. Enable mipmap generation for regular PNG/JPG/WebP textures.
|
||||
3. Use trilinear mipmap filtering for minification.
|
||||
4. Keep existing opacity/alpha material mapping intact.
|
||||
|
||||
This mirrors the intent of the designer upload pipeline without rewriting model files at runtime. The sibling `upload-GLTF` project already has the stronger asset-side path: Blender GLB export with Draco, texture resizing, KTX2 generation with mipmaps, WebP fallback, and GLTF JSON URI/extension rewriting for `KHR_texture_basisu`.
|
||||
|
||||
Runtime texture filtering improves distant texture stability and GPU sampling behavior, but it does not reduce mesh triangle count. Triangle reduction still comes from streaming, distance unloading, or optimized source assets.
|
||||
|
||||
## Terrain-Snapped Map Placement
|
||||
|
||||
Map object heights are corrected at runtime through:
|
||||
|
||||
```txt
|
||||
src/hooks/three/useTerrainHeight.ts
|
||||
```
|
||||
|
||||
The terrain raycast is not done every frame. The terrain mesh list is built from the cached terrain GLTF, then each model or instance computes its snapped `y` when it is mounted or when its instance data changes.
|
||||
|
||||
Applied paths:
|
||||
|
||||
1. Regular `GameMap` model instances.
|
||||
2. Generated static map models.
|
||||
3. Instanced static map assets.
|
||||
4. Vegetation and crop chunks.
|
||||
|
||||
Only the `y` coordinate is replaced. `x`, `z`, and rotation stay from `map.json`. Runtime scale is also normalized when a static map node has a non-uniform scale, which prevents exported values like `[1, 2, 1]` from stretching or shrinking a map model unexpectedly.
|
||||
|
||||
## Current Code-Side Optimization
|
||||
|
||||
Repeated static assets are configured in:
|
||||
@@ -162,10 +238,13 @@ src/world/map-generated/GeneratedMapNodeInstance.tsx
|
||||
Current generated model component:
|
||||
|
||||
```txt
|
||||
src/components/three/models/generated/EcoleModel.tsx
|
||||
src/components/three/world/EcoleModel.tsx
|
||||
src/components/three/world/LafabrikModel.tsx
|
||||
src/components/three/world/FermeVerticaleModel.tsx
|
||||
src/components/three/world/GenerateurModel.tsx
|
||||
```
|
||||
|
||||
`ecole` is a safe first candidate because it appears once in `map.json`, has one material, and does not participate in player collision or repair gameplay. Its source GLTF has 107 primitives, so the generated component also merges compatible geometry groups before mounting the meshes.
|
||||
`ecole`, `lafabrik`, `fermeverticale`, and `generateur` use this path. Their components share the same merged static model renderer, which groups compatible geometry by material before mounting meshes.
|
||||
|
||||
This path should be used selectively. It improves control and can remove clone overhead, but it does not reduce source triangle count by itself.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AnimationAction } from "three";
|
||||
import {
|
||||
AnimatedModelContext,
|
||||
type AnimatedModelContextValue,
|
||||
} from "@/components/three/models/useAnimatedModel";
|
||||
} from "@/hooks/animation/useAnimatedModel";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { ModelTransformProps } from "@/types/three/three";
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
|
||||
|
||||
interface EcoleModelProps {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
|
||||
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
|
||||
}
|
||||
|
||||
useGLTF.preload(ECOLE_MODEL_PATH);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const FERME_VERTICALE_MODEL_PATH = "/models/fermeverticale/model.gltf";
|
||||
|
||||
interface FermeVerticaleModelProps {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
export function FermeVerticaleModel(
|
||||
props: FermeVerticaleModelProps,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<MergedStaticMapModel modelPath={FERME_VERTICALE_MODEL_PATH} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(FERME_VERTICALE_MODEL_PATH);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const GENERATEUR_MODEL_PATH = "/models/generateur/model.gltf";
|
||||
|
||||
interface GenerateurModelProps {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
export function GenerateurModel(
|
||||
props: GenerateurModelProps,
|
||||
): React.JSX.Element {
|
||||
return <MergedStaticMapModel modelPath={GENERATEUR_MODEL_PATH} {...props} />;
|
||||
}
|
||||
|
||||
useGLTF.preload(GENERATEUR_MODEL_PATH);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const LAFABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
|
||||
|
||||
interface LafabrikModelProps {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
export function LafabrikModel(props: LafabrikModelProps): React.JSX.Element {
|
||||
return <MergedStaticMapModel modelPath={LAFABRIK_MODEL_PATH} {...props} />;
|
||||
}
|
||||
|
||||
useGLTF.preload(LAFABRIK_MODEL_PATH);
|
||||
+37
-26
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
|
||||
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
|
||||
|
||||
interface EcoleModelProps {
|
||||
interface MergedStaticMapModelProps {
|
||||
modelPath: string;
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
@@ -89,42 +90,54 @@ function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.values()].map((group) => {
|
||||
if (group.geometries.length === 1) {
|
||||
return [...groups.values()]
|
||||
.map((group) => {
|
||||
if (group.geometries.length === 1) {
|
||||
return {
|
||||
geometry: group.geometries[0] as THREE.BufferGeometry,
|
||||
material: group.material,
|
||||
};
|
||||
}
|
||||
|
||||
const geometry = mergeGeometries(group.geometries, false);
|
||||
|
||||
for (const sourceGeometry of group.geometries) {
|
||||
sourceGeometry.dispose();
|
||||
}
|
||||
|
||||
if (!geometry) {
|
||||
disposeMaterial(group.material);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
geometry: group.geometries[0] as THREE.BufferGeometry,
|
||||
geometry,
|
||||
material: group.material,
|
||||
};
|
||||
}
|
||||
|
||||
const geometry = mergeGeometries(group.geometries, false);
|
||||
|
||||
for (const sourceGeometry of group.geometries) {
|
||||
sourceGeometry.dispose();
|
||||
}
|
||||
|
||||
return {
|
||||
geometry,
|
||||
material: group.material,
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter((meshData): meshData is MergedMeshData => meshData !== null);
|
||||
}
|
||||
|
||||
export function EcoleModel({
|
||||
export function MergedStaticMapModel({
|
||||
modelPath,
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
castShadow = true,
|
||||
receiveShadow = true,
|
||||
onLoaded,
|
||||
}: EcoleModelProps): React.JSX.Element {
|
||||
const { scene } = useGLTF(ECOLE_MODEL_PATH);
|
||||
}: MergedStaticMapModelProps): React.JSX.Element {
|
||||
const { scene } = useGLTF(modelPath);
|
||||
const maxAnisotropy = useThree((state) =>
|
||||
state.gl.capabilities.getMaxAnisotropy(),
|
||||
);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||
const mergedMeshes = createMergedMeshes(scene);
|
||||
const meshes = mergedMeshes.map((meshData) => {
|
||||
const mesh = new THREE.Mesh(meshData.geometry, meshData.material);
|
||||
@@ -146,7 +159,7 @@ export function EcoleModel({
|
||||
disposeMaterial(mesh.material);
|
||||
}
|
||||
};
|
||||
}, [castShadow, onLoaded, receiveShadow, scene]);
|
||||
}, [castShadow, maxAnisotropy, modelPath, onLoaded, receiveShadow, scene]);
|
||||
|
||||
return (
|
||||
<group
|
||||
@@ -157,5 +170,3 @@ export function EcoleModel({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(ECOLE_MODEL_PATH);
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
|
||||
const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
||||
const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0];
|
||||
@@ -39,12 +41,16 @@ export function TerrainModel({
|
||||
const internalRef = useRef<THREE.Group>(null);
|
||||
const ref = groupRef ?? internalRef;
|
||||
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
||||
const maxAnisotropy = useThree((state) =>
|
||||
state.gl.capabilities.getMaxAnisotropy(),
|
||||
);
|
||||
|
||||
const terrainModel = useMemo(() => {
|
||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||
const model = scene.clone(true);
|
||||
applyTerrainMaterialSettings(model, receiveShadow);
|
||||
return model;
|
||||
}, [scene, receiveShadow]);
|
||||
}, [maxAnisotropy, scene, receiveShadow]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoaded?.();
|
||||
|
||||
@@ -80,6 +80,12 @@ export const docGroups: DocGroup[] = [
|
||||
subtitle: "Step into Three.js internals",
|
||||
meta: "11",
|
||||
},
|
||||
{
|
||||
path: "/docs/map-performance",
|
||||
title: "Map Performance",
|
||||
subtitle: "Draw calls, triangles, and streaming",
|
||||
meta: "12",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -89,25 +95,25 @@ export const docGroups: DocGroup[] = [
|
||||
path: "/docs/features",
|
||||
title: "Features",
|
||||
subtitle: "Implemented scope",
|
||||
meta: "12",
|
||||
meta: "13",
|
||||
},
|
||||
{
|
||||
path: "/docs/main-feature",
|
||||
title: "Main Feature",
|
||||
subtitle: "Repair-game prototype",
|
||||
meta: "13",
|
||||
meta: "14",
|
||||
},
|
||||
{
|
||||
path: "/docs/editor",
|
||||
title: "Editor User Guide",
|
||||
subtitle: "Editing workflow",
|
||||
meta: "14",
|
||||
meta: "15",
|
||||
},
|
||||
{
|
||||
path: "/docs/animation",
|
||||
title: "Animation & 3D Model System",
|
||||
subtitle: "Components and usage",
|
||||
meta: "15",
|
||||
meta: "16",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -118,7 +124,7 @@ export const docGroups: DocGroup[] = [
|
||||
path: "/docs/code-review",
|
||||
title: "Code Review Prep",
|
||||
subtitle: "Presentation support",
|
||||
meta: "16",
|
||||
meta: "17",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,17 +2,17 @@ import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
|
||||
|
||||
export const FOG_CONFIG = {
|
||||
enabled: true,
|
||||
color: "#c8dbbe",
|
||||
near: 50,
|
||||
far: 70,
|
||||
color: "#eef3f5",
|
||||
near: 38,
|
||||
far: 45,
|
||||
};
|
||||
|
||||
export const CHUNK_CONFIG = {
|
||||
enabled: true,
|
||||
chunkSize: 40,
|
||||
loadRadius: 70,
|
||||
unloadRadius: 80,
|
||||
updateInterval: 500,
|
||||
chunkSize: 35,
|
||||
loadRadius: 45,
|
||||
unloadRadius: 45,
|
||||
updateInterval: 350,
|
||||
};
|
||||
|
||||
export const GROUND_PLANE_COLOR = TERRAIN_COLORS.grass1.hex;
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import {
|
||||
logModelLoadSuccess,
|
||||
type ModelLoadLogContext,
|
||||
} from "@/utils/three/modelLoadLogger";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
|
||||
export function useLoggedGLTF(
|
||||
modelPath: string,
|
||||
context: Omit<ModelLoadLogContext, "modelPath">,
|
||||
) {
|
||||
const gltf = useGLTF(modelPath);
|
||||
const maxAnisotropy = useThree((state) =>
|
||||
state.gl.capabilities.getMaxAnisotropy(),
|
||||
);
|
||||
const hasLoggedRef = useRef(false);
|
||||
const { position, rotation, scale, scope } = context;
|
||||
|
||||
useEffect(() => {
|
||||
optimizeGLTFSceneTextures(gltf.scene, maxAnisotropy);
|
||||
}, [gltf.scene, maxAnisotropy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoggedRef.current) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
||||
const RAYCAST_Y = 500;
|
||||
const RAYCAST_FAR = 1000;
|
||||
const DOWN = new THREE.Vector3(0, -1, 0);
|
||||
|
||||
interface TerrainHeightSampler {
|
||||
getHeight: (x: number, z: number) => number | null;
|
||||
}
|
||||
|
||||
function createTerrainHeightSampler(
|
||||
scene: THREE.Object3D,
|
||||
): TerrainHeightSampler {
|
||||
const meshes: THREE.Mesh[] = [];
|
||||
const raycaster = new THREE.Raycaster(
|
||||
new THREE.Vector3(),
|
||||
DOWN,
|
||||
0,
|
||||
RAYCAST_FAR,
|
||||
);
|
||||
|
||||
scene.updateMatrixWorld(true);
|
||||
scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
meshes.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
getHeight: (x, z) => {
|
||||
raycaster.set(new THREE.Vector3(x, RAYCAST_Y, z), DOWN);
|
||||
const hit = raycaster.intersectObjects(meshes, false)[0];
|
||||
return hit?.point.y ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useTerrainHeightSampler(): TerrainHeightSampler {
|
||||
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
||||
|
||||
return useMemo(() => createTerrainHeightSampler(scene), [scene]);
|
||||
}
|
||||
|
||||
export function useTerrainSnappedPosition(
|
||||
position: Vector3Tuple,
|
||||
): Vector3Tuple {
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
|
||||
return useMemo(() => {
|
||||
const [x, y, z] = position;
|
||||
const height = terrainHeight.getHeight(x, z);
|
||||
return [x, height ?? y, z];
|
||||
}, [position, terrainHeight]);
|
||||
}
|
||||
|
||||
export function normalizeMapScale(scale: Vector3Tuple): Vector3Tuple {
|
||||
const [x, y, z] = scale;
|
||||
const isUniform = Math.abs(x - y) < 0.001 && Math.abs(x - z) < 0.001;
|
||||
return isUniform ? scale : [x, x, x];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import mapPerformance from "../../../../docs/technical/map-performance.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsMapPerformancePage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={mapPerformance}
|
||||
frContent={mapPerformance}
|
||||
meta="12"
|
||||
title="Map Performance"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DocsInteractionRoute,
|
||||
DocsLayoutRoute,
|
||||
DocsMainFeatureRoute,
|
||||
DocsMapPerformanceRoute,
|
||||
DocsMissionFlowRoute,
|
||||
DocsReadmeRoute,
|
||||
DocsRepairGameRoute,
|
||||
@@ -62,6 +63,7 @@ const docsChildRoutes = [
|
||||
{ path: "hand-tracking", component: DocsHandTrackingRoute },
|
||||
{ path: "zustand", component: DocsZustandRoute },
|
||||
{ path: "three-debugging", component: DocsThreeDebuggingRoute },
|
||||
{ path: "map-performance", component: DocsMapPerformanceRoute },
|
||||
{ path: "features", component: DocsFeaturesRoute },
|
||||
{ path: "main-feature", component: DocsMainFeatureRoute },
|
||||
{ path: "editor", component: DocsEditorRoute },
|
||||
|
||||
@@ -99,6 +99,10 @@ const LazyDocsThreeDebuggingPage = lazyNamed(
|
||||
() => import("@/pages/docs/three-debugging/page"),
|
||||
"DocsThreeDebuggingPage",
|
||||
);
|
||||
const LazyDocsMapPerformancePage = lazyNamed(
|
||||
() => import("@/pages/docs/map-performance/page"),
|
||||
"DocsMapPerformancePage",
|
||||
);
|
||||
|
||||
export const DocsLayoutRoute = createDocsRoute(LazyDocsLayout);
|
||||
export const DocsReadmeRoute = createDocsRoute(LazyDocsReadmePage);
|
||||
@@ -124,3 +128,6 @@ export const DocsMissionFlowRoute = createDocsRoute(LazyDocsMissionFlowPage);
|
||||
export const DocsThreeDebuggingRoute = createDocsRoute(
|
||||
LazyDocsThreeDebuggingPage,
|
||||
);
|
||||
export const DocsMapPerformanceRoute = createDocsRoute(
|
||||
LazyDocsMapPerformancePage,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
const TEXTURE_KEYS = [
|
||||
"map",
|
||||
"alphaMap",
|
||||
"aoMap",
|
||||
"bumpMap",
|
||||
"displacementMap",
|
||||
"emissiveMap",
|
||||
"envMap",
|
||||
"lightMap",
|
||||
"metalnessMap",
|
||||
"normalMap",
|
||||
"roughnessMap",
|
||||
] as const;
|
||||
|
||||
type TextureKey = (typeof TEXTURE_KEYS)[number];
|
||||
type TexturedMaterial = THREE.Material &
|
||||
Partial<Record<TextureKey, THREE.Texture>>;
|
||||
|
||||
const optimizedTextures = new WeakSet<THREE.Texture>();
|
||||
|
||||
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));
|
||||
|
||||
if (!(texture instanceof THREE.CompressedTexture)) {
|
||||
texture.generateMipmaps = true;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
}
|
||||
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
function optimizeMaterialTextures(
|
||||
material: THREE.Material,
|
||||
maxAnisotropy: number,
|
||||
): void {
|
||||
const texturedMaterial = material as TexturedMaterial;
|
||||
|
||||
for (const key of TEXTURE_KEYS) {
|
||||
const texture = texturedMaterial[key];
|
||||
if (texture instanceof THREE.Texture) {
|
||||
optimizeTexture(texture, maxAnisotropy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function optimizeGLTFSceneTextures(
|
||||
scene: THREE.Object3D,
|
||||
maxAnisotropy: number,
|
||||
): void {
|
||||
scene.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
|
||||
if (Array.isArray(child.material)) {
|
||||
for (const material of child.material) {
|
||||
optimizeMaterialTextures(material, maxAnisotropy);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
optimizeMaterialTextures(child.material, maxAnisotropy);
|
||||
});
|
||||
}
|
||||
+26
-10
@@ -6,6 +6,8 @@ import {
|
||||
GAME_SCENE_SKY_MODEL_SCALE,
|
||||
PHYSICS_SCENE_BACKGROUND_COLOR,
|
||||
} from "@/data/world/environmentConfig";
|
||||
import { FOG_CONFIG } from "@/data/world/fogConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import {
|
||||
isMapModelVisible,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
import { SkyModel } from "@/components/three/world/SkyModel";
|
||||
|
||||
export function Environment(): React.JSX.Element {
|
||||
const cameraMode = useCameraMode();
|
||||
const sceneMode = useSceneMode();
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
@@ -25,15 +28,28 @@ export function Environment(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
return showSky ? (
|
||||
<SkyModel
|
||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
|
||||
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
|
||||
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
||||
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
||||
/>
|
||||
) : (
|
||||
<color attach="background" args={[GAME_SCENE_FALLBACK_BACKGROUND_COLOR]} />
|
||||
return (
|
||||
<>
|
||||
{FOG_CONFIG.enabled && sceneMode === "game" && cameraMode === "player" ? (
|
||||
<fog
|
||||
attach="fog"
|
||||
args={[FOG_CONFIG.color, FOG_CONFIG.near, FOG_CONFIG.far]}
|
||||
/>
|
||||
) : null}
|
||||
{showSky ? (
|
||||
<SkyModel
|
||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
|
||||
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
|
||||
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
||||
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
||||
/>
|
||||
) : (
|
||||
<color
|
||||
attach="background"
|
||||
args={[GAME_SCENE_FALLBACK_BACKGROUND_COLOR]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-6
@@ -10,6 +10,10 @@ import {
|
||||
import * as THREE from "three";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import {
|
||||
normalizeMapScale,
|
||||
useTerrainSnappedPosition,
|
||||
} from "@/hooks/three/useTerrainHeight";
|
||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||
import {
|
||||
isMapModelVisible,
|
||||
@@ -33,7 +37,7 @@ interface LoadedMapNode {
|
||||
modelUrl: string | null;
|
||||
}
|
||||
|
||||
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking"]);
|
||||
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]);
|
||||
const LITE_MAP_SKIPPED_NODE_NAMES = new Set([
|
||||
"arbre",
|
||||
"buisson",
|
||||
@@ -333,11 +337,13 @@ function ModelInstance({
|
||||
onLoaded: () => void;
|
||||
}): React.JSX.Element {
|
||||
const { position, rotation, scale } = node;
|
||||
const snappedPosition = useTerrainSnappedPosition(position);
|
||||
const normalizedScale = normalizeMapScale(scale);
|
||||
const { scene } = useLoggedGLTF(modelUrl, {
|
||||
scope: "GameMap.ModelInstance",
|
||||
position,
|
||||
position: snappedPosition,
|
||||
rotation,
|
||||
scale,
|
||||
scale: normalizedScale,
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
|
||||
@@ -354,18 +360,19 @@ function ModelInstance({
|
||||
return (
|
||||
<primitive
|
||||
object={sceneInstance}
|
||||
position={position}
|
||||
position={snappedPosition}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
scale={normalizedScale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FallbackMapNode({ node }: { node: MapNode }): React.JSX.Element {
|
||||
const { position, rotation, scale } = node;
|
||||
const normalizedScale = normalizeMapScale(scale);
|
||||
|
||||
return (
|
||||
<mesh position={position} rotation={rotation} scale={scale}>
|
||||
<mesh position={position} rotation={rotation} scale={normalizedScale}>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#64748b" wireframe />
|
||||
</mesh>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { EcoleModel } from "@/components/three/models/generated/EcoleModel";
|
||||
import { EcoleModel } from "@/components/three/world/EcoleModel";
|
||||
import { FermeVerticaleModel } from "@/components/three/world/FermeVerticaleModel";
|
||||
import { GenerateurModel } from "@/components/three/world/GenerateurModel";
|
||||
import { LafabrikModel } from "@/components/three/world/LafabrikModel";
|
||||
import {
|
||||
normalizeMapScale,
|
||||
useTerrainSnappedPosition,
|
||||
} from "@/hooks/three/useTerrainHeight";
|
||||
import type { MapNode } from "@/types/editor/editor";
|
||||
|
||||
interface GeneratedMapNodeInstanceProps {
|
||||
@@ -10,12 +17,48 @@ export function GeneratedMapNodeInstance({
|
||||
node,
|
||||
onLoaded,
|
||||
}: GeneratedMapNodeInstanceProps): React.JSX.Element | null {
|
||||
const position = useTerrainSnappedPosition(node.position);
|
||||
const scale = normalizeMapScale(node.scale);
|
||||
|
||||
if (node.name === "ecole") {
|
||||
return (
|
||||
<EcoleModel
|
||||
position={node.position}
|
||||
position={position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
scale={scale}
|
||||
onLoaded={onLoaded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.name === "fermeverticale") {
|
||||
return (
|
||||
<FermeVerticaleModel
|
||||
position={position}
|
||||
rotation={node.rotation}
|
||||
scale={scale}
|
||||
onLoaded={onLoaded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.name === "generateur") {
|
||||
return (
|
||||
<GenerateurModel
|
||||
position={position}
|
||||
rotation={node.rotation}
|
||||
scale={scale}
|
||||
onLoaded={onLoaded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.name === "lafabrik") {
|
||||
return (
|
||||
<LafabrikModel
|
||||
position={position}
|
||||
rotation={node.rotation}
|
||||
scale={scale}
|
||||
onLoaded={onLoaded}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
const GENERATED_MAP_MODEL_NAMES = new Set(["ecole"]);
|
||||
const GENERATED_MAP_MODEL_NAMES = new Set([
|
||||
"ecole",
|
||||
"fermeverticale",
|
||||
"generateur",
|
||||
"lafabrik",
|
||||
]);
|
||||
|
||||
export function isGeneratedMapModelName(name: string): boolean {
|
||||
return GENERATED_MAP_MODEL_NAMES.has(name);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
||||
import {
|
||||
normalizeMapScale,
|
||||
useTerrainHeightSampler,
|
||||
} from "@/hooks/three/useTerrainHeight";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
|
||||
|
||||
interface InstancedMapAssetProps {
|
||||
@@ -93,25 +99,32 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.values()].map((group) => {
|
||||
if (group.geometries.length === 1) {
|
||||
return [...groups.values()]
|
||||
.map((group) => {
|
||||
if (group.geometries.length === 1) {
|
||||
return {
|
||||
geometry: group.geometries[0] as THREE.BufferGeometry,
|
||||
material: group.material,
|
||||
};
|
||||
}
|
||||
|
||||
const mergedGeometry = mergeGeometries(group.geometries, false);
|
||||
|
||||
for (const geometry of group.geometries) {
|
||||
geometry.dispose();
|
||||
}
|
||||
|
||||
if (!mergedGeometry) {
|
||||
disposeMaterialOnly(group.material);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
geometry: group.geometries[0] as THREE.BufferGeometry,
|
||||
geometry: mergedGeometry,
|
||||
material: group.material,
|
||||
};
|
||||
}
|
||||
|
||||
const mergedGeometry = mergeGeometries(group.geometries, false);
|
||||
|
||||
for (const geometry of group.geometries) {
|
||||
geometry.dispose();
|
||||
}
|
||||
|
||||
return {
|
||||
geometry: mergedGeometry,
|
||||
material: group.material,
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter((meshData): meshData is MeshData => meshData !== null);
|
||||
}
|
||||
|
||||
function setInstanceMatrices(
|
||||
@@ -146,21 +159,40 @@ export function InstancedMapAsset({
|
||||
receiveShadow,
|
||||
}: InstancedMapAssetProps): React.JSX.Element | null {
|
||||
const { scene } = useGLTF(modelPath);
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const maxAnisotropy = useThree((state) =>
|
||||
state.gl.capabilities.getMaxAnisotropy(),
|
||||
);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const groundedInstances = useMemo(
|
||||
() =>
|
||||
instances.map((instance) => {
|
||||
const [x, y, z] = instance.position;
|
||||
const height = terrainHeight.getHeight(x, z);
|
||||
|
||||
return {
|
||||
...instance,
|
||||
position: [x, height ?? y, z] as MapAssetInstance["position"],
|
||||
scale: normalizeMapScale(instance.scale),
|
||||
};
|
||||
}),
|
||||
[instances, terrainHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group || instances.length === 0) return;
|
||||
if (!group || groundedInstances.length === 0) return;
|
||||
|
||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||
const meshDataList = extractMeshes(scene);
|
||||
const instancedMeshes = meshDataList.map((meshData, index) => {
|
||||
const instancedMesh = new THREE.InstancedMesh(
|
||||
meshData.geometry,
|
||||
meshData.material,
|
||||
instances.length,
|
||||
groundedInstances.length,
|
||||
);
|
||||
|
||||
setInstanceMatrices(instancedMesh, instances);
|
||||
setInstanceMatrices(instancedMesh, groundedInstances);
|
||||
instancedMesh.castShadow = castShadow;
|
||||
instancedMesh.receiveShadow = receiveShadow;
|
||||
instancedMesh.name = `instanced-map-asset-${index}`;
|
||||
@@ -180,7 +212,7 @@ export function InstancedMapAsset({
|
||||
disposeInstancedMapMesh(mesh);
|
||||
}
|
||||
};
|
||||
}, [castShadow, instances, receiveShadow, scene]);
|
||||
}, [castShadow, groundedInstances, maxAnisotropy, receiveShadow, scene]);
|
||||
|
||||
if (instances.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -1,25 +1,4 @@
|
||||
export const MAP_INSTANCING_ASSETS = {
|
||||
generateur: {
|
||||
mapName: "generateur",
|
||||
modelPath: "/models/generateur/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
lafabrik: {
|
||||
mapName: "lafabrik",
|
||||
modelPath: "/models/lafabrik/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
fermeverticale: {
|
||||
mapName: "fermeverticale",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
boiteauxlettres: {
|
||||
mapName: "boiteauxlettres",
|
||||
modelPath: "/models/boiteauxlettres/model.gltf",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
import type { VegetationInstance } from "@/world/vegetation/useVegetationData";
|
||||
import { disposeInstancedMesh } from "@/utils/three/dispose";
|
||||
|
||||
interface InstancedVegetationProps {
|
||||
modelPath: string;
|
||||
instances: VegetationInstance[];
|
||||
scaleMultiplier: number;
|
||||
castShadow: boolean;
|
||||
receiveShadow: boolean;
|
||||
}
|
||||
@@ -71,12 +74,17 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
|
||||
|
||||
function createInstanceMatrices(
|
||||
instances: VegetationInstance[],
|
||||
scaleMultiplier: number,
|
||||
): THREE.Matrix4[] {
|
||||
const matrices: THREE.Matrix4[] = [];
|
||||
const position = new THREE.Vector3();
|
||||
const rotation = new THREE.Euler();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3(1, 1, 1);
|
||||
const scale = new THREE.Vector3(
|
||||
scaleMultiplier,
|
||||
scaleMultiplier,
|
||||
scaleMultiplier,
|
||||
);
|
||||
|
||||
for (const instance of instances) {
|
||||
const matrix = new THREE.Matrix4();
|
||||
@@ -94,16 +102,36 @@ function createInstanceMatrices(
|
||||
export function InstancedVegetation({
|
||||
modelPath,
|
||||
instances,
|
||||
scaleMultiplier,
|
||||
castShadow,
|
||||
receiveShadow,
|
||||
}: InstancedVegetationProps): React.JSX.Element | null {
|
||||
const { scene } = useGLTF(modelPath);
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const maxAnisotropy = useThree((state) =>
|
||||
state.gl.capabilities.getMaxAnisotropy(),
|
||||
);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
const meshDataList = useMemo(() => extractMeshes(scene), [scene]);
|
||||
const meshDataList = useMemo(() => {
|
||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||
return extractMeshes(scene);
|
||||
}, [maxAnisotropy, scene]);
|
||||
const groundedInstances = useMemo(
|
||||
() =>
|
||||
instances.map((instance) => {
|
||||
const [x, y, z] = instance.position;
|
||||
const height = terrainHeight.getHeight(x, z);
|
||||
return {
|
||||
...instance,
|
||||
position: [x, height ?? y, z] as VegetationInstance["position"],
|
||||
};
|
||||
}),
|
||||
[instances, terrainHeight],
|
||||
);
|
||||
const matrices = useMemo(
|
||||
() => createInstanceMatrices(instances),
|
||||
[instances],
|
||||
() => createInstanceMatrices(groundedInstances, scaleMultiplier),
|
||||
[groundedInstances, scaleMultiplier],
|
||||
);
|
||||
|
||||
const instancedMeshes = useMemo(() => {
|
||||
@@ -111,7 +139,7 @@ export function InstancedVegetation({
|
||||
const instancedMesh = new THREE.InstancedMesh(
|
||||
meshData.geometry,
|
||||
meshData.material,
|
||||
instances.length,
|
||||
groundedInstances.length,
|
||||
);
|
||||
|
||||
for (let i = 0; i < matrices.length; i++) {
|
||||
@@ -130,7 +158,13 @@ export function InstancedVegetation({
|
||||
|
||||
return instancedMesh;
|
||||
});
|
||||
}, [meshDataList, matrices, instances.length, castShadow, receiveShadow]);
|
||||
}, [
|
||||
meshDataList,
|
||||
matrices,
|
||||
groundedInstances.length,
|
||||
castShadow,
|
||||
receiveShadow,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
@@ -143,7 +177,7 @@ export function InstancedVegetation({
|
||||
return () => {
|
||||
for (const mesh of instancedMeshes) {
|
||||
group.remove(mesh);
|
||||
disposeInstancedMesh(mesh);
|
||||
mesh.dispose();
|
||||
}
|
||||
};
|
||||
}, [instancedMeshes]);
|
||||
@@ -163,7 +197,7 @@ export function InstancedVegetation({
|
||||
};
|
||||
}, [meshDataList]);
|
||||
|
||||
if (instances.length === 0) {
|
||||
if (groundedInstances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,167 @@
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useMemo, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { CHUNK_CONFIG } from "@/data/world/fogConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import {
|
||||
isMapModelVisible,
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation";
|
||||
import { useVegetationData } from "@/world/vegetation/useVegetationData";
|
||||
import {
|
||||
type VegetationInstance,
|
||||
useVegetationData,
|
||||
} from "@/world/vegetation/useVegetationData";
|
||||
import {
|
||||
VEGETATION_TYPES,
|
||||
type VegetationType,
|
||||
} from "@/world/vegetation/vegetationConfig";
|
||||
|
||||
interface VegetationChunk {
|
||||
key: string;
|
||||
type: VegetationType;
|
||||
modelPath: string;
|
||||
scaleMultiplier: number;
|
||||
castShadow: boolean;
|
||||
receiveShadow: boolean;
|
||||
centerX: number;
|
||||
centerZ: number;
|
||||
instances: VegetationInstance[];
|
||||
}
|
||||
|
||||
function getChunkKey(instance: VegetationInstance): string {
|
||||
const [x, , z] = instance.position;
|
||||
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
|
||||
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
|
||||
return `${chunkX}:${chunkZ}`;
|
||||
}
|
||||
|
||||
function createVegetationChunks(
|
||||
type: VegetationType,
|
||||
instances: VegetationInstance[],
|
||||
): VegetationChunk[] {
|
||||
const config = VEGETATION_TYPES[type];
|
||||
const chunks = new Map<string, VegetationInstance[]>();
|
||||
|
||||
for (const instance of instances) {
|
||||
const key = getChunkKey(instance);
|
||||
const chunk = chunks.get(key);
|
||||
if (chunk) {
|
||||
chunk.push(instance);
|
||||
} else {
|
||||
chunks.set(key, [instance]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
|
||||
const center = chunkInstances.reduce(
|
||||
(sum, instance) => {
|
||||
sum.x += instance.position[0];
|
||||
sum.z += instance.position[2];
|
||||
return sum;
|
||||
},
|
||||
{ x: 0, z: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
key: `${type}:${chunkKey}`,
|
||||
type,
|
||||
modelPath: config.modelPath,
|
||||
scaleMultiplier: config.scaleMultiplier,
|
||||
castShadow: config.castShadow,
|
||||
receiveShadow: config.receiveShadow,
|
||||
centerX: center.x / chunkInstances.length,
|
||||
centerZ: center.z / chunkInstances.length,
|
||||
instances: chunkInstances,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function VegetationSystem(): React.JSX.Element | null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const cameraMode = useCameraMode();
|
||||
const sceneMode = useSceneMode();
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
const { data, isLoading } = useVegetationData();
|
||||
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
|
||||
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const streamingEnabled =
|
||||
CHUNK_CONFIG.enabled && sceneMode === "game" && cameraMode === "player";
|
||||
|
||||
const chunks = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return Object.entries(VEGETATION_TYPES).flatMap(([type, config]) => {
|
||||
if (!config.enabled) return [];
|
||||
if (!isMapModelVisible(config.mapName, { groups, models })) return [];
|
||||
|
||||
const entry = data.get(config.mapName);
|
||||
if (!entry || entry.instances.length === 0) return [];
|
||||
|
||||
return createVegetationChunks(type as VegetationType, entry.instances);
|
||||
});
|
||||
}, [data, groups, models]);
|
||||
|
||||
const visibleChunks = streamingEnabled
|
||||
? chunks.filter((chunk) => activeChunkKeys.has(chunk.key))
|
||||
: chunks;
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!streamingEnabled) return;
|
||||
|
||||
const now = clock.elapsedTime * 1000;
|
||||
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
|
||||
lastUpdateRef.current = now;
|
||||
|
||||
const nextKeys = new Set<string>();
|
||||
const cameraX = camera.position.x;
|
||||
const cameraZ = camera.position.z;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const distance = Math.hypot(
|
||||
chunk.centerX - cameraX,
|
||||
chunk.centerZ - cameraZ,
|
||||
);
|
||||
const wasActive = activeChunkKeys.has(chunk.key);
|
||||
const radius = wasActive
|
||||
? CHUNK_CONFIG.unloadRadius
|
||||
: CHUNK_CONFIG.loadRadius;
|
||||
|
||||
if (distance <= radius) {
|
||||
nextKeys.add(chunk.key);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
nextKeys.size === activeChunkKeys.size &&
|
||||
[...nextKeys].every((key) => activeChunkKeys.has(key))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveChunkKeys(nextKeys);
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabledTypes = Object.entries(VEGETATION_TYPES).filter(
|
||||
([, config]) =>
|
||||
config.enabled && isMapModelVisible(config.mapName, { groups, models }),
|
||||
);
|
||||
|
||||
return (
|
||||
<group name="vegetation-system">
|
||||
{enabledTypes.map(([type, config]) => {
|
||||
const instances = data.get(type as VegetationType);
|
||||
|
||||
if (!instances || instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense key={type} fallback={null}>
|
||||
<InstancedVegetation
|
||||
modelPath={config.modelPath}
|
||||
instances={instances}
|
||||
castShadow={config.castShadow}
|
||||
receiveShadow={config.receiveShadow}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
{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}
|
||||
/>
|
||||
</Suspense>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,19 +4,61 @@ export const VEGETATION_LOD = {
|
||||
windFadeEnd: 70,
|
||||
};
|
||||
|
||||
export const VEGETATION_TYPES = {
|
||||
buissons: {
|
||||
mapName: "buisson",
|
||||
modelPath: "/models/buisson/model.gltf",
|
||||
scaleMultiplier: 2,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
sapin: {
|
||||
mapName: "sapin",
|
||||
modelPath: "/models/sapin/model.gltf",
|
||||
scaleMultiplier: 2,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
arbre: {
|
||||
mapName: "arbre",
|
||||
modelPath: "/models/arbre/model.gltf",
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
champdeble: {
|
||||
mapName: "champdeble",
|
||||
modelPath: "/models/champdeble/model.gltf",
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
champdesoja: {
|
||||
mapName: "champdesoja",
|
||||
modelPath: "/models/champdesoja/model.gltf",
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
champsdetournesol: {
|
||||
mapName: "champsdetournesol",
|
||||
modelPath: "/models/champsdetournesol/model.gltf",
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type VegetationType = keyof typeof VEGETATION_TYPES;
|
||||
|
||||
export const INSTANCED_MAP_EXCEPTIONS = new Set([
|
||||
"Scene",
|
||||
"blocking",
|
||||
"terrain",
|
||||
]);
|
||||
|
||||
export const INSTANCED_MAP_CHUNK_SIZE = 45;
|
||||
|
||||
export const INSTANCED_MAP_NO_SHADOW_NAMES = new Set([
|
||||
"arbre",
|
||||
"sapin",
|
||||
"buisson",
|
||||
"champdeble",
|
||||
"champdesoja",
|
||||
"champsdetournesol",
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user