10 Commits

Author SHA1 Message Date
Tom Boullay 439f9c1dad feat: add VegetationSystem with InstancedMesh rendering
🔍 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
2026-05-14 00:16:00 +02:00
Tom Boullay 260bfea716 fix: add disposal on unmount in SkyModel and SimpleModel 2026-05-14 00:15:52 +02:00
Tom Boullay b3a3f3557c fix: add disposal on unmount in TerrainModel 2026-05-14 00:15:41 +02:00
Tom Boullay 621556b38c fix: add disposal on unmount in useClonedObject hook 2026-05-14 00:15:32 +02:00
Tom Boullay 5c55f2c7f4 feat: add disposeObject3D utility for GPU memory cleanup 2026-05-14 00:15:23 +02:00
Tom Boullay bb4bccb175 Update weekly-branch-promotions.yml 2026-05-13 15:26:36 +02:00
Tom Boullay ae835e5008 update: models 2026-05-13 15:00:53 +02:00
Tom Boullay 1d3aa1c9f2 add: world config (wind, graphics, terrain, fog) 2026-05-13 15:00:13 +02:00
Tom Boullay 23cab2da5e update: CI branch promotions schedule 2026-05-13 15:00:05 +02:00
Tom Boullay 44216f9395 update: models 2026-05-13 14:59:57 +02:00
84 changed files with 1038 additions and 44 deletions
+71 -29
View File
@@ -1,59 +1,58 @@
name: 🔁 Weekly Branch Promotions
name: 🔁 Branch Promotions
on:
schedule:
- cron: "0 6 * * 1"
- cron: "0 6 * * 1,4" # Lundi et Jeudi à 6h UTC (design → develop)
- cron: "0 6 * * 1" # Lundi à 6h UTC (develop → main)
workflow_dispatch:
inputs:
promotion:
description: "Which promotion to run"
required: true
type: choice
options:
- design-to-develop
- develop-to-main
- both
permissions:
contents: read
pull-requests: write
concurrency:
group: weekly-branch-promotions
group: branch-promotions
cancel-in-progress: false
jobs:
open-promotion-pr:
name: Open ${{ matrix.head }} → ${{ matrix.base }}
design-to-develop:
name: Open design → develop
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- head: develop
base: design
title: "chore: merge develop into design"
- head: design
base: main
title: "chore: merge design into main"
if: |
(github.event_name == 'schedule') ||
(github.event_name == 'workflow_dispatch' && (github.event.inputs.promotion == 'design-to-develop' || github.event.inputs.promotion == 'both'))
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🔁 Open promotion PR
env:
GH_TOKEN: ${{ github.token }}
BASE_BRANCH: ${{ matrix.base }}
HEAD_BRANCH: ${{ matrix.head }}
PR_TITLE: ${{ matrix.title }}
run: |
set -euo pipefail
git fetch origin "$BASE_BRANCH" "$HEAD_BRANCH"
git fetch origin develop design
if git merge-base --is-ancestor "origin/$HEAD_BRANCH" "origin/$BASE_BRANCH"; then
echo "No promotion needed: $BASE_BRANCH already contains $HEAD_BRANCH."
if git merge-base --is-ancestor origin/design origin/develop; then
echo "No promotion needed: develop already contains design."
exit 0
fi
existing_pr="$(gh pr list \
--state open \
--base "$BASE_BRANCH" \
--head "$GITHUB_REPOSITORY_OWNER:$HEAD_BRANCH" \
--base develop \
--head "$GITHUB_REPOSITORY_OWNER:design" \
--json number \
--jq '.[0].number // empty')"
@@ -63,7 +62,50 @@ jobs:
fi
gh pr create \
--base "$BASE_BRANCH" \
--head "$HEAD_BRANCH" \
--title "$PR_TITLE" \
--body "Automated weekly promotion PR from \`$HEAD_BRANCH\` to \`$BASE_BRANCH\`."
--base develop \
--head design \
--title "chore: merge design into develop" \
--body "Automated promotion PR from \`design\` to \`develop\`."
develop-to-main:
name: Open develop → main
runs-on: ubuntu-latest
if: |
(github.event_name == 'schedule' && github.event.schedule == '0 6 * * 1') ||
(github.event_name == 'workflow_dispatch' && (github.event.inputs.promotion == 'develop-to-main' || github.event.inputs.promotion == 'both'))
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🔁 Open promotion PR
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git fetch origin main develop
if git merge-base --is-ancestor origin/develop origin/main; then
echo "No promotion needed: main already contains develop."
exit 0
fi
existing_pr="$(gh pr list \
--state open \
--base main \
--head "$GITHUB_REPOSITORY_OWNER:develop" \
--json number \
--jq '.[0].number // empty')"
if [ -n "$existing_pr" ]; then
echo "Promotion PR already open: #$existing_pr."
exit 0
fi
gh pr create \
--base main \
--head develop \
--title "chore: merge develop into main" \
--body "Automated weekly promotion PR from \`develop\` to \`main\`."
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.
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.
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.
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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -1
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { disposeObject3D } from "@/utils/three/dispose";
export interface SimpleModelConfig extends ModelTransformProps {
modelPath: string;
@@ -29,6 +30,12 @@ export function SimpleModel({
});
const model = useMemo(() => scene.clone(true), [scene]);
useEffect(() => {
return () => {
disposeObject3D(model);
};
}, [model]);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
+9 -2
View File
@@ -1,8 +1,9 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Component, useMemo, useRef, type ReactNode } from "react";
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;
@@ -80,6 +81,12 @@ function SkyModelContent({
});
const model = useMemo(() => createSkyModel(scene), [scene]);
useEffect(() => {
return () => {
disposeObject3D(model);
};
}, [model]);
useFrame(() => {
groupRef.current?.position.copy(camera.position);
});
@@ -122,5 +129,5 @@ function createSkyMaterial<T extends THREE.Material>(material: T): T {
return skyMaterial as T;
}
useGLTF.preload("/models/skybox/skybox.gltf");
useGLTF.preload("/models/skybox/model.gltf");
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
@@ -0,0 +1,73 @@
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];
interface TerrainModelProps {
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple;
receiveShadow?: boolean;
visible?: boolean;
groupRef?: React.RefObject<THREE.Group>;
onLoaded?: () => void;
}
function applyTerrainMaterialSettings(
scene: THREE.Object3D,
receiveShadow: boolean,
): void {
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.receiveShadow = receiveShadow;
}
});
}
export function TerrainModel({
position = TERRAIN_DEFAULT_POSITION,
rotation = [0, 0, 0],
scale = [1, 1, 1],
receiveShadow = true,
visible = true,
groupRef,
onLoaded,
}: TerrainModelProps): React.JSX.Element {
const internalRef = useRef<THREE.Group>(null);
const ref = groupRef ?? internalRef;
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
const terrainModel = useMemo(() => {
const model = scene.clone(true);
applyTerrainMaterialSettings(model, receiveShadow);
return model;
}, [scene, receiveShadow]);
useEffect(() => {
return () => {
disposeObject3D(terrainModel);
};
}, [terrainModel]);
useEffect(() => {
onLoaded?.();
}, [onLoaded]);
return (
<group
ref={ref}
position={position}
rotation={rotation}
scale={scale}
visible={visible}
>
<primitive object={terrainModel} />
</group>
);
}
useGLTF.preload(TERRAIN_MODEL_PATH);
+1 -1
View File
@@ -1,5 +1,5 @@
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/skybox.gltf";
export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
export const GAME_SCENE_SKY_MODEL_SCALE = 300;
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
+18
View File
@@ -0,0 +1,18 @@
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
export const FOG_CONFIG = {
enabled: true,
color: "#c8dbbe",
near: 50,
far: 70,
};
export const CHUNK_CONFIG = {
enabled: true,
chunkSize: 40,
loadRadius: 70,
unloadRadius: 80,
updateInterval: 500,
};
export const GROUND_PLANE_COLOR = TERRAIN_COLORS.grass1.hex;
+13
View File
@@ -0,0 +1,13 @@
export const GRAPHICS_DEFAULTS = {
dynamicGrass: true,
dynamicTrees: true,
dynamicClouds: true,
shadowsEnabled: true,
grassDensity: 1.0,
};
export const GRAPHICS_BOUNDS = {
grassDensity: { min: 0.1, max: 2.0, step: 0.1 },
};
export type GraphicsState = typeof GRAPHICS_DEFAULTS;
+108
View File
@@ -0,0 +1,108 @@
export const TERRAIN_COLORS = {
grass1: {
hex: "#84C66B",
rgb: [132, 198, 107] as const,
type: "grass" as const,
grassTipColor: "#84C66B",
},
grass2: {
hex: "#67B058",
rgb: [103, 176, 88] as const,
type: "grass" as const,
grassTipColor: "#67B058",
},
grass3: {
hex: "#A3CA5B",
rgb: [163, 202, 91] as const,
type: "grass" as const,
grassTipColor: "#A3CA5B",
},
potager: {
hex: "#342420",
rgb: [52, 36, 32] as const,
type: "tile" as const,
tileModel: "/models/potager/potager.gltf",
tileSize: 1,
},
terre: {
hex: "#513E2C",
rgb: [81, 62, 44] as const,
type: "none" as const,
},
chemin: {
hex: "#F5D896",
rgb: [245, 216, 150] as const,
type: "tile" as const,
tileModel: "/models/chemins/model.gltf",
tileSize: 1,
},
eau: {
hex: "#91DAF5",
rgb: [145, 218, 245] as const,
type: "water" as const,
},
cailloux: {
hex: "#B6D3DE",
rgb: [182, 211, 222] as const,
type: "none" as const,
},
} as const;
export type TerrainColorKey = keyof typeof TERRAIN_COLORS;
export type TerrainType = "grass" | "tile" | "water" | "none";
export const GRASS_BASE_COLOR = "#1a3a1a";
export const COLOR_TOLERANCE = 15;
export function colorMatchesTerrain(
r: number,
g: number,
b: number,
targetRgb: readonly [number, number, number],
tolerance: number = COLOR_TOLERANCE,
): boolean {
return (
Math.abs(r - targetRgb[0]) <= tolerance &&
Math.abs(g - targetRgb[1]) <= tolerance &&
Math.abs(b - targetRgb[2]) <= tolerance
);
}
export function getTerrainTypeFromColor(
r: number,
g: number,
b: number,
): TerrainColorKey | null {
for (const [key, config] of Object.entries(TERRAIN_COLORS)) {
if (colorMatchesTerrain(r, g, b, config.rgb)) {
return key as TerrainColorKey;
}
}
return null;
}
export function isGrassZone(r: number, g: number, b: number): boolean {
return (
colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass1.rgb) ||
colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass2.rgb) ||
colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass3.rgb)
);
}
export function getGrassTipColor(
r: number,
g: number,
b: number,
): string | null {
if (colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass1.rgb)) {
return TERRAIN_COLORS.grass1.grassTipColor;
}
if (colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass2.rgb)) {
return TERRAIN_COLORS.grass2.grassTipColor;
}
if (colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass3.rgb)) {
return TERRAIN_COLORS.grass3.grassTipColor;
}
return null;
}
+15
View File
@@ -0,0 +1,15 @@
export const WIND_DEFAULTS = {
speed: 0.3,
direction: Math.PI * 0.25,
strength: 1.0,
noiseScale: 0.9,
};
export const WIND_BOUNDS = {
speed: { min: 0, max: 2, step: 0.1 },
direction: { min: -Math.PI, max: Math.PI, step: 0.1 },
strength: { min: 0, max: 3, step: 0.1 },
noiseScale: { min: 0.1, max: 5, step: 0.1 },
};
export type WindState = typeof WIND_DEFAULTS;
+12 -3
View File
@@ -1,6 +1,15 @@
import { useMemo } from "react";
import type * as THREE from "three";
import { useEffect, useMemo } from "react";
import * as THREE from "three";
import { disposeObject3D } from "@/utils/three/dispose";
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
return useMemo(() => object.clone(true) as T, [object]);
const clone = useMemo(() => object.clone(true) as T, [object]);
useEffect(() => {
return () => {
disposeObject3D(clone);
};
}, [clone]);
return clone;
}
+58
View File
@@ -0,0 +1,58 @@
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import type { GraphicsState } from "@/data/world/graphicsConfig";
export function useGraphicsSettings(): GraphicsState {
return useWorldSettingsStore((state) => state.graphics);
}
export function useSetGraphicsSettings(): (
graphics: Partial<GraphicsState>
) => void {
return useWorldSettingsStore((state) => state.setGraphics);
}
export function useDynamicGrass(): boolean {
return useWorldSettingsStore((state) => state.graphics.dynamicGrass);
}
export function useDynamicTrees(): boolean {
return useWorldSettingsStore((state) => state.graphics.dynamicTrees);
}
export function useDynamicClouds(): boolean {
return useWorldSettingsStore((state) => state.graphics.dynamicClouds);
}
export function useShadowsEnabled(): boolean {
return useWorldSettingsStore((state) => state.graphics.shadowsEnabled);
}
export function useGrassDensity(): number {
return useWorldSettingsStore((state) => state.graphics.grassDensity);
}
export function useGraphicsSetters() {
const setDynamicGrass = useWorldSettingsStore(
(state) => state.setDynamicGrass
);
const setDynamicTrees = useWorldSettingsStore(
(state) => state.setDynamicTrees
);
const setDynamicClouds = useWorldSettingsStore(
(state) => state.setDynamicClouds
);
const setShadowsEnabled = useWorldSettingsStore(
(state) => state.setShadowsEnabled
);
const setGrassDensity = useWorldSettingsStore(
(state) => state.setGrassDensity
);
return {
setDynamicGrass,
setDynamicTrees,
setDynamicClouds,
setShadowsEnabled,
setGrassDensity,
};
}
+22
View File
@@ -0,0 +1,22 @@
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import type { WindState } from "@/data/world/windConfig";
export function useWind(): WindState {
return useWorldSettingsStore((state) => state.wind);
}
export function useSetWind(): (wind: Partial<WindState>) => void {
return useWorldSettingsStore((state) => state.setWind);
}
export function useWindSpeed(): number {
return useWorldSettingsStore((state) => state.wind.speed);
}
export function useWindDirection(): number {
return useWorldSettingsStore((state) => state.wind.direction);
}
export function useWindStrength(): number {
return useWorldSettingsStore((state) => state.wind.strength);
}
@@ -0,0 +1,88 @@
import { create } from "zustand";
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
import {
GRAPHICS_DEFAULTS,
type GraphicsState,
} from "@/data/world/graphicsConfig";
interface WorldSettingsState {
wind: WindState;
graphics: GraphicsState;
}
interface WorldSettingsActions {
setWind: (wind: Partial<WindState>) => void;
setWindSpeed: (speed: number) => void;
setWindDirection: (direction: number) => void;
setWindStrength: (strength: number) => void;
setGraphics: (graphics: Partial<GraphicsState>) => void;
setDynamicGrass: (enabled: boolean) => void;
setDynamicTrees: (enabled: boolean) => void;
setDynamicClouds: (enabled: boolean) => void;
setShadowsEnabled: (enabled: boolean) => void;
setGrassDensity: (density: number) => void;
resetToDefaults: () => void;
}
type WorldSettingsStore = WorldSettingsState & WorldSettingsActions;
const DEFAULT_STATE: WorldSettingsState = {
wind: { ...WIND_DEFAULTS },
graphics: { ...GRAPHICS_DEFAULTS },
};
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
...DEFAULT_STATE,
setWind: (windUpdate) =>
set((state) => ({
wind: { ...state.wind, ...windUpdate },
})),
setWindSpeed: (speed) =>
set((state) => ({
wind: { ...state.wind, speed },
})),
setWindDirection: (direction) =>
set((state) => ({
wind: { ...state.wind, direction },
})),
setWindStrength: (strength) =>
set((state) => ({
wind: { ...state.wind, strength },
})),
setGraphics: (graphicsUpdate) =>
set((state) => ({
graphics: { ...state.graphics, ...graphicsUpdate },
})),
setDynamicGrass: (dynamicGrass) =>
set((state) => ({
graphics: { ...state.graphics, dynamicGrass },
})),
setDynamicTrees: (dynamicTrees) =>
set((state) => ({
graphics: { ...state.graphics, dynamicTrees },
})),
setDynamicClouds: (dynamicClouds) =>
set((state) => ({
graphics: { ...state.graphics, dynamicClouds },
})),
setShadowsEnabled: (shadowsEnabled) =>
set((state) => ({
graphics: { ...state.graphics, shadowsEnabled },
})),
setGrassDensity: (grassDensity) =>
set((state) => ({
graphics: { ...state.graphics, grassDensity },
})),
resetToDefaults: () => set(DEFAULT_STATE),
}));
+42
View File
@@ -0,0 +1,42 @@
import * as THREE from "three";
export function disposeObject3D(object: THREE.Object3D): void {
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry?.dispose();
if (Array.isArray(child.material)) {
for (const material of child.material) {
disposeMaterial(material);
}
} else if (child.material) {
disposeMaterial(child.material);
}
}
});
}
function disposeMaterial(material: THREE.Material): void {
material.dispose();
for (const key of Object.keys(material)) {
const value = (material as Record<string, unknown>)[key];
if (value instanceof THREE.Texture) {
value.dispose();
}
}
}
export function disposeInstancedMesh(mesh: THREE.InstancedMesh): void {
mesh.geometry?.dispose();
if (Array.isArray(mesh.material)) {
for (const material of mesh.material) {
disposeMaterial(material);
}
} else if (mesh.material) {
disposeMaterial(mesh.material);
}
mesh.dispose();
}
@@ -0,0 +1,134 @@
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import type { VegetationInstance } from "@/world/vegetation/useVegetationData";
import { disposeInstancedMesh } from "@/utils/three/dispose";
interface InstancedVegetationProps {
modelPath: string;
instances: VegetationInstance[];
castShadow: boolean;
receiveShadow: boolean;
}
interface MeshData {
geometry: THREE.BufferGeometry;
material: THREE.Material | THREE.Material[];
}
function extractMeshes(scene: THREE.Group): MeshData[] {
const meshes: MeshData[] = [];
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.push({
geometry: child.geometry.clone(),
material: Array.isArray(child.material)
? child.material.map((m) => m.clone())
: child.material.clone(),
});
}
});
return meshes;
}
function createInstanceMatrices(
instances: VegetationInstance[],
): THREE.Matrix4[] {
const matrices: THREE.Matrix4[] = [];
const position = new THREE.Vector3();
const rotation = new THREE.Euler();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
for (const instance of instances) {
const matrix = new THREE.Matrix4();
position.set(...instance.position);
rotation.set(...instance.rotation);
quaternion.setFromEuler(rotation);
scale.set(...instance.scale);
matrix.compose(position, quaternion, scale);
matrices.push(matrix);
}
return matrices;
}
export function InstancedVegetation({
modelPath,
instances,
castShadow,
receiveShadow,
}: InstancedVegetationProps): React.JSX.Element | null {
const { scene } = useGLTF(modelPath);
const groupRef = useRef<THREE.Group>(null);
const meshDataList = useMemo(() => extractMeshes(scene), [scene]);
const matrices = useMemo(
() => createInstanceMatrices(instances),
[instances],
);
const instancedMeshes = useMemo(() => {
return meshDataList.map((meshData, index) => {
const instancedMesh = new THREE.InstancedMesh(
meshData.geometry,
meshData.material,
instances.length,
);
for (let i = 0; i < matrices.length; i++) {
instancedMesh.setMatrixAt(i, matrices[i]);
}
instancedMesh.instanceMatrix.needsUpdate = true;
instancedMesh.castShadow = castShadow;
instancedMesh.receiveShadow = receiveShadow;
instancedMesh.name = `instanced-mesh-${index}`;
instancedMesh.frustumCulled = true;
instancedMesh.computeBoundingSphere();
return instancedMesh;
});
}, [meshDataList, matrices, instances.length, castShadow, receiveShadow]);
useEffect(() => {
const group = groupRef.current;
if (!group) return;
for (const mesh of instancedMeshes) {
group.add(mesh);
}
return () => {
for (const mesh of instancedMeshes) {
group.remove(mesh);
disposeInstancedMesh(mesh);
}
};
}, [instancedMeshes]);
useEffect(() => {
return () => {
for (const meshData of meshDataList) {
meshData.geometry.dispose();
if (Array.isArray(meshData.material)) {
for (const mat of meshData.material) {
mat.dispose();
}
} else {
meshData.material.dispose();
}
}
};
}, [meshDataList]);
if (instances.length === 0) {
return null;
}
return <group ref={groupRef} />;
}
+42
View File
@@ -0,0 +1,42 @@
import { Suspense } from "react";
import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation";
import { useVegetationData } from "@/world/vegetation/useVegetationData";
import {
VEGETATION_TYPES,
type VegetationType,
} from "@/world/vegetation/vegetationConfig";
export function VegetationSystem(): React.JSX.Element | null {
const { data, isLoading } = useVegetationData();
if (isLoading || !data) {
return null;
}
const enabledTypes = Object.entries(VEGETATION_TYPES).filter(
([, config]) => config.enabled,
);
return (
<group name="vegetation-system">
{enabledTypes.map(([type, config]) => {
const instances = data.get(type as VegetationType);
if (!instances || instances.length === 0) {
return null;
}
return (
<Suspense key={type} fallback={null}>
<InstancedVegetation
modelPath={config.modelPath}
instances={instances}
castShadow={config.castShadow}
receiveShadow={config.receiveShadow}
/>
</Suspense>
);
})}
</group>
);
}
+84
View File
@@ -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 {
VEGETATION_MAX_INSTANCES,
VEGETATION_TYPES,
type VegetationType,
} from "@/world/vegetation/vegetationConfig";
export interface VegetationInstance {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
export type VegetationData = Map<VegetationType, VegetationInstance[]>;
function mapNodeToInstance(node: MapNode): VegetationInstance {
return {
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
}
function extractVegetationData(mapNodes: MapNode[]): VegetationData {
const data: VegetationData = new Map();
for (const [type, config] of Object.entries(VEGETATION_TYPES)) {
if (!config.enabled) continue;
const instances = mapNodes
.filter((node) => node.name === config.mapName)
.slice(0, VEGETATION_MAX_INSTANCES)
.map(mapNodeToInstance);
if (instances.length > 0) {
data.set(type as VegetationType, instances);
}
}
return data;
}
export function useVegetationData(): {
data: VegetationData | null;
isLoading: boolean;
} {
const [data, setData] = useState<VegetationData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function load() {
const cachedNodes = getMapNodes();
if (cachedNodes) {
if (!cancelled) {
setData(extractVegetationData(cachedNodes));
setIsLoading(false);
}
return;
}
await loadMapSceneData();
const nodes = getMapNodes();
if (!cancelled && nodes) {
setData(extractVegetationData(nodes));
setIsLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
return { data, isLoading };
}
+67
View File
@@ -0,0 +1,67 @@
export const VEGETATION_LOD = {
windAnimationRadius: 70,
windFadeStart: 50,
windFadeEnd: 70,
};
export const VEGETATION_MAX_INSTANCES = 500;
export const VEGETATION_TYPES = {
buissons: {
mapName: "buissons",
modelPath: "/models/buisson/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: false,
windEnabled: false,
windIntensity: 1.2,
},
sapin: {
mapName: "sapin",
modelPath: "/models/sapin/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
windEnabled: false,
windIntensity: 0.6,
},
arbre: {
mapName: "arbre",
modelPath: "/models/arbre/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
windEnabled: false,
windIntensity: 0.8,
},
champdeble: {
mapName: "champdeble",
modelPath: "/models/champdeble/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: false,
windEnabled: false,
windIntensity: 1.0,
},
champdesoja: {
mapName: "champdesoja",
modelPath: "/models/champdesoja/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: false,
windEnabled: false,
windIntensity: 1.0,
},
champsdetournesol: {
mapName: "champsdetournesol",
modelPath: "/models/champsdetournesol/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: false,
windEnabled: false,
windIntensity: 0.9,
},
} as const;
export type VegetationType = keyof typeof VEGETATION_TYPES;
export type VegetationConfig = (typeof VEGETATION_TYPES)[VegetationType];