refactor: tighten terrain and sky model resource ownership
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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<T extends THREE.Material>(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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
</ModelErrorBoundary>
|
||||
))}
|
||||
</group>
|
||||
<MapInstancingSystem />
|
||||
<VegetationSystem />
|
||||
<TerrainModel />
|
||||
<GameMapCollision
|
||||
@@ -256,7 +259,10 @@ function liteMap(node: MapNode): boolean {
|
||||
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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user