Feat/map-environment #6
Binary file not shown.
Binary file not shown.
+150
-144
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTexture } from "@react-three/drei";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useWind } from "@/hooks/world/useWind";
|
import { useWind } from "@/hooks/world/useWind";
|
||||||
@@ -10,8 +11,6 @@ import {
|
|||||||
import type { TerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
|
import type { TerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
|
||||||
|
|
||||||
interface GrassPatchProps {
|
interface GrassPatchProps {
|
||||||
chunkX: number;
|
|
||||||
chunkZ: number;
|
|
||||||
density: number;
|
density: number;
|
||||||
terrainSampler: TerrainGrassSampler;
|
terrainSampler: TerrainGrassSampler;
|
||||||
}
|
}
|
||||||
@@ -21,15 +20,126 @@ function random01(seed: number): number {
|
|||||||
return value - Math.floor(value);
|
return value - Math.floor(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGrassMaterial(): THREE.ShaderMaterial {
|
function pushVector(target: number[], value: THREE.Vector3): void {
|
||||||
|
target.push(value.x, value.y, value.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushColor(target: number[], value: THREE.Color): void {
|
||||||
|
target.push(value.r, value.g, value.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGrassGeometry(density: number): THREE.BufferGeometry {
|
||||||
|
const positions: number[] = [];
|
||||||
|
const colors: number[] = [];
|
||||||
|
const uvs: number[] = [];
|
||||||
|
const bladeOrigins: number[] = [];
|
||||||
|
const yaws: number[] = [];
|
||||||
|
const bladeCount = Math.round(GRASS_CONFIG.bladeCount * density);
|
||||||
|
const halfPatchSize = GRASS_CONFIG.patchSize * 0.5;
|
||||||
|
|
||||||
|
for (let index = 0; index < bladeCount; index++) {
|
||||||
|
const seed = index * 997;
|
||||||
|
const origin = new THREE.Vector3(
|
||||||
|
random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize,
|
||||||
|
0,
|
||||||
|
random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize,
|
||||||
|
);
|
||||||
|
const yawAngle = random01(seed + 3) * Math.PI * 2;
|
||||||
|
const yaw = new THREE.Vector3(Math.sin(yawAngle), 0, -Math.cos(yawAngle));
|
||||||
|
const colorIndex = Math.floor(random01(seed + 4) * GRASS_COLORS.length);
|
||||||
|
const color = new THREE.Color(GRASS_COLORS[colorIndex] ?? GRASS_COLORS[0]);
|
||||||
|
const markerColors = [
|
||||||
|
new THREE.Color(0.1, 0, 0),
|
||||||
|
new THREE.Color(0, 0, 0.1),
|
||||||
|
new THREE.Color(1, 1, 1),
|
||||||
|
] as const;
|
||||||
|
const uv = new THREE.Vector2(
|
||||||
|
origin.x / GRASS_CONFIG.patchSize + 0.5,
|
||||||
|
origin.z / GRASS_CONFIG.patchSize + 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) {
|
||||||
|
pushVector(positions, origin);
|
||||||
|
pushColor(colors, markerColors[vertexIndex] ?? markerColors[2]);
|
||||||
|
pushVector(bladeOrigins, origin);
|
||||||
|
pushVector(yaws, yaw);
|
||||||
|
pushColor(colors, color);
|
||||||
|
uvs.push(uv.x, uv.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
const markerColorValues: number[] = [];
|
||||||
|
const bladeColorValues: number[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < colors.length; index += 6) {
|
||||||
|
markerColorValues.push(
|
||||||
|
colors[index] ?? 0,
|
||||||
|
colors[index + 1] ?? 0,
|
||||||
|
colors[index + 2] ?? 0,
|
||||||
|
);
|
||||||
|
bladeColorValues.push(
|
||||||
|
colors[index + 3] ?? 0,
|
||||||
|
colors[index + 4] ?? 0,
|
||||||
|
colors[index + 5] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.setAttribute(
|
||||||
|
"position",
|
||||||
|
new THREE.Float32BufferAttribute(positions, 3),
|
||||||
|
);
|
||||||
|
geometry.setAttribute(
|
||||||
|
"color",
|
||||||
|
new THREE.Float32BufferAttribute(markerColorValues, 3),
|
||||||
|
);
|
||||||
|
geometry.setAttribute(
|
||||||
|
"aBladeColor",
|
||||||
|
new THREE.Float32BufferAttribute(bladeColorValues, 3),
|
||||||
|
);
|
||||||
|
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
||||||
|
geometry.setAttribute(
|
||||||
|
"aBladeOrigin",
|
||||||
|
new THREE.Float32BufferAttribute(bladeOrigins, 3),
|
||||||
|
);
|
||||||
|
geometry.setAttribute("aYaw", new THREE.Float32BufferAttribute(yaws, 3));
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
geometry.computeBoundingSphere();
|
||||||
|
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGrassMaterial(
|
||||||
|
terrainSampler: TerrainGrassSampler,
|
||||||
|
noiseTexture: THREE.Texture,
|
||||||
|
grassTexture: THREE.Texture,
|
||||||
|
): THREE.ShaderMaterial {
|
||||||
return new THREE.ShaderMaterial({
|
return new THREE.ShaderMaterial({
|
||||||
side: THREE.DoubleSide,
|
|
||||||
vertexShader: grassVertexShader,
|
vertexShader: grassVertexShader,
|
||||||
fragmentShader: grassFragmentShader,
|
fragmentShader: grassFragmentShader,
|
||||||
|
vertexColors: true,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
uniforms: {
|
uniforms: {
|
||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
|
uNoiseTexture: { value: noiseTexture },
|
||||||
|
uDiffuseMap: { value: grassTexture },
|
||||||
|
uHeightMap: { value: terrainSampler.heightTexture },
|
||||||
uPlayerPosition: { value: new THREE.Vector3() },
|
uPlayerPosition: { value: new THREE.Vector3() },
|
||||||
uPatchSize: { value: GRASS_CONFIG.chunkSize },
|
uBoundingBoxMin: {
|
||||||
|
value: new THREE.Vector3(
|
||||||
|
terrainSampler.bounds.minX,
|
||||||
|
terrainSampler.minHeight,
|
||||||
|
terrainSampler.bounds.minZ,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
uBoundingBoxMax: {
|
||||||
|
value: new THREE.Vector3(
|
||||||
|
terrainSampler.bounds.maxX,
|
||||||
|
terrainSampler.maxHeight,
|
||||||
|
terrainSampler.bounds.maxZ,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
uPatchSize: { value: GRASS_CONFIG.patchSize },
|
||||||
uBladeWidth: { value: GRASS_CONFIG.bladeWidth },
|
uBladeWidth: { value: GRASS_CONFIG.bladeWidth },
|
||||||
uWindDirection: { value: 0 },
|
uWindDirection: { value: 0 },
|
||||||
uWindSpeed: { value: 0 },
|
uWindSpeed: { value: 0 },
|
||||||
@@ -41,153 +151,51 @@ function createGrassMaterial(): THREE.ShaderMaterial {
|
|||||||
uMaxBendAngle: { value: GRASS_CONFIG.maxBendAngle },
|
uMaxBendAngle: { value: GRASS_CONFIG.maxBendAngle },
|
||||||
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
|
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
|
||||||
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
|
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
|
||||||
|
uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushVector(target: number[], value: THREE.Vector3): void {
|
|
||||||
target.push(value.x, value.y, value.z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushColor(target: number[], value: THREE.Color): void {
|
|
||||||
target.push(value.r, value.g, value.b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addGrassBlade(
|
|
||||||
positions: number[],
|
|
||||||
bladeColors: number[],
|
|
||||||
bladeBases: number[],
|
|
||||||
bladeNormals: number[],
|
|
||||||
sideFactors: number[],
|
|
||||||
tipFactors: number[],
|
|
||||||
randoms: number[],
|
|
||||||
yaws: number[],
|
|
||||||
basePosition: THREE.Vector3,
|
|
||||||
normal: THREE.Vector3,
|
|
||||||
color: THREE.Color,
|
|
||||||
yaw: THREE.Vector3,
|
|
||||||
random: number,
|
|
||||||
): void {
|
|
||||||
const vertices = [
|
|
||||||
{ side: 1, tip: 0 },
|
|
||||||
{ side: -1, tip: 0 },
|
|
||||||
{ side: 0, tip: 1 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const vertex of vertices) {
|
|
||||||
pushVector(positions, basePosition);
|
|
||||||
pushColor(bladeColors, color);
|
|
||||||
pushVector(bladeBases, basePosition);
|
|
||||||
pushVector(bladeNormals, normal);
|
|
||||||
pushVector(yaws, yaw);
|
|
||||||
sideFactors.push(vertex.side);
|
|
||||||
tipFactors.push(vertex.tip);
|
|
||||||
randoms.push(random);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGrassGeometry(
|
|
||||||
chunkX: number,
|
|
||||||
chunkZ: number,
|
|
||||||
density: number,
|
|
||||||
terrainSampler: TerrainGrassSampler,
|
|
||||||
): THREE.BufferGeometry | null {
|
|
||||||
const positions: number[] = [];
|
|
||||||
const bladeColors: number[] = [];
|
|
||||||
const bladeBases: number[] = [];
|
|
||||||
const bladeNormals: number[] = [];
|
|
||||||
const sideFactors: number[] = [];
|
|
||||||
const tipFactors: number[] = [];
|
|
||||||
const randoms: number[] = [];
|
|
||||||
const yaws: number[] = [];
|
|
||||||
const startX = chunkX * GRASS_CONFIG.chunkSize;
|
|
||||||
const startZ = chunkZ * GRASS_CONFIG.chunkSize;
|
|
||||||
const bladeCount = Math.round(GRASS_CONFIG.baseBladesPerChunk * density);
|
|
||||||
|
|
||||||
for (let index = 0; index < bladeCount; index++) {
|
|
||||||
const seed = (chunkX + 101) * 92821 + (chunkZ + 103) * 68917 + index * 997;
|
|
||||||
const x = startX + random01(seed + 1) * GRASS_CONFIG.chunkSize;
|
|
||||||
const z = startZ + random01(seed + 2) * GRASS_CONFIG.chunkSize;
|
|
||||||
const sample = terrainSampler.sample(x, z);
|
|
||||||
if (!sample) continue;
|
|
||||||
|
|
||||||
const colorIndex = Math.floor(random01(seed + 3) * GRASS_COLORS.length);
|
|
||||||
const color = new THREE.Color(GRASS_COLORS[colorIndex] ?? GRASS_COLORS[0]);
|
|
||||||
const yawAngle = random01(seed + 4) * Math.PI * 2;
|
|
||||||
const yaw = new THREE.Vector3(Math.sin(yawAngle), 0, -Math.cos(yawAngle));
|
|
||||||
const basePosition = sample.position
|
|
||||||
.clone()
|
|
||||||
.addScaledVector(sample.normal, GRASS_CONFIG.surfaceOffset);
|
|
||||||
|
|
||||||
addGrassBlade(
|
|
||||||
positions,
|
|
||||||
bladeColors,
|
|
||||||
bladeBases,
|
|
||||||
bladeNormals,
|
|
||||||
sideFactors,
|
|
||||||
tipFactors,
|
|
||||||
randoms,
|
|
||||||
yaws,
|
|
||||||
basePosition,
|
|
||||||
sample.normal,
|
|
||||||
color,
|
|
||||||
yaw,
|
|
||||||
random01(seed + 5),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (positions.length === 0) return null;
|
|
||||||
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
|
||||||
geometry.setAttribute(
|
|
||||||
"position",
|
|
||||||
new THREE.Float32BufferAttribute(positions, 3),
|
|
||||||
);
|
|
||||||
geometry.setAttribute(
|
|
||||||
"aBladeColor",
|
|
||||||
new THREE.Float32BufferAttribute(bladeColors, 3),
|
|
||||||
);
|
|
||||||
geometry.setAttribute(
|
|
||||||
"aBladeBase",
|
|
||||||
new THREE.Float32BufferAttribute(bladeBases, 3),
|
|
||||||
);
|
|
||||||
geometry.setAttribute(
|
|
||||||
"aBladeNormal",
|
|
||||||
new THREE.Float32BufferAttribute(bladeNormals, 3),
|
|
||||||
);
|
|
||||||
geometry.setAttribute(
|
|
||||||
"aSideFactor",
|
|
||||||
new THREE.Float32BufferAttribute(sideFactors, 1),
|
|
||||||
);
|
|
||||||
geometry.setAttribute(
|
|
||||||
"aTipFactor",
|
|
||||||
new THREE.Float32BufferAttribute(tipFactors, 1),
|
|
||||||
);
|
|
||||||
geometry.setAttribute(
|
|
||||||
"aRandom",
|
|
||||||
new THREE.Float32BufferAttribute(randoms, 1),
|
|
||||||
);
|
|
||||||
geometry.setAttribute("aYaw", new THREE.Float32BufferAttribute(yaws, 3));
|
|
||||||
geometry.computeVertexNormals();
|
|
||||||
geometry.computeBoundingSphere();
|
|
||||||
|
|
||||||
return geometry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GrassPatch({
|
export function GrassPatch({
|
||||||
chunkX,
|
|
||||||
chunkZ,
|
|
||||||
density,
|
density,
|
||||||
terrainSampler,
|
terrainSampler,
|
||||||
}: GrassPatchProps): React.JSX.Element | null {
|
}: GrassPatchProps): React.JSX.Element {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const wind = useWind();
|
const wind = useWind();
|
||||||
|
const [noiseTexture, grassTexture] = useTexture([
|
||||||
|
"/textures/grass/noise.png",
|
||||||
|
"/textures/grass/grass.jpg",
|
||||||
|
]) as [THREE.Texture, THREE.Texture];
|
||||||
|
const grassTextures = useMemo(() => {
|
||||||
|
const noise = noiseTexture.clone();
|
||||||
|
const grass = grassTexture.clone();
|
||||||
|
|
||||||
|
noise.wrapS = noise.wrapT = THREE.RepeatWrapping;
|
||||||
|
grass.wrapS = grass.wrapT = THREE.MirroredRepeatWrapping;
|
||||||
|
noise.needsUpdate = true;
|
||||||
|
grass.needsUpdate = true;
|
||||||
|
|
||||||
|
return { grass, noise };
|
||||||
|
}, [grassTexture, noiseTexture]);
|
||||||
const materialRef = useRef<THREE.ShaderMaterial | null>(null);
|
const materialRef = useRef<THREE.ShaderMaterial | null>(null);
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(() => createGrassGeometry(density), [density]);
|
||||||
() => createGrassGeometry(chunkX, chunkZ, density, terrainSampler),
|
|
||||||
[chunkX, chunkZ, density, terrainSampler],
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
grassTextures.grass.dispose();
|
||||||
|
grassTextures.noise.dispose();
|
||||||
|
};
|
||||||
|
}, [grassTextures]);
|
||||||
|
|
||||||
|
const material = useMemo(
|
||||||
|
() =>
|
||||||
|
createGrassMaterial(
|
||||||
|
terrainSampler,
|
||||||
|
grassTextures.noise,
|
||||||
|
grassTextures.grass,
|
||||||
|
),
|
||||||
|
[grassTextures, terrainSampler],
|
||||||
);
|
);
|
||||||
const material = useMemo(() => createGrassMaterial(), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
materialRef.current = material;
|
materialRef.current = material;
|
||||||
@@ -199,7 +207,7 @@ export function GrassPatch({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
geometry?.dispose();
|
geometry.dispose();
|
||||||
};
|
};
|
||||||
}, [geometry]);
|
}, [geometry]);
|
||||||
|
|
||||||
@@ -220,7 +228,5 @@ export function GrassPatch({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!geometry) return null;
|
|
||||||
|
|
||||||
return <mesh geometry={geometry} material={material} frustumCulled={false} />;
|
return <mesh geometry={geometry} material={material} frustumCulled={false} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,146 +1,25 @@
|
|||||||
import { Suspense, useCallback, useMemo, useRef, useState } from "react";
|
import { Suspense } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
|
||||||
import {
|
import {
|
||||||
useDynamicGrass,
|
useDynamicGrass,
|
||||||
useGrassDensity,
|
useGrassDensity,
|
||||||
} from "@/hooks/world/useGraphicsSettings";
|
} from "@/hooks/world/useGraphicsSettings";
|
||||||
import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface";
|
|
||||||
import { GRASS_CONFIG } from "@/world/grass/grassConfig";
|
import { GRASS_CONFIG } from "@/world/grass/grassConfig";
|
||||||
import { GrassPatch } from "@/world/grass/GrassPatch";
|
import { GrassPatch } from "@/world/grass/GrassPatch";
|
||||||
import { useTerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
|
import { useTerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
|
||||||
|
|
||||||
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 {
|
export function GrassSystem(): React.JSX.Element | null {
|
||||||
const camera = useThree((state) => state.camera);
|
|
||||||
const terrainSampler = useTerrainGrassSampler();
|
const terrainSampler = useTerrainGrassSampler();
|
||||||
const sceneMode = useSceneMode();
|
|
||||||
const dynamicGrass = useDynamicGrass();
|
const dynamicGrass = useDynamicGrass();
|
||||||
const grassDensity = useGrassDensity();
|
const grassDensity = useGrassDensity();
|
||||||
const lastUpdateRef = useRef(-GRASS_CONFIG.updateInterval);
|
|
||||||
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
const density = Math.max(0, grassDensity);
|
const density = Math.max(0, grassDensity);
|
||||||
const chunks = useMemo(
|
|
||||||
() => createGrassChunks(terrainSampler.bounds),
|
|
||||||
[terrainSampler],
|
|
||||||
);
|
|
||||||
const streamingEnabled = sceneMode === "game";
|
|
||||||
|
|
||||||
const updateActiveChunks = useCallback(() => {
|
if (!GRASS_CONFIG.enabled || !dynamicGrass || density <= 0) {
|
||||||
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 ||
|
|
||||||
chunks.length === 0
|
|
||||||
) {
|
|
||||||
return null;
|
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 (
|
return (
|
||||||
<group name="grass-system">
|
<Suspense fallback={null}>
|
||||||
{visibleChunks.map((chunk) => (
|
<GrassPatch density={density} terrainSampler={terrainSampler} />
|
||||||
<Suspense key={chunk.key} fallback={null}>
|
</Suspense>
|
||||||
<GrassPatch
|
|
||||||
chunkX={chunk.x}
|
|
||||||
chunkZ={chunk.z}
|
|
||||||
density={density}
|
|
||||||
terrainSampler={terrainSampler}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
export const GRASS_CONFIG = {
|
export const GRASS_CONFIG = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
chunkSize: 20,
|
patchSize: 30,
|
||||||
loadRadius: 30,
|
bladeCount: 18000,
|
||||||
unloadRadius: 34,
|
|
||||||
updateInterval: 250,
|
|
||||||
baseBladesPerChunk: 420,
|
|
||||||
bladeWidth: 0.08,
|
bladeWidth: 0.08,
|
||||||
maxBladeHeight: 0.36,
|
maxBladeHeight: 0.36,
|
||||||
randomHeightAmount: 0.25,
|
randomHeightAmount: 0.25,
|
||||||
surfaceOffset: 0.025,
|
surfaceOffset: 0.025,
|
||||||
|
heightTextureSize: 128,
|
||||||
windNoiseScale: 0.9,
|
windNoiseScale: 0.9,
|
||||||
baldPatchModifier: 2.5,
|
baldPatchModifier: 2.5,
|
||||||
falloffSharpness: 0.35,
|
falloffSharpness: 0.35,
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
export const grassVertexShader = /* glsl */ `
|
export const grassVertexShader = /* glsl */ `
|
||||||
attribute vec3 aBladeColor;
|
|
||||||
attribute vec3 aBladeNormal;
|
|
||||||
attribute vec3 aBladeBase;
|
|
||||||
attribute float aSideFactor;
|
|
||||||
attribute float aTipFactor;
|
|
||||||
attribute float aRandom;
|
|
||||||
attribute vec3 aYaw;
|
attribute vec3 aYaw;
|
||||||
|
attribute vec3 aBladeOrigin;
|
||||||
|
attribute vec3 aBladeColor;
|
||||||
|
|
||||||
varying vec3 vColor;
|
varying vec3 vColor;
|
||||||
|
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
uniform vec3 uPlayerPosition;
|
uniform vec3 uPlayerPosition;
|
||||||
|
uniform sampler2D uHeightMap;
|
||||||
|
uniform sampler2D uDiffuseMap;
|
||||||
|
uniform sampler2D uNoiseTexture;
|
||||||
|
uniform vec3 uBoundingBoxMin;
|
||||||
|
uniform vec3 uBoundingBoxMax;
|
||||||
uniform float uPatchSize;
|
uniform float uPatchSize;
|
||||||
uniform float uBladeWidth;
|
uniform float uBladeWidth;
|
||||||
uniform float uWindDirection;
|
uniform float uWindDirection;
|
||||||
@@ -23,6 +24,7 @@ export const grassVertexShader = /* glsl */ `
|
|||||||
uniform float uMaxBendAngle;
|
uniform float uMaxBendAngle;
|
||||||
uniform float uMaxBladeHeight;
|
uniform float uMaxBladeHeight;
|
||||||
uniform float uRandomHeightAmount;
|
uniform float uRandomHeightAmount;
|
||||||
|
uniform float uSurfaceOffset;
|
||||||
|
|
||||||
float random(vec2 st) {
|
float random(vec2 st) {
|
||||||
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
|
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
|
||||||
@@ -45,33 +47,59 @@ export const grassVertexShader = /* glsl */ `
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec3 origin = aBladeBase;
|
vec3 transformed = position;
|
||||||
vec3 transformed = origin;
|
vec3 origin = aBladeOrigin;
|
||||||
float halfPatchSize = uPatchSize * 0.5;
|
float halfPatchSize = uPatchSize * 0.5;
|
||||||
vec2 uv = vec2(origin.x, origin.z) * 0.05;
|
|
||||||
|
|
||||||
float heightNoise =
|
origin.x = mod(origin.x - uPlayerPosition.x + halfPatchSize, uPatchSize) - halfPatchSize;
|
||||||
random(floor(uv * uHeightNoiseFrequency) + aRandom) *
|
origin.z = mod(origin.z - uPlayerPosition.z + halfPatchSize, uPatchSize) - halfPatchSize;
|
||||||
uMaxBladeHeight *
|
|
||||||
uHeightNoiseAmplitude;
|
|
||||||
float heightModifier = heightNoise + random(uv + aRandom) * (uRandomHeightAmount * 0.1);
|
|
||||||
|
|
||||||
float edgeDistanceX = abs(origin.x - uPlayerPosition.x) / halfPatchSize;
|
vec3 worldPos = vec3(uPlayerPosition.x + origin.x, 0.0, uPlayerPosition.z + origin.z);
|
||||||
float edgeDistanceZ = abs(origin.z - uPlayerPosition.z) / halfPatchSize;
|
transformed.x = worldPos.x;
|
||||||
|
transformed.z = worldPos.z;
|
||||||
|
|
||||||
|
vec2 terrainUv = vec2(
|
||||||
|
mapValue(worldPos.x, uBoundingBoxMin.x, uBoundingBoxMax.x, 0.0, 1.0),
|
||||||
|
mapValue(worldPos.z, uBoundingBoxMin.z, uBoundingBoxMax.z, 0.0, 1.0)
|
||||||
|
);
|
||||||
|
terrainUv = clamp(terrainUv, 0.0, 1.0);
|
||||||
|
|
||||||
|
float terrainHeightRatio = texture2D(uHeightMap, terrainUv).r;
|
||||||
|
float terrainHeight = mix(uBoundingBoxMin.y, uBoundingBoxMax.y, terrainHeightRatio);
|
||||||
|
transformed.y = terrainHeight + uSurfaceOffset;
|
||||||
|
|
||||||
|
vec3 heightNoise = texture2D(uNoiseTexture, terrainUv.yx * vec2(uHeightNoiseFrequency)).rgb;
|
||||||
|
float heightModifier = ((heightNoise.r + heightNoise.g + heightNoise.b) * uMaxBladeHeight) * uHeightNoiseAmplitude;
|
||||||
|
heightModifier += random(terrainUv) * (uRandomHeightAmount * 0.1);
|
||||||
|
|
||||||
|
float edgeDistanceX = abs(origin.x) / halfPatchSize;
|
||||||
|
float edgeDistanceZ = abs(origin.z) / halfPatchSize;
|
||||||
float edgeFactor = 1.0 - max(edgeDistanceX, edgeDistanceZ);
|
float edgeFactor = 1.0 - max(edgeDistanceX, edgeDistanceZ);
|
||||||
edgeFactor = pow(clamp(edgeFactor, 0.0, 1.0), uFalloffSharpness);
|
edgeFactor = pow(clamp(edgeFactor, 0.0, 1.0), uFalloffSharpness);
|
||||||
|
|
||||||
float baldPatch = random(floor(uv * 3.0)) * (uBaldPatchModifier * (1.0 - edgeFactor));
|
float baldPatchOffset = heightNoise.r * (uBaldPatchModifier * (1.0 - edgeFactor));
|
||||||
heightModifier = max(0.0, heightModifier - baldPatch);
|
heightModifier -= baldPatchOffset;
|
||||||
|
|
||||||
float distanceFromCenter = length(origin.xz - uPlayerPosition.xz) / halfPatchSize;
|
float edgeFade =
|
||||||
|
smoothstep(uBoundingBoxMin.x, uBoundingBoxMin.x + 2.0, worldPos.x) *
|
||||||
|
smoothstep(uBoundingBoxMax.x, uBoundingBoxMax.x - 2.0, worldPos.x) *
|
||||||
|
smoothstep(uBoundingBoxMin.z, uBoundingBoxMin.z + 2.0, worldPos.z) *
|
||||||
|
smoothstep(uBoundingBoxMax.z, uBoundingBoxMax.z - 2.0, worldPos.z);
|
||||||
|
heightModifier *= edgeFade;
|
||||||
|
|
||||||
|
float sideFactor = (color.r == 0.1) ? 1.0 : (color.b == 0.1) ? -1.0 : 0.0;
|
||||||
|
float tipFactor = color.g;
|
||||||
|
float width = smoothstep(0.5, 1.0, heightModifier * 2.0) * uBladeWidth;
|
||||||
|
transformed += aYaw * (width / 2.0) * sideFactor;
|
||||||
|
|
||||||
|
vec3 textureColor = texture2D(uDiffuseMap, terrainUv * 10.0).rgb;
|
||||||
|
vec3 colorNoise = texture2D(uNoiseTexture, terrainUv.yx * vec2(uHeightNoiseFrequency) + (uTime * 0.1)).rgb;
|
||||||
|
vColor = mix(aBladeColor * 0.55, aBladeColor, tipFactor) * textureColor * mix(vec3(0.75), vec3(1.15), colorNoise);
|
||||||
|
|
||||||
|
float distanceFromCenter = length(origin.xz) / halfPatchSize;
|
||||||
float innerCircleFactor = clamp(smoothstep(0.0, 0.5, distanceFromCenter), 0.0, 1.0);
|
float innerCircleFactor = clamp(smoothstep(0.0, 0.5, distanceFromCenter), 0.0, 1.0);
|
||||||
heightModifier *= mix(0.25, 1.0, innerCircleFactor);
|
heightModifier *= mix(0.25, 1.0, innerCircleFactor);
|
||||||
|
|
||||||
vec3 tangent = normalize(aYaw - aBladeNormal * dot(aYaw, aBladeNormal));
|
|
||||||
transformed += tangent * (uBladeWidth * 0.5) * aSideFactor;
|
|
||||||
transformed += aBladeNormal * heightModifier * aTipFactor;
|
|
||||||
|
|
||||||
float noiseScale = uWindNoiseScale * 0.1;
|
float noiseScale = uWindNoiseScale * 0.1;
|
||||||
vec2 noiseUV = vec2(origin.x * noiseScale, origin.z * noiseScale);
|
vec2 noiseUV = vec2(origin.x * noiseScale, origin.z * noiseScale);
|
||||||
mat2 rotation = mat2(
|
mat2 rotation = mat2(
|
||||||
@@ -79,20 +107,18 @@ export const grassVertexShader = /* glsl */ `
|
|||||||
sin(uWindDirection), cos(uWindDirection)
|
sin(uWindDirection), cos(uWindDirection)
|
||||||
);
|
);
|
||||||
vec2 rotatedNoiseUV = rotation * noiseUV + uTime * vec2(uWindSpeed);
|
vec2 rotatedNoiseUV = rotation * noiseUV + uTime * vec2(uWindSpeed);
|
||||||
float windA = random(floor(rotatedNoiseUV * 10.0));
|
vec3 windNoise = texture2D(uNoiseTexture, rotatedNoiseUV).rgb;
|
||||||
float windB = random(floor(rotatedNoiseUV.yx * 10.0 + 17.0));
|
|
||||||
vec3 axis = normalize(vec3(windA, 0.0, windB));
|
vec3 axis = vec3(windNoise.g, 0.0, windNoise.b);
|
||||||
float angle = radians(mapValue(windA + windB, 0.0, 2.0, -uMaxBendAngle, uMaxBendAngle)) * aTipFactor;
|
float angle = radians(mapValue(windNoise.g + windNoise.b, 0.0, 2.0, -uMaxBendAngle, uMaxBendAngle)) * tipFactor;
|
||||||
mat3 rotationMatrix = rotate3d(axis, angle);
|
mat3 rotationMatrix = rotate3d(axis, angle);
|
||||||
|
|
||||||
vec3 relativePosition = transformed - origin;
|
vec3 basePosition = vec3(transformed.x, transformed.y - heightModifier, transformed.z);
|
||||||
|
vec3 relativePosition = transformed - basePosition;
|
||||||
relativePosition = rotationMatrix * relativePosition;
|
relativePosition = rotationMatrix * relativePosition;
|
||||||
transformed = origin + relativePosition;
|
transformed = basePosition + relativePosition;
|
||||||
|
transformed.y += heightModifier * tipFactor;
|
||||||
|
|
||||||
vec3 baseColor = aBladeColor * 0.45;
|
|
||||||
vec3 tipColor = aBladeColor;
|
|
||||||
float shadeNoise = random(floor((uv + uTime * 0.01) * 24.0));
|
|
||||||
vColor = mix(baseColor, tipColor, aTipFactor) * mix(0.72, 1.08, shadeNoise);
|
|
||||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
|
|||||||
import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface";
|
import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
|
import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
|
||||||
|
import { GRASS_CONFIG } from "@/world/grass/grassConfig";
|
||||||
|
|
||||||
const RAYCAST_Y = 500;
|
const RAYCAST_Y = 500;
|
||||||
const RAYCAST_FAR = 1000;
|
const RAYCAST_FAR = 1000;
|
||||||
@@ -20,6 +21,9 @@ export interface TerrainGrassSample {
|
|||||||
|
|
||||||
export interface TerrainGrassSampler {
|
export interface TerrainGrassSampler {
|
||||||
bounds: TerrainSurfaceBounds;
|
bounds: TerrainSurfaceBounds;
|
||||||
|
heightTexture: THREE.DataTexture;
|
||||||
|
maxHeight: number;
|
||||||
|
minHeight: number;
|
||||||
sample: (x: number, z: number) => TerrainGrassSample | null;
|
sample: (x: number, z: number) => TerrainGrassSample | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,33 +86,98 @@ function createTerrainGrassSampler(
|
|||||||
maxZ: terrainBounds.max.z,
|
maxZ: terrainBounds.max.z,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sample = (x: number, z: number): TerrainGrassSample | null => {
|
||||||
|
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
||||||
|
inverseTerrainMatrix,
|
||||||
|
);
|
||||||
|
const localDirection =
|
||||||
|
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
||||||
|
|
||||||
|
raycaster.set(localOrigin, localDirection);
|
||||||
|
const hit = raycaster.intersectObjects(meshes, false)[0];
|
||||||
|
if (!hit) return null;
|
||||||
|
|
||||||
|
const normal = hit.face?.normal
|
||||||
|
.clone()
|
||||||
|
.transformDirection(hit.object.matrixWorld)
|
||||||
|
.applyMatrix3(normalMatrix)
|
||||||
|
.normalize();
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: hit.point.clone().applyMatrix4(terrainMatrix),
|
||||||
|
normal: normal ?? new THREE.Vector3(0, 1, 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { heightTexture, maxHeight, minHeight } = createTerrainHeightTexture(
|
||||||
|
bounds,
|
||||||
|
sample,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bounds,
|
bounds,
|
||||||
sample: (x, z) => {
|
heightTexture,
|
||||||
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
maxHeight,
|
||||||
inverseTerrainMatrix,
|
minHeight,
|
||||||
);
|
sample,
|
||||||
const localDirection =
|
|
||||||
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
|
||||||
|
|
||||||
raycaster.set(localOrigin, localDirection);
|
|
||||||
const hit = raycaster.intersectObjects(meshes, false)[0];
|
|
||||||
if (!hit) return null;
|
|
||||||
|
|
||||||
const normal = hit.face?.normal
|
|
||||||
.clone()
|
|
||||||
.transformDirection(hit.object.matrixWorld)
|
|
||||||
.applyMatrix3(normalMatrix)
|
|
||||||
.normalize();
|
|
||||||
|
|
||||||
return {
|
|
||||||
position: hit.point.clone().applyMatrix4(terrainMatrix),
|
|
||||||
normal: normal ?? new THREE.Vector3(0, 1, 0),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTerrainHeightTexture(
|
||||||
|
bounds: TerrainSurfaceBounds,
|
||||||
|
sample: (x: number, z: number) => TerrainGrassSample | null,
|
||||||
|
): { heightTexture: THREE.DataTexture; maxHeight: number; minHeight: number } {
|
||||||
|
const size = GRASS_CONFIG.heightTextureSize;
|
||||||
|
const heights = new Float32Array(size * size);
|
||||||
|
let minHeight = Number.POSITIVE_INFINITY;
|
||||||
|
let maxHeight = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let zIndex = 0; zIndex < size; zIndex++) {
|
||||||
|
for (let xIndex = 0; xIndex < size; xIndex++) {
|
||||||
|
const xRatio = size <= 1 ? 0 : xIndex / (size - 1);
|
||||||
|
const zRatio = size <= 1 ? 0 : zIndex / (size - 1);
|
||||||
|
const x = bounds.minX + (bounds.maxX - bounds.minX) * xRatio;
|
||||||
|
const z = bounds.minZ + (bounds.maxZ - bounds.minZ) * zRatio;
|
||||||
|
const terrainSample = sample(x, z);
|
||||||
|
const height = terrainSample?.position.y ?? 0;
|
||||||
|
const index = zIndex * size + xIndex;
|
||||||
|
|
||||||
|
heights[index] = height;
|
||||||
|
minHeight = Math.min(minHeight, height);
|
||||||
|
maxHeight = Math.max(maxHeight, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(minHeight) || !Number.isFinite(maxHeight)) {
|
||||||
|
minHeight = 0;
|
||||||
|
maxHeight = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = Math.max(maxHeight - minHeight, 0.0001);
|
||||||
|
const data = new Uint8Array(size * size);
|
||||||
|
|
||||||
|
for (let index = 0; index < heights.length; index++) {
|
||||||
|
data[index] = Math.round(
|
||||||
|
(((heights[index] ?? minHeight) - minHeight) / range) * 255,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightTexture = new THREE.DataTexture(
|
||||||
|
data,
|
||||||
|
size,
|
||||||
|
size,
|
||||||
|
THREE.RedFormat,
|
||||||
|
THREE.UnsignedByteType,
|
||||||
|
);
|
||||||
|
heightTexture.magFilter = THREE.LinearFilter;
|
||||||
|
heightTexture.minFilter = THREE.LinearFilter;
|
||||||
|
heightTexture.wrapS = THREE.ClampToEdgeWrapping;
|
||||||
|
heightTexture.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
|
heightTexture.needsUpdate = true;
|
||||||
|
|
||||||
|
return { heightTexture, maxHeight, minHeight };
|
||||||
|
}
|
||||||
|
|
||||||
export function useTerrainGrassSampler(): TerrainGrassSampler {
|
export function useTerrainGrassSampler(): TerrainGrassSampler {
|
||||||
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
||||||
const terrainNode = getMapNodesByName("terrain")[0];
|
const terrainNode = getMapNodesByName("terrain")[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user