From 7a72743e5cc5bd1599ff1ba4568029ffe4d86acd Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 28 May 2026 00:31:45 +0200 Subject: [PATCH] refactor(environment): use player-centered ghibli grass patch --- public/textures/grass/grass.jpg | 3 + public/textures/grass/noise.png | 3 + src/world/grass/GrassPatch.tsx | 294 +++++++++++----------- src/world/grass/GrassSystem.tsx | 131 +--------- src/world/grass/grassConfig.ts | 8 +- src/world/grass/grassShaders.ts | 92 ++++--- src/world/grass/useTerrainGrassSampler.ts | 113 +++++++-- 7 files changed, 314 insertions(+), 330 deletions(-) create mode 100644 public/textures/grass/grass.jpg create mode 100644 public/textures/grass/noise.png diff --git a/public/textures/grass/grass.jpg b/public/textures/grass/grass.jpg new file mode 100644 index 0000000..93b20f0 --- /dev/null +++ b/public/textures/grass/grass.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f2581ae7efc473aeffb1ec62bf14726bac7b3af3631865171e82dae4b6485448 +size 38570 diff --git a/public/textures/grass/noise.png b/public/textures/grass/noise.png new file mode 100644 index 0000000..88bd9c3 --- /dev/null +++ b/public/textures/grass/noise.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52b89ff8ae978423bc51544fdeee716ee037059b1eedcad137cd26932812b606 +size 213035 diff --git a/src/world/grass/GrassPatch.tsx b/src/world/grass/GrassPatch.tsx index 015cac4..73cc78b 100644 --- a/src/world/grass/GrassPatch.tsx +++ b/src/world/grass/GrassPatch.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef } from "react"; +import { useTexture } from "@react-three/drei"; import { useFrame, useThree } from "@react-three/fiber"; import * as THREE from "three"; import { useWind } from "@/hooks/world/useWind"; @@ -10,8 +11,6 @@ import { import type { TerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler"; interface GrassPatchProps { - chunkX: number; - chunkZ: number; density: number; terrainSampler: TerrainGrassSampler; } @@ -21,15 +20,126 @@ function random01(seed: number): number { 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({ - side: THREE.DoubleSide, vertexShader: grassVertexShader, fragmentShader: grassFragmentShader, + vertexColors: true, + side: THREE.DoubleSide, uniforms: { uTime: { value: 0 }, + uNoiseTexture: { value: noiseTexture }, + uDiffuseMap: { value: grassTexture }, + uHeightMap: { value: terrainSampler.heightTexture }, 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 }, uWindDirection: { value: 0 }, uWindSpeed: { value: 0 }, @@ -41,153 +151,51 @@ function createGrassMaterial(): THREE.ShaderMaterial { uMaxBendAngle: { value: GRASS_CONFIG.maxBendAngle }, uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight }, 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({ - chunkX, - chunkZ, density, terrainSampler, -}: GrassPatchProps): React.JSX.Element | null { +}: GrassPatchProps): React.JSX.Element { const camera = useThree((state) => state.camera); 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(null); - const geometry = useMemo( - () => createGrassGeometry(chunkX, chunkZ, density, terrainSampler), - [chunkX, chunkZ, density, terrainSampler], + const geometry = useMemo(() => createGrassGeometry(density), [density]); + + 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(() => { materialRef.current = material; @@ -199,7 +207,7 @@ export function GrassPatch({ useEffect(() => { return () => { - geometry?.dispose(); + geometry.dispose(); }; }, [geometry]); @@ -220,7 +228,5 @@ export function GrassPatch({ } }); - if (!geometry) return null; - return ; } diff --git a/src/world/grass/GrassSystem.tsx b/src/world/grass/GrassSystem.tsx index 32b7f09..4db0d66 100644 --- a/src/world/grass/GrassSystem.tsx +++ b/src/world/grass/GrassSystem.tsx @@ -1,146 +1,25 @@ -import { Suspense, useCallback, useMemo, useRef, useState } from "react"; -import { useFrame, useThree } from "@react-three/fiber"; -import { useSceneMode } from "@/hooks/debug/useSceneMode"; +import { Suspense } from "react"; 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"; 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 { - const camera = useThree((state) => state.camera); const terrainSampler = useTerrainGrassSampler(); - const sceneMode = useSceneMode(); const dynamicGrass = useDynamicGrass(); const grassDensity = useGrassDensity(); - const lastUpdateRef = useRef(-GRASS_CONFIG.updateInterval); - const [activeChunkKeys, setActiveChunkKeys] = useState>( - () => new Set(), - ); const density = Math.max(0, grassDensity); - const chunks = useMemo( - () => createGrassChunks(terrainSampler.bounds), - [terrainSampler], - ); - const streamingEnabled = sceneMode === "game"; - const updateActiveChunks = useCallback(() => { - const nextKeys = new Set(); - - for (const chunk of chunks) { - const distance = Math.hypot( - chunk.centerX - camera.position.x, - chunk.centerZ - camera.position.z, - ); - const wasActive = activeChunkKeys.has(chunk.key); - const radius = wasActive - ? GRASS_CONFIG.unloadRadius - : GRASS_CONFIG.loadRadius; - - if (distance <= radius) { - nextKeys.add(chunk.key); - } - } - - if ( - nextKeys.size === activeChunkKeys.size && - [...nextKeys].every((key) => activeChunkKeys.has(key)) - ) { - return; - } - - setActiveChunkKeys(nextKeys); - }, [activeChunkKeys, camera, chunks]); - - useFrame(({ clock }) => { - if (!streamingEnabled) return; - - const now = clock.elapsedTime * 1000; - if (now - lastUpdateRef.current < GRASS_CONFIG.updateInterval) return; - lastUpdateRef.current = now; - - updateActiveChunks(); - }); - - if ( - !GRASS_CONFIG.enabled || - !dynamicGrass || - density <= 0 || - chunks.length === 0 - ) { + if (!GRASS_CONFIG.enabled || !dynamicGrass || density <= 0) { return null; } - const visibleChunks = streamingEnabled - ? chunks.filter((chunk) => { - if (activeChunkKeys.size > 0) { - return activeChunkKeys.has(chunk.key); - } - - return ( - Math.hypot( - chunk.centerX - camera.position.x, - chunk.centerZ - camera.position.z, - ) <= GRASS_CONFIG.loadRadius - ); - }) - : chunks; - return ( - - {visibleChunks.map((chunk) => ( - - - - ))} - + + + ); } diff --git a/src/world/grass/grassConfig.ts b/src/world/grass/grassConfig.ts index 6d88155..7434d2d 100644 --- a/src/world/grass/grassConfig.ts +++ b/src/world/grass/grassConfig.ts @@ -1,14 +1,12 @@ export const GRASS_CONFIG = { enabled: true, - chunkSize: 20, - loadRadius: 30, - unloadRadius: 34, - updateInterval: 250, - baseBladesPerChunk: 420, + patchSize: 30, + bladeCount: 18000, bladeWidth: 0.08, maxBladeHeight: 0.36, randomHeightAmount: 0.25, surfaceOffset: 0.025, + heightTextureSize: 128, windNoiseScale: 0.9, baldPatchModifier: 2.5, falloffSharpness: 0.35, diff --git a/src/world/grass/grassShaders.ts b/src/world/grass/grassShaders.ts index 7f1d3f4..ebd1964 100644 --- a/src/world/grass/grassShaders.ts +++ b/src/world/grass/grassShaders.ts @@ -1,16 +1,17 @@ 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 aBladeOrigin; + attribute vec3 aBladeColor; varying vec3 vColor; uniform float uTime; uniform vec3 uPlayerPosition; + uniform sampler2D uHeightMap; + uniform sampler2D uDiffuseMap; + uniform sampler2D uNoiseTexture; + uniform vec3 uBoundingBoxMin; + uniform vec3 uBoundingBoxMax; uniform float uPatchSize; uniform float uBladeWidth; uniform float uWindDirection; @@ -23,6 +24,7 @@ export const grassVertexShader = /* glsl */ ` uniform float uMaxBendAngle; uniform float uMaxBladeHeight; uniform float uRandomHeightAmount; + uniform float uSurfaceOffset; float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123); @@ -45,33 +47,59 @@ export const grassVertexShader = /* glsl */ ` } void main() { - vec3 origin = aBladeBase; - vec3 transformed = origin; + vec3 transformed = position; + vec3 origin = aBladeOrigin; float halfPatchSize = uPatchSize * 0.5; - vec2 uv = vec2(origin.x, origin.z) * 0.05; - float heightNoise = - random(floor(uv * uHeightNoiseFrequency) + aRandom) * - uMaxBladeHeight * - uHeightNoiseAmplitude; - float heightModifier = heightNoise + random(uv + aRandom) * (uRandomHeightAmount * 0.1); + origin.x = mod(origin.x - uPlayerPosition.x + halfPatchSize, uPatchSize) - halfPatchSize; + origin.z = mod(origin.z - uPlayerPosition.z + halfPatchSize, uPatchSize) - halfPatchSize; - float edgeDistanceX = abs(origin.x - uPlayerPosition.x) / halfPatchSize; - float edgeDistanceZ = abs(origin.z - uPlayerPosition.z) / halfPatchSize; + vec3 worldPos = vec3(uPlayerPosition.x + origin.x, 0.0, uPlayerPosition.z + origin.z); + 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); 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 baldPatchOffset = heightNoise.r * (uBaldPatchModifier * (1.0 - edgeFactor)); + 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); 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( @@ -79,20 +107,18 @@ export const grassVertexShader = /* glsl */ ` sin(uWindDirection), cos(uWindDirection) ); 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; + vec3 windNoise = texture2D(uNoiseTexture, rotatedNoiseUV).rgb; + + vec3 axis = vec3(windNoise.g, 0.0, windNoise.b); + float angle = radians(mapValue(windNoise.g + windNoise.b, 0.0, 2.0, -uMaxBendAngle, uMaxBendAngle)) * tipFactor; 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; - 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); } `; diff --git a/src/world/grass/useTerrainGrassSampler.ts b/src/world/grass/useTerrainGrassSampler.ts index e3347cc..d7f1982 100644 --- a/src/world/grass/useTerrainGrassSampler.ts +++ b/src/world/grass/useTerrainGrassSampler.ts @@ -5,6 +5,7 @@ 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"; +import { GRASS_CONFIG } from "@/world/grass/grassConfig"; const RAYCAST_Y = 500; const RAYCAST_FAR = 1000; @@ -20,6 +21,9 @@ export interface TerrainGrassSample { export interface TerrainGrassSampler { bounds: TerrainSurfaceBounds; + heightTexture: THREE.DataTexture; + maxHeight: number; + minHeight: number; sample: (x: number, z: number) => TerrainGrassSample | null; } @@ -82,33 +86,98 @@ function createTerrainGrassSampler( 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 { 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), - }; - }, + heightTexture, + maxHeight, + minHeight, + sample, }; } +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 { const { scene } = useGLTF(TERRAIN_MODEL_PATH); const terrainNode = getMapNodesByName("terrain")[0];