diff --git a/src/data/world/mapPerformanceConfig.ts b/src/data/world/mapPerformanceConfig.ts index d345acd..74ce9fe 100644 --- a/src/data/world/mapPerformanceConfig.ts +++ b/src/data/world/mapPerformanceConfig.ts @@ -15,6 +15,7 @@ export type MapPerformanceModelName = | "champdeble" | "champdesoja" | "champsdetournesol" + | "potager" | "ecole" | "generateur" | "fermeverticale" @@ -50,6 +51,7 @@ export const MAP_PERFORMANCE_MODEL_NAMES: readonly MapPerformanceModelName[] = [ "champdeble", "champdesoja", "champsdetournesol", + "potager", "ecole", "generateur", "fermeverticale", @@ -78,6 +80,7 @@ export const MAP_PERFORMANCE_MODEL_GROUPS: Record< champdeble: ["vegetation", "crops"], champdesoja: ["vegetation", "crops"], champsdetournesol: ["vegetation", "crops"], + potager: ["vegetation", "crops"], ecole: ["buildings", "landmarks"], generateur: ["landmarks"], fermeverticale: ["buildings", "landmarks"], diff --git a/src/data/world/terrainConfig.ts b/src/data/world/terrainConfig.ts index 9cde64a..60ae90c 100644 --- a/src/data/world/terrainConfig.ts +++ b/src/data/world/terrainConfig.ts @@ -1,15 +1,9 @@ -import type { - TerrainSurfaceColorConfig, - TerrainSurfaceProjectionConfig, -} from "@/types/world/terrainSurface"; +import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface"; export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf"; export const TERRAIN_WATER_HEIGHT = 0.8; export const TERRAIN_TILE_SIZE = 1; -export const TERRAIN_SURFACE_COLOR_TOLERANCE = 5; -export const TERRAIN_SURFACE_PROJECTION = - {} satisfies TerrainSurfaceProjectionConfig; export const TERRAIN_COLORS = { grass1: { @@ -60,5 +54,3 @@ export const TERRAIN_COLORS = { kind: "rock", }, } satisfies Record; - -export type TerrainColorKey = keyof typeof TERRAIN_COLORS; diff --git a/src/data/world/vegetationConfig.ts b/src/data/world/vegetationConfig.ts index c1ab643..09a762f 100644 --- a/src/data/world/vegetationConfig.ts +++ b/src/data/world/vegetationConfig.ts @@ -5,7 +5,8 @@ export const VEGETATION_TYPES = { scaleMultiplier: 1.5, castShadow: true, receiveShadow: true, - windStrength: 0.08, + windStrength: 0.06, + rotationOffset: [0, 0, 0], enabled: true, }, sapin: { @@ -14,7 +15,8 @@ export const VEGETATION_TYPES = { scaleMultiplier: 4, castShadow: true, receiveShadow: true, - windStrength: 0.04, + windStrength: 0.12, + rotationOffset: [0, 0, 0], enabled: true, }, arbre: { @@ -23,7 +25,8 @@ export const VEGETATION_TYPES = { scaleMultiplier: 1, castShadow: true, receiveShadow: true, - windStrength: 0.06, + windStrength: 0.15, + rotationOffset: [0, 0, 0], enabled: true, }, champdeble: { @@ -32,7 +35,8 @@ export const VEGETATION_TYPES = { scaleMultiplier: 1, castShadow: true, receiveShadow: true, - windStrength: 0.18, + windStrength: 0.15, + rotationOffset: [0, 0, 0], enabled: true, }, champdesoja: { @@ -41,7 +45,8 @@ export const VEGETATION_TYPES = { scaleMultiplier: 1, castShadow: true, receiveShadow: true, - windStrength: 0.16, + windStrength: 0.15, + rotationOffset: [0, 0, 0], enabled: true, }, champsdetournesol: { @@ -50,7 +55,18 @@ export const VEGETATION_TYPES = { scaleMultiplier: 1, castShadow: true, receiveShadow: true, - windStrength: 0.14, + windStrength: 0.15, + rotationOffset: [0, 0, 0], + enabled: true, + }, + potager: { + mapName: "potager", + modelPath: "/models/potager/potager.gltf", + scaleMultiplier: 1, + castShadow: true, + receiveShadow: true, + windStrength: 0, + rotationOffset: [0, 0, 0], enabled: true, }, } as const; @@ -62,10 +78,18 @@ export const VEGETATION_TYPE_KEYS = [ "champdeble", "champdesoja", "champsdetournesol", + "potager", ] as const satisfies readonly (keyof typeof VEGETATION_TYPES)[]; export type VegetationType = (typeof VEGETATION_TYPE_KEYS)[number]; +export function getVegetationModelScaleMultiplier(name: string): number { + return ( + Object.values(VEGETATION_TYPES).find((config) => config.mapName === name) + ?.scaleMultiplier ?? 1 + ); +} + export const INSTANCED_MAP_EXCEPTIONS = new Set([ "Scene", "blocking", diff --git a/src/hooks/world/useVegetationData.ts b/src/hooks/world/useVegetationData.ts index fea6cb7..a3139f8 100644 --- a/src/hooks/world/useVegetationData.ts +++ b/src/hooks/world/useVegetationData.ts @@ -1,14 +1,14 @@ import { useEffect, useState } from "react"; import { INSTANCED_MAP_EXCEPTIONS } from "@/data/world/vegetationConfig"; -import type { MapNode } from "@/types/map/mapScene"; -import { - type MapNodeInstanceTransform, - mapNodeToInstanceTransform, -} from "@/utils/map/mapInstanceTransform"; +import type { MapNode, VegetationInstance } from "@/types/map/mapScene"; +import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform"; import { logger } from "@/utils/core/Logger"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; - -export type VegetationInstance = MapNodeInstanceTransform; +import { + createPotagerMapNode, + isPotagerSourceMapNode, + POTAGER_MAP_NAME, +} from "@/utils/map/potagerMapNodes"; interface InstancedMapEntry { modelPath: string; @@ -17,12 +17,35 @@ interface InstancedMapEntry { export type VegetationData = Map; +function createPositionKey(node: MapNode): string { + return node.position.map((value) => value.toFixed(3)).join(":"); +} + function extractVegetationData( mapNodes: MapNode[], models: Map, ): VegetationData { const data: VegetationData = new Map(); + function addInstance( + mapName: string, + modelPath: string, + node: MapNode, + ): void { + const entry = data.get(mapName); + const instance = mapNodeToInstanceTransform(node); + + if (entry) { + entry.instances.push(instance); + return; + } + + data.set(mapName, { + modelPath, + instances: [instance], + }); + } + for (const node of mapNodes) { if (node.type !== "Object3D") continue; if (INSTANCED_MAP_EXCEPTIONS.has(node.name)) continue; @@ -30,16 +53,36 @@ function extractVegetationData( const modelPath = models.get(node.name); if (!modelPath) continue; - const entry = data.get(node.name); + addInstance(node.name, modelPath, node); + } - if (entry) { - entry.instances.push(mapNodeToInstanceTransform(node)); - } else { - data.set(node.name, { - modelPath, - instances: [mapNodeToInstanceTransform(node)], - }); + const existingPotagerPositionKeys = new Set( + mapNodes + .filter((node) => node.name === POTAGER_MAP_NAME) + .map(createPositionKey), + ); + + for (const node of mapNodes) { + if (!isPotagerSourceMapNode(node)) continue; + if (existingPotagerPositionKeys.has(createPositionKey(node))) continue; + + addInstance( + POTAGER_MAP_NAME, + "/models/potager/potager.gltf", + createPotagerMapNode(node), + ); + } + + const potagerEntry = data.get(POTAGER_MAP_NAME); + if (potagerEntry) { + const uniqueInstances = new Map(); + for (const instance of potagerEntry.instances) { + uniqueInstances.set( + instance.position.map((value) => value.toFixed(3)).join(":"), + instance, + ); } + potagerEntry.instances = [...uniqueInstances.values()]; } return data; @@ -64,6 +107,11 @@ export function useVegetationData(): { logger.error("Vegetation", "Failed to load vegetation data", { error: error instanceof Error ? error : String(error), }); + if (!cancelled) { + setData(null); + setIsLoading(false); + } + return; } if (!cancelled) { diff --git a/src/pages/docs/map-performance/page.tsx b/src/pages/docs/map-performance/page.tsx index d1a7f2d..db8f1b7 100644 --- a/src/pages/docs/map-performance/page.tsx +++ b/src/pages/docs/map-performance/page.tsx @@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument"; export function DocsMapPerformancePage(): React.JSX.Element { return ( - + ); } diff --git a/src/types/map/mapScene.ts b/src/types/map/mapScene.ts index f444f70..28dc438 100644 --- a/src/types/map/mapScene.ts +++ b/src/types/map/mapScene.ts @@ -9,6 +9,15 @@ export interface MapNode { sourcePath?: number[]; } +export interface MapNodeInstanceTransform { + position: Vector3Tuple; + rotation: Vector3Tuple; + scale: Vector3Tuple; +} + +export type MapAssetInstance = MapNodeInstanceTransform; +export type VegetationInstance = MapNodeInstanceTransform; + export interface HierarchicalMapNode extends MapNode { role?: "group"; children?: HierarchicalMapNode[]; diff --git a/src/utils/map/loadMapSceneData.ts b/src/utils/map/loadMapSceneData.ts index 9c04229..77b5cd4 100644 --- a/src/utils/map/loadMapSceneData.ts +++ b/src/utils/map/loadMapSceneData.ts @@ -3,7 +3,13 @@ import type { MapNode, SceneData, } from "@/types/map/mapScene"; +import { logger } from "@/utils/core/Logger"; import { parseMapData } from "@/utils/map/mapNodeValidation"; +import { + createPotagerMapNode, + isPotagerSourceMapNode, + POTAGER_MAP_NAME, +} from "@/utils/map/potagerMapNodes"; const MAP_JSON_PATH = "/map.json"; const MODEL_FILE_NAMES = ["model.glb", "model.gltf"]; @@ -59,9 +65,101 @@ async function loadMapSceneDataInternal(): Promise { export async function createSceneDataFromMapPayload( mapPayload: unknown, ): Promise { - const { mapNodes, mapTree } = parseMapData(mapPayload); + const { mapTree } = parseMapData(mapPayload); + const mapTreeWithPotagers = ensurePotagerMapTree(mapTree); + const mapNodes = flattenMapTree(mapTreeWithPotagers); const deduplicatedNodes = deduplicateMapNodes(mapNodes); - return createSceneData(deduplicatedNodes, mapTree); + return createSceneData(deduplicatedNodes, mapTreeWithPotagers); +} + +function isSamePosition(a: MapNode, b: MapNode): boolean { + return a.position.every((value, index) => { + const otherValue = b.position[index]; + return otherValue !== undefined && Math.abs(value - otherValue) < 0.0001; + }); +} + +function cloneMapTree( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], +): HierarchicalMapNode | HierarchicalMapNode[] { + return JSON.parse(JSON.stringify(mapTree)) as + | HierarchicalMapNode + | HierarchicalMapNode[]; +} + +function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] { + const childNodes = + node.children?.flatMap((child, index) => + flattenMapNode(child, [...path, index]), + ) ?? []; + + if (node.role === "group" || node.type === "Mesh") { + return childNodes; + } + + return [ + { + name: node.name, + type: node.type, + position: node.position, + rotation: node.rotation, + scale: node.scale, + sourcePath: path, + }, + ...childNodes, + ]; +} + +function flattenMapTree( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], +): MapNode[] { + return Array.isArray(mapTree) + ? mapTree.flatMap((node, index) => flattenMapNode(node, [index])) + : flattenMapNode(mapTree, []); +} + +function collectExplicitPotagerNodes( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], +): MapNode[] { + return flattenMapTree(mapTree).filter( + (node) => node.name === POTAGER_MAP_NAME, + ); +} + +function ensurePotagerMapTree( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], +): HierarchicalMapNode | HierarchicalMapNode[] { + const nextTree = cloneMapTree(mapTree); + const explicitPotagers = collectExplicitPotagerNodes(nextTree); + + function visit(node: HierarchicalMapNode): void { + if (!node.children) return; + + const nextChildren: HierarchicalMapNode[] = []; + node.children.forEach((child) => { + nextChildren.push(child); + visit(child); + + if (!isPotagerSourceMapNode(child)) return; + + const hasMatchingPotager = explicitPotagers.some((potager) => + isSamePosition(potager, child), + ); + if (hasMatchingPotager) return; + + nextChildren.push(createPotagerMapNode(child)); + }); + + node.children = nextChildren; + } + + if (Array.isArray(nextTree)) { + nextTree.forEach((node) => visit(node)); + } else { + visit(nextTree); + } + + return nextTree; } function createPositionKey(node: MapNode): string { @@ -124,7 +222,7 @@ async function loadMapModelUrls( } async function loadModelEntry(modelName: string): Promise { - for (const fileName of MODEL_FILE_NAMES) { + for (const fileName of [...MODEL_FILE_NAMES, `${modelName}.gltf`]) { const modelUrl = `/models/${modelName}/${fileName}`; try { @@ -133,7 +231,12 @@ async function loadModelEntry(modelName: string): Promise { if (response.ok && !contentType.includes(HTML_CONTENT_TYPE)) { return [modelName, modelUrl]; } - } catch { + } catch (error) { + logger.warn("MapSceneData", "Failed to probe map model URL", { + modelName, + modelUrl, + error: error instanceof Error ? error : String(error), + }); continue; } } diff --git a/src/utils/map/mapInstanceTransform.ts b/src/utils/map/mapInstanceTransform.ts index 5736bc6..0cf39cb 100644 --- a/src/utils/map/mapInstanceTransform.ts +++ b/src/utils/map/mapInstanceTransform.ts @@ -1,11 +1,4 @@ -import type { MapNode } from "@/types/map/mapScene"; -import type { Vector3Tuple } from "@/types/three/three"; - -export interface MapNodeInstanceTransform { - position: Vector3Tuple; - rotation: Vector3Tuple; - scale: Vector3Tuple; -} +import type { MapNode, MapNodeInstanceTransform } from "@/types/map/mapScene"; export function mapNodeToInstanceTransform( node: MapNode, diff --git a/src/utils/map/mapRuntimeClassification.ts b/src/utils/map/mapRuntimeClassification.ts index 6cc028f..8f02234 100644 --- a/src/utils/map/mapRuntimeClassification.ts +++ b/src/utils/map/mapRuntimeClassification.ts @@ -8,6 +8,7 @@ const RUNTIME_VEGETATION_NODE_NAMES = new Set([ "champdeble", "champdesoja", "champsdetournesol", + "potager", "sapin", ]); diff --git a/src/utils/map/potagerMapNodes.ts b/src/utils/map/potagerMapNodes.ts new file mode 100644 index 0000000..252c6ce --- /dev/null +++ b/src/utils/map/potagerMapNodes.ts @@ -0,0 +1,35 @@ +import type { MapNode } from "@/types/map/mapScene"; + +export const POTAGER_MAP_NAME = "potager"; +export const POTAGER_DEFAULT_ROTATION_OFFSET = [0, 0, 0] as const; + +export const POTAGER_SOURCE_MAP_NAMES = new Set([ + "champdeble", + "champdesoja", + "champsdetournesol", +]); + +export function isPotagerSourceMapNode(node: MapNode): boolean { + const role = "role" in node ? node.role : undefined; + + return ( + node.type === "Object3D" && + role !== "group" && + POTAGER_SOURCE_MAP_NAMES.has(node.name) && + !node.position.every((value) => Math.abs(value) < 0.0001) + ); +} + +export function createPotagerMapNode(sourceNode: MapNode): MapNode { + return { + name: POTAGER_MAP_NAME, + type: sourceNode.type, + position: sourceNode.position, + rotation: [ + sourceNode.rotation[0] + POTAGER_DEFAULT_ROTATION_OFFSET[0], + sourceNode.rotation[1] + POTAGER_DEFAULT_ROTATION_OFFSET[1], + sourceNode.rotation[2] + POTAGER_DEFAULT_ROTATION_OFFSET[2], + ], + scale: sourceNode.scale, + }; +} diff --git a/src/world/vegetation/InstancedVegetation.tsx b/src/world/vegetation/InstancedVegetation.tsx index a4fff48..4c93999 100644 --- a/src/world/vegetation/InstancedVegetation.tsx +++ b/src/world/vegetation/InstancedVegetation.tsx @@ -4,7 +4,7 @@ import { useGLTF } from "@react-three/drei"; import { useFrame, useThree } from "@react-three/fiber"; import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js"; import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight"; -import type { VegetationInstance } from "@/hooks/world/useVegetationData"; +import type { VegetationInstance } from "@/types/map/mapScene"; import { useWind } from "@/hooks/world/useWind"; import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene"; @@ -15,6 +15,7 @@ interface InstancedVegetationProps { castShadow: boolean; receiveShadow: boolean; windStrength: number; + rotationOffset: readonly [number, number, number]; } interface MeshData { @@ -186,6 +187,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] { function createInstanceMatrices( instances: VegetationInstance[], scaleMultiplier: number, + rotationOffset: readonly [number, number, number], geometryBottomY: number, ): THREE.Matrix4[] { const matrices: THREE.Matrix4[] = []; @@ -203,7 +205,11 @@ function createInstanceMatrices( position.set(...instance.position); position.y += -geometryBottomY * scaleMultiplier; - rotation.set(...instance.rotation); + rotation.set( + instance.rotation[0] + rotationOffset[0], + instance.rotation[1] + rotationOffset[1], + instance.rotation[2] + rotationOffset[2], + ); quaternion.setFromEuler(rotation); matrix.compose(position, quaternion, scale); matrices.push(matrix); @@ -233,6 +239,7 @@ export function InstancedVegetation({ castShadow, receiveShadow, windStrength, + rotationOffset, }: InstancedVegetationProps): React.JSX.Element | null { const { scene } = useGLTF(modelPath); const wind = useWind(); @@ -269,9 +276,10 @@ export function InstancedVegetation({ createInstanceMatrices( groundedInstances, scaleMultiplier, + rotationOffset, getMeshBottomY(meshDataList), ), - [groundedInstances, meshDataList, scaleMultiplier], + [groundedInstances, meshDataList, rotationOffset, scaleMultiplier], ); const instancedMeshes = useMemo(() => { diff --git a/src/world/vegetation/VegetationSystem.tsx b/src/world/vegetation/VegetationSystem.tsx index 5dab55b..ad07688 100644 --- a/src/world/vegetation/VegetationSystem.tsx +++ b/src/world/vegetation/VegetationSystem.tsx @@ -8,16 +8,19 @@ import { useMapPerformanceStore, } from "@/managers/stores/useMapPerformanceStore"; import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation"; -import { - type VegetationInstance, - useVegetationData, -} from "@/hooks/world/useVegetationData"; +import { useVegetationData } from "@/hooks/world/useVegetationData"; +import type { VegetationInstance } from "@/types/map/mapScene"; import { VEGETATION_TYPE_KEYS, VEGETATION_TYPES, type VegetationType, } from "@/data/world/vegetationConfig"; +interface VegetationSystemProps { + onlyModelName?: string | null; + streaming?: boolean; +} + interface VegetationChunk { key: string; type: VegetationType; @@ -26,6 +29,7 @@ interface VegetationChunk { castShadow: boolean; receiveShadow: boolean; windStrength: number; + rotationOffset: readonly [number, number, number]; centerX: number; centerZ: number; instances: VegetationInstance[]; @@ -73,6 +77,7 @@ function createVegetationChunks( castShadow: config.castShadow, receiveShadow: config.receiveShadow, windStrength: config.windStrength, + rotationOffset: config.rotationOffset, centerX: center.x / chunkInstances.length, centerZ: center.z / chunkInstances.length, instances: chunkInstances, @@ -80,14 +85,20 @@ function createVegetationChunks( }); } -export function VegetationSystem(): React.JSX.Element | null { +export function VegetationSystem({ + onlyModelName = null, + streaming = true, +}: VegetationSystemProps): React.JSX.Element | null { const cameraMode = useCameraMode(); const sceneMode = useSceneMode(); const groups = useMapPerformanceStore((state) => state.groups); const models = useMapPerformanceStore((state) => state.models); const { data, isLoading } = useVegetationData(); const streamingEnabled = - CHUNK_CONFIG.enabled && sceneMode === "game" && cameraMode === "player"; + streaming && + CHUNK_CONFIG.enabled && + sceneMode === "game" && + cameraMode === "player"; const chunks = useMemo(() => { if (!data) return []; @@ -95,6 +106,8 @@ export function VegetationSystem(): React.JSX.Element | null { return VEGETATION_TYPE_KEYS.flatMap((type) => { const config = VEGETATION_TYPES[type]; + if (onlyModelName && config.mapName !== onlyModelName) return []; + if (!config.enabled) return []; if (!isMapModelVisible(config.mapName, { groups, models })) return []; @@ -103,7 +116,7 @@ export function VegetationSystem(): React.JSX.Element | null { return createVegetationChunks(type, entry.instances); }); - }, [data, groups, models]); + }, [data, groups, models, onlyModelName]); const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled); @@ -122,6 +135,7 @@ export function VegetationSystem(): React.JSX.Element | null { castShadow={chunk.castShadow} receiveShadow={chunk.receiveShadow} windStrength={chunk.windStrength} + rotationOffset={chunk.rotationOffset} /> ))}