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);
+ }
+`;