refactor(environment): use player-centered ghibli grass patch

This commit is contained in:
Tom Boullay
2026-05-28 00:31:45 +02:00
parent 65651405b6
commit 7a72743e5c
7 changed files with 314 additions and 330 deletions
Binary file not shown.
Binary file not shown.
+150 -144
View File
@@ -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<THREE.ShaderMaterial | null>(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 <mesh geometry={geometry} material={material} frustumCulled={false} />;
}
+4 -125
View File
@@ -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<Set<string>>(
() => 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<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
) {
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 (
<group name="grass-system">
{visibleChunks.map((chunk) => (
<Suspense key={chunk.key} fallback={null}>
<GrassPatch
chunkX={chunk.x}
chunkZ={chunk.z}
density={density}
terrainSampler={terrainSampler}
/>
<Suspense fallback={null}>
<GrassPatch density={density} terrainSampler={terrainSampler} />
</Suspense>
))}
</group>
);
}
+3 -5
View File
@@ -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,
+59 -33
View File
@@ -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);
}
`;
+73 -4
View File
@@ -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,9 +86,7 @@ function createTerrainGrassSampler(
maxZ: terrainBounds.max.z,
};
return {
bounds,
sample: (x, z) => {
const sample = (x: number, z: number): TerrainGrassSample | null => {
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
inverseTerrainMatrix,
);
@@ -105,8 +107,75 @@ function createTerrainGrassSampler(
position: hit.point.clone().applyMatrix4(terrainMatrix),
normal: normal ?? new THREE.Vector3(0, 1, 0),
};
},
};
const { heightTexture, maxHeight, minHeight } = createTerrainHeightTexture(
bounds,
sample,
);
return {
bounds,
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 {