From d17738eaf11646a8a696e32303da907471729e0b Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 25 May 2026 00:51:03 +0200 Subject: [PATCH] perf(map): snap assets to terrain --- src/components/three/world/EcoleModel.tsx | 20 ++++++ .../three/world/FermeVerticaleModel.tsx | 24 +++++++ .../three/world/GenerateurModel.tsx | 22 ++++++ src/components/three/world/LafabrikModel.tsx | 20 ++++++ .../MergedStaticMapModel.tsx} | 24 ++++--- src/components/three/world/TerrainModel.tsx | 8 ++- src/data/world/fogConfig.ts | 8 +-- src/hooks/three/useLoggedGLTF.ts | 9 +++ src/hooks/three/useTerrainHeight.ts | 64 +++++++++++++++++ src/utils/three/optimizeGLTFScene.ts | 68 +++++++++++++++++++ src/world/GameMap.tsx | 19 ++++-- .../GeneratedMapNodeInstance.tsx | 49 ++++++++++++- .../map-generated/generatedMapModelConfig.ts | 7 +- .../map-instancing/InstancedMapAsset.tsx | 35 ++++++++-- .../map-instancing/mapInstancingConfig.ts | 21 ------ src/world/vegetation/InstancedVegetation.tsx | 49 +++++++++++-- src/world/vegetation/VegetationSystem.tsx | 11 +-- src/world/vegetation/vegetationConfig.ts | 6 ++ 18 files changed, 402 insertions(+), 62 deletions(-) create mode 100644 src/components/three/world/EcoleModel.tsx create mode 100644 src/components/three/world/FermeVerticaleModel.tsx create mode 100644 src/components/three/world/GenerateurModel.tsx create mode 100644 src/components/three/world/LafabrikModel.tsx rename src/components/three/{models/generated/EcoleModel.tsx => world/MergedStaticMapModel.tsx} (87%) create mode 100644 src/hooks/three/useTerrainHeight.ts create mode 100644 src/utils/three/optimizeGLTFScene.ts diff --git a/src/components/three/world/EcoleModel.tsx b/src/components/three/world/EcoleModel.tsx new file mode 100644 index 0000000..75398ae --- /dev/null +++ b/src/components/three/world/EcoleModel.tsx @@ -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 ; +} + +useGLTF.preload(ECOLE_MODEL_PATH); diff --git a/src/components/three/world/FermeVerticaleModel.tsx b/src/components/three/world/FermeVerticaleModel.tsx new file mode 100644 index 0000000..44ed4bd --- /dev/null +++ b/src/components/three/world/FermeVerticaleModel.tsx @@ -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 ( + + ); +} + +useGLTF.preload(FERME_VERTICALE_MODEL_PATH); diff --git a/src/components/three/world/GenerateurModel.tsx b/src/components/three/world/GenerateurModel.tsx new file mode 100644 index 0000000..5b5cd39 --- /dev/null +++ b/src/components/three/world/GenerateurModel.tsx @@ -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 ; +} + +useGLTF.preload(GENERATEUR_MODEL_PATH); diff --git a/src/components/three/world/LafabrikModel.tsx b/src/components/three/world/LafabrikModel.tsx new file mode 100644 index 0000000..0c080b7 --- /dev/null +++ b/src/components/three/world/LafabrikModel.tsx @@ -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 ; +} + +useGLTF.preload(LAFABRIK_MODEL_PATH); diff --git a/src/components/three/models/generated/EcoleModel.tsx b/src/components/three/world/MergedStaticMapModel.tsx similarity index 87% rename from src/components/three/models/generated/EcoleModel.tsx rename to src/components/three/world/MergedStaticMapModel.tsx index 970d16f..f2e75ae 100644 --- a/src/components/three/models/generated/EcoleModel.tsx +++ b/src/components/three/world/MergedStaticMapModel.tsx @@ -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; @@ -117,21 +118,26 @@ function createMergedMeshes(scene: THREE.Group): MergedMeshData[] { .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(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); @@ -153,7 +159,7 @@ export function EcoleModel({ disposeMaterial(mesh.material); } }; - }, [castShadow, onLoaded, receiveShadow, scene]); + }, [castShadow, maxAnisotropy, modelPath, onLoaded, receiveShadow, scene]); return ( ); } - -useGLTF.preload(ECOLE_MODEL_PATH); diff --git a/src/components/three/world/TerrainModel.tsx b/src/components/three/world/TerrainModel.tsx index 34de05c..c1f2a29 100644 --- a/src/components/three/world/TerrainModel.tsx +++ b/src/components/three/world/TerrainModel.tsx @@ -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(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?.(); diff --git a/src/data/world/fogConfig.ts b/src/data/world/fogConfig.ts index edf90e6..7df1d7e 100644 --- a/src/data/world/fogConfig.ts +++ b/src/data/world/fogConfig.ts @@ -2,16 +2,16 @@ import { TERRAIN_COLORS } from "@/data/world/terrainConfig"; export const FOG_CONFIG = { enabled: true, - color: "#c8dbbe", - near: 34, - far: 58, + color: "#eef3f5", + near: 38, + far: 45, }; export const CHUNK_CONFIG = { enabled: true, chunkSize: 35, loadRadius: 45, - unloadRadius: 58, + unloadRadius: 45, updateInterval: 350, }; diff --git a/src/hooks/three/useLoggedGLTF.ts b/src/hooks/three/useLoggedGLTF.ts index 8e08ce3..8b535d9 100644 --- a/src/hooks/three/useLoggedGLTF.ts +++ b/src/hooks/three/useLoggedGLTF.ts @@ -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, ) { 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; diff --git a/src/hooks/three/useTerrainHeight.ts b/src/hooks/three/useTerrainHeight.ts new file mode 100644 index 0000000..5587962 --- /dev/null +++ b/src/hooks/three/useTerrainHeight.ts @@ -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]; +} diff --git a/src/utils/three/optimizeGLTFScene.ts b/src/utils/three/optimizeGLTFScene.ts new file mode 100644 index 0000000..710ebbb --- /dev/null +++ b/src/utils/three/optimizeGLTFScene.ts @@ -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>; + +const optimizedTextures = new WeakSet(); + +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); + }); +} diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 03c842c..de446e2 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -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 ( ); } function FallbackMapNode({ node }: { node: MapNode }): React.JSX.Element { const { position, rotation, scale } = node; + const normalizedScale = normalizeMapScale(scale); return ( - + diff --git a/src/world/map-generated/GeneratedMapNodeInstance.tsx b/src/world/map-generated/GeneratedMapNodeInstance.tsx index bf87b21..5f1e5bb 100644 --- a/src/world/map-generated/GeneratedMapNodeInstance.tsx +++ b/src/world/map-generated/GeneratedMapNodeInstance.tsx @@ -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 ( + ); + } + + if (node.name === "fermeverticale") { + return ( + + ); + } + + if (node.name === "generateur") { + return ( + + ); + } + + if (node.name === "lafabrik") { + return ( + ); diff --git a/src/world/map-generated/generatedMapModelConfig.ts b/src/world/map-generated/generatedMapModelConfig.ts index 686d295..878daa1 100644 --- a/src/world/map-generated/generatedMapModelConfig.ts +++ b/src/world/map-generated/generatedMapModelConfig.ts @@ -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); diff --git a/src/world/map-instancing/InstancedMapAsset.tsx b/src/world/map-instancing/InstancedMapAsset.tsx index dfd695f..38dd5c6 100644 --- a/src/world/map-instancing/InstancedMapAsset.tsx +++ b/src/world/map-instancing/InstancedMapAsset.tsx @@ -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 { @@ -153,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(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}`; @@ -187,7 +212,7 @@ export function InstancedMapAsset({ disposeInstancedMapMesh(mesh); } }; - }, [castShadow, instances, receiveShadow, scene]); + }, [castShadow, groundedInstances, maxAnisotropy, receiveShadow, scene]); if (instances.length === 0) { return null; diff --git a/src/world/map-instancing/mapInstancingConfig.ts b/src/world/map-instancing/mapInstancingConfig.ts index 0b8b02b..77aa728 100644 --- a/src/world/map-instancing/mapInstancingConfig.ts +++ b/src/world/map-instancing/mapInstancingConfig.ts @@ -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", diff --git a/src/world/vegetation/InstancedVegetation.tsx b/src/world/vegetation/InstancedVegetation.tsx index 847ebfc..ff8598a 100644 --- a/src/world/vegetation/InstancedVegetation.tsx +++ b/src/world/vegetation/InstancedVegetation.tsx @@ -1,12 +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"; interface InstancedVegetationProps { modelPath: string; instances: VegetationInstance[]; + scaleMultiplier: number; castShadow: boolean; receiveShadow: boolean; } @@ -70,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(); @@ -93,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(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(() => { @@ -110,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++) { @@ -129,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; @@ -162,7 +197,7 @@ export function InstancedVegetation({ }; }, [meshDataList]); - if (instances.length === 0) { + if (groundedInstances.length === 0) { return null; } diff --git a/src/world/vegetation/VegetationSystem.tsx b/src/world/vegetation/VegetationSystem.tsx index 1684a96..cb84d67 100644 --- a/src/world/vegetation/VegetationSystem.tsx +++ b/src/world/vegetation/VegetationSystem.tsx @@ -21,6 +21,7 @@ interface VegetationChunk { key: string; type: VegetationType; modelPath: string; + scaleMultiplier: number; castShadow: boolean; receiveShadow: boolean; centerX: number; @@ -66,6 +67,7 @@ function createVegetationChunks( key: `${type}:${chunkKey}`, type, modelPath: config.modelPath, + scaleMultiplier: config.scaleMultiplier, castShadow: config.castShadow, receiveShadow: config.receiveShadow, centerX: center.x / chunkInstances.length, @@ -103,6 +105,10 @@ export function VegetationSystem(): React.JSX.Element | null { }); }, [data, groups, models]); + const visibleChunks = streamingEnabled + ? chunks.filter((chunk) => activeChunkKeys.has(chunk.key)) + : chunks; + useFrame(({ clock }) => { if (!streamingEnabled) return; @@ -143,10 +149,6 @@ export function VegetationSystem(): React.JSX.Element | null { return null; } - const visibleChunks = streamingEnabled - ? chunks.filter((chunk) => activeChunkKeys.has(chunk.key)) - : chunks; - return ( {visibleChunks.map((chunk) => ( @@ -154,6 +156,7 @@ export function VegetationSystem(): React.JSX.Element | null { diff --git a/src/world/vegetation/vegetationConfig.ts b/src/world/vegetation/vegetationConfig.ts index b279fea..a49fcec 100644 --- a/src/world/vegetation/vegetationConfig.ts +++ b/src/world/vegetation/vegetationConfig.ts @@ -8,6 +8,7 @@ export const VEGETATION_TYPES = { buissons: { mapName: "buisson", modelPath: "/models/buisson/model.gltf", + scaleMultiplier: 2, castShadow: true, receiveShadow: true, enabled: true, @@ -15,6 +16,7 @@ export const VEGETATION_TYPES = { sapin: { mapName: "sapin", modelPath: "/models/sapin/model.gltf", + scaleMultiplier: 2, castShadow: true, receiveShadow: true, enabled: true, @@ -22,6 +24,7 @@ export const VEGETATION_TYPES = { arbre: { mapName: "arbre", modelPath: "/models/arbre/model.gltf", + scaleMultiplier: 1, castShadow: true, receiveShadow: true, enabled: true, @@ -29,6 +32,7 @@ export const VEGETATION_TYPES = { champdeble: { mapName: "champdeble", modelPath: "/models/champdeble/model.gltf", + scaleMultiplier: 1, castShadow: true, receiveShadow: true, enabled: true, @@ -36,6 +40,7 @@ export const VEGETATION_TYPES = { champdesoja: { mapName: "champdesoja", modelPath: "/models/champdesoja/model.gltf", + scaleMultiplier: 1, castShadow: true, receiveShadow: true, enabled: true, @@ -43,6 +48,7 @@ export const VEGETATION_TYPES = { champsdetournesol: { mapName: "champsdetournesol", modelPath: "/models/champsdetournesol/model.gltf", + scaleMultiplier: 1, castShadow: true, receiveShadow: true, enabled: true,