4 Commits

Author SHA1 Message Date
Tom Boullay f2cecfbbc9 fix(lighting): keep shadows enabled after scene loading
🔍 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-28 01:33:15 +02:00
Tom Boullay 1b9ac5c996 feat(environment): apply shared wind to vegetation 2026-05-28 01:28:16 +02:00
Tom Boullay e45fdbf97d fix(lighting): keep shadow map centered on player 2026-05-28 01:20:12 +02:00
Tom Boullay 08b01715c0 feat(map): stream secondary map assets by chunk 2026-05-28 01:16:29 +02:00
7 changed files with 359 additions and 34 deletions
+7
View File
@@ -56,12 +56,19 @@ export function HomePage(): React.JSX.Element {
({ gl }: { gl: THREE.WebGLRenderer }) => {
const canvas = gl.domElement;
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - GPU resources exhausted");
};
const handleContextRestored = () => {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
logger.info("WebGL", "Context restored");
};
+17 -7
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import type { AmbientLight, DirectionalLight } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import type { AmbientLight, DirectionalLight, Object3D } from "three";
import {
AMBIENT_INTENSITY_MAX,
AMBIENT_INTENSITY_MIN,
@@ -22,17 +22,22 @@ import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { LIGHTING_STATE } from "@/world/lightingState";
const SHADOW_MAP_SIZE = 2048;
const SHADOW_CAMERA_SIZE = 170;
const SHADOW_CAMERA_SIZE = 95;
const SHADOW_CAMERA_NEAR = 0.5;
const SHADOW_CAMERA_FAR = 300;
export function Lighting(): React.JSX.Element {
const camera = useThree((state) => state.camera);
const ambient = useRef<AmbientLight>(null);
const sun = useRef<DirectionalLight>(null);
const sunTarget = useRef<Object3D>(null);
useEffect(() => {
if (!sun.current) return;
if (!sun.current || !sunTarget.current) return;
sun.current.target = sunTarget.current;
sun.current.shadow.autoUpdate = true;
sun.current.shadow.needsUpdate = true;
sun.current.shadow.mapSize.width = SHADOW_MAP_SIZE;
sun.current.shadow.mapSize.height = SHADOW_MAP_SIZE;
sun.current.shadow.camera.left = -SHADOW_CAMERA_SIZE;
@@ -82,14 +87,18 @@ export function Lighting(): React.JSX.Element {
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
}
if (sun.current) {
if (sun.current && sunTarget.current) {
sunTarget.current.position.set(camera.position.x, 0, camera.position.z);
sunTarget.current.updateMatrixWorld();
sun.current.position.set(
LIGHTING_STATE.sunX,
camera.position.x + LIGHTING_STATE.sunX,
LIGHTING_STATE.sunY,
LIGHTING_STATE.sunZ,
camera.position.z + LIGHTING_STATE.sunZ,
);
sun.current.color.set(LIGHTING_STATE.sunColor);
sun.current.intensity = LIGHTING_STATE.sunIntensity;
sun.current.updateMatrixWorld();
sun.current.shadow.needsUpdate = true;
}
});
@@ -111,6 +120,7 @@ export function Lighting(): React.JSX.Element {
color={LIGHTING_STATE.sunColor}
castShadow
/>
<object3D ref={sunTarget} />
</>
);
}
+159 -25
View File
@@ -1,4 +1,8 @@
import { Suspense } from "react";
import { Suspense, useCallback, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { CHUNK_CONFIG } from "@/data/world/fogConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import {
isMapModelVisible,
useMapPerformanceStore,
@@ -6,44 +10,174 @@ import {
import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset";
import {
MAP_INSTANCING_ASSETS,
type MapInstancingAssetConfig,
type MapInstancingAssetType,
} from "@/world/map-instancing/mapInstancingConfig";
import { useMapInstancingData } from "@/world/map-instancing/useMapInstancingData";
import {
type MapAssetInstance,
useMapInstancingData,
} from "@/world/map-instancing/useMapInstancingData";
interface MapAssetChunk {
key: string;
config: MapInstancingAssetConfig;
centerX: number;
centerZ: number;
instances: MapAssetInstance[];
}
function getChunkKey(instance: MapAssetInstance): string {
const [x, , z] = instance.position;
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
return `${chunkX}:${chunkZ}`;
}
function createMapAssetChunks(
type: MapInstancingAssetType,
config: MapInstancingAssetConfig,
instances: MapAssetInstance[],
): MapAssetChunk[] {
const chunks = new Map<string, MapAssetInstance[]>();
for (const instance of instances) {
const key = getChunkKey(instance);
const chunk = chunks.get(key);
if (chunk) {
chunk.push(instance);
} else {
chunks.set(key, [instance]);
}
}
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
const center = chunkInstances.reduce(
(sum, instance) => {
sum.x += instance.position[0];
sum.z += instance.position[2];
return sum;
},
{ x: 0, z: 0 },
);
return {
key: `${type}:${chunkKey}`,
config,
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
instances: chunkInstances,
};
});
}
export function MapInstancingSystem(): React.JSX.Element | null {
const camera = useThree((state) => state.camera);
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useMapInstancingData();
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
() => new Set(),
);
const streamingEnabled =
CHUNK_CONFIG.enabled && sceneMode === "game" && cameraMode === "player";
const chunks = useMemo(() => {
if (!data) return [];
return Object.entries(MAP_INSTANCING_ASSETS).flatMap(([type, config]) => {
if (
!config.enabled ||
!isMapModelVisible(config.mapName, { groups, models })
) {
return [];
}
const instances = data.get(type as MapInstancingAssetType);
if (!instances || instances.length === 0) return [];
return createMapAssetChunks(
type as MapInstancingAssetType,
config,
instances,
);
});
}, [data, groups, models]);
const visibleChunks = streamingEnabled
? chunks.filter((chunk) => {
if (activeChunkKeys.size > 0) {
return activeChunkKeys.has(chunk.key);
}
return (
Math.hypot(
chunk.centerX - camera.position.x,
chunk.centerZ - camera.position.z,
) <= CHUNK_CONFIG.loadRadius
);
})
: chunks;
const updateActiveChunks = useCallback(() => {
const nextKeys = new Set<string>();
const cameraX = camera.position.x;
const cameraZ = camera.position.z;
for (const chunk of chunks) {
const distance = Math.hypot(
chunk.centerX - cameraX,
chunk.centerZ - cameraZ,
);
const wasActive = activeChunkKeys.has(chunk.key);
const radius = wasActive
? CHUNK_CONFIG.unloadRadius
: CHUNK_CONFIG.loadRadius;
if (distance <= radius) {
nextKeys.add(chunk.key);
}
}
if (
nextKeys.size === activeChunkKeys.size &&
[...nextKeys].every((key) => activeChunkKeys.has(key))
) {
return;
}
setActiveChunkKeys(nextKeys);
}, [activeChunkKeys, camera, chunks]);
useFrame(({ clock }) => {
if (!streamingEnabled) return;
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
updateActiveChunks();
});
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>
);
})}
{visibleChunks.map((chunk) => (
<Suspense key={chunk.key} fallback={null}>
<InstancedMapAsset
modelPath={chunk.config.modelPath}
instances={chunk.instances}
castShadow={chunk.config.castShadow}
receiveShadow={chunk.config.receiveShadow}
/>
</Suspense>
))}
</group>
);
}
@@ -41,6 +41,34 @@ export const MAP_INSTANCING_ASSETS = {
receiveShadow: true,
enabled: true,
},
panneauaffichage: {
mapName: "panneauaffichage",
modelPath: "/models/panneauaffichage/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneauclassique: {
mapName: "panneauclassique",
modelPath: "/models/panneauclassique/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneaufleche: {
mapName: "panneaufleche",
modelPath: "/models/panneaufleche/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneausolaire: {
mapName: "panneausolaire",
modelPath: "/models/panneausolaire/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
} as const;
export type MapInstancingAssetType = keyof typeof MAP_INSTANCING_ASSETS;
+139 -2
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { useFrame, useThree } from "@react-three/fiber";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import { useWind } from "@/hooks/world/useWind";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
import type { VegetationInstance } from "@/world/vegetation/useVegetationData";
@@ -13,6 +14,7 @@ interface InstancedVegetationProps {
scaleMultiplier: number;
castShadow: boolean;
receiveShadow: boolean;
windStrength: number;
}
interface MeshData {
@@ -20,6 +22,111 @@ interface MeshData {
material: THREE.Material;
}
type WindShaderMaterial = THREE.Material & {
userData: THREE.Material["userData"] & {
windUniforms?: VegetationWindUniforms;
};
};
interface VegetationWindUniforms {
time: { value: number };
direction: { value: number };
speed: { value: number };
strength: { value: number };
noiseScale: { value: number };
}
function updateVegetationWindUniforms(
uniforms: VegetationWindUniforms,
elapsedTime: number,
direction: number,
speed: number,
strength: number,
noiseScale: number,
): void {
uniforms.time.value = elapsedTime;
uniforms.direction.value = direction;
uniforms.speed.value = speed;
uniforms.strength.value = strength;
uniforms.noiseScale.value = noiseScale;
}
function addWindWeightAttribute(geometry: THREE.BufferGeometry): void {
geometry.computeBoundingBox();
const position = geometry.getAttribute("position");
const bounds = geometry.boundingBox;
if (!position || !bounds) return;
const height = Math.max(bounds.max.y - bounds.min.y, 0.0001);
const weights = new Float32Array(position.count);
for (let index = 0; index < position.count; index++) {
const y = position.getY(index);
const normalizedHeight = THREE.MathUtils.clamp(
(y - bounds.min.y) / height,
0,
1,
);
weights[index] = normalizedHeight * normalizedHeight;
}
geometry.setAttribute("aWindWeight", new THREE.BufferAttribute(weights, 1));
}
function applyVegetationWindMaterial(
material: THREE.Material,
): WindShaderMaterial {
const windMaterial = material as WindShaderMaterial;
const windUniforms: VegetationWindUniforms = {
time: { value: 0 },
direction: { value: 0 },
speed: { value: 0 },
strength: { value: 0 },
noiseScale: { value: 1 },
};
windMaterial.userData.windUniforms = windUniforms;
windMaterial.onBeforeCompile = (shader) => {
shader.uniforms.uVegetationWindTime = windUniforms.time;
shader.uniforms.uVegetationWindDirection = windUniforms.direction;
shader.uniforms.uVegetationWindSpeed = windUniforms.speed;
shader.uniforms.uVegetationWindStrength = windUniforms.strength;
shader.uniforms.uVegetationWindNoiseScale = windUniforms.noiseScale;
shader.vertexShader = shader.vertexShader
.replace(
"#include <common>",
`#include <common>
attribute float aWindWeight;
uniform float uVegetationWindTime;
uniform float uVegetationWindDirection;
uniform float uVegetationWindSpeed;
uniform float uVegetationWindStrength;
uniform float uVegetationWindNoiseScale;`,
)
.replace(
"#include <begin_vertex>",
`#include <begin_vertex>
#ifdef USE_INSTANCING
vec2 instanceOffset = instanceMatrix[3].xz;
#else
vec2 instanceOffset = vec2(0.0);
#endif
vec2 windDirection = vec2(cos(uVegetationWindDirection), sin(uVegetationWindDirection));
float windPhase = dot(instanceOffset + position.xz, windDirection) * uVegetationWindNoiseScale;
float windWave = sin(windPhase + uVegetationWindTime * uVegetationWindSpeed);
float windGust = sin(windPhase * 0.37 + uVegetationWindTime * uVegetationWindSpeed * 0.63);
float windOffset = (windWave * 0.65 + windGust * 0.35) * uVegetationWindStrength * aWindWeight;
transformed.xz += windDirection * windOffset;`,
);
};
windMaterial.customProgramCacheKey = () => "vegetation-wind-v1";
return windMaterial;
}
function extractMeshes(scene: THREE.Group): MeshData[] {
const meshesByMaterial = new Map<
string,
@@ -64,9 +171,11 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
return null;
}
addWindWeightAttribute(mergedGeometry);
return {
geometry: mergedGeometry,
material,
material: applyVegetationWindMaterial(material),
};
})
.filter((meshData): meshData is MeshData => meshData !== null);
@@ -121,13 +230,16 @@ export function InstancedVegetation({
scaleMultiplier,
castShadow,
receiveShadow,
windStrength,
}: InstancedVegetationProps): React.JSX.Element | null {
const { scene } = useGLTF(modelPath);
const wind = useWind();
const terrainHeight = useTerrainHeightSampler();
const maxAnisotropy = useThree((state) =>
state.gl.capabilities.getMaxAnisotropy(),
);
const groupRef = useRef<THREE.Group>(null);
const windUniformsRef = useRef<VegetationWindUniforms[]>([]);
const meshDataList = useMemo(() => {
optimizeGLTFSceneTextures(scene, maxAnisotropy);
@@ -204,7 +316,19 @@ export function InstancedVegetation({
}, [instancedMeshes]);
useEffect(() => {
windUniformsRef.current = meshDataList
.map(
(meshData) =>
(meshData.material as WindShaderMaterial).userData.windUniforms,
)
.filter(
(uniforms): uniforms is VegetationWindUniforms =>
uniforms !== undefined,
);
return () => {
windUniformsRef.current = [];
for (const meshData of meshDataList) {
meshData.geometry.dispose();
if (Array.isArray(meshData.material)) {
@@ -218,6 +342,19 @@ export function InstancedVegetation({
};
}, [meshDataList]);
useFrame(({ clock }) => {
for (const windUniforms of windUniformsRef.current) {
updateVegetationWindUniforms(
windUniforms,
clock.elapsedTime,
wind.direction,
wind.speed,
wind.strength * windStrength,
wind.noiseScale,
);
}
});
if (groundedInstances.length === 0) {
return null;
}
@@ -24,6 +24,7 @@ interface VegetationChunk {
scaleMultiplier: number;
castShadow: boolean;
receiveShadow: boolean;
windStrength: number;
centerX: number;
centerZ: number;
instances: VegetationInstance[];
@@ -70,6 +71,7 @@ function createVegetationChunks(
scaleMultiplier: config.scaleMultiplier,
castShadow: config.castShadow,
receiveShadow: config.receiveShadow,
windStrength: config.windStrength,
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
instances: chunkInstances,
@@ -174,6 +176,7 @@ export function VegetationSystem(): React.JSX.Element | null {
scaleMultiplier={chunk.scaleMultiplier}
castShadow={chunk.castShadow}
receiveShadow={chunk.receiveShadow}
windStrength={chunk.windStrength}
/>
</Suspense>
))}
+6
View File
@@ -11,6 +11,7 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 2,
castShadow: true,
receiveShadow: true,
windStrength: 0.08,
enabled: true,
},
sapin: {
@@ -19,6 +20,7 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 5,
castShadow: true,
receiveShadow: true,
windStrength: 0.04,
enabled: true,
},
arbre: {
@@ -27,6 +29,7 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.06,
enabled: true,
},
champdeble: {
@@ -35,6 +38,7 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.18,
enabled: true,
},
champdesoja: {
@@ -43,6 +47,7 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.16,
enabled: true,
},
champsdetournesol: {
@@ -51,6 +56,7 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.14,
enabled: true,
},
} as const;