Merge branch 'feat/map-environment' of https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik into feat/map-environment
🔍 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:
Tom Boullay
2026-05-24 22:07:15 +02:00
147 changed files with 1490 additions and 329 deletions
+12 -1
View File
@@ -1,4 +1,5 @@
import {
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH,
@@ -6,10 +7,17 @@ import {
PHYSICS_SCENE_BACKGROUND_COLOR,
} from "@/data/world/environmentConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import {
isMapModelVisible,
useMapPerformanceStore,
} from "@/managers/stores/useMapPerformanceStore";
import { SkyModel } from "@/components/three/world/SkyModel";
export function Environment(): React.JSX.Element {
const sceneMode = useSceneMode();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const showSky = isMapModelVisible("sky", { groups, models });
if (sceneMode === "physics") {
return (
@@ -17,12 +25,15 @@ export function Environment(): React.JSX.Element {
);
}
return (
return showSky ? (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
/>
) : (
<color attach="background" args={[GAME_SCENE_FALLBACK_BACKGROUND_COLOR]} />
);
}
+59 -15
View File
@@ -11,24 +11,38 @@ import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import {
isMapModelVisible,
useMapPerformanceStore,
} from "@/managers/stores/useMapPerformanceStore";
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 { 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";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import { INSTANCED_MAP_EXCEPTIONS } from "@/world/vegetation/vegetationConfig";
import type { MapNode } from "@/types/editor/editor";
import type { OctreeReadyHandler } from "@/types/three/three";
const MODEL_RENDER_SCALE: [number, number, number] = [1, 1, 1];
interface LoadedMapNode {
node: MapNode;
modelUrl: string | null;
}
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking"]);
const LITE_MAP_SKIPPED_NODE_NAMES = new Set([
"arbre",
"buisson",
"champdeble",
"champdesoja",
"champsdetournesol",
"sapin",
]);
interface ErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
@@ -91,6 +105,8 @@ export function GameMap({
onOctreeReady,
}: GameMapProps): React.JSX.Element {
const settledMapNodesRef = useRef(new Set<number>());
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
const [mapLoaded, setMapLoaded] = useState(false);
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
@@ -211,16 +227,23 @@ export function GameMap({
node={mapNode.node}
onSettled={() => handleMapNodeSettled(index)}
>
<MapNodeInstance
node={mapNode.node}
modelUrl={mapNode.modelUrl}
onSettled={() => handleMapNodeSettled(index)}
/>
{isMapModelVisible(mapNode.node.name, { groups, models }) ? (
<MapNodeInstance
node={mapNode.node}
modelUrl={mapNode.modelUrl}
onSettled={() => handleMapNodeSettled(index)}
/>
) : (
<HiddenMapNode onSettled={() => handleMapNodeSettled(index)} />
)}
</ModelErrorBoundary>
))}
</group>
<MapInstancingSystem />
<VegetationSystem />
<TerrainModel />
{isMapModelVisible("terrain", { groups, models }) ? (
<TerrainModel />
) : null}
<GameMapCollision
buildOctree={buildOctree}
mapReady={mapReady}
@@ -233,6 +256,14 @@ export function GameMap({
);
}
function HiddenMapNode({ onSettled }: { onSettled: () => void }): null {
useEffect(() => {
onSettled();
}, [onSettled]);
return null;
}
/**
* Temporary development-only map reducer.
*
@@ -250,7 +281,10 @@ function liteMap(node: MapNode): boolean {
return false;
}
return INSTANCED_MAP_EXCEPTIONS.has(node.name);
return (
!LITE_MAP_SKIPPED_NODE_NAMES.has(node.name) &&
!isInstancedMapNodeName(node.name)
);
}
function MapNodeInstance({
@@ -262,11 +296,21 @@ function MapNodeInstance({
modelUrl: string | null;
onSettled: () => void;
}): React.JSX.Element {
const isGeneratedModel = isGeneratedMapModelName(node.name);
useEffect(() => {
if (modelUrl !== null) return;
if (modelUrl !== null || isGeneratedModel) return;
onSettled();
}, [modelUrl, onSettled]);
}, [isGeneratedModel, modelUrl, onSettled]);
if (isGeneratedModel) {
return (
<Suspense fallback={<FallbackMapNode node={node} />}>
<GeneratedMapNodeInstance node={node} onLoaded={onSettled} />
</Suspense>
);
}
if (!modelUrl) {
return <FallbackMapNode node={node} />;
@@ -288,12 +332,12 @@ function ModelInstance({
modelUrl: string;
onLoaded: () => void;
}): React.JSX.Element {
const { position, rotation } = node;
const { position, rotation, scale } = node;
const { scene } = useLoggedGLTF(modelUrl, {
scope: "GameMap.ModelInstance",
position,
rotation,
scale: MODEL_RENDER_SCALE,
scale,
});
const sceneInstance = useClonedObject(scene);
@@ -312,7 +356,7 @@ function ModelInstance({
object={sceneInstance}
position={position}
rotation={rotation}
scale={MODEL_RENDER_SCALE}
scale={scale}
/>
);
}
+3
View File
@@ -5,6 +5,7 @@ import {
PLAYER_SPAWN_POSITION_PHYSICS,
} from "@/data/player/playerConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
@@ -35,6 +36,8 @@ interface WorldProps {
}
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
useMapPerformanceDebug();
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
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);
}
@@ -0,0 +1,190 @@
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 { 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[];
}
interface MeshMergeGroup {
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 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 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[] {
const groups = new Map<string, MeshMergeGroup>();
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 mergedGeometry = mergeGeometries(group.geometries, false);
for (const geometry of group.geometries) {
geometry.dispose();
}
return {
geometry: mergedGeometry,
material: group.material,
};
});
}
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,49 @@
import { Suspense } from "react";
import {
isMapModelVisible,
useMapPerformanceStore,
} from "@/managers/stores/useMapPerformanceStore";
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 groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useMapInstancingData();
if (isLoading || !data) {
return null;
}
const enabledAssets = Object.entries(MAP_INSTANCING_ASSETS).filter(
([, config]) =>
config.enabled && isMapModelVisible(config.mapName, { groups, models }),
);
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 };
}
+25 -45
View File
@@ -1,68 +1,48 @@
import { Suspense } from "react";
import {
isMapModelVisible,
useMapPerformanceStore,
} from "@/managers/stores/useMapPerformanceStore";
import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation";
import { useVegetationData } from "@/world/vegetation/useVegetationData";
import {
type VegetationInstance,
useVegetationData,
} from "@/world/vegetation/useVegetationData";
import {
INSTANCED_MAP_CHUNK_SIZE,
INSTANCED_MAP_NO_SHADOW_NAMES,
VEGETATION_TYPES,
type VegetationType,
} from "@/world/vegetation/vegetationConfig";
function createChunkKey(instance: VegetationInstance): string {
const [x, , z] = instance.position;
const chunkX = Math.floor(x / INSTANCED_MAP_CHUNK_SIZE);
const chunkZ = Math.floor(z / INSTANCED_MAP_CHUNK_SIZE);
return `${chunkX}:${chunkZ}`;
}
function chunkInstances(
instances: VegetationInstance[],
): Map<string, VegetationInstance[]> {
const chunks = new Map<string, VegetationInstance[]>();
for (const instance of instances) {
const key = createChunkKey(instance);
const chunk = chunks.get(key);
if (chunk) {
chunk.push(instance);
} else {
chunks.set(key, [instance]);
}
}
return chunks;
}
export function VegetationSystem(): React.JSX.Element | null {
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useVegetationData();
if (isLoading || !data) {
return null;
}
const enabledTypes = Object.entries(VEGETATION_TYPES).filter(
([, config]) =>
config.enabled && isMapModelVisible(config.mapName, { groups, models }),
);
return (
<group name="instanced-map-system">
{[...data.entries()].map(([modelName, entry]) => {
if (entry.instances.length === 0) {
<group name="vegetation-system">
{enabledTypes.map(([type, config]) => {
const instances = data.get(type as VegetationType);
if (!instances || instances.length === 0) {
return null;
}
const castShadow = !INSTANCED_MAP_NO_SHADOW_NAMES.has(modelName);
const receiveShadow = castShadow;
const chunks = chunkInstances(entry.instances);
return [...chunks.entries()].map(([chunkKey, instances]) => (
<Suspense key={`${modelName}:${chunkKey}`} fallback={null}>
return (
<Suspense key={type} fallback={null}>
<InstancedVegetation
modelPath={entry.modelPath}
modelPath={config.modelPath}
instances={instances}
castShadow={castShadow}
receiveShadow={receiveShadow}
castShadow={config.castShadow}
receiveShadow={config.receiveShadow}
/>
</Suspense>
));
);
})}
</group>
);