Feat/map-environment #6

Merged
math-pixel merged 116 commits from feat/map-environment into develop 2026-05-29 00:00:51 +00:00
5 changed files with 328 additions and 215 deletions
Showing only changes of commit fe989c9550 - Show all commits
+107 -161
View File
@@ -1,32 +1,19 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three"; 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 { useWind } from "@/hooks/world/useWind";
import type { TerrainSurfaceData } from "@/types/world/terrainSurface"; import { GRASS_COLORS, GRASS_CONFIG } from "@/world/grass/grassConfig";
import { sampleTerrainSurfaceAtXZ } from "@/utils/world/terrainSurfaceSampler";
import {
getGrassTipColor,
GRASS_CONFIG,
GRASS_SURFACE_KEYS,
} from "@/world/grass/grassConfig";
import { import {
grassFragmentShader, grassFragmentShader,
grassVertexShader, grassVertexShader,
} from "@/world/grass/grassShaders"; } from "@/world/grass/grassShaders";
import type { TerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
interface GrassPatchProps { interface GrassPatchProps {
chunkX: number; chunkX: number;
chunkZ: number; chunkZ: number;
density: number; density: number;
terrainSurfaceData: TerrainSurfaceData; terrainSampler: TerrainGrassSampler;
}
interface GrassBladeVertexData {
color: number[];
heightFactor: number;
position: number[];
} }
function random01(seed: number): number { function random01(seed: number): number {
@@ -34,81 +21,68 @@ function random01(seed: number): number {
return value - Math.floor(value); return value - Math.floor(value);
} }
function lerp(min: number, max: number, ratio: number): number {
return min + (max - min) * ratio;
}
function createGrassMaterial(): THREE.ShaderMaterial { function createGrassMaterial(): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({ return new THREE.ShaderMaterial({
side: THREE.DoubleSide, side: THREE.DoubleSide,
vertexColors: true,
vertexShader: grassVertexShader, vertexShader: grassVertexShader,
fragmentShader: grassFragmentShader, fragmentShader: grassFragmentShader,
uniforms: { uniforms: {
uTime: { value: 0 }, uTime: { value: 0 },
uPlayerPosition: { value: new THREE.Vector3() },
uPatchSize: { value: GRASS_CONFIG.chunkSize },
uBladeWidth: { value: GRASS_CONFIG.bladeWidth },
uWindDirection: { value: 0 }, uWindDirection: { value: 0 },
uWindSpeed: { value: 0 }, uWindSpeed: { value: 0 },
uWindStrength: { value: 0 },
uWindNoiseScale: { value: GRASS_CONFIG.windNoiseScale }, uWindNoiseScale: { value: GRASS_CONFIG.windNoiseScale },
uBendStrength: { value: GRASS_CONFIG.windBendStrength }, uBaldPatchModifier: { value: GRASS_CONFIG.baldPatchModifier },
uFalloffSharpness: { value: GRASS_CONFIG.falloffSharpness },
uHeightNoiseFrequency: { value: GRASS_CONFIG.heightNoiseFrequency },
uHeightNoiseAmplitude: { value: GRASS_CONFIG.heightNoiseAmplitude },
uMaxBendAngle: { value: GRASS_CONFIG.maxBendAngle },
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
}, },
}); });
} }
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( function addGrassBlade(
positions: number[], positions: number[],
colors: number[], bladeColors: number[],
bladeBases: number[], bladeBases: number[],
heightFactors: number[], bladeNormals: number[],
windPhases: number[], sideFactors: number[],
tipFactors: number[],
randoms: number[],
yaws: number[],
basePosition: THREE.Vector3, basePosition: THREE.Vector3,
yaw: number, normal: THREE.Vector3,
width: number, color: THREE.Color,
height: number, yaw: THREE.Vector3,
baseColor: THREE.Color, random: number,
tipColor: THREE.Color,
windPhase: number,
): void { ): void {
const rightX = Math.cos(yaw) * width * 0.5; const vertices = [
const rightZ = Math.sin(yaw) * width * 0.5; { side: 1, tip: 0 },
const leanX = Math.cos(yaw + Math.PI * 0.5) * width * 0.22; { side: -1, tip: 0 },
const leanZ = Math.sin(yaw + Math.PI * 0.5) * width * 0.22; { side: 0, tip: 1 },
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) { for (const vertex of vertices) {
positions.push(...vertex.position); pushVector(positions, basePosition);
colors.push(...vertex.color); pushColor(bladeColors, color);
bladeBases.push(basePosition.x, basePosition.y, basePosition.z); pushVector(bladeBases, basePosition);
heightFactors.push(vertex.heightFactor); pushVector(bladeNormals, normal);
windPhases.push(windPhase); pushVector(yaws, yaw);
sideFactors.push(vertex.side);
tipFactors.push(vertex.tip);
randoms.push(random);
} }
} }
@@ -116,107 +90,84 @@ function createGrassGeometry(
chunkX: number, chunkX: number,
chunkZ: number, chunkZ: number,
density: number, density: number,
terrainSurfaceData: TerrainSurfaceData, terrainSampler: TerrainGrassSampler,
getHeight: (x: number, z: number) => number | null,
): THREE.BufferGeometry | null { ): THREE.BufferGeometry | null {
const positions: number[] = []; const positions: number[] = [];
const colors: number[] = []; const bladeColors: number[] = [];
const bladeBases: number[] = []; const bladeBases: number[] = [];
const heightFactors: number[] = []; const bladeNormals: number[] = [];
const windPhases: number[] = []; const sideFactors: number[] = [];
const baseColor = new THREE.Color(GRASS_CONFIG.baseColor); const tipFactors: number[] = [];
const randoms: number[] = [];
const yaws: number[] = [];
const startX = chunkX * GRASS_CONFIG.chunkSize; const startX = chunkX * GRASS_CONFIG.chunkSize;
const startZ = chunkZ * GRASS_CONFIG.chunkSize; const startZ = chunkZ * GRASS_CONFIG.chunkSize;
const endX = startX + GRASS_CONFIG.chunkSize; const bladeCount = Math.round(GRASS_CONFIG.baseBladesPerChunk * density);
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 index = 0; index < bladeCount; index++) {
for (let z = startZ; z < endZ; z += GRASS_CONFIG.sampleStep) { const seed = (chunkX + 101) * 92821 + (chunkZ + 103) * 68917 + index * 997;
for ( const x = startX + random01(seed + 1) * GRASS_CONFIG.chunkSize;
let bladeIndex = 0; const z = startZ + random01(seed + 2) * GRASS_CONFIG.chunkSize;
bladeIndex < GRASS_CONFIG.bladesPerCell; const sample = terrainSampler.sample(x, z);
bladeIndex++ if (!sample) continue;
) {
if (bladeCount >= bladeBudget) break;
const seed = const colorIndex = Math.floor(random01(seed + 3) * GRASS_COLORS.length);
(chunkX + 101) * 92821 + const color = new THREE.Color(GRASS_COLORS[colorIndex] ?? GRASS_COLORS[0]);
(chunkZ + 103) * 68917 + const yawAngle = random01(seed + 4) * Math.PI * 2;
Math.round(x * 13) * 193 + const yaw = new THREE.Vector3(Math.sin(yawAngle), 0, -Math.cos(yawAngle));
Math.round(z * 17) * 389 + const basePosition = sample.position
bladeIndex * 997; .clone()
if (random01(seed) > density) continue; .addScaledVector(sample.normal, GRASS_CONFIG.surfaceOffset);
const sampleX = x + (random01(seed + 1) - 0.5) * GRASS_CONFIG.jitter; addGrassBlade(
const sampleZ = z + (random01(seed + 2) - 0.5) * GRASS_CONFIG.jitter; positions,
const sample = sampleTerrainSurfaceAtXZ( bladeColors,
terrainSurfaceData.imageData, bladeBases,
sampleX, bladeNormals,
sampleZ, sideFactors,
terrainSurfaceData.bounds, tipFactors,
TERRAIN_SURFACE_PROJECTION, randoms,
); yaws,
basePosition,
if (!sample.key || !GRASS_SURFACE_KEYS.has(sample.key as never)) sample.normal,
continue; color,
yaw,
const height = getHeight(sampleX, sampleZ); random01(seed + 5),
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; if (positions.length === 0) return null;
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute( geometry.setAttribute(
"position", "position",
new THREE.Float32BufferAttribute(positions, 3), new THREE.Float32BufferAttribute(positions, 3),
); );
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); geometry.setAttribute(
"aBladeColor",
new THREE.Float32BufferAttribute(bladeColors, 3),
);
geometry.setAttribute( geometry.setAttribute(
"aBladeBase", "aBladeBase",
new THREE.Float32BufferAttribute(bladeBases, 3), new THREE.Float32BufferAttribute(bladeBases, 3),
); );
geometry.setAttribute( geometry.setAttribute(
"aHeightFactor", "aBladeNormal",
new THREE.Float32BufferAttribute(heightFactors, 1), new THREE.Float32BufferAttribute(bladeNormals, 3),
); );
geometry.setAttribute( geometry.setAttribute(
"aWindPhase", "aSideFactor",
new THREE.Float32BufferAttribute(windPhases, 1), 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.computeVertexNormals();
geometry.computeBoundingSphere(); geometry.computeBoundingSphere();
@@ -227,21 +178,14 @@ export function GrassPatch({
chunkX, chunkX,
chunkZ, chunkZ,
density, density,
terrainSurfaceData, terrainSampler,
}: GrassPatchProps): React.JSX.Element | null { }: GrassPatchProps): React.JSX.Element | null {
const terrainHeight = useTerrainHeightSampler(); const camera = useThree((state) => state.camera);
const wind = useWind(); const wind = useWind();
const materialRef = useRef<THREE.ShaderMaterial | null>(null); const materialRef = useRef<THREE.ShaderMaterial | null>(null);
const geometry = useMemo( const geometry = useMemo(
() => () => createGrassGeometry(chunkX, chunkZ, density, terrainSampler),
createGrassGeometry( [chunkX, chunkZ, density, terrainSampler],
chunkX,
chunkZ,
density,
terrainSurfaceData,
terrainHeight.getHeight,
),
[chunkX, chunkZ, density, terrainHeight.getHeight, terrainSurfaceData],
); );
const material = useMemo(() => createGrassMaterial(), []); const material = useMemo(() => createGrassMaterial(), []);
@@ -265,9 +209,11 @@ export function GrassPatch({
const uniforms = currentMaterial.uniforms; const uniforms = currentMaterial.uniforms;
if (uniforms.uTime) uniforms.uTime.value = clock.elapsedTime; if (uniforms.uTime) uniforms.uTime.value = clock.elapsedTime;
if (uniforms.uPlayerPosition) {
uniforms.uPlayerPosition.value.copy(camera.position);
}
if (uniforms.uWindDirection) uniforms.uWindDirection.value = wind.direction; if (uniforms.uWindDirection) uniforms.uWindDirection.value = wind.direction;
if (uniforms.uWindSpeed) uniforms.uWindSpeed.value = wind.speed; if (uniforms.uWindSpeed) uniforms.uWindSpeed.value = wind.speed;
if (uniforms.uWindStrength) uniforms.uWindStrength.value = wind.strength;
if (uniforms.uWindNoiseScale) { if (uniforms.uWindNoiseScale) {
uniforms.uWindNoiseScale.value = uniforms.uWindNoiseScale.value =
GRASS_CONFIG.windNoiseScale * wind.noiseScale; GRASS_CONFIG.windNoiseScale * wind.noiseScale;
@@ -276,5 +222,5 @@ export function GrassPatch({
if (!geometry) return null; if (!geometry) return null;
return <mesh geometry={geometry} material={material} frustumCulled />; return <mesh geometry={geometry} material={material} frustumCulled={false} />;
} }
+6 -7
View File
@@ -1,7 +1,6 @@
import { Suspense, useCallback, useMemo, useRef, useState } from "react"; import { Suspense, useCallback, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useTerrainSurfaceData } from "@/hooks/world/useTerrainSurfaceData";
import { import {
useDynamicGrass, useDynamicGrass,
useGrassDensity, useGrassDensity,
@@ -9,6 +8,7 @@ import {
import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface"; 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";
interface GrassChunk { interface GrassChunk {
centerX: number; centerX: number;
@@ -52,7 +52,7 @@ function createGrassChunks(bounds: TerrainSurfaceBounds): GrassChunk[] {
export function GrassSystem(): React.JSX.Element | null { export function GrassSystem(): React.JSX.Element | null {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const terrainSurfaceData = useTerrainSurfaceData(); const terrainSampler = useTerrainGrassSampler();
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
const dynamicGrass = useDynamicGrass(); const dynamicGrass = useDynamicGrass();
const grassDensity = useGrassDensity(); const grassDensity = useGrassDensity();
@@ -62,9 +62,8 @@ export function GrassSystem(): React.JSX.Element | null {
); );
const density = Math.max(0, grassDensity); const density = Math.max(0, grassDensity);
const chunks = useMemo( const chunks = useMemo(
() => () => createGrassChunks(terrainSampler.bounds),
terrainSurfaceData ? createGrassChunks(terrainSurfaceData.bounds) : [], [terrainSampler],
[terrainSurfaceData],
); );
const streamingEnabled = sceneMode === "game"; const streamingEnabled = sceneMode === "game";
@@ -110,7 +109,7 @@ export function GrassSystem(): React.JSX.Element | null {
!GRASS_CONFIG.enabled || !GRASS_CONFIG.enabled ||
!dynamicGrass || !dynamicGrass ||
density <= 0 || density <= 0 ||
!terrainSurfaceData chunks.length === 0
) { ) {
return null; return null;
} }
@@ -138,7 +137,7 @@ export function GrassSystem(): React.JSX.Element | null {
chunkX={chunk.x} chunkX={chunk.x}
chunkZ={chunk.z} chunkZ={chunk.z}
density={density} density={density}
terrainSurfaceData={terrainSurfaceData} terrainSampler={terrainSampler}
/> />
</Suspense> </Suspense>
))} ))}
+12 -25
View File
@@ -1,33 +1,20 @@
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
export const GRASS_CONFIG = { export const GRASS_CONFIG = {
enabled: true, enabled: true,
chunkSize: 20, chunkSize: 20,
loadRadius: 30, loadRadius: 30,
unloadRadius: 34, unloadRadius: 34,
updateInterval: 250, updateInterval: 250,
sampleStep: 1.15, baseBladesPerChunk: 2600,
jitter: 0.42, bladeWidth: 0.08,
bladesPerCell: 2, maxBladeHeight: 0.36,
maxBladesPerChunk: 720, randomHeightAmount: 0.25,
bladeWidth: 0.12, surfaceOffset: 0.025,
minBladeHeight: 0.42, windNoiseScale: 0.9,
maxBladeHeight: 0.82, baldPatchModifier: 2.5,
surfaceOffset: 0.06, falloffSharpness: 0.35,
baseColor: "#1f3512", heightNoiseFrequency: 12,
windBendStrength: 0.42, heightNoiseAmplitude: 3,
windNoiseScale: 0.09, maxBendAngle: 22,
} as const; } as const;
export const GRASS_SURFACE_KEYS = new Set([ export const GRASS_COLORS = ["#84C66B", "#67B058", "#A3CA5B"] as const;
"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;
}
+80 -22
View File
@@ -1,40 +1,98 @@
export const grassVertexShader = /* glsl */ ` export const grassVertexShader = /* glsl */ `
attribute vec3 aColor; attribute vec3 aBladeColor;
attribute vec3 aBladeNormal;
attribute vec3 aBladeBase; attribute vec3 aBladeBase;
attribute float aHeightFactor; attribute float aSideFactor;
attribute float aWindPhase; attribute float aTipFactor;
attribute float aRandom;
attribute vec3 aYaw;
varying vec3 vColor; varying vec3 vColor;
uniform float uTime; uniform float uTime;
uniform vec3 uPlayerPosition;
uniform float uPatchSize;
uniform float uBladeWidth;
uniform float uWindDirection; uniform float uWindDirection;
uniform float uWindSpeed; uniform float uWindSpeed;
uniform float uWindStrength;
uniform float uWindNoiseScale; uniform float uWindNoiseScale;
uniform float uBendStrength; uniform float uBaldPatchModifier;
uniform float uFalloffSharpness;
uniform float uHeightNoiseFrequency;
uniform float uHeightNoiseAmplitude;
uniform float uMaxBendAngle;
uniform float uMaxBladeHeight;
uniform float uRandomHeightAmount;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
mat3 rotate3d(in vec3 axis, const in float angle) {
axis = normalize(axis);
float s = sin(angle);
float c = cos(angle);
float oc = 1.0 - c;
return mat3(
oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s,
oc * axis.x * axis.y + axis.z * s, oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s,
oc * axis.z * axis.x - axis.y * s, oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c
);
}
float mapValue(float value, float inMin, float inMax, float outMin, float outMax) {
return mix(outMin, outMax, (value - inMin) / (inMax - inMin));
}
void main() { void main() {
vec3 transformed = position; vec3 origin = aBladeBase;
float topFactor = aHeightFactor * aHeightFactor; vec3 transformed = origin;
vec2 windDirection = normalize(vec2(cos(uWindDirection), sin(uWindDirection))); float halfPatchSize = uPatchSize * 0.5;
vec2 uv = vec2(origin.x, origin.z) * 0.05;
float primaryWind = sin( float heightNoise =
uTime * max(uWindSpeed, 0.05) + random(floor(uv * uHeightNoiseFrequency) + aRandom) *
aWindPhase + uMaxBladeHeight *
aBladeBase.x * uWindNoiseScale + uHeightNoiseAmplitude;
aBladeBase.z * uWindNoiseScale float heightModifier = heightNoise + random(uv + aRandom) * (uRandomHeightAmount * 0.1);
float edgeDistanceX = abs(origin.x - uPlayerPosition.x) / halfPatchSize;
float edgeDistanceZ = abs(origin.z - uPlayerPosition.z) / halfPatchSize;
float edgeFactor = 1.0 - max(edgeDistanceX, edgeDistanceZ);
edgeFactor = pow(clamp(edgeFactor, 0.0, 1.0), uFalloffSharpness);
float baldPatch = random(floor(uv * 3.0)) * (uBaldPatchModifier * (1.0 - edgeFactor));
heightModifier = max(0.0, heightModifier - baldPatch);
float distanceFromCenter = length(origin.xz - uPlayerPosition.xz) / halfPatchSize;
float innerCircleFactor = clamp(smoothstep(0.0, 0.5, distanceFromCenter), 0.0, 1.0);
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;
vec2 noiseUV = vec2(origin.x * noiseScale, origin.z * noiseScale);
mat2 rotation = mat2(
cos(uWindDirection), -sin(uWindDirection),
sin(uWindDirection), cos(uWindDirection)
); );
float secondaryWind = sin( vec2 rotatedNoiseUV = rotation * noiseUV + uTime * vec2(uWindSpeed);
uTime * max(uWindSpeed, 0.05) * 1.73 + float windA = random(floor(rotatedNoiseUV * 10.0));
aWindPhase * 0.71 + float windB = random(floor(rotatedNoiseUV.yx * 10.0 + 17.0));
aBladeBase.x * uWindNoiseScale * 0.53 - vec3 axis = normalize(vec3(windA, 0.0, windB));
aBladeBase.z * uWindNoiseScale * 0.89 float angle = radians(mapValue(windA + windB, 0.0, 2.0, -uMaxBendAngle, uMaxBendAngle)) * aTipFactor;
) * 0.35; mat3 rotationMatrix = rotate3d(axis, angle);
float bend = (primaryWind + secondaryWind) * uWindStrength * uBendStrength * topFactor; vec3 relativePosition = transformed - origin;
transformed.xz += windDirection * bend; relativePosition = rotationMatrix * relativePosition;
transformed = origin + relativePosition;
vColor = aColor; 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);
} }
`; `;
+123
View File
@@ -0,0 +1,123 @@
import { useMemo } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface";
import type { Vector3Tuple } from "@/types/three/three";
import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
const RAYCAST_Y = 500;
const RAYCAST_FAR = 1000;
const DOWN = new THREE.Vector3(0, -1, 0);
const DEFAULT_TERRAIN_POSITION: Vector3Tuple = [0, 0, 0];
const DEFAULT_TERRAIN_ROTATION: Vector3Tuple = [0, 0, 0];
const DEFAULT_TERRAIN_SCALE: Vector3Tuple = [1, 1, 1];
export interface TerrainGrassSample {
normal: THREE.Vector3;
position: THREE.Vector3;
}
export interface TerrainGrassSampler {
bounds: TerrainSurfaceBounds;
sample: (x: number, z: number) => TerrainGrassSample | null;
}
function createFallbackBounds(): TerrainSurfaceBounds {
return {
minX: -120,
maxX: 120,
minZ: -120,
maxZ: 120,
};
}
function createTerrainMatrix(
position: Vector3Tuple,
rotation: Vector3Tuple,
scale: Vector3Tuple,
): THREE.Matrix4 {
return new THREE.Matrix4().compose(
new THREE.Vector3(...position),
new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)),
new THREE.Vector3(...scale),
);
}
function createTerrainGrassSampler(
scene: THREE.Object3D,
position: Vector3Tuple,
rotation: Vector3Tuple,
scale: Vector3Tuple,
): TerrainGrassSampler {
const meshes: THREE.Mesh[] = [];
const terrainMatrix = createTerrainMatrix(position, rotation, scale);
const inverseTerrainMatrix = terrainMatrix.clone().invert();
const normalMatrix = new THREE.Matrix3().getNormalMatrix(terrainMatrix);
const raycaster = new THREE.Raycaster(
new THREE.Vector3(),
DOWN,
0,
RAYCAST_FAR,
);
scene.updateMatrixWorld(true);
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.push(child);
}
});
const terrainBounds = new THREE.Box3().setFromObject(scene);
if (!terrainBounds.isEmpty()) {
terrainBounds.applyMatrix4(terrainMatrix);
}
const bounds = terrainBounds.isEmpty()
? createFallbackBounds()
: {
minX: terrainBounds.min.x,
maxX: terrainBounds.max.x,
minZ: terrainBounds.min.z,
maxZ: terrainBounds.max.z,
};
return {
bounds,
sample: (x, z) => {
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),
};
},
};
}
export function useTerrainGrassSampler(): TerrainGrassSampler {
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
const terrainNode = getMapNodesByName("terrain")[0];
const position = terrainNode?.position ?? DEFAULT_TERRAIN_POSITION;
const rotation = terrainNode?.rotation ?? DEFAULT_TERRAIN_ROTATION;
const scale = terrainNode?.scale ?? DEFAULT_TERRAIN_SCALE;
return useMemo(
() => createTerrainGrassSampler(scene, position, rotation, scale),
[position, rotation, scale, scene],
);
}