refactor(environment): replace grass with terrain-mesh ghibli shader

This commit is contained in:
Tom Boullay
2026-05-28 00:20:01 +02:00
parent e15fb18d6b
commit fe989c9550
5 changed files with 328 additions and 215 deletions
+107 -161
View File
@@ -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<THREE.ShaderMaterial | null>(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 <mesh geometry={geometry} material={material} frustumCulled />;
return <mesh geometry={geometry} material={material} frustumCulled={false} />;
}