diff --git a/src/world/grass/GrassPatch.tsx b/src/world/grass/GrassPatch.tsx index c2e07c8..015cac4 100644 --- a/src/world/grass/GrassPatch.tsx +++ b/src/world/grass/GrassPatch.tsx @@ -1,32 +1,19 @@ 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 { 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 { GRASS_COLORS, GRASS_CONFIG } from "@/world/grass/grassConfig"; import { grassFragmentShader, grassVertexShader, } from "@/world/grass/grassShaders"; +import type { TerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler"; interface GrassPatchProps { chunkX: number; chunkZ: number; density: number; - terrainSurfaceData: TerrainSurfaceData; -} - -interface GrassBladeVertexData { - color: number[]; - heightFactor: number; - position: number[]; + terrainSampler: TerrainGrassSampler; } function random01(seed: number): number { @@ -34,81 +21,68 @@ function random01(seed: number): number { 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 }, + uPlayerPosition: { value: new THREE.Vector3() }, + uPatchSize: { value: GRASS_CONFIG.chunkSize }, + uBladeWidth: { value: GRASS_CONFIG.bladeWidth }, uWindDirection: { value: 0 }, uWindSpeed: { value: 0 }, - uWindStrength: { value: 0 }, 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( positions: number[], - colors: number[], + bladeColors: number[], bladeBases: number[], - heightFactors: number[], - windPhases: number[], + bladeNormals: number[], + sideFactors: number[], + tipFactors: number[], + randoms: number[], + yaws: number[], basePosition: THREE.Vector3, - yaw: number, - width: number, - height: number, - baseColor: THREE.Color, - tipColor: THREE.Color, - windPhase: number, + normal: THREE.Vector3, + color: THREE.Color, + yaw: THREE.Vector3, + random: 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, - }, + const vertices = [ + { side: 1, tip: 0 }, + { side: -1, tip: 0 }, + { side: 0, tip: 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); + 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); } } @@ -116,107 +90,84 @@ function createGrassGeometry( chunkX: number, chunkZ: number, density: number, - terrainSurfaceData: TerrainSurfaceData, - getHeight: (x: number, z: number) => number | null, + terrainSampler: TerrainGrassSampler, ): THREE.BufferGeometry | null { const positions: number[] = []; - const colors: number[] = []; + const bladeColors: number[] = []; const bladeBases: number[] = []; - const heightFactors: number[] = []; - const windPhases: number[] = []; - const baseColor = new THREE.Color(GRASS_CONFIG.baseColor); + 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 endX = startX + GRASS_CONFIG.chunkSize; - const endZ = startZ + GRASS_CONFIG.chunkSize; - const bladeBudget = Math.round(GRASS_CONFIG.maxBladesPerChunk * density); - let bladeCount = 0; + const bladeCount = Math.round(GRASS_CONFIG.baseBladesPerChunk * density); - 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; + 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 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 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); - 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; - } - } + addGrassBlade( + positions, + bladeColors, + bladeBases, + bladeNormals, + sideFactors, + tipFactors, + randoms, + yaws, + basePosition, + sample.normal, + color, + yaw, + random01(seed + 5), + ); } - if (bladeCount === 0) return null; + if (positions.length === 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( + "aBladeColor", + new THREE.Float32BufferAttribute(bladeColors, 3), + ); geometry.setAttribute( "aBladeBase", new THREE.Float32BufferAttribute(bladeBases, 3), ); geometry.setAttribute( - "aHeightFactor", - new THREE.Float32BufferAttribute(heightFactors, 1), + "aBladeNormal", + new THREE.Float32BufferAttribute(bladeNormals, 3), ); geometry.setAttribute( - "aWindPhase", - new THREE.Float32BufferAttribute(windPhases, 1), + "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(); @@ -227,21 +178,14 @@ export function GrassPatch({ chunkX, chunkZ, density, - terrainSurfaceData, + terrainSampler, }: GrassPatchProps): React.JSX.Element | null { - const terrainHeight = useTerrainHeightSampler(); + const camera = useThree((state) => state.camera); const wind = useWind(); const materialRef = useRef(null); const geometry = useMemo( - () => - createGrassGeometry( - chunkX, - chunkZ, - density, - terrainSurfaceData, - terrainHeight.getHeight, - ), - [chunkX, chunkZ, density, terrainHeight.getHeight, terrainSurfaceData], + () => createGrassGeometry(chunkX, chunkZ, density, terrainSampler), + [chunkX, chunkZ, density, terrainSampler], ); const material = useMemo(() => createGrassMaterial(), []); @@ -265,9 +209,11 @@ export function GrassPatch({ const uniforms = currentMaterial.uniforms; 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.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; @@ -276,5 +222,5 @@ export function GrassPatch({ if (!geometry) return null; - return ; + return ; } diff --git a/src/world/grass/GrassSystem.tsx b/src/world/grass/GrassSystem.tsx index 3b8053e..32b7f09 100644 --- a/src/world/grass/GrassSystem.tsx +++ b/src/world/grass/GrassSystem.tsx @@ -1,7 +1,6 @@ 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, @@ -9,6 +8,7 @@ import { import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface"; import { GRASS_CONFIG } from "@/world/grass/grassConfig"; import { GrassPatch } from "@/world/grass/GrassPatch"; +import { useTerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler"; interface GrassChunk { centerX: number; @@ -52,7 +52,7 @@ function createGrassChunks(bounds: TerrainSurfaceBounds): GrassChunk[] { export function GrassSystem(): React.JSX.Element | null { const camera = useThree((state) => state.camera); - const terrainSurfaceData = useTerrainSurfaceData(); + const terrainSampler = useTerrainGrassSampler(); const sceneMode = useSceneMode(); const dynamicGrass = useDynamicGrass(); const grassDensity = useGrassDensity(); @@ -62,9 +62,8 @@ export function GrassSystem(): React.JSX.Element | null { ); const density = Math.max(0, grassDensity); const chunks = useMemo( - () => - terrainSurfaceData ? createGrassChunks(terrainSurfaceData.bounds) : [], - [terrainSurfaceData], + () => createGrassChunks(terrainSampler.bounds), + [terrainSampler], ); const streamingEnabled = sceneMode === "game"; @@ -110,7 +109,7 @@ export function GrassSystem(): React.JSX.Element | null { !GRASS_CONFIG.enabled || !dynamicGrass || density <= 0 || - !terrainSurfaceData + chunks.length === 0 ) { return null; } @@ -138,7 +137,7 @@ export function GrassSystem(): React.JSX.Element | null { chunkX={chunk.x} chunkZ={chunk.z} density={density} - terrainSurfaceData={terrainSurfaceData} + terrainSampler={terrainSampler} /> ))} diff --git a/src/world/grass/grassConfig.ts b/src/world/grass/grassConfig.ts index dd237d2..cd0ab5c 100644 --- a/src/world/grass/grassConfig.ts +++ b/src/world/grass/grassConfig.ts @@ -1,33 +1,20 @@ -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, + baseBladesPerChunk: 2600, + bladeWidth: 0.08, + maxBladeHeight: 0.36, + randomHeightAmount: 0.25, + surfaceOffset: 0.025, + windNoiseScale: 0.9, + baldPatchModifier: 2.5, + falloffSharpness: 0.35, + heightNoiseFrequency: 12, + heightNoiseAmplitude: 3, + maxBendAngle: 22, } 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; -} +export const GRASS_COLORS = ["#84C66B", "#67B058", "#A3CA5B"] as const; diff --git a/src/world/grass/grassShaders.ts b/src/world/grass/grassShaders.ts index 90280e2..7f1d3f4 100644 --- a/src/world/grass/grassShaders.ts +++ b/src/world/grass/grassShaders.ts @@ -1,40 +1,98 @@ export const grassVertexShader = /* glsl */ ` - attribute vec3 aColor; + attribute vec3 aBladeColor; + attribute vec3 aBladeNormal; attribute vec3 aBladeBase; - attribute float aHeightFactor; - attribute float aWindPhase; + attribute float aSideFactor; + attribute float aTipFactor; + attribute float aRandom; + attribute vec3 aYaw; varying vec3 vColor; uniform float uTime; + uniform vec3 uPlayerPosition; + uniform float uPatchSize; + uniform float uBladeWidth; uniform float uWindDirection; uniform float uWindSpeed; - uniform float uWindStrength; 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() { - vec3 transformed = position; - float topFactor = aHeightFactor * aHeightFactor; - vec2 windDirection = normalize(vec2(cos(uWindDirection), sin(uWindDirection))); + vec3 origin = aBladeBase; + vec3 transformed = origin; + float halfPatchSize = uPatchSize * 0.5; + vec2 uv = vec2(origin.x, origin.z) * 0.05; - float primaryWind = sin( - uTime * max(uWindSpeed, 0.05) + - aWindPhase + - aBladeBase.x * uWindNoiseScale + - aBladeBase.z * uWindNoiseScale + float heightNoise = + random(floor(uv * uHeightNoiseFrequency) + aRandom) * + uMaxBladeHeight * + uHeightNoiseAmplitude; + 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( - uTime * max(uWindSpeed, 0.05) * 1.73 + - aWindPhase * 0.71 + - aBladeBase.x * uWindNoiseScale * 0.53 - - aBladeBase.z * uWindNoiseScale * 0.89 - ) * 0.35; + vec2 rotatedNoiseUV = rotation * noiseUV + uTime * vec2(uWindSpeed); + float windA = random(floor(rotatedNoiseUV * 10.0)); + float windB = random(floor(rotatedNoiseUV.yx * 10.0 + 17.0)); + vec3 axis = normalize(vec3(windA, 0.0, windB)); + float angle = radians(mapValue(windA + windB, 0.0, 2.0, -uMaxBendAngle, uMaxBendAngle)) * aTipFactor; + mat3 rotationMatrix = rotate3d(axis, angle); - float bend = (primaryWind + secondaryWind) * uWindStrength * uBendStrength * topFactor; - transformed.xz += windDirection * bend; + vec3 relativePosition = transformed - origin; + 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); } `; diff --git a/src/world/grass/useTerrainGrassSampler.ts b/src/world/grass/useTerrainGrassSampler.ts new file mode 100644 index 0000000..e3347cc --- /dev/null +++ b/src/world/grass/useTerrainGrassSampler.ts @@ -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], + ); +}