diff --git a/public/img/grass_alpha.png b/public/img/grass_alpha.png new file mode 100644 index 0000000..108b0d2 Binary files /dev/null and b/public/img/grass_alpha.png differ diff --git a/public/img/grass_normal.png b/public/img/grass_normal.png new file mode 100644 index 0000000..07fd773 Binary files /dev/null and b/public/img/grass_normal.png differ diff --git a/src/app/page.tsx b/src/app/page.tsx index fbf7d1f..2ed1b2b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,24 +1,464 @@ 'use client'; -import { OrbitControls } from '@react-three/drei'; -import { Canvas, useFrame, useLoader } from '@react-three/fiber'; -import { - Bloom, - EffectComposer, - GodRays, - Noise -} from '@react-three/postprocessing'; -import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import { OrbitControls, PerspectiveCamera } from '@react-three/drei'; +import { Canvas, useLoader, useFrame } from '@react-three/fiber'; +import { forwardRef, useLayoutEffect, useMemo, useRef } from 'react'; -import { Mesh, TextureLoader } from 'three'; +import { BufferAttribute, BufferGeometry, Mesh, Object3D, InstancedMesh, DoubleSide, TextureLoader, Color, MeshStandardMaterial } from 'three'; import './page.css'; +import { createNoise2D } from 'simplex-noise'; +import { useControls } from 'leva'; -const SealCube = forwardRef((props, ref) => { - const texture = useLoader(TextureLoader, '/img/niko.jpg'); +import grassVert from './shaders/grass.vert'; +import grassFrag from './shaders/grass.frag'; +import { Bloom, EffectComposer } from '@react-three/postprocessing'; + +interface Shader { + uniforms: { [key: string]: { value: unknown } }; + vertexShader: string; + fragmentShader: string; +} + +function getTerrainHeight( + localX: number, + localZ: number, + worldXBase: number, + worldZBase: number, + scale: number, + hillScale: number, + hillHeight: number, + detailScale: number, + detailHeight: number, + noise2D: (x: number, y: number) => number +) { + const worldX = (worldXBase + localX) * 0.1; + const worldZ = (worldZBase + localZ) * 0.1; + + const noiseHill = noise2D(worldX * hillScale, worldZ * hillScale) * hillHeight; + const noiseDetail = noise2D(worldX * detailScale, worldZ * detailScale) * detailHeight; + + return (noiseHill + noiseDetail) * scale; +} + +interface GrassProps { + x: number; + y: number; + size: number; + count: number; + grassSize: number; + scale: number; + hillScale: number; + hillHeight: number; + detailScale: number; + detailHeight: number; + noise2D: (x: number, y: number) => number; + enableShadows?: boolean; +} + +function Grass({ x, y, size, count, grassSize, scale, hillScale, hillHeight, detailScale, detailHeight, noise2D }: GrassProps) { + const meshRef = useRef(null); + const dummyRef = useRef(new Object3D()); + + const [alphaMap, normalMap] = useLoader(TextureLoader, ['/img/grass_alpha.png', '/img/grass_normal.png']); + + const materialRef = useRef(null); + + useFrame((state) => { + if (materialRef.current && materialRef.current.userData && materialRef.current.userData.shader) { + (materialRef.current.userData.shader as Shader).uniforms.uTime.value = state.clock.getElapsedTime(); + } + }); + + const geometry = useMemo(() => { + const geo = new BufferGeometry(); + + const w = 0.5; + const h = 2; + + const positions = [ + -w, 0, 0, w, 0, 0, -w, h, 0, w, h, 0, + 0, 0, w, 0, 0, -w, 0, h, w, 0, h, -w + ]; + + const uvs = [ + 0, 0, 1, 0, 0, 1, 1, 1, + 0, 0, 1, 0, 0, 1, 1, 1 + ]; + + const indices = [ + 0, 1, 2, 2, 1, 3, + 4, 5, 6, 6, 5, 7 + ]; + + geo.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3)); + geo.setAttribute('uv', new BufferAttribute(new Float32Array(uvs), 2)); + geo.setIndex(indices); + geo.computeVertexNormals(); + + return geo; + }, []); + + useLayoutEffect(() => { + if (!meshRef.current) return; + const dummy = dummyRef.current; + + const worldXBase = x * size; + const worldZBase = y * size; + + const color = new Color(); + const lushColor = new Color('#03ce03'); + const dryColor = new Color('#91c404'); + + for (let i = 0; i < count; i++) { + const localX = (Math.random() - 0.5) * size; + const localZ = (Math.random() - 0.5) * size; + + const localY = getTerrainHeight( + localX, + localZ, + worldXBase, + worldZBase, + scale, + hillScale, + hillHeight, + detailScale, + detailHeight, + noise2D + ); + + dummy.position.set(localX, localY, localZ); + + dummy.rotation.y = Math.random() * Math.PI * 2; + dummy.rotation.x = (Math.random() - 0.5) * 0.2; + dummy.rotation.z = (Math.random() - 0.5) * 0.2; + + const baseScale = grassSize + (Math.random() * grassSize * 0.5); + const heightMult = 0.5 + Math.random() * 1.0; + dummy.scale.set(baseScale, baseScale * heightMult, baseScale); + + dummy.updateMatrix(); + meshRef.current.setMatrixAt(i, dummy.matrix); + + const globalX = worldXBase + localX; + const globalZ = worldZBase + localZ; + const noiseVal = noise2D(globalX * 0.02, globalZ * 0.02); + + const t = (noiseVal + 1) / 2; + + const randomInternal = (Math.random() - 0.5) * 0.2; + const finalT = Math.max(0, Math.min(1, t + randomInternal)); + + color.lerpColors(dryColor, lushColor, finalT); + + meshRef.current.setColorAt(i, color); + } + meshRef.current.instanceMatrix.needsUpdate = true; + if (meshRef.current.instanceColor) meshRef.current.instanceColor.needsUpdate = true; + }, [x, y, size, count, grassSize, scale, hillScale, hillHeight, detailScale, detailHeight, noise2D]); + + const onBeforeCompile = useMemo(() => (shader: Shader) => { + shader.uniforms.uTime = { value: 0 }; + + shader.vertexShader = ` + uniform float uTime; + varying vec2 vGrassUv; + + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); + } + + float fbm(vec2 p) { + float value = 0.0; + float amplitude = 0.5; + float frequency = 1.0; + + for(int i = 0; i < 4; i++) { + value += amplitude * noise(p * frequency); + frequency *= 2.0; + amplitude *= 0.5; + } + + return value; + } + + ${shader.vertexShader} + `; + shader.vertexShader = shader.vertexShader.replace( + '#include ', + ` + #include + vGrassUv = uv; + ${grassVert} + ` + ); + + shader.fragmentShader = ` + uniform float uTime; + varying vec2 vGrassUv; + ${shader.fragmentShader} + `; + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + ` + #include + ${grassFrag} + ` + ); + + if (materialRef.current) { + materialRef.current.userData.shader = shader; + } + }, []); + + return ( + + + + ); +} + +interface TerrainChunkProps { + x: number, + y: number, + size: number, + resolution: number, + scale: number, + hillScale: number, + hillHeight: number, + detailScale: number, + detailHeight: number, + noise2D: (x: number, y: number) => number; + wireframe?: boolean; + grassCount: number; + grassSize: number; + grassLOD: number; +} +function TerrainChunk({ x, y, size, resolution, scale, hillScale, hillHeight, detailScale, detailHeight, noise2D, wireframe = false, grassCount, grassSize, grassLOD }: TerrainChunkProps) { + const distance = Math.sqrt((x * size) ** 2 + (y * size) ** 2); + + let adjustedGrassCount = grassCount; + if (distance > grassLOD) { + adjustedGrassCount = 0; + } else if (distance > grassLOD * 0.6) { + const fadeStart = grassLOD * 0.6; + const fadeRange = grassLOD * 0.4; + const fadeFactor = 1.0 - ((distance - fadeStart) / fadeRange); + adjustedGrassCount = Math.floor(grassCount * fadeFactor * fadeFactor); + } const meshRef = useRef(null); - useImperativeHandle(ref, () => meshRef.current!, []); + const geometry = useMemo(() => { + const geo = new BufferGeometry(); + + const vertices: Array = []; + const indices: Array = []; + + const step = size / (resolution - 1); + const halfSize = size / 2; + const worldXBase = x * size; + const worldZBase = y * size; + + /* + vtx gen + */ + for (let iz = 0; iz < resolution; iz++) { + for (let ix = 0; ix < resolution; ix++) { + const localX = ix * step - halfSize; + const localZ = iz * step - halfSize; + + const localY = getTerrainHeight( + localX, + localZ, + worldXBase, + worldZBase, + scale, + hillScale, + hillHeight, + detailScale, + detailHeight, + noise2D + ); + + vertices.push(localX, localY, localZ); + } + } + + /* + idx gen + */ + for (let iz = 0; iz < resolution - 1; iz++) { + for (let ix = 0; ix < resolution - 1; ix++) { + const topLeft = iz * resolution + ix; + const topRight = topLeft + 1; + const bottomLeft = (iz + 1) * resolution + ix; + const bottomRight = bottomLeft + 1; + + indices.push(topLeft, bottomLeft, topRight); + indices.push(topRight, bottomLeft, bottomRight); + } + } + geo.setAttribute('position', new BufferAttribute(new Float32Array(vertices), 3)); + + const colors: Array = []; + for (let iz = 0; iz < resolution; iz++) { + for (let ix = 0; ix < resolution; ix++) { + const localX = ix * step - halfSize; + const localZ = iz * step - halfSize; + const globalX = worldXBase + localX; + const globalZ = worldZBase + localZ; + const colorNoise = noise2D(globalX * 0.02, globalZ * 0.02); + const t = (colorNoise + 1) / 2; + + const dryGreen = { r: 0.1, g: 0.3, b: 0.0 }; + const lushGreen = { r: 0.0, g: 0.4, b: 0.0 }; + + const r = dryGreen.r + (lushGreen.r - dryGreen.r) * t; + const g = dryGreen.g + (lushGreen.g - dryGreen.g) * t; + const b = dryGreen.b + (lushGreen.b - dryGreen.b) * t; + + colors.push(r, g, b); + } + } + geo.setAttribute('color', new BufferAttribute(new Float32Array(colors), 3)); + + geo.setIndex(indices); + geo.computeVertexNormals(); + + return geo; + }, [x, y, size, resolution, scale, hillScale, hillHeight, detailScale, detailHeight, noise2D]); + + return ( + + + + + {!wireframe && adjustedGrassCount > 0 && ( + + )} + + ) +} + +interface TerrainProps { + chunks?: number; + chunkSize?: number; + resolution?: number; + scale?: number; + hillScale: number, + hillHeight: number, + detailScale: number, + detailHeight: number, + wireframe?: boolean; + grassCount?: number; + grassSize?: number; + grassLOD?: number; +} +function Terrain({ + chunks = 5, + chunkSize = 10, + resolution = 8, + scale = 1, + hillScale = 0.2, + hillHeight = 3, + detailScale = 3, + detailHeight = 0.1, + wireframe = false, + grassCount = 6000, + grassSize = 0.6, + grassLOD = 40 +}: TerrainProps) { + const noise2D = useMemo(() => createNoise2D(), []); + const offset = -Math.floor(chunks / 2); + + const chunkPositions = useMemo(() => { + const positions: [number, number][] = []; + for (let x = 0; x < chunks; x++) + for (let y = 0; y < chunks; y++) + positions.push([x + offset, y + offset]); + + return positions; + }, [chunks, offset]); + + return ( + + {chunkPositions.map(([x, y], index) => ( + + ))} + + ); +} + +function SealCube() { + const texture = useLoader(TextureLoader, '/img/niko.jpg'); + const meshRef = useRef(null); useFrame((state, delta) => { if (meshRef.current) { @@ -28,59 +468,70 @@ const SealCube = forwardRef((props, ref) => { }); return ( - + ); -}); -SealCube.displayName = 'SealCube'; +} export default function Home() { - const [sunMesh, setSunMesh] = useState(null); + // const { chunks, chunkSize, chunkRes, terrainScale, hillScale, hillHeight, detailScale, detailHeight, wireframe, grassCount, grassSize, grassLOD } = useControls( + // { + // chunks: 8, + // chunkSize: 10, + // chunkRes: { value: 8, min: 2, max: 64, step: 1 }, + // terrainScale: 1, + // hillScale: 0.1, + // hillHeight: 6.0, + // detailScale: 1.0, + // detailHeight: 0.1, + // wireframe: false, + // grassCount: { value: 6000, min: 0, max: 10000, step: 100 }, + // grassSize: { value: 0.6, min: 0.01, max: 1.0, step: 0.01 }, + // grassLOD: { value: 40, min: 10, max: 100, step: 5, label: 'Grass LOD Distance' }, + // } + // ); return ( - { - if (mesh && !sunMesh) { - setSunMesh(mesh); - } - }} - /> - - - - { - sunMesh ? ( - - ) : ( - <> - ) - // it feels genuinely so cursed to have to do : <> just because EffectComposer can't handle null - } + - + + + + + + ); } diff --git a/src/app/shaders/grass.frag b/src/app/shaders/grass.frag new file mode 100644 index 0000000..673537a --- /dev/null +++ b/src/app/shaders/grass.frag @@ -0,0 +1,4 @@ +vec3 rootColor = diffuseColor.rgb * 0.5; +vec3 tipColor = diffuseColor.rgb * 1.5 + vec3(0.1, 0.1, 0.0); // Slight yellow tint at tips + +diffuseColor.rgb = mix(rootColor, tipColor, vGrassUv.y); diff --git a/src/app/shaders/grass.vert b/src/app/shaders/grass.vert new file mode 100644 index 0000000..ddc1e26 --- /dev/null +++ b/src/app/shaders/grass.vert @@ -0,0 +1,25 @@ +float worldX = instanceMatrix[3][0]; +float worldZ = instanceMatrix[3][2]; + +float windSpeed = 0.3; +float windScale = 0.04; + +vec2 windPos = vec2(worldX, worldZ) * windScale; +vec2 windFlow = windPos + vec2(uTime * windSpeed, uTime * windSpeed * 0.7); + +float windStrength = fbm(windFlow); +windStrength = windStrength * 0.6 + 0.3; + +float turbulence = fbm(windPos * 3.0 + uTime * 2.0) * 0.15; + +vec2 windDir = vec2( + fbm(windPos * 0.5 + vec2(uTime * 0.1, 0.0)), + fbm(windPos * 0.5 + vec2(0.0, uTime * 0.1)) +); +windDir = normalize(windDir - 0.5) * 0.8; + +float swayAmount = (windStrength + turbulence) * uv.y * uv.y; + +transformed.x += swayAmount * windDir.x; +transformed.z += swayAmount * windDir.y; +transformed.y -= abs(swayAmount) * 0.2;