From 0230795f55d4a8758d440376063902bb52a3f63b Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 28 May 2026 00:07:02 +0200 Subject: [PATCH] feat: add chunked grass and organize environment systems --- src/world/Environment.tsx | 54 ++---- src/world/GameMap.tsx | 8 - src/world/fog/FogSystem.tsx | 45 +++++ src/world/grass/GrassPatch.tsx | 280 ++++++++++++++++++++++++++++++++ src/world/grass/GrassSystem.tsx | 147 +++++++++++++++++ src/world/grass/grassConfig.ts | 33 ++++ src/world/grass/grassShaders.ts | 48 ++++++ 7 files changed, 565 insertions(+), 50 deletions(-) create mode 100644 src/world/fog/FogSystem.tsx create mode 100644 src/world/grass/GrassPatch.tsx create mode 100644 src/world/grass/GrassSystem.tsx create mode 100644 src/world/grass/grassConfig.ts create mode 100644 src/world/grass/grassShaders.ts diff --git a/src/world/Environment.tsx b/src/world/Environment.tsx index 7ca2a23..394170f 100644 --- a/src/world/Environment.tsx +++ b/src/world/Environment.tsx @@ -1,6 +1,3 @@ -import { useMemo } from "react"; -import { useFrame, useThree } from "@react-three/fiber"; -import * as THREE from "three"; import { GAME_SCENE_FALLBACK_BACKGROUND_COLOR, GAME_SCENE_FALLBACK_SKY_MODEL_PATH, @@ -9,46 +6,25 @@ import { GAME_SCENE_SKY_MODEL_SCALE, PHYSICS_SCENE_BACKGROUND_COLOR, } from "@/data/world/environmentConfig"; -import { FOG_LIGHTING_COLOR_MIX } from "@/data/world/fogConfig"; -import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode"; -import { useFogSettings } from "@/hooks/world/useFogSettings"; import { isMapModelVisible, useMapPerformanceStore, } from "@/managers/stores/useMapPerformanceStore"; import { SkyModel } from "@/components/three/world/SkyModel"; -import { useDebugStore } from "@/hooks/debug/useDebugStore"; -import { LIGHTING_STATE } from "@/world/lightingState"; - -const tempSunFogColor = new THREE.Color(); - -function getLightingFogColor(target: THREE.Color): THREE.Color { - target.set(LIGHTING_STATE.ambientColor); - target.multiplyScalar(FOG_LIGHTING_COLOR_MIX.ambient); - tempSunFogColor.set(LIGHTING_STATE.sunColor); - target.add(tempSunFogColor.multiplyScalar(FOG_LIGHTING_COLOR_MIX.sun)); - - return target; -} +import { CloudSystem } from "@/world/clouds/CloudSystem"; +import { FogSystem } from "@/world/fog/FogSystem"; +import { GrassSystem } from "@/world/grass/GrassSystem"; +import { VegetationSystem } from "@/world/vegetation/VegetationSystem"; +import { WaterSystem } from "@/world/water/WaterSystem"; +import { WorldPlane } from "@/world/WorldPlane"; export function Environment(): React.JSX.Element { - const cameraMode = useCameraMode(); const sceneMode = useSceneMode(); - const fog = useFogSettings(); - const fogEnabled = useDebugStore((debug) => debug.getFogEnabled()); const groups = useMapPerformanceStore((state) => state.groups); const models = useMapPerformanceStore((state) => state.models); - const scene = useThree((state) => state.scene); - const fogColor = useMemo(() => getLightingFogColor(new THREE.Color()), []); const showSky = isMapModelVisible("sky", { groups, models }); - useFrame(() => { - if (!scene.fog) return; - - getLightingFogColor(scene.fog.color); - }); - if (sceneMode === "physics") { return ( @@ -57,18 +33,7 @@ export function Environment(): React.JSX.Element { return ( <> - {fogEnabled && - sceneMode === "game" && - cameraMode === "player" && - fog.mode === "linear" ? ( - - ) : null} - {fogEnabled && - sceneMode === "game" && - cameraMode === "player" && - fog.mode === "exp2" ? ( - - ) : null} + {showSky ? ( )} + + + + + ); } diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 0e5039e..8bbebe9 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -23,13 +23,9 @@ import { } from "@/managers/stores/useMapPerformanceStore"; import { useGameStore } from "@/managers/stores/useGameStore"; import { GameMapCollision } from "@/world/GameMapCollision"; -import { CloudSystem } from "@/world/clouds/CloudSystem"; import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance"; import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig"; import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem"; -import { VegetationSystem } from "@/world/vegetation/VegetationSystem"; -import { WaterSystem } from "@/world/water/WaterSystem"; -import { WorldPlane } from "@/world/WorldPlane"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import { logger } from "@/utils/core/Logger"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; @@ -262,10 +258,6 @@ export function GameMap({ ))} - - - - {isMapModelVisible("terrain", { groups, models }) ? ( terrainNode ? ( debug.getFogEnabled()); + const scene = useThree((state) => state.scene); + const fogColor = useMemo(() => getLightingFogColor(new THREE.Color()), []); + const shouldShowFog = + fogEnabled && sceneMode === "game" && cameraMode === "player"; + + useFrame(() => { + if (!scene.fog) return; + + getLightingFogColor(scene.fog.color); + }); + + if (!shouldShowFog) return null; + + if (fog.mode === "linear") { + return ; + } + + return ; +} diff --git a/src/world/grass/GrassPatch.tsx b/src/world/grass/GrassPatch.tsx new file mode 100644 index 0000000..c2e07c8 --- /dev/null +++ b/src/world/grass/GrassPatch.tsx @@ -0,0 +1,280 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import { TERRAIN_SURFACE_PROJECTION } from "@/data/world/terrainConfig"; +import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight"; +import { useWind } from "@/hooks/world/useWind"; +import type { TerrainSurfaceData } from "@/types/world/terrainSurface"; +import { sampleTerrainSurfaceAtXZ } from "@/utils/world/terrainSurfaceSampler"; +import { + getGrassTipColor, + GRASS_CONFIG, + GRASS_SURFACE_KEYS, +} from "@/world/grass/grassConfig"; +import { + grassFragmentShader, + grassVertexShader, +} from "@/world/grass/grassShaders"; + +interface GrassPatchProps { + chunkX: number; + chunkZ: number; + density: number; + terrainSurfaceData: TerrainSurfaceData; +} + +interface GrassBladeVertexData { + color: number[]; + heightFactor: number; + position: number[]; +} + +function random01(seed: number): number { + const value = Math.sin(seed * 12.9898) * 43758.5453; + return value - Math.floor(value); +} + +function lerp(min: number, max: number, ratio: number): number { + return min + (max - min) * ratio; +} + +function createGrassMaterial(): THREE.ShaderMaterial { + return new THREE.ShaderMaterial({ + side: THREE.DoubleSide, + vertexColors: true, + vertexShader: grassVertexShader, + fragmentShader: grassFragmentShader, + uniforms: { + uTime: { value: 0 }, + uWindDirection: { value: 0 }, + uWindSpeed: { value: 0 }, + uWindStrength: { value: 0 }, + uWindNoiseScale: { value: GRASS_CONFIG.windNoiseScale }, + uBendStrength: { value: GRASS_CONFIG.windBendStrength }, + }, + }); +} + +function addGrassBlade( + positions: number[], + colors: number[], + bladeBases: number[], + heightFactors: number[], + windPhases: number[], + basePosition: THREE.Vector3, + yaw: number, + width: number, + height: number, + baseColor: THREE.Color, + tipColor: THREE.Color, + windPhase: number, +): void { + const rightX = Math.cos(yaw) * width * 0.5; + const rightZ = Math.sin(yaw) * width * 0.5; + const leanX = Math.cos(yaw + Math.PI * 0.5) * width * 0.22; + const leanZ = Math.sin(yaw + Math.PI * 0.5) * width * 0.22; + const vertexData: GrassBladeVertexData[] = [ + { + position: [ + basePosition.x - rightX, + basePosition.y, + basePosition.z - rightZ, + ], + color: [baseColor.r, baseColor.g, baseColor.b], + heightFactor: 0, + }, + { + position: [ + basePosition.x + rightX, + basePosition.y, + basePosition.z + rightZ, + ], + color: [baseColor.r, baseColor.g, baseColor.b], + heightFactor: 0, + }, + { + position: [ + basePosition.x + leanX, + basePosition.y + height, + basePosition.z + leanZ, + ], + color: [tipColor.r, tipColor.g, tipColor.b], + heightFactor: 1, + }, + ]; + + for (const vertex of vertexData) { + positions.push(...vertex.position); + colors.push(...vertex.color); + bladeBases.push(basePosition.x, basePosition.y, basePosition.z); + heightFactors.push(vertex.heightFactor); + windPhases.push(windPhase); + } +} + +function createGrassGeometry( + chunkX: number, + chunkZ: number, + density: number, + terrainSurfaceData: TerrainSurfaceData, + getHeight: (x: number, z: number) => number | null, +): THREE.BufferGeometry | null { + const positions: number[] = []; + const colors: number[] = []; + const bladeBases: number[] = []; + const heightFactors: number[] = []; + const windPhases: number[] = []; + const baseColor = new THREE.Color(GRASS_CONFIG.baseColor); + const startX = chunkX * GRASS_CONFIG.chunkSize; + const startZ = chunkZ * GRASS_CONFIG.chunkSize; + const endX = startX + GRASS_CONFIG.chunkSize; + const endZ = startZ + GRASS_CONFIG.chunkSize; + const bladeBudget = Math.round(GRASS_CONFIG.maxBladesPerChunk * density); + let bladeCount = 0; + + for (let x = startX; x < endX; x += GRASS_CONFIG.sampleStep) { + for (let z = startZ; z < endZ; z += GRASS_CONFIG.sampleStep) { + for ( + let bladeIndex = 0; + bladeIndex < GRASS_CONFIG.bladesPerCell; + bladeIndex++ + ) { + if (bladeCount >= bladeBudget) break; + + const seed = + (chunkX + 101) * 92821 + + (chunkZ + 103) * 68917 + + Math.round(x * 13) * 193 + + Math.round(z * 17) * 389 + + bladeIndex * 997; + if (random01(seed) > density) continue; + + const sampleX = x + (random01(seed + 1) - 0.5) * GRASS_CONFIG.jitter; + const sampleZ = z + (random01(seed + 2) - 0.5) * GRASS_CONFIG.jitter; + const sample = sampleTerrainSurfaceAtXZ( + terrainSurfaceData.imageData, + sampleX, + sampleZ, + terrainSurfaceData.bounds, + TERRAIN_SURFACE_PROJECTION, + ); + + if (!sample.key || !GRASS_SURFACE_KEYS.has(sample.key as never)) + continue; + + const height = getHeight(sampleX, sampleZ); + if (height === null) continue; + + const heightRatio = random01(seed + 3); + const widthRatio = random01(seed + 4); + const tipColor = new THREE.Color(getGrassTipColor(sample.key)); + const basePosition = new THREE.Vector3( + sampleX, + height + GRASS_CONFIG.surfaceOffset, + sampleZ, + ); + + addGrassBlade( + positions, + colors, + bladeBases, + heightFactors, + windPhases, + basePosition, + random01(seed + 5) * Math.PI * 2, + GRASS_CONFIG.bladeWidth * lerp(0.75, 1.25, widthRatio), + lerp( + GRASS_CONFIG.minBladeHeight, + GRASS_CONFIG.maxBladeHeight, + heightRatio, + ), + baseColor, + tipColor, + random01(seed + 6) * Math.PI * 2, + ); + bladeCount += 1; + } + } + } + + if (bladeCount === 0) return null; + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute(positions, 3), + ); + geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); + geometry.setAttribute( + "aBladeBase", + new THREE.Float32BufferAttribute(bladeBases, 3), + ); + geometry.setAttribute( + "aHeightFactor", + new THREE.Float32BufferAttribute(heightFactors, 1), + ); + geometry.setAttribute( + "aWindPhase", + new THREE.Float32BufferAttribute(windPhases, 1), + ); + geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + + return geometry; +} + +export function GrassPatch({ + chunkX, + chunkZ, + density, + terrainSurfaceData, +}: GrassPatchProps): React.JSX.Element | null { + const terrainHeight = useTerrainHeightSampler(); + const wind = useWind(); + const materialRef = useRef(null); + const geometry = useMemo( + () => + createGrassGeometry( + chunkX, + chunkZ, + density, + terrainSurfaceData, + terrainHeight.getHeight, + ), + [chunkX, chunkZ, density, terrainHeight.getHeight, terrainSurfaceData], + ); + const material = useMemo(() => createGrassMaterial(), []); + + useEffect(() => { + materialRef.current = material; + return () => { + materialRef.current = null; + material.dispose(); + }; + }, [material]); + + useEffect(() => { + return () => { + geometry?.dispose(); + }; + }, [geometry]); + + useFrame(({ clock }) => { + const currentMaterial = materialRef.current; + if (!currentMaterial) return; + + const uniforms = currentMaterial.uniforms; + if (uniforms.uTime) uniforms.uTime.value = clock.elapsedTime; + if (uniforms.uWindDirection) uniforms.uWindDirection.value = wind.direction; + if (uniforms.uWindSpeed) uniforms.uWindSpeed.value = wind.speed; + if (uniforms.uWindStrength) uniforms.uWindStrength.value = wind.strength; + if (uniforms.uWindNoiseScale) { + uniforms.uWindNoiseScale.value = + GRASS_CONFIG.windNoiseScale * wind.noiseScale; + } + }); + + if (!geometry) return null; + + return ; +} diff --git a/src/world/grass/GrassSystem.tsx b/src/world/grass/GrassSystem.tsx new file mode 100644 index 0000000..3b8053e --- /dev/null +++ b/src/world/grass/GrassSystem.tsx @@ -0,0 +1,147 @@ +import { Suspense, useCallback, useMemo, useRef, useState } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import { useSceneMode } from "@/hooks/debug/useSceneMode"; +import { useTerrainSurfaceData } from "@/hooks/world/useTerrainSurfaceData"; +import { + useDynamicGrass, + useGrassDensity, +} from "@/hooks/world/useGraphicsSettings"; +import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface"; +import { GRASS_CONFIG } from "@/world/grass/grassConfig"; +import { GrassPatch } from "@/world/grass/GrassPatch"; + +interface GrassChunk { + centerX: number; + centerZ: number; + key: string; + x: number; + z: number; +} + +function getChunkRange(min: number, max: number): number[] { + const start = Math.floor(min / GRASS_CONFIG.chunkSize); + const end = Math.floor(max / GRASS_CONFIG.chunkSize); + const chunks: number[] = []; + + for (let value = start; value <= end; value++) { + chunks.push(value); + } + + return chunks; +} + +function createGrassChunks(bounds: TerrainSurfaceBounds): GrassChunk[] { + const chunks: GrassChunk[] = []; + const xChunks = getChunkRange(bounds.minX, bounds.maxX); + const zChunks = getChunkRange(bounds.minZ, bounds.maxZ); + + for (const x of xChunks) { + for (const z of zChunks) { + chunks.push({ + centerX: x * GRASS_CONFIG.chunkSize + GRASS_CONFIG.chunkSize * 0.5, + centerZ: z * GRASS_CONFIG.chunkSize + GRASS_CONFIG.chunkSize * 0.5, + key: `${x}:${z}`, + x, + z, + }); + } + } + + return chunks; +} + +export function GrassSystem(): React.JSX.Element | null { + const camera = useThree((state) => state.camera); + const terrainSurfaceData = useTerrainSurfaceData(); + const sceneMode = useSceneMode(); + const dynamicGrass = useDynamicGrass(); + const grassDensity = useGrassDensity(); + const lastUpdateRef = useRef(-GRASS_CONFIG.updateInterval); + const [activeChunkKeys, setActiveChunkKeys] = useState>( + () => new Set(), + ); + const density = Math.max(0, grassDensity); + const chunks = useMemo( + () => + terrainSurfaceData ? createGrassChunks(terrainSurfaceData.bounds) : [], + [terrainSurfaceData], + ); + const streamingEnabled = sceneMode === "game"; + + const updateActiveChunks = useCallback(() => { + const nextKeys = new Set(); + + for (const chunk of chunks) { + const distance = Math.hypot( + chunk.centerX - camera.position.x, + chunk.centerZ - camera.position.z, + ); + const wasActive = activeChunkKeys.has(chunk.key); + const radius = wasActive + ? GRASS_CONFIG.unloadRadius + : GRASS_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 < GRASS_CONFIG.updateInterval) return; + lastUpdateRef.current = now; + + updateActiveChunks(); + }); + + if ( + !GRASS_CONFIG.enabled || + !dynamicGrass || + density <= 0 || + !terrainSurfaceData + ) { + return null; + } + + 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, + ) <= GRASS_CONFIG.loadRadius + ); + }) + : chunks; + + return ( + + {visibleChunks.map((chunk) => ( + + + + ))} + + ); +} diff --git a/src/world/grass/grassConfig.ts b/src/world/grass/grassConfig.ts new file mode 100644 index 0000000..dd237d2 --- /dev/null +++ b/src/world/grass/grassConfig.ts @@ -0,0 +1,33 @@ +import { TERRAIN_COLORS } from "@/data/world/terrainConfig"; + +export const GRASS_CONFIG = { + enabled: true, + chunkSize: 20, + loadRadius: 30, + unloadRadius: 34, + updateInterval: 250, + sampleStep: 1.15, + jitter: 0.42, + bladesPerCell: 2, + maxBladesPerChunk: 720, + bladeWidth: 0.12, + minBladeHeight: 0.42, + maxBladeHeight: 0.82, + surfaceOffset: 0.06, + baseColor: "#1f3512", + windBendStrength: 0.42, + windNoiseScale: 0.09, +} as const; + +export const GRASS_SURFACE_KEYS = new Set([ + "grass1", + "grass2", + "grass3", +] as const); + +export function getGrassTipColor(surfaceKey: string | null): string { + if (surfaceKey === "grass1") return TERRAIN_COLORS.grass1.grassTipColor; + if (surfaceKey === "grass2") return TERRAIN_COLORS.grass2.grassTipColor; + if (surfaceKey === "grass3") return TERRAIN_COLORS.grass3.grassTipColor; + return TERRAIN_COLORS.grass1.grassTipColor; +} diff --git a/src/world/grass/grassShaders.ts b/src/world/grass/grassShaders.ts new file mode 100644 index 0000000..90280e2 --- /dev/null +++ b/src/world/grass/grassShaders.ts @@ -0,0 +1,48 @@ +export const grassVertexShader = /* glsl */ ` + attribute vec3 aColor; + attribute vec3 aBladeBase; + attribute float aHeightFactor; + attribute float aWindPhase; + + varying vec3 vColor; + + uniform float uTime; + uniform float uWindDirection; + uniform float uWindSpeed; + uniform float uWindStrength; + uniform float uWindNoiseScale; + uniform float uBendStrength; + + void main() { + vec3 transformed = position; + float topFactor = aHeightFactor * aHeightFactor; + vec2 windDirection = normalize(vec2(cos(uWindDirection), sin(uWindDirection))); + + float primaryWind = sin( + uTime * max(uWindSpeed, 0.05) + + aWindPhase + + aBladeBase.x * uWindNoiseScale + + aBladeBase.z * uWindNoiseScale + ); + float secondaryWind = sin( + uTime * max(uWindSpeed, 0.05) * 1.73 + + aWindPhase * 0.71 + + aBladeBase.x * uWindNoiseScale * 0.53 - + aBladeBase.z * uWindNoiseScale * 0.89 + ) * 0.35; + + float bend = (primaryWind + secondaryWind) * uWindStrength * uBendStrength * topFactor; + transformed.xz += windDirection * bend; + + vColor = aColor; + gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); + } +`; + +export const grassFragmentShader = /* glsl */ ` + varying vec3 vColor; + + void main() { + gl_FragColor = vec4(vColor, 1.0); + } +`;