709 lines
15 KiB
TypeScript
709 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
Environment,
|
|
OrbitControls,
|
|
PerspectiveCamera,
|
|
useProgress
|
|
} from '@react-three/drei';
|
|
import { Canvas, useLoader, useFrame } from '@react-three/fiber';
|
|
import {
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from 'react';
|
|
|
|
import {
|
|
BufferAttribute,
|
|
BufferGeometry,
|
|
Mesh,
|
|
Object3D,
|
|
InstancedMesh,
|
|
DoubleSide,
|
|
TextureLoader,
|
|
Color,
|
|
MeshStandardMaterial,
|
|
FogExp2
|
|
} from 'three';
|
|
import { LUTCubeLoader } from 'three/examples/jsm/loaders/LUTCubeLoader';
|
|
|
|
import './page.css';
|
|
import { createNoise2D } from 'simplex-noise';
|
|
import { useControls } from 'leva';
|
|
|
|
import grassVert from './shaders/grass.vert';
|
|
import grassFrag from './shaders/grass.frag';
|
|
import {
|
|
Bloom,
|
|
EffectComposer,
|
|
Noise,
|
|
Vignette,
|
|
DepthOfField,
|
|
GodRays,
|
|
SMAA,
|
|
HueSaturation,
|
|
BrightnessContrast,
|
|
LUT
|
|
} 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;
|
|
grassLOD: number;
|
|
enableShadows?: boolean;
|
|
}
|
|
|
|
function Grass({
|
|
x,
|
|
y,
|
|
size,
|
|
count,
|
|
grassSize,
|
|
scale,
|
|
hillScale,
|
|
hillHeight,
|
|
detailScale,
|
|
detailHeight,
|
|
noise2D,
|
|
grassLOD
|
|
}: GrassProps) {
|
|
const meshRef = useRef<InstancedMesh>(null);
|
|
const dummyRef = useRef<Object3D>(new Object3D());
|
|
|
|
const [alphaMap, normalMap] = useLoader(TextureLoader, [
|
|
'/img/grass_alpha.png',
|
|
'/img/grass_normal.png'
|
|
]);
|
|
|
|
const materialRef = useRef<
|
|
MeshStandardMaterial & { userData: { shader: Shader } }
|
|
>(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 segments = 4;
|
|
|
|
const positions: number[] = [];
|
|
const uvs: number[] = [];
|
|
const indices: number[] = [];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const angle = (Math.PI / 3) * i;
|
|
const sinA = Math.sin(angle);
|
|
const cosA = Math.cos(angle);
|
|
|
|
const offset = positions.length / 3;
|
|
|
|
for (let y = 0; y <= segments; y++) {
|
|
const v = y / segments;
|
|
const yPos = v * h;
|
|
|
|
positions.push(-w * cosA, yPos, -w * sinA);
|
|
uvs.push(0, v);
|
|
|
|
positions.push(w * cosA, yPos, w * sinA);
|
|
uvs.push(1, v);
|
|
}
|
|
|
|
for (let y = 0; y < segments; y++) {
|
|
const row1 = offset + y * 2;
|
|
const row2 = offset + (y + 1) * 2;
|
|
|
|
indices.push(row1, row1 + 1, row2);
|
|
indices.push(row1 + 1, row2 + 1, row2);
|
|
}
|
|
}
|
|
|
|
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('#348a34');
|
|
const dryColor = new Color('#556b19');
|
|
|
|
const chunkRadius = size * Math.sqrt(2);
|
|
|
|
let instanceIndex = 0;
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const localX = (Math.random() - 0.5) * size;
|
|
const localZ = (Math.random() - 0.5) * size;
|
|
|
|
const globalX = worldXBase + localX;
|
|
const globalZ = worldZBase + localZ;
|
|
|
|
const dist = Math.sqrt(globalX * globalX + globalZ * globalZ);
|
|
|
|
const maxDist = grassLOD;
|
|
|
|
const falloffStart = maxDist * 0.1;
|
|
const falloffEnd = maxDist;
|
|
|
|
let fallofFactor = (dist - falloffStart) / (falloffEnd - falloffStart);
|
|
fallofFactor = Math.max(0, Math.min(1, fallofFactor));
|
|
|
|
const density = (1.0 - fallofFactor) * (1.0 - fallofFactor);
|
|
|
|
if (Math.random() > density) continue;
|
|
|
|
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(instanceIndex, dummy.matrix);
|
|
|
|
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(instanceIndex, color);
|
|
|
|
instanceIndex++;
|
|
}
|
|
meshRef.current.count = instanceIndex;
|
|
|
|
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,
|
|
grassLOD
|
|
]);
|
|
|
|
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 <begin_vertex>',
|
|
`
|
|
#include <begin_vertex>
|
|
vGrassUv = uv;
|
|
${grassVert}
|
|
`
|
|
);
|
|
|
|
shader.fragmentShader = `
|
|
uniform float uTime;
|
|
varying vec2 vGrassUv;
|
|
${shader.fragmentShader}
|
|
`;
|
|
shader.fragmentShader = shader.fragmentShader.replace(
|
|
'#include <color_fragment>',
|
|
`
|
|
#include <color_fragment>
|
|
${grassFrag}
|
|
`
|
|
);
|
|
|
|
if (materialRef.current) {
|
|
materialRef.current.userData.shader = shader;
|
|
}
|
|
},
|
|
[grassLOD]
|
|
);
|
|
|
|
return (
|
|
<instancedMesh
|
|
ref={meshRef}
|
|
args={[undefined, undefined, count]}
|
|
position={[x * size, 0, y * size]}
|
|
geometry={geometry}
|
|
>
|
|
<meshStandardMaterial
|
|
ref={materialRef}
|
|
color='#ffffff'
|
|
side={DoubleSide}
|
|
alphaMap={alphaMap}
|
|
alphaTest={0.5}
|
|
normalMap={normalMap}
|
|
roughness={0.8}
|
|
metalness={0.1}
|
|
onBeforeCompile={onBeforeCompile}
|
|
/>
|
|
</instancedMesh>
|
|
);
|
|
}
|
|
|
|
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 chunkDist = Math.sqrt((x * size) ** 2 + (y * size) ** 2);
|
|
const shouldRenderGrass = chunkDist < grassLOD + size;
|
|
|
|
const meshRef = useRef<Mesh>(null);
|
|
|
|
const geometry = useMemo(() => {
|
|
const geo = new BufferGeometry();
|
|
|
|
const vertices: Array<number> = [];
|
|
const indices: Array<number> = [];
|
|
|
|
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<number> = [];
|
|
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.01, g: 0.01, b: 0.0 };
|
|
const lushGreen = { r: 0.0, g: 0.05, 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 (
|
|
<group>
|
|
<mesh
|
|
ref={meshRef}
|
|
geometry={geometry}
|
|
position={[x * size, 0, y * size]}
|
|
>
|
|
<meshStandardMaterial
|
|
vertexColors
|
|
roughness={1.0}
|
|
metalness={0.0}
|
|
wireframe={wireframe}
|
|
/>
|
|
</mesh>
|
|
{!wireframe && shouldRenderGrass && (
|
|
<Grass
|
|
x={x}
|
|
y={y}
|
|
size={size}
|
|
count={grassCount}
|
|
grassSize={grassSize}
|
|
scale={scale}
|
|
hillScale={hillScale}
|
|
hillHeight={hillHeight}
|
|
detailScale={detailScale}
|
|
detailHeight={detailHeight}
|
|
noise2D={noise2D}
|
|
grassLOD={grassLOD}
|
|
/>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<group>
|
|
{chunkPositions.map(([x, y], index) => (
|
|
<TerrainChunk
|
|
key={`${x}-${y}-${index}`}
|
|
x={x}
|
|
y={y}
|
|
size={chunkSize}
|
|
resolution={resolution}
|
|
scale={scale}
|
|
hillScale={hillScale}
|
|
hillHeight={hillHeight}
|
|
detailScale={detailScale}
|
|
detailHeight={detailHeight}
|
|
noise2D={noise2D}
|
|
wireframe={wireframe}
|
|
grassCount={grassCount}
|
|
grassSize={grassSize}
|
|
grassLOD={grassLOD}
|
|
/>
|
|
))}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
const SealCube = forwardRef<Mesh>((props, ref) => {
|
|
const texture = useLoader(TextureLoader, '/img/niko.jpg');
|
|
const meshRef = useRef<Mesh>(null);
|
|
|
|
useImperativeHandle(ref, () => meshRef.current!, []);
|
|
|
|
useFrame((state, delta) => {
|
|
if (meshRef.current) {
|
|
meshRef.current.rotation.x += delta * 0.5;
|
|
meshRef.current.rotation.y += delta * 0.5;
|
|
}
|
|
});
|
|
|
|
return (
|
|
<mesh ref={meshRef} position={[0, 3, 0]} castShadow receiveShadow>
|
|
<boxGeometry args={[0.85, 0.85, 0.85]} />
|
|
<meshBasicMaterial map={texture} depthWrite={true} />
|
|
</mesh>
|
|
);
|
|
});
|
|
SealCube.displayName = 'SealCube';
|
|
|
|
function Loader() {
|
|
const { progress, active } = useProgress();
|
|
const [visible, setVisible] = useState(true);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!active && progress === 100) {
|
|
const timeout = setTimeout(() => setVisible(false), 500);
|
|
return () => clearTimeout(timeout);
|
|
}
|
|
}, [progress, active]);
|
|
|
|
return (
|
|
<div className={`loader ${!visible ? 'loader hidden' : ''}`}>
|
|
<img src='/img/niko.jpg' className='niko-spin' />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LutEffect() {
|
|
const lutTexture = useLoader(LUTCubeLoader, '/lut/Moody3.cube');
|
|
return <LUT lut={lutTexture.texture3D} />;
|
|
}
|
|
|
|
export default function Home() {
|
|
const [sealMesh, setSealMesh] = useState<Mesh | null>(null);
|
|
|
|
return (
|
|
<>
|
|
<Loader />
|
|
<Canvas
|
|
shadows
|
|
camera={{ position: [0, 5, 15], fov: 50, far: 100 }}
|
|
gl={{ antialias: true }}
|
|
className='canvas'
|
|
>
|
|
<EffectComposer>
|
|
<DepthOfField target={[0, 3, 0]} focalLength={10} bokehScale={5} />
|
|
<Vignette />
|
|
<Noise opacity={0.05} />
|
|
<Bloom
|
|
intensity={2}
|
|
luminanceThreshold={0.5}
|
|
luminanceSmoothing={0.1}
|
|
/>
|
|
<SMAA />
|
|
<HueSaturation saturation={0.3} />
|
|
<BrightnessContrast brightness={0.1} contrast={-0.1} />
|
|
<LutEffect />
|
|
</EffectComposer>
|
|
|
|
<Environment
|
|
files={'hdr/sky.hdr'}
|
|
environmentIntensity={1}
|
|
background
|
|
/>
|
|
<fogExp2 attach='fog' args={[0x9a9a9a, 0.01]} />
|
|
|
|
<Terrain
|
|
chunks={16}
|
|
chunkSize={10.0}
|
|
resolution={8.0}
|
|
scale={1}
|
|
hillScale={0.1}
|
|
hillHeight={6.0}
|
|
detailScale={1.0}
|
|
detailHeight={0.2}
|
|
wireframe={false}
|
|
grassCount={6000}
|
|
grassSize={0.6}
|
|
grassLOD={60}
|
|
/>
|
|
<SealCube
|
|
ref={(mesh) => {
|
|
if (mesh && !sealMesh) setSealMesh(mesh);
|
|
}}
|
|
/>
|
|
|
|
<OrbitControls
|
|
target={[0, 3, 0]}
|
|
enablePan={false}
|
|
makeDefault
|
|
minDistance={2}
|
|
maxDistance={10}
|
|
/>
|
|
</Canvas>
|
|
</>
|
|
);
|
|
}
|