perf(map): snap assets to terrain

This commit is contained in:
Tom Boullay
2026-05-25 00:51:03 +02:00
parent 50fa94b3ad
commit d17738eaf1
18 changed files with 402 additions and 62 deletions
+13 -6
View File
@@ -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);
+30 -5
View File
@@ -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<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}`;
@@ -187,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",
+42 -7
View File
@@ -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<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(() => {
@@ -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;
}
+7 -4
View File
@@ -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 (
<group name="vegetation-system">
{visibleChunks.map((chunk) => (
@@ -154,6 +156,7 @@ export function VegetationSystem(): React.JSX.Element | null {
<InstancedVegetation
modelPath={chunk.modelPath}
instances={chunk.instances}
scaleMultiplier={chunk.scaleMultiplier}
castShadow={chunk.castShadow}
receiveShadow={chunk.receiveShadow}
/>
+6
View File
@@ -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,