Feat/map-environment #6

Merged
math-pixel merged 116 commits from feat/map-environment into develop 2026-05-29 00:00:51 +00:00
10 changed files with 421 additions and 12 deletions
Showing only changes of commit 072dec03b4 - Show all commits
+1
View File
@@ -143,6 +143,7 @@ WS ws://localhost:8000/ws
| `docs/technical/hand-tracking.md` | Webcam, backend/browser MediaPipe, glove, and gesture flow | | `docs/technical/hand-tracking.md` | Webcam, backend/browser MediaPipe, glove, and gesture flow |
| `docs/technical/zustand.md` | Game, settings, and subtitle stores | | `docs/technical/zustand.md` | Game, settings, and subtitle stores |
| `docs/technical/three-debugging.md` | DevTools workflow for stepping into Three.js internals | | `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/editor.md` | Editor implementation details |
| `docs/technical/animation.md` | Animated, explodable, and reusable 3D model components | | `docs/technical/animation.md` | Animated, explodable, and reusable 3D model components |
| `docs/user/features.md` | Implemented feature inventory | | `docs/user/features.md` | Implemented feature inventory |
+51
View File
@@ -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.
+18 -3
View File
@@ -3,7 +3,6 @@ import { useGLTF } from "@react-three/drei";
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react"; import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { disposeObject3D } from "@/utils/three/dispose";
interface SkyModelProps { interface SkyModelProps {
modelPath: string; modelPath: string;
@@ -28,6 +27,7 @@ interface SkyModelErrorBoundaryState {
const SKY_MODEL_SCALE = 1; const SKY_MODEL_SCALE = 1;
const SKY_MODEL_RENDER_ORDER = -1000; const SKY_MODEL_RENDER_ORDER = -1000;
const SKYBOX_MODEL_PATH = "/models/skybox/skybox.glb";
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb"; const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
class SkyModelErrorBoundary extends Component< class SkyModelErrorBoundary extends Component<
@@ -83,7 +83,7 @@ function SkyModelContent({
useEffect(() => { useEffect(() => {
return () => { return () => {
disposeObject3D(model); disposeSkyModelMaterials(model);
}; };
}, [model]); }, [model]);
@@ -129,5 +129,20 @@ function createSkyMaterial<T extends THREE.Material>(material: T): T {
return skyMaterial as 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); useGLTF.preload(LEGACY_SKY_MODEL_PATH);
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import { disposeObject3D } from "@/utils/three/dispose";
const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf"; const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0]; const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0];
@@ -47,12 +46,6 @@ export function TerrainModel({
return model; return model;
}, [scene, receiveShadow]); }, [scene, receiveShadow]);
useEffect(() => {
return () => {
disposeObject3D(terrainModel);
};
}, [terrainModel]);
useEffect(() => { useEffect(() => {
onLoaded?.(); onLoaded?.();
}, [onLoaded]); }, [onLoaded]);
+1 -1
View File
@@ -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_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
export const GAME_SCENE_SKY_MODEL_SCALE = 100; export const GAME_SCENE_SKY_MODEL_SCALE = 100;
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1; export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
+7 -1
View File
@@ -12,6 +12,8 @@ import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { TerrainModel } from "@/components/three/world/TerrainModel"; import { TerrainModel } from "@/components/three/world/TerrainModel";
import { GameMapCollision } from "@/world/GameMapCollision"; 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 { VegetationSystem } from "@/world/vegetation/VegetationSystem";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger"; import { logger } from "@/utils/core/Logger";
@@ -225,6 +227,7 @@ export function GameMap({
</ModelErrorBoundary> </ModelErrorBoundary>
))} ))}
</group> </group>
<MapInstancingSystem />
<VegetationSystem /> <VegetationSystem />
<TerrainModel /> <TerrainModel />
<GameMapCollision <GameMapCollision
@@ -256,7 +259,10 @@ function liteMap(node: MapNode): boolean {
return false; return false;
} }
return !LITE_MAP_SKIPPED_NODE_NAMES.has(node.name); return (
!LITE_MAP_SKIPPED_NODE_NAMES.has(node.name) &&
!isInstancedMapNodeName(node.name)
);
} }
function MapNodeInstance({ function MapNodeInstance({
@@ -0,0 +1,137 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
interface InstancedMapAssetProps {
modelPath: string;
instances: MapAssetInstance[];
castShadow: boolean;
receiveShadow: boolean;
}
interface MeshData {
geometry: THREE.BufferGeometry;
material: THREE.Material | THREE.Material[];
}
function cloneMaterial(
material: THREE.Material | THREE.Material[],
): THREE.Material | THREE.Material[] {
return Array.isArray(material)
? material.map((item) => 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<THREE.Group>(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 <group ref={groupRef} />;
}
@@ -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 (
<group name="map-instancing-system">
{enabledAssets.map(([type, config]) => {
const instances = data.get(type as MapInstancingAssetType);
if (!instances || instances.length === 0) {
return null;
}
return (
<Suspense key={type} fallback={null}>
<InstancedMapAsset
modelPath={config.modelPath}
instances={instances}
castShadow={config.castShadow}
receiveShadow={config.receiveShadow}
/>
</Suspense>
);
})}
</group>
);
}
@@ -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<string> = 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);
}
@@ -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<MapInstancingAssetType, MapAssetInstance[]>;
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<MapInstancingData | null>(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 };
}