Compare commits
5 Commits
cf08062def
...
4e6582b543
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e6582b543 | |||
| e4ee2d768b | |||
| 5e594c51f7 | |||
| 26ddbebe14 | |||
| 48c2b4f0cd |
@@ -10,9 +10,9 @@ This document tracks the current map-rendering performance pass.
|
|||||||
- `src/world/map-instancing/MapInstancingSystem.tsx` instances selected repeated static map assets.
|
- `src/world/map-instancing/MapInstancingSystem.tsx` instances selected repeated static map assets.
|
||||||
- `src/world/GameMapCollision.tsx` keeps terrain collision separate for the player octree.
|
- `src/world/GameMapCollision.tsx` keeps terrain collision separate for the player octree.
|
||||||
|
|
||||||
## Main Bottlenecks Found
|
## Draw-Call Bottlenecks Found
|
||||||
|
|
||||||
The most important signal is draw calls, not only triangle count.
|
The first performance bottleneck was draw calls. Some assets were exported as many small GLTF primitives even when they used only a few materials.
|
||||||
|
|
||||||
| Model | Instances | Meshes / primitives | Notes |
|
| Model | Instances | Meshes / primitives | Notes |
|
||||||
| ---------------- | --------: | ------------------: | ---------------------------------------------------------------- |
|
| ---------------- | --------: | ------------------: | ---------------------------------------------------------------- |
|
||||||
@@ -21,7 +21,121 @@ The most important signal is draw calls, not only triangle count.
|
|||||||
| `ecole` | 1 | 107 | One material but many primitives; should be merged. |
|
| `ecole` | 1 | 107 | One material but many primitives; should be merged. |
|
||||||
| `fermeverticale` | 3 | 1 | Geometry is fine; textures are large for the visible complexity. |
|
| `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.
|
`generateur` was especially expensive because three visible instances could multiply thousands of primitives into thousands of draw calls. Instancing reduces repeated instance cost, but the source asset still needs a cleaner export.
|
||||||
|
|
||||||
|
## Runtime Merge Pass
|
||||||
|
|
||||||
|
`InstancedMapAsset` now groups source meshes by material and compatible geometry attributes before creating `THREE.InstancedMesh` objects. This reduces the runtime draw groups even when the source GLTF is exported as many small meshes.
|
||||||
|
|
||||||
|
Estimated source primitive count versus runtime merged groups:
|
||||||
|
|
||||||
|
| Model | Source primitives | Runtime merged groups |
|
||||||
|
| ------------ | ----------------: | --------------------: |
|
||||||
|
| `generateur` | 3152 | 8 |
|
||||||
|
| `ecole` | 107 | 2 |
|
||||||
|
| `eolienne` | 118 | 8 |
|
||||||
|
| `lafabrik` | 56 | 14 |
|
||||||
|
|
||||||
|
This is a code-side safety net, not a replacement for clean asset exports. Clean GLB exports with merged meshes and fewer textures remain the preferred long-term path.
|
||||||
|
|
||||||
|
## Current Triangle Bottleneck
|
||||||
|
|
||||||
|
After the runtime merge pass, draw calls can drop dramatically, but FPS can still stay low because the scene now remains triangle-bound. A debug capture after the merge showed roughly:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
138 draw calls
|
||||||
|
~69.6M triangles
|
||||||
|
~10 FPS
|
||||||
|
```
|
||||||
|
|
||||||
|
That means the renderer is no longer mostly blocked by draw-call submission. It is mostly drawing too many visible triangles.
|
||||||
|
|
||||||
|
Estimated triangle contribution from `map.json` instance counts:
|
||||||
|
|
||||||
|
| Model | Instances | Triangles each | Estimated total triangles |
|
||||||
|
| ------------------- | --------: | -------------: | ------------------------: |
|
||||||
|
| `buisson` | 646 | 37 500 | ~24.2M |
|
||||||
|
| `champdesoja` | 1181 | 16 268 | ~19.2M |
|
||||||
|
| `arbre` | 291 | 38 906 | ~11.3M |
|
||||||
|
| `champdeble` | 1307 | 6 260 | ~8.2M |
|
||||||
|
| `champsdetournesol` | 1163 | 3 264 | ~3.8M |
|
||||||
|
| `sapin` | 93 | 23 972 | ~2.2M |
|
||||||
|
|
||||||
|
These vegetation and crop assets account for almost all of the current `~69M` triangle count. By comparison, the previously suspicious static buildings are much smaller in triangle cost:
|
||||||
|
|
||||||
|
| Model | Estimated total triangles |
|
||||||
|
| ---------------- | ------------------------: |
|
||||||
|
| `generateur` | ~123k |
|
||||||
|
| `lafabrik` | ~124k |
|
||||||
|
| `ecole` | ~5k |
|
||||||
|
| `fermeverticale` | ~1k |
|
||||||
|
|
||||||
|
`InstancedMesh` reduces draw calls, but it does not reduce triangle count. If 646 bushes each contain 37 500 triangles, the GPU still has to draw about 24 million bush triangles when those instances are visible.
|
||||||
|
|
||||||
|
## Debug Performance Controls
|
||||||
|
|
||||||
|
The next useful runtime tool is a debug-only performance folder that can isolate model families. This should be mounted only when `?debug` is enabled.
|
||||||
|
|
||||||
|
Proposed controls:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Performance / Map
|
||||||
|
- vegetation
|
||||||
|
- crops
|
||||||
|
- trees
|
||||||
|
- buildings
|
||||||
|
- landmarks
|
||||||
|
- props
|
||||||
|
- terrain
|
||||||
|
- sky
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful per-model toggles:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
buisson
|
||||||
|
arbre
|
||||||
|
sapin
|
||||||
|
champdeble
|
||||||
|
champdesoja
|
||||||
|
champsdetournesol
|
||||||
|
fermeverticale
|
||||||
|
lafabrik
|
||||||
|
immeuble1
|
||||||
|
eolienne
|
||||||
|
pylone
|
||||||
|
```
|
||||||
|
|
||||||
|
The purpose is diagnostic, not final gameplay behavior. The expected workflow is:
|
||||||
|
|
||||||
|
1. Open `/?debug` with R3F perf enabled.
|
||||||
|
2. Disable one family or model type.
|
||||||
|
3. Watch `triangles`, `calls`, and FPS.
|
||||||
|
4. Identify which model groups need LOD, density reduction, or asset re-export.
|
||||||
|
|
||||||
|
Recommended implementation files:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/managers/stores/useMapPerformanceStore.ts
|
||||||
|
src/hooks/debug/useMapPerformanceDebug.ts
|
||||||
|
src/world/vegetation/VegetationSystem.tsx
|
||||||
|
src/world/map-instancing/MapInstancingSystem.tsx
|
||||||
|
src/world/GameMap.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
The store should stay runtime/debug-only. It should not change persisted production map data.
|
||||||
|
|
||||||
|
## Triangle-Reduction Follow-Up
|
||||||
|
|
||||||
|
Once the expensive model families are isolated, the real triangle fixes are:
|
||||||
|
|
||||||
|
1. Lower-poly vegetation and crop exports.
|
||||||
|
2. LOD variants for trees, bushes, and crop fields.
|
||||||
|
3. Distance-based culling for vegetation/crop instances.
|
||||||
|
4. Chunked instancing so Three.js can frustum-cull groups instead of one huge global `InstancedMesh`.
|
||||||
|
5. Billboard/impostor versions for far vegetation.
|
||||||
|
|
||||||
|
Chunked instancing is especially important. A single `InstancedMesh` containing every bush has one global bounding sphere. If that bounding sphere is visible, Three.js may keep the whole batch visible. Splitting instances into grid chunks allows entire offscreen chunks to be skipped.
|
||||||
|
|
||||||
## Current Code-Side Optimization
|
## Current Code-Side Optimization
|
||||||
|
|
||||||
@@ -31,18 +145,40 @@ Repeated static assets are configured in:
|
|||||||
src/world/map-instancing/mapInstancingConfig.ts
|
src/world/map-instancing/mapInstancingConfig.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Those names are excluded from the regular `GameMap` clone path, then rendered by `MapInstancingSystem` with `THREE.InstancedMesh`.
|
Those names are excluded from the regular `GameMap` clone path, then rendered by `MapInstancingSystem` with merged `THREE.InstancedMesh` batches.
|
||||||
|
|
||||||
This keeps the existing map authoring format while reducing repeated draw calls for selected assets.
|
This keeps the existing map authoring format while reducing repeated draw calls for selected assets.
|
||||||
|
|
||||||
|
## Generated R3F Model Path
|
||||||
|
|
||||||
|
Unique static map assets can use explicit R3F components instead of the generic cloned GLTF path. This follows the same intent as `gltfjsx`: expose the model as a React component, then keep control over mesh/material setup in code.
|
||||||
|
|
||||||
|
Current generated map-model entry point:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/world/map-generated/GeneratedMapNodeInstance.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Current generated model component:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/components/three/models/generated/EcoleModel.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
`ecole` is a safe first candidate because it appears once in `map.json`, has one material, and does not participate in player collision or repair gameplay. Its source GLTF has 107 primitives, so the generated component also merges compatible geometry groups before mounting the meshes.
|
||||||
|
|
||||||
|
This path should be used selectively. It improves control and can remove clone overhead, but it does not reduce source triangle count by itself.
|
||||||
|
|
||||||
## Asset-Side Follow-Up
|
## Asset-Side Follow-Up
|
||||||
|
|
||||||
Design/export should prioritize:
|
Design/export should prioritize:
|
||||||
|
|
||||||
1. Merge `generateur` meshes from 3152 primitives to a small number of material groups.
|
1. Produce lower-poly `buisson`, `arbre`, `sapin`, and crop assets.
|
||||||
2. Reduce `lafabrik` texture count and downscale flat/low-detail maps.
|
2. Add LOD or billboard variants for far vegetation.
|
||||||
3. Merge `ecole` primitives because it uses a single material.
|
3. Merge `generateur` meshes from 3152 primitives to a small number of material groups.
|
||||||
4. Prefer runtime `.glb` or compressed runtime textures when the pipeline supports it.
|
4. Reduce `lafabrik` texture count and downscale flat/low-detail maps.
|
||||||
|
5. Merge `ecole` primitives because it uses a single material.
|
||||||
|
6. Prefer runtime `.glb` or compressed runtime textures when the pipeline supports it.
|
||||||
|
|
||||||
## Safety Rules
|
## Safety Rules
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MergedMeshData {
|
||||||
|
geometry: THREE.BufferGeometry;
|
||||||
|
material: THREE.Material | THREE.Material[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeometryGroup {
|
||||||
|
geometries: 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 disposeMaterial(material: THREE.Material | THREE.Material[]): void {
|
||||||
|
if (Array.isArray(material)) {
|
||||||
|
for (const item of material) {
|
||||||
|
item.dispose();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
material.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGeometrySignature(geometry: THREE.BufferGeometry): string {
|
||||||
|
const attributes = Object.entries(geometry.attributes)
|
||||||
|
.map(([name, attribute]) => {
|
||||||
|
return `${name}:${attribute.itemSize}:${attribute.normalized}`;
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
|
||||||
|
return `${geometry.index ? "indexed" : "non-indexed"}:${attributes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMaterialKey(
|
||||||
|
material: THREE.Material | THREE.Material[],
|
||||||
|
): string {
|
||||||
|
if (Array.isArray(material)) {
|
||||||
|
return material.map((item) => item.uuid).join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
return material.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
|
||||||
|
const groups = new Map<string, GeometryGroup>();
|
||||||
|
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
scene.traverse((child) => {
|
||||||
|
if (!(child instanceof THREE.Mesh)) return;
|
||||||
|
|
||||||
|
const geometry = child.geometry.clone();
|
||||||
|
geometry.applyMatrix4(child.matrixWorld);
|
||||||
|
const material = child.material;
|
||||||
|
const key = `${createMaterialKey(material)}:${createGeometrySignature(geometry)}`;
|
||||||
|
const group = groups.get(key);
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
group.geometries.push(geometry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.set(key, {
|
||||||
|
geometries: [geometry],
|
||||||
|
material: cloneMaterial(material),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...groups.values()].map((group) => {
|
||||||
|
if (group.geometries.length === 1) {
|
||||||
|
return {
|
||||||
|
geometry: group.geometries[0] as THREE.BufferGeometry,
|
||||||
|
material: group.material,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = mergeGeometries(group.geometries, false);
|
||||||
|
|
||||||
|
for (const sourceGeometry of group.geometries) {
|
||||||
|
sourceGeometry.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
geometry,
|
||||||
|
material: group.material,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EcoleModel({
|
||||||
|
position,
|
||||||
|
rotation,
|
||||||
|
scale,
|
||||||
|
castShadow = true,
|
||||||
|
receiveShadow = true,
|
||||||
|
onLoaded,
|
||||||
|
}: EcoleModelProps): React.JSX.Element {
|
||||||
|
const { scene } = useGLTF(ECOLE_MODEL_PATH);
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const group = groupRef.current;
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const mergedMeshes = createMergedMeshes(scene);
|
||||||
|
const meshes = mergedMeshes.map((meshData) => {
|
||||||
|
const mesh = new THREE.Mesh(meshData.geometry, meshData.material);
|
||||||
|
mesh.castShadow = castShadow;
|
||||||
|
mesh.receiveShadow = receiveShadow;
|
||||||
|
return mesh;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoaded?.();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
group.remove(mesh);
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
disposeMaterial(mesh.material);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [castShadow, onLoaded, receiveShadow, scene]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
ref={groupRef}
|
||||||
|
position={position}
|
||||||
|
rotation={rotation}
|
||||||
|
scale={scale}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useGLTF.preload(ECOLE_MODEL_PATH);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import {
|
||||||
|
MAP_PERFORMANCE_GROUP_NAMES,
|
||||||
|
MAP_PERFORMANCE_MODEL_NAMES,
|
||||||
|
useMapPerformanceStore,
|
||||||
|
} from "@/managers/stores/useMapPerformanceStore";
|
||||||
|
|
||||||
|
function toLabel(value: string): string {
|
||||||
|
return value
|
||||||
|
.split(/[-_\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMapPerformanceDebug(): void {
|
||||||
|
useDebugFolder("Performance / Map", (folder) => {
|
||||||
|
const {
|
||||||
|
groups,
|
||||||
|
models,
|
||||||
|
setGroupVisible,
|
||||||
|
setModelVisible,
|
||||||
|
resetVisibility,
|
||||||
|
} = useMapPerformanceStore.getState();
|
||||||
|
const controls = {
|
||||||
|
...groups,
|
||||||
|
...models,
|
||||||
|
reset: () => {
|
||||||
|
resetVisibility();
|
||||||
|
for (const key of [
|
||||||
|
...MAP_PERFORMANCE_GROUP_NAMES,
|
||||||
|
...MAP_PERFORMANCE_MODEL_NAMES,
|
||||||
|
]) {
|
||||||
|
controls[key] = true;
|
||||||
|
}
|
||||||
|
folder.controllersRecursive().forEach((controller) => {
|
||||||
|
controller.updateDisplay();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const group of MAP_PERFORMANCE_GROUP_NAMES) {
|
||||||
|
folder
|
||||||
|
.add(controls, group)
|
||||||
|
.name(toLabel(group))
|
||||||
|
.onChange((visible: boolean) => setGroupVisible(group, visible));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const model of MAP_PERFORMANCE_MODEL_NAMES) {
|
||||||
|
folder
|
||||||
|
.add(controls, model)
|
||||||
|
.name(toLabel(model))
|
||||||
|
.onChange((visible: boolean) => setModelVisible(model, visible));
|
||||||
|
}
|
||||||
|
|
||||||
|
folder.add(controls, "reset").name("Reset visibility");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type MapPerformanceGroupName =
|
||||||
|
| "vegetation"
|
||||||
|
| "crops"
|
||||||
|
| "trees"
|
||||||
|
| "buildings"
|
||||||
|
| "landmarks"
|
||||||
|
| "props"
|
||||||
|
| "terrain"
|
||||||
|
| "sky";
|
||||||
|
|
||||||
|
export type MapPerformanceModelName =
|
||||||
|
| "buisson"
|
||||||
|
| "arbre"
|
||||||
|
| "sapin"
|
||||||
|
| "champdeble"
|
||||||
|
| "champdesoja"
|
||||||
|
| "champsdetournesol"
|
||||||
|
| "ecole"
|
||||||
|
| "generateur"
|
||||||
|
| "fermeverticale"
|
||||||
|
| "lafabrik"
|
||||||
|
| "immeuble1"
|
||||||
|
| "eolienne"
|
||||||
|
| "pylone"
|
||||||
|
| "boiteauxlettres"
|
||||||
|
| "maison1"
|
||||||
|
| "parcebike"
|
||||||
|
| "terrain"
|
||||||
|
| "sky";
|
||||||
|
|
||||||
|
export interface MapPerformanceVisibility {
|
||||||
|
groups: Record<MapPerformanceGroupName, boolean>;
|
||||||
|
models: Record<MapPerformanceModelName, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapPerformanceActions {
|
||||||
|
setGroupVisible: (group: MapPerformanceGroupName, visible: boolean) => void;
|
||||||
|
setModelVisible: (model: MapPerformanceModelName, visible: boolean) => void;
|
||||||
|
resetVisibility: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapPerformanceStore = MapPerformanceVisibility & MapPerformanceActions;
|
||||||
|
|
||||||
|
export const MAP_PERFORMANCE_GROUP_NAMES: readonly MapPerformanceGroupName[] = [
|
||||||
|
"vegetation",
|
||||||
|
"crops",
|
||||||
|
"trees",
|
||||||
|
"buildings",
|
||||||
|
"landmarks",
|
||||||
|
"props",
|
||||||
|
"terrain",
|
||||||
|
"sky",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MAP_PERFORMANCE_MODEL_NAMES: readonly MapPerformanceModelName[] = [
|
||||||
|
"buisson",
|
||||||
|
"arbre",
|
||||||
|
"sapin",
|
||||||
|
"champdeble",
|
||||||
|
"champdesoja",
|
||||||
|
"champsdetournesol",
|
||||||
|
"ecole",
|
||||||
|
"generateur",
|
||||||
|
"fermeverticale",
|
||||||
|
"lafabrik",
|
||||||
|
"immeuble1",
|
||||||
|
"eolienne",
|
||||||
|
"pylone",
|
||||||
|
"boiteauxlettres",
|
||||||
|
"maison1",
|
||||||
|
"parcebike",
|
||||||
|
"terrain",
|
||||||
|
"sky",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODEL_GROUPS: Record<
|
||||||
|
MapPerformanceModelName,
|
||||||
|
readonly MapPerformanceGroupName[]
|
||||||
|
> = {
|
||||||
|
buisson: ["vegetation"],
|
||||||
|
arbre: ["vegetation", "trees"],
|
||||||
|
sapin: ["vegetation", "trees"],
|
||||||
|
champdeble: ["vegetation", "crops"],
|
||||||
|
champdesoja: ["vegetation", "crops"],
|
||||||
|
champsdetournesol: ["vegetation", "crops"],
|
||||||
|
ecole: ["buildings", "landmarks"],
|
||||||
|
generateur: ["landmarks"],
|
||||||
|
fermeverticale: ["buildings", "landmarks"],
|
||||||
|
lafabrik: ["buildings", "landmarks"],
|
||||||
|
immeuble1: ["buildings"],
|
||||||
|
eolienne: ["props"],
|
||||||
|
pylone: ["props"],
|
||||||
|
boiteauxlettres: ["props"],
|
||||||
|
maison1: ["buildings"],
|
||||||
|
parcebike: ["props"],
|
||||||
|
terrain: ["terrain"],
|
||||||
|
sky: ["sky"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createVisibleRecord<T extends string>(
|
||||||
|
keys: readonly T[],
|
||||||
|
): Record<T, boolean> {
|
||||||
|
return Object.fromEntries(keys.map((key) => [key, true])) as Record<
|
||||||
|
T,
|
||||||
|
boolean
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultVisibility(): MapPerformanceVisibility {
|
||||||
|
return {
|
||||||
|
groups: createVisibleRecord(MAP_PERFORMANCE_GROUP_NAMES),
|
||||||
|
models: createVisibleRecord(MAP_PERFORMANCE_MODEL_NAMES),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMapPerformanceModelName(
|
||||||
|
name: string,
|
||||||
|
): name is MapPerformanceModelName {
|
||||||
|
return MAP_PERFORMANCE_MODEL_NAMES.includes(name as MapPerformanceModelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMapModelVisible(
|
||||||
|
name: string,
|
||||||
|
visibility: MapPerformanceVisibility,
|
||||||
|
): boolean {
|
||||||
|
if (!isMapPerformanceModelName(name)) return true;
|
||||||
|
if (!visibility.models[name]) return false;
|
||||||
|
|
||||||
|
return MODEL_GROUPS[name].every((group) => visibility.groups[group]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMapPerformanceStore = create<MapPerformanceStore>()((set) => ({
|
||||||
|
...createDefaultVisibility(),
|
||||||
|
setGroupVisible: (group, visible) =>
|
||||||
|
set((state) => ({
|
||||||
|
groups: { ...state.groups, [group]: visible },
|
||||||
|
})),
|
||||||
|
setModelVisible: (model, visible) =>
|
||||||
|
set((state) => ({
|
||||||
|
models: { ...state.models, [model]: visible },
|
||||||
|
})),
|
||||||
|
resetVisibility: () => set(createDefaultVisibility()),
|
||||||
|
}));
|
||||||
@@ -7,10 +7,17 @@ import {
|
|||||||
PHYSICS_SCENE_BACKGROUND_COLOR,
|
PHYSICS_SCENE_BACKGROUND_COLOR,
|
||||||
} from "@/data/world/environmentConfig";
|
} from "@/data/world/environmentConfig";
|
||||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||||
|
import {
|
||||||
|
isMapModelVisible,
|
||||||
|
useMapPerformanceStore,
|
||||||
|
} from "@/managers/stores/useMapPerformanceStore";
|
||||||
import { SkyModel } from "@/components/three/world/SkyModel";
|
import { SkyModel } from "@/components/three/world/SkyModel";
|
||||||
|
|
||||||
export function Environment(): React.JSX.Element {
|
export function Environment(): React.JSX.Element {
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
|
const groups = useMapPerformanceStore((state) => state.groups);
|
||||||
|
const models = useMapPerformanceStore((state) => state.models);
|
||||||
|
const showSky = isMapModelVisible("sky", { groups, models });
|
||||||
|
|
||||||
if (sceneMode === "physics") {
|
if (sceneMode === "physics") {
|
||||||
return (
|
return (
|
||||||
@@ -18,7 +25,7 @@ export function Environment(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return showSky ? (
|
||||||
<SkyModel
|
<SkyModel
|
||||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||||
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
|
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
|
||||||
@@ -26,5 +33,7 @@ export function Environment(): React.JSX.Element {
|
|||||||
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
||||||
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<color attach="background" args={[GAME_SCENE_FALLBACK_BACKGROUND_COLOR]} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-8
@@ -11,7 +11,13 @@ import * as THREE from "three";
|
|||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
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 {
|
||||||
|
isMapModelVisible,
|
||||||
|
useMapPerformanceStore,
|
||||||
|
} from "@/managers/stores/useMapPerformanceStore";
|
||||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||||
|
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
||||||
|
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
|
||||||
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
||||||
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
|
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
|
||||||
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||||
@@ -99,6 +105,8 @@ export function GameMap({
|
|||||||
onOctreeReady,
|
onOctreeReady,
|
||||||
}: GameMapProps): React.JSX.Element {
|
}: GameMapProps): React.JSX.Element {
|
||||||
const settledMapNodesRef = useRef(new Set<number>());
|
const settledMapNodesRef = useRef(new Set<number>());
|
||||||
|
const groups = useMapPerformanceStore((state) => state.groups);
|
||||||
|
const models = useMapPerformanceStore((state) => state.models);
|
||||||
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
|
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
|
||||||
const [mapLoaded, setMapLoaded] = useState(false);
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
||||||
@@ -219,17 +227,23 @@ export function GameMap({
|
|||||||
node={mapNode.node}
|
node={mapNode.node}
|
||||||
onSettled={() => handleMapNodeSettled(index)}
|
onSettled={() => handleMapNodeSettled(index)}
|
||||||
>
|
>
|
||||||
<MapNodeInstance
|
{isMapModelVisible(mapNode.node.name, { groups, models }) ? (
|
||||||
node={mapNode.node}
|
<MapNodeInstance
|
||||||
modelUrl={mapNode.modelUrl}
|
node={mapNode.node}
|
||||||
onSettled={() => handleMapNodeSettled(index)}
|
modelUrl={mapNode.modelUrl}
|
||||||
/>
|
onSettled={() => handleMapNodeSettled(index)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HiddenMapNode onSettled={() => handleMapNodeSettled(index)} />
|
||||||
|
)}
|
||||||
</ModelErrorBoundary>
|
</ModelErrorBoundary>
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
<MapInstancingSystem />
|
<MapInstancingSystem />
|
||||||
<VegetationSystem />
|
<VegetationSystem />
|
||||||
<TerrainModel />
|
{isMapModelVisible("terrain", { groups, models }) ? (
|
||||||
|
<TerrainModel />
|
||||||
|
) : null}
|
||||||
<GameMapCollision
|
<GameMapCollision
|
||||||
buildOctree={buildOctree}
|
buildOctree={buildOctree}
|
||||||
mapReady={mapReady}
|
mapReady={mapReady}
|
||||||
@@ -242,6 +256,14 @@ export function GameMap({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HiddenMapNode({ onSettled }: { onSettled: () => void }): null {
|
||||||
|
useEffect(() => {
|
||||||
|
onSettled();
|
||||||
|
}, [onSettled]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Temporary development-only map reducer.
|
* Temporary development-only map reducer.
|
||||||
*
|
*
|
||||||
@@ -274,11 +296,21 @@ function MapNodeInstance({
|
|||||||
modelUrl: string | null;
|
modelUrl: string | null;
|
||||||
onSettled: () => void;
|
onSettled: () => void;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
const isGeneratedModel = isGeneratedMapModelName(node.name);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modelUrl !== null) return;
|
if (modelUrl !== null || isGeneratedModel) return;
|
||||||
|
|
||||||
onSettled();
|
onSettled();
|
||||||
}, [modelUrl, onSettled]);
|
}, [isGeneratedModel, modelUrl, onSettled]);
|
||||||
|
|
||||||
|
if (isGeneratedModel) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<FallbackMapNode node={node} />}>
|
||||||
|
<GeneratedMapNodeInstance node={node} onLoaded={onSettled} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!modelUrl) {
|
if (!modelUrl) {
|
||||||
return <FallbackMapNode node={node} />;
|
return <FallbackMapNode node={node} />;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||||
|
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||||
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||||
@@ -35,6 +36,8 @@ interface WorldProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||||
|
useMapPerformanceDebug();
|
||||||
|
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { EcoleModel } from "@/components/three/models/generated/EcoleModel";
|
||||||
|
import type { MapNode } from "@/types/editor/editor";
|
||||||
|
|
||||||
|
interface GeneratedMapNodeInstanceProps {
|
||||||
|
node: MapNode;
|
||||||
|
onLoaded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneratedMapNodeInstance({
|
||||||
|
node,
|
||||||
|
onLoaded,
|
||||||
|
}: GeneratedMapNodeInstanceProps): React.JSX.Element | null {
|
||||||
|
if (node.name === "ecole") {
|
||||||
|
return (
|
||||||
|
<EcoleModel
|
||||||
|
position={node.position}
|
||||||
|
rotation={node.rotation}
|
||||||
|
scale={node.scale}
|
||||||
|
onLoaded={onLoaded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const GENERATED_MAP_MODEL_NAMES = new Set(["ecole"]);
|
||||||
|
|
||||||
|
export function isGeneratedMapModelName(name: string): boolean {
|
||||||
|
return GENERATED_MAP_MODEL_NAMES.has(name);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, 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 { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
||||||
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
|
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
|
||||||
|
|
||||||
interface InstancedMapAssetProps {
|
interface InstancedMapAssetProps {
|
||||||
@@ -15,6 +16,11 @@ interface MeshData {
|
|||||||
material: THREE.Material | THREE.Material[];
|
material: THREE.Material | THREE.Material[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeshMergeGroup {
|
||||||
|
geometries: THREE.BufferGeometry[];
|
||||||
|
material: THREE.Material | THREE.Material[];
|
||||||
|
}
|
||||||
|
|
||||||
function cloneMaterial(
|
function cloneMaterial(
|
||||||
material: THREE.Material | THREE.Material[],
|
material: THREE.Material | THREE.Material[],
|
||||||
): THREE.Material | THREE.Material[] {
|
): THREE.Material | THREE.Material[] {
|
||||||
@@ -42,8 +48,29 @@ function disposeInstancedMapMesh(mesh: THREE.InstancedMesh): void {
|
|||||||
mesh.dispose();
|
mesh.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createGeometrySignature(geometry: THREE.BufferGeometry): string {
|
||||||
|
const attributes = Object.entries(geometry.attributes)
|
||||||
|
.map(([name, attribute]) => {
|
||||||
|
return `${name}:${attribute.itemSize}:${attribute.normalized}`;
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
|
||||||
|
return `${geometry.index ? "indexed" : "non-indexed"}:${attributes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMaterialKey(
|
||||||
|
material: THREE.Material | THREE.Material[],
|
||||||
|
): string {
|
||||||
|
if (Array.isArray(material)) {
|
||||||
|
return material.map((item) => item.uuid).join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
return material.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
function extractMeshes(scene: THREE.Group): MeshData[] {
|
function extractMeshes(scene: THREE.Group): MeshData[] {
|
||||||
const meshes: MeshData[] = [];
|
const groups = new Map<string, MeshMergeGroup>();
|
||||||
|
|
||||||
scene.updateMatrixWorld(true);
|
scene.updateMatrixWorld(true);
|
||||||
scene.traverse((child) => {
|
scene.traverse((child) => {
|
||||||
@@ -51,14 +78,40 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
|
|||||||
|
|
||||||
const geometry = child.geometry.clone();
|
const geometry = child.geometry.clone();
|
||||||
geometry.applyMatrix4(child.matrixWorld);
|
geometry.applyMatrix4(child.matrixWorld);
|
||||||
|
const material = child.material;
|
||||||
|
const key = `${createMaterialKey(material)}:${createGeometrySignature(geometry)}`;
|
||||||
|
const group = groups.get(key);
|
||||||
|
|
||||||
meshes.push({
|
if (group) {
|
||||||
geometry,
|
group.geometries.push(geometry);
|
||||||
material: cloneMaterial(child.material),
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.set(key, {
|
||||||
|
geometries: [geometry],
|
||||||
|
material: cloneMaterial(material),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return meshes;
|
return [...groups.values()].map((group) => {
|
||||||
|
if (group.geometries.length === 1) {
|
||||||
|
return {
|
||||||
|
geometry: group.geometries[0] as THREE.BufferGeometry,
|
||||||
|
material: group.material,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedGeometry = mergeGeometries(group.geometries, false);
|
||||||
|
|
||||||
|
for (const geometry of group.geometries) {
|
||||||
|
geometry.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
geometry: mergedGeometry,
|
||||||
|
material: group.material,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInstanceMatrices(
|
function setInstanceMatrices(
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import {
|
||||||
|
isMapModelVisible,
|
||||||
|
useMapPerformanceStore,
|
||||||
|
} from "@/managers/stores/useMapPerformanceStore";
|
||||||
import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset";
|
import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset";
|
||||||
import {
|
import {
|
||||||
MAP_INSTANCING_ASSETS,
|
MAP_INSTANCING_ASSETS,
|
||||||
@@ -7,6 +11,8 @@ import {
|
|||||||
import { useMapInstancingData } from "@/world/map-instancing/useMapInstancingData";
|
import { useMapInstancingData } from "@/world/map-instancing/useMapInstancingData";
|
||||||
|
|
||||||
export function MapInstancingSystem(): React.JSX.Element | null {
|
export function MapInstancingSystem(): React.JSX.Element | null {
|
||||||
|
const groups = useMapPerformanceStore((state) => state.groups);
|
||||||
|
const models = useMapPerformanceStore((state) => state.models);
|
||||||
const { data, isLoading } = useMapInstancingData();
|
const { data, isLoading } = useMapInstancingData();
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
@@ -14,7 +20,8 @@ export function MapInstancingSystem(): React.JSX.Element | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enabledAssets = Object.entries(MAP_INSTANCING_ASSETS).filter(
|
const enabledAssets = Object.entries(MAP_INSTANCING_ASSETS).filter(
|
||||||
([, config]) => config.enabled,
|
([, config]) =>
|
||||||
|
config.enabled && isMapModelVisible(config.mapName, { groups, models }),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import {
|
||||||
|
isMapModelVisible,
|
||||||
|
useMapPerformanceStore,
|
||||||
|
} from "@/managers/stores/useMapPerformanceStore";
|
||||||
import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation";
|
import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation";
|
||||||
import { useVegetationData } from "@/world/vegetation/useVegetationData";
|
import { useVegetationData } from "@/world/vegetation/useVegetationData";
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +11,8 @@ import {
|
|||||||
} from "@/world/vegetation/vegetationConfig";
|
} from "@/world/vegetation/vegetationConfig";
|
||||||
|
|
||||||
export function VegetationSystem(): React.JSX.Element | null {
|
export function VegetationSystem(): React.JSX.Element | null {
|
||||||
|
const groups = useMapPerformanceStore((state) => state.groups);
|
||||||
|
const models = useMapPerformanceStore((state) => state.models);
|
||||||
const { data, isLoading } = useVegetationData();
|
const { data, isLoading } = useVegetationData();
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
@@ -14,7 +20,8 @@ export function VegetationSystem(): React.JSX.Element | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enabledTypes = Object.entries(VEGETATION_TYPES).filter(
|
const enabledTypes = Object.entries(VEGETATION_TYPES).filter(
|
||||||
([, config]) => config.enabled,
|
([, config]) =>
|
||||||
|
config.enabled && isMapModelVisible(config.mapName, { groups, models }),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user