Feat/map-environment #6

Merged
math-pixel merged 116 commits from feat/map-environment into develop 2026-05-29 00:00:51 +00:00
7 changed files with 565 additions and 50 deletions
Showing only changes of commit 0230795f55 - Show all commits
+12 -42
View File
@@ -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 (
<color attach="background" args={[PHYSICS_SCENE_BACKGROUND_COLOR]} />
@@ -57,18 +33,7 @@ export function Environment(): React.JSX.Element {
return (
<>
{fogEnabled &&
sceneMode === "game" &&
cameraMode === "player" &&
fog.mode === "linear" ? (
<fog attach="fog" args={[fogColor, fog.near, fog.far]} />
) : null}
{fogEnabled &&
sceneMode === "game" &&
cameraMode === "player" &&
fog.mode === "exp2" ? (
<fogExp2 attach="fog" args={[fogColor, fog.density]} />
) : null}
<FogSystem />
{showSky ? (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
@@ -83,6 +48,11 @@ export function Environment(): React.JSX.Element {
args={[GAME_SCENE_FALLBACK_BACKGROUND_COLOR]}
/>
)}
<WorldPlane />
<WaterSystem />
<CloudSystem />
<GrassSystem />
<VegetationSystem />
</>
);
}
-8
View File
@@ -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({
))}
</group>
<MapInstancingSystem />
<WorldPlane />
<WaterSystem />
<CloudSystem />
<VegetationSystem />
{isMapModelVisible("terrain", { groups, models }) ? (
terrainNode ? (
<TerrainModel
+45
View File
@@ -0,0 +1,45 @@
import { useMemo } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { FOG_LIGHTING_COLOR_MIX } from "@/data/world/fogConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useFogSettings } from "@/hooks/world/useFogSettings";
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;
}
export function FogSystem(): React.JSX.Element | null {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const fog = useFogSettings();
const fogEnabled = useDebugStore((debug) => 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 <fog attach="fog" args={[fogColor, fog.near, fog.far]} />;
}
return <fogExp2 attach="fog" args={[fogColor, fog.density]} />;
}
+280
View File
@@ -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<THREE.ShaderMaterial | null>(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 <mesh geometry={geometry} material={material} frustumCulled />;
}
+147
View File
@@ -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<Set<string>>(
() => 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<string>();
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 (
<group name="grass-system">
{visibleChunks.map((chunk) => (
<Suspense key={chunk.key} fallback={null}>
<GrassPatch
chunkX={chunk.x}
chunkZ={chunk.z}
density={density}
terrainSurfaceData={terrainSurfaceData}
/>
</Suspense>
))}
</group>
);
}
+33
View File
@@ -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;
}
+48
View File
@@ -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);
}
`;