From 072dec03b4563b040a5bf0f9c0c078e6b3564fbe Mon Sep 17 00:00:00 2001 From: tom-boullay Date: Thu, 21 May 2026 11:55:49 +0200 Subject: [PATCH] refactor: tighten terrain and sky model resource ownership --- README.md | 1 + docs/technical/map-performance.md | 51 +++++++ src/components/three/world/SkyModel.tsx | 21 ++- src/components/three/world/TerrainModel.tsx | 7 - src/data/world/environmentConfig.ts | 2 +- src/world/GameMap.tsx | 8 +- .../map-instancing/InstancedMapAsset.tsx | 137 ++++++++++++++++++ .../map-instancing/MapInstancingSystem.tsx | 42 ++++++ .../map-instancing/mapInstancingConfig.ts | 80 ++++++++++ .../map-instancing/useMapInstancingData.ts | 84 +++++++++++ 10 files changed, 421 insertions(+), 12 deletions(-) create mode 100644 docs/technical/map-performance.md create mode 100644 src/world/map-instancing/InstancedMapAsset.tsx create mode 100644 src/world/map-instancing/MapInstancingSystem.tsx create mode 100644 src/world/map-instancing/mapInstancingConfig.ts create mode 100644 src/world/map-instancing/useMapInstancingData.ts diff --git a/README.md b/README.md index b2dcd37..dfa1236 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ WS ws://localhost:8000/ws | `docs/technical/hand-tracking.md` | Webcam, backend/browser MediaPipe, glove, and gesture flow | | `docs/technical/zustand.md` | Game, settings, and subtitle stores | | `docs/technical/three-debugging.md` | DevTools workflow for stepping into Three.js internals | +| `docs/technical/map-performance.md` | Map draw-call bottlenecks and optimization notes | | `docs/technical/editor.md` | Editor implementation details | | `docs/technical/animation.md` | Animated, explodable, and reusable 3D model components | | `docs/user/features.md` | Implemented feature inventory | diff --git a/docs/technical/map-performance.md b/docs/technical/map-performance.md new file mode 100644 index 0000000..12da025 --- /dev/null +++ b/docs/technical/map-performance.md @@ -0,0 +1,51 @@ +# Map Performance Notes + +This document tracks the current map-rendering performance pass. + +## Current Runtime Path + +- `public/map.json` is the source of map transforms. +- `src/world/GameMap.tsx` renders regular visual map nodes. +- `src/world/vegetation/VegetationSystem.tsx` already instances dense vegetation. +- `src/world/map-instancing/MapInstancingSystem.tsx` instances selected repeated static map assets. +- `src/world/GameMapCollision.tsx` keeps terrain collision separate for the player octree. + +## Main Bottlenecks Found + +The most important signal is draw calls, not only triangle count. + +| Model | Instances | Meshes / primitives | Notes | +| ---------------- | --------: | ------------------: | ---------------------------------------------------------------- | +| `generateur` | 3 | 3152 | Worst draw-call offender. Needs asset-side mesh merging. | +| `lafabrik` | 4 | 56 | Moderate draw calls, heavy 2048 texture set. | +| `ecole` | 1 | 107 | One material but many primitives; should be merged. | +| `fermeverticale` | 3 | 1 | Geometry is fine; textures are large for the visible complexity. | + +`generateur` is especially expensive because three visible instances can multiply thousands of primitives into thousands of draw calls. Instancing reduces repeated instance cost, but the source asset still needs a cleaner export. + +## Current Code-Side Optimization + +Repeated static assets are configured in: + +```txt +src/world/map-instancing/mapInstancingConfig.ts +``` + +Those names are excluded from the regular `GameMap` clone path, then rendered by `MapInstancingSystem` with `THREE.InstancedMesh`. + +This keeps the existing map authoring format while reducing repeated draw calls for selected assets. + +## Asset-Side Follow-Up + +Design/export should prioritize: + +1. Merge `generateur` meshes from 3152 primitives to a small number of material groups. +2. Reduce `lafabrik` texture count and downscale flat/low-detail maps. +3. Merge `ecole` primitives because it uses a single material. +4. Prefer runtime `.glb` or compressed runtime textures when the pipeline supports it. + +## Safety Rules + +- Do not instance `terrain` for player collision without validating `Octree.fromGraphNode` support. +- Do not replace repair-game models with optimized map models unless repair node names are preserved. +- Dispose only GPU resources created locally. Do not dispose textures or geometries owned by `useGLTF`'s cache. diff --git a/src/components/three/world/SkyModel.tsx b/src/components/three/world/SkyModel.tsx index 9e37722..43e7c76 100644 --- a/src/components/three/world/SkyModel.tsx +++ b/src/components/three/world/SkyModel.tsx @@ -3,7 +3,6 @@ import { useGLTF } from "@react-three/drei"; import { Component, useEffect, useMemo, useRef, type ReactNode } from "react"; import * as THREE from "three"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; -import { disposeObject3D } from "@/utils/three/dispose"; interface SkyModelProps { modelPath: string; @@ -28,6 +27,7 @@ interface SkyModelErrorBoundaryState { const SKY_MODEL_SCALE = 1; const SKY_MODEL_RENDER_ORDER = -1000; +const SKYBOX_MODEL_PATH = "/models/skybox/skybox.glb"; const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb"; class SkyModelErrorBoundary extends Component< @@ -83,7 +83,7 @@ function SkyModelContent({ useEffect(() => { return () => { - disposeObject3D(model); + disposeSkyModelMaterials(model); }; }, [model]); @@ -129,5 +129,20 @@ function createSkyMaterial(material: T): T { return skyMaterial as T; } -useGLTF.preload("/models/skybox/model.gltf"); +function disposeSkyModelMaterials(model: THREE.Object3D): void { + model.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return; + + if (Array.isArray(object.material)) { + for (const material of object.material) { + material.dispose(); + } + return; + } + + object.material.dispose(); + }); +} + +useGLTF.preload(SKYBOX_MODEL_PATH); useGLTF.preload(LEGACY_SKY_MODEL_PATH); diff --git a/src/components/three/world/TerrainModel.tsx b/src/components/three/world/TerrainModel.tsx index 62d10c3..34de05c 100644 --- a/src/components/three/world/TerrainModel.tsx +++ b/src/components/three/world/TerrainModel.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef } from "react"; import * as THREE from "three"; import { useGLTF } from "@react-three/drei"; import type { Vector3Tuple } from "@/types/three/three"; -import { disposeObject3D } from "@/utils/three/dispose"; const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf"; const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0]; @@ -47,12 +46,6 @@ export function TerrainModel({ return model; }, [scene, receiveShadow]); - useEffect(() => { - return () => { - disposeObject3D(terrainModel); - }; - }, [terrainModel]); - useEffect(() => { onLoaded?.(); }, [onLoaded]); diff --git a/src/data/world/environmentConfig.ts b/src/data/world/environmentConfig.ts index 8918947..82329d1 100644 --- a/src/data/world/environmentConfig.ts +++ b/src/data/world/environmentConfig.ts @@ -1,4 +1,4 @@ -export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf"; +export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/skybox.glb"; export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb"; export const GAME_SCENE_SKY_MODEL_SCALE = 100; export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1; diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index d74d2dc..9ede61e 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -12,6 +12,8 @@ import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { TerrainModel } from "@/components/three/world/TerrainModel"; import { GameMapCollision } from "@/world/GameMapCollision"; +import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem"; +import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig"; import { VegetationSystem } from "@/world/vegetation/VegetationSystem"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import { logger } from "@/utils/core/Logger"; @@ -225,6 +227,7 @@ export function GameMap({ ))} + item.clone()) + : material.clone(); +} + +function disposeMaterialOnly( + material: THREE.Material | THREE.Material[], +): void { + if (Array.isArray(material)) { + for (const item of material) { + item.dispose(); + } + return; + } + + material.dispose(); +} + +function disposeInstancedMapMesh(mesh: THREE.InstancedMesh): void { + mesh.geometry.dispose(); + disposeMaterialOnly(mesh.material); + mesh.dispose(); +} + +function extractMeshes(scene: THREE.Group): MeshData[] { + const meshes: MeshData[] = []; + + scene.updateMatrixWorld(true); + scene.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + + const geometry = child.geometry.clone(); + geometry.applyMatrix4(child.matrixWorld); + + meshes.push({ + geometry, + material: cloneMaterial(child.material), + }); + }); + + return meshes; +} + +function setInstanceMatrices( + instancedMesh: THREE.InstancedMesh, + instances: MapAssetInstance[], +): void { + const position = new THREE.Vector3(); + const rotation = new THREE.Euler(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + const matrix = new THREE.Matrix4(); + + for (let i = 0; i < instances.length; i++) { + const instance = instances[i]; + if (!instance) continue; + + position.set(...instance.position); + rotation.set(...instance.rotation); + quaternion.setFromEuler(rotation); + scale.set(...instance.scale); + matrix.compose(position, quaternion, scale); + instancedMesh.setMatrixAt(i, matrix); + } + + instancedMesh.instanceMatrix.needsUpdate = true; +} + +export function InstancedMapAsset({ + modelPath, + instances, + castShadow, + receiveShadow, +}: InstancedMapAssetProps): React.JSX.Element | null { + const { scene } = useGLTF(modelPath); + const groupRef = useRef(null); + + useEffect(() => { + const group = groupRef.current; + if (!group || instances.length === 0) return; + + const meshDataList = extractMeshes(scene); + const instancedMeshes = meshDataList.map((meshData, index) => { + const instancedMesh = new THREE.InstancedMesh( + meshData.geometry, + meshData.material, + instances.length, + ); + + setInstanceMatrices(instancedMesh, instances); + instancedMesh.castShadow = castShadow; + instancedMesh.receiveShadow = receiveShadow; + instancedMesh.name = `instanced-map-asset-${index}`; + instancedMesh.frustumCulled = true; + instancedMesh.computeBoundingSphere(); + + return instancedMesh; + }); + + for (const mesh of instancedMeshes) { + group.add(mesh); + } + + return () => { + for (const mesh of instancedMeshes) { + group.remove(mesh); + disposeInstancedMapMesh(mesh); + } + }; + }, [castShadow, instances, receiveShadow, scene]); + + if (instances.length === 0) { + return null; + } + + return ; +} diff --git a/src/world/map-instancing/MapInstancingSystem.tsx b/src/world/map-instancing/MapInstancingSystem.tsx new file mode 100644 index 0000000..f0c4329 --- /dev/null +++ b/src/world/map-instancing/MapInstancingSystem.tsx @@ -0,0 +1,42 @@ +import { Suspense } from "react"; +import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset"; +import { + MAP_INSTANCING_ASSETS, + type MapInstancingAssetType, +} from "@/world/map-instancing/mapInstancingConfig"; +import { useMapInstancingData } from "@/world/map-instancing/useMapInstancingData"; + +export function MapInstancingSystem(): React.JSX.Element | null { + const { data, isLoading } = useMapInstancingData(); + + if (isLoading || !data) { + return null; + } + + const enabledAssets = Object.entries(MAP_INSTANCING_ASSETS).filter( + ([, config]) => config.enabled, + ); + + return ( + + {enabledAssets.map(([type, config]) => { + const instances = data.get(type as MapInstancingAssetType); + + if (!instances || instances.length === 0) { + return null; + } + + return ( + + + + ); + })} + + ); +} diff --git a/src/world/map-instancing/mapInstancingConfig.ts b/src/world/map-instancing/mapInstancingConfig.ts new file mode 100644 index 0000000..0b8b02b --- /dev/null +++ b/src/world/map-instancing/mapInstancingConfig.ts @@ -0,0 +1,80 @@ +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", + castShadow: true, + receiveShadow: true, + enabled: true, + }, + pylone: { + mapName: "pylone", + modelPath: "/models/pylone/model.gltf", + castShadow: true, + receiveShadow: true, + enabled: true, + }, + immeuble1: { + mapName: "immeuble1", + modelPath: "/models/immeuble1/model.gltf", + castShadow: true, + receiveShadow: true, + enabled: true, + }, + maison1: { + mapName: "maison1", + modelPath: "/models/maison1/model.gltf", + castShadow: true, + receiveShadow: true, + enabled: true, + }, + eolienne: { + mapName: "eolienne", + modelPath: "/models/eolienne/model.gltf", + castShadow: true, + receiveShadow: true, + enabled: true, + }, + parcebike: { + mapName: "parcebike", + modelPath: "/models/parcebike/model.gltf", + castShadow: true, + receiveShadow: true, + enabled: true, + }, +} as const; + +export type MapInstancingAssetType = keyof typeof MAP_INSTANCING_ASSETS; + +export type MapInstancingAssetConfig = + (typeof MAP_INSTANCING_ASSETS)[MapInstancingAssetType]; + +export const MAP_INSTANCED_NODE_NAMES: ReadonlySet = new Set( + Object.values(MAP_INSTANCING_ASSETS) + .filter((config) => config.enabled) + .map((config) => config.mapName), +); + +export function isInstancedMapNodeName(name: string): boolean { + return MAP_INSTANCED_NODE_NAMES.has(name); +} diff --git a/src/world/map-instancing/useMapInstancingData.ts b/src/world/map-instancing/useMapInstancingData.ts new file mode 100644 index 0000000..86f7edb --- /dev/null +++ b/src/world/map-instancing/useMapInstancingData.ts @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import type { MapNode } from "@/types/editor/editor"; +import type { Vector3Tuple } from "@/types/three/three"; +import { getMapNodes, loadMapSceneData } from "@/utils/map/loadMapSceneData"; +import { + MAP_INSTANCING_ASSETS, + type MapInstancingAssetType, +} from "@/world/map-instancing/mapInstancingConfig"; + +export interface MapAssetInstance { + position: Vector3Tuple; + rotation: Vector3Tuple; + scale: Vector3Tuple; +} + +export type MapInstancingData = Map; + +function mapNodeToInstance(node: MapNode): MapAssetInstance { + return { + position: node.position, + rotation: node.rotation, + scale: node.scale, + }; +} + +function extractMapInstancingData(mapNodes: MapNode[]): MapInstancingData { + const data: MapInstancingData = new Map(); + + for (const [type, config] of Object.entries(MAP_INSTANCING_ASSETS)) { + if (!config.enabled) continue; + + const instances = mapNodes + .filter( + (node) => node.name === config.mapName && node.type === "Object3D", + ) + .map(mapNodeToInstance); + + if (instances.length > 0) { + data.set(type as MapInstancingAssetType, instances); + } + } + + return data; +} + +export function useMapInstancingData(): { + data: MapInstancingData | null; + isLoading: boolean; +} { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + async function load() { + const cachedNodes = getMapNodes(); + + if (cachedNodes) { + if (!cancelled) { + setData(extractMapInstancingData(cachedNodes)); + setIsLoading(false); + } + return; + } + + await loadMapSceneData(); + const nodes = getMapNodes(); + + if (!cancelled && nodes) { + setData(extractMapInstancingData(nodes)); + setIsLoading(false); + } + } + + load(); + + return () => { + cancelled = true; + }; + }, []); + + return { data, isLoading }; +}