Spaces:
Running
Running
Codex CLI
feat(world): switch tree materials to simpler Lambert shading for performance optimization
09a3a37
import * as THREE from 'three'; | |
import { CFG } from './config.js'; | |
import { G } from './globals.js'; | |
// --- Shared materials, geometries, and wind uniforms for trees --- | |
// We keep these module-scoped so all trees can share them efficiently. | |
const FOLIAGE_WIND = { uTime: { value: 0 }, uStrength: { value: 0.35 } }; | |
// Use simpler Lambert shading for mass content to reduce uniforms | |
const TRUNK_MAT = new THREE.MeshLambertMaterial({ | |
color: 0x6b4f32, | |
emissive: 0x000000 | |
}); | |
// Foliage material with simple vertex sway, inspired by reference project | |
const FOLIAGE_MAT = new THREE.MeshLambertMaterial({ | |
color: 0x2f6b3d, | |
emissive: 0x000000 | |
}); | |
FOLIAGE_MAT.onBeforeCompile = (shader) => { | |
shader.uniforms.uTime = FOLIAGE_WIND.uTime; | |
shader.uniforms.uStrength = FOLIAGE_WIND.uStrength; | |
shader.vertexShader = ( | |
'uniform float uTime;\n' + | |
'uniform float uStrength;\n' + | |
shader.vertexShader | |
).replace( | |
'#include <begin_vertex>', | |
`#include <begin_vertex> | |
float sway = sin(uTime * 1.7 + position.y * 0.35) * 0.5 + | |
sin(uTime * 0.9 + position.y * 0.7) * 0.5; | |
transformed.x += sway * uStrength * (0.4 + position.y * 0.06); | |
transformed.z += cos(uTime * 1.1 + position.y * 0.42) * uStrength * (0.3 + position.y * 0.05);` | |
); | |
}; | |
FOLIAGE_MAT.needsUpdate = true; | |
// Reusable base geometries (scaled per-tree) | |
// Trunk: slight taper, height 10, translated so base at y=0 | |
const GEO_TRUNK = new THREE.CylinderGeometry(0.7, 1.2, 10, 8, 1, false); | |
GEO_TRUNK.translate(0, 5, 0); | |
// Foliage stack (positions expect trunk height ~10) | |
const GEO_CONE1 = new THREE.ConeGeometry(6, 10, 8); GEO_CONE1.translate(0, 14, 0); | |
const GEO_CONE2 = new THREE.ConeGeometry(5, 9, 8); GEO_CONE2.translate(0, 20, 0); | |
const GEO_CONE3 = new THREE.ConeGeometry(4, 8, 8); GEO_CONE3.translate(0, 25, 0); | |
const GEO_SPH = new THREE.SphereGeometry(3.5, 8, 6); GEO_SPH.translate(0, 28.5, 0); | |
// Allow main loop to advance wind time | |
export function tickForest(timeSec) { | |
FOLIAGE_WIND.uTime.value = timeSec; | |
// Distance-based chunk culling for grass/flowers (cheap per-frame visibility) | |
const cam = G.camera; | |
if (!cam || !G.foliage) return; | |
const px = cam.position.x; | |
const pz = cam.position.z; | |
const gv = CFG.foliage; | |
const g2 = gv.grassViewDist * gv.grassViewDist; | |
const f2 = gv.flowerViewDist * gv.flowerViewDist; | |
for (let i = 0; i < G.foliage.grass.length; i++) { | |
const m = G.foliage.grass[i]; | |
const dx = (m.position.x) - px; | |
const dz = (m.position.z) - pz; | |
m.visible = (dx * dx + dz * dz) <= g2; | |
} | |
for (let i = 0; i < G.foliage.flowers.length; i++) { | |
const m = G.foliage.flowers[i]; | |
const dx = (m.position.x) - px; | |
const dz = (m.position.z) - pz; | |
m.visible = (dx * dx + dz * dz) <= f2; | |
} | |
} | |
// --- Ground cover (grass, flowers, bushes, rocks) --- | |
// Shared materials | |
const GRASS_MAT = new THREE.MeshLambertMaterial({ | |
color: 0xffffff, | |
vertexColors: true, | |
side: THREE.DoubleSide | |
}); | |
GRASS_MAT.onBeforeCompile = (shader) => { | |
shader.uniforms.uTime = FOLIAGE_WIND.uTime; | |
shader.uniforms.uStrength = FOLIAGE_WIND.uStrength; | |
shader.vertexShader = ( | |
'uniform float uTime;\n' + | |
'uniform float uStrength;\n' + | |
shader.vertexShader | |
).replace( | |
'#include <begin_vertex>', | |
`#include <begin_vertex> | |
#ifdef USE_INSTANCING | |
// derive a per-instance pseudo-random from translation | |
float iRand = fract(sin(instanceMatrix[3].x*12.9898 + instanceMatrix[3].z*78.233) * 43758.5453); | |
#else | |
float iRand = 0.5; | |
#endif | |
float h = clamp(position.y, 0.0, 1.0); | |
float sway = (sin(uTime*2.2 + iRand*6.2831) * 0.6 + cos(uTime*1.3 + iRand*11.0) * 0.4); | |
float bend = uStrength * h * h; // bend increases towards tip | |
transformed.x += sway * bend * 0.25; | |
transformed.z += sway * bend * 0.18;` | |
); | |
}; | |
GRASS_MAT.needsUpdate = true; | |
const FLOWER_MAT = new THREE.MeshLambertMaterial({ | |
color: 0xffffff, | |
vertexColors: true, | |
side: THREE.DoubleSide | |
}); | |
FLOWER_MAT.onBeforeCompile = (shader) => { | |
shader.uniforms.uTime = FOLIAGE_WIND.uTime; | |
shader.uniforms.uStrength = FOLIAGE_WIND.uStrength; | |
shader.vertexShader = ( | |
'uniform float uTime;\n' + | |
'uniform float uStrength;\n' + | |
shader.vertexShader | |
).replace( | |
'#include <begin_vertex>', | |
`#include <begin_vertex> | |
#ifdef USE_INSTANCING | |
float iRand = fract(sin(instanceMatrix[3].x*19.123 + instanceMatrix[3].z*47.321) * 15731.123); | |
#else | |
float iRand = 0.5; | |
#endif | |
float h = clamp(position.y, 0.0, 1.0); | |
float sway = sin(uTime*2.6 + iRand*8.0); | |
float bend = uStrength * h; | |
transformed.x += sway * bend * 0.18; | |
transformed.z += sway * bend * 0.14;` | |
); | |
}; | |
FLOWER_MAT.needsUpdate = true; | |
const BUSH_MAT = new THREE.MeshLambertMaterial({ | |
color: 0x2b6a37, | |
flatShading: false | |
}); | |
const ROCK_MAT = new THREE.MeshLambertMaterial({ | |
color: 0x7b7066, | |
flatShading: true | |
}); | |
// Base geometries (kept small, cloned per-chunk to inject bounding spheres) | |
function makeCrossBladeGeometry(width = 0.5, height = 1.2) { | |
// Two crossed quads around Y axis | |
const hw = width * 0.5; | |
const h = height; | |
const positions = [ | |
// quad A (X axis) | |
-hw, 0, 0, hw, 0, 0, hw, h, 0, | |
-hw, 0, 0, hw, h, 0, -hw, h, 0, | |
// quad B (Z axis) | |
0, 0, -hw, 0, 0, hw, 0, h, hw, | |
0, 0, -hw, 0, h, hw, 0, h, -hw, | |
]; | |
const colors = []; | |
for (let i = 0; i < 12; i++) { | |
const y = positions[i*3 + 1]; | |
const t = y / h; // 0 at base -> 1 at tip | |
const r = THREE.MathUtils.lerp(0.13, 0.25, t); | |
const g = THREE.MathUtils.lerp(0.28, 0.55, t); | |
const b = THREE.MathUtils.lerp(0.12, 0.22, t); | |
colors.push(r, g, b); | |
} | |
const geo = new THREE.BufferGeometry(); | |
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |
geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); | |
geo.computeVertexNormals(); | |
return geo; | |
} | |
const BASE_GEOM = { | |
// Smaller blades and petals relative to trees | |
grass: makeCrossBladeGeometry(0.22, 0.45), | |
flower: makeCrossBladeGeometry(0.18, 0.32), | |
bush: new THREE.SphereGeometry(0.8, 8, 6), | |
rock: new THREE.IcosahedronGeometry(0.7, 0) | |
}; | |
// Deterministic per-chunk RNG | |
function lcg(seed) { | |
let s = (seed >>> 0) || 1; | |
return () => (s = (1664525 * s + 1013904223) >>> 0) / 4294967296; | |
} | |
export function generateGroundCover() { | |
const S = CFG.foliage; | |
FOLIAGE_WIND.uStrength.value = S.windStrength; | |
const half = CFG.forestSize / 2; | |
const chunk = Math.max(8, S.chunkSize | 0); | |
const chunksX = Math.ceil(CFG.forestSize / chunk); | |
const chunksZ = Math.ceil(CFG.forestSize / chunk); | |
const halfChunk = chunk * 0.5; | |
const seed = (CFG.seed | 0) ^ (S.seedOffset | 0); | |
// Helper to set a safe bounding sphere for a chunk-sized instanced mesh | |
function setChunkBounds(mesh) { | |
const g = mesh.geometry; | |
const r = Math.sqrt(halfChunk*halfChunk*2 + 25); // generous Y span | |
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), r); | |
} | |
// Skip center clearing smoothly | |
function densityAt(x, z) { | |
const r = Math.hypot(x, z); | |
const edge = CFG.clearRadius; | |
const k = THREE.MathUtils.clamp((r - edge) / (edge * 1.2), 0, 1); | |
return THREE.MathUtils.lerp(S.densityNearClear, 1, k); | |
} | |
// Reset chunk refs | |
if (!G.foliage) G.foliage = { grass: [], flowers: [] }; | |
G.foliage.grass.length = 0; | |
G.foliage.flowers.length = 0; | |
// Per-chunk generation | |
for (let iz = 0; iz < chunksZ; iz++) { | |
for (let ix = 0; ix < chunksX; ix++) { | |
const cx = -half + ix * chunk + halfChunk; | |
const cz = -half + iz * chunk + halfChunk; | |
// Keep within world bounds | |
if (Math.abs(cx) > half || Math.abs(cz) > half) continue; | |
// Density scaler by clearing and macro noise for patchiness | |
const den = densityAt(cx, cz); | |
const patch = (fbm(cx, cz, 1 / 60, 3, 2, 0.5, seed) * 0.5 + 0.5); | |
const grassCount = Math.max(0, Math.round(S.grassPerChunk * den * (0.6 + 0.8 * patch))); | |
const flowerCount = Math.max(0, Math.round(S.flowersPerChunk * den * (0.6 + 0.8 * (1 - patch)))); | |
const bushesCount = Math.max(0, Math.round(S.bushesPerChunk * den * (0.7 + 0.6 * patch))); | |
const rocksCount = Math.max(0, Math.round(S.rocksPerChunk * den * (0.7 + 0.6 * (1 - patch)))); | |
// No work for empty chunks | |
if (!grassCount && !flowerCount && !bushesCount && !rocksCount) continue; | |
const rng = lcg(((ix + 1) * 73856093) ^ ((iz + 1) * 19349663) ^ seed); | |
// Grass | |
if (grassCount > 0) { | |
const geom = BASE_GEOM.grass.clone(); | |
const mesh = new THREE.InstancedMesh(geom, GRASS_MAT, grassCount); | |
mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage); | |
mesh.castShadow = false; | |
mesh.receiveShadow = false; | |
mesh.position.set(cx, 0, cz); | |
setChunkBounds(mesh); | |
const m = new THREE.Matrix4(); | |
const pos = new THREE.Vector3(); | |
const quat = new THREE.Quaternion(); | |
const scl = new THREE.Vector3(); | |
const color = new THREE.Color(); | |
for (let i = 0; i < grassCount; i++) { | |
const dx = (rng() - 0.5) * chunk; | |
const dz = (rng() - 0.5) * chunk; | |
const wx = cx + dx; | |
const wz = cz + dz; | |
const wy = getTerrainHeight(wx, wz); | |
// Simple placement; allow gentle slopes without extra checks | |
const yaw = rng() * Math.PI * 2; | |
quat.setFromEuler(new THREE.Euler(0, yaw, 0)); | |
// 1.5x larger than current small baseline | |
const scale = (0.6 + rng() * 0.35) * 1.5; | |
scl.set(scale, scale, scale); | |
pos.set(dx, wy, dz); | |
m.compose(pos, quat, scl); | |
mesh.setMatrixAt(i, m); | |
// instance color variation | |
color.setRGB(THREE.MathUtils.lerp(0.18, 0.26, rng()), THREE.MathUtils.lerp(0.45, 0.62, rng()), THREE.MathUtils.lerp(0.16, 0.24, rng())); | |
mesh.setColorAt(i, color); | |
} | |
mesh.instanceMatrix.needsUpdate = true; | |
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; | |
G.scene.add(mesh); | |
G.foliage.grass.push(mesh); | |
} | |
// Flowers | |
if (flowerCount > 0) { | |
const geom = BASE_GEOM.flower.clone(); | |
const mesh = new THREE.InstancedMesh(geom, FLOWER_MAT, flowerCount); | |
mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage); | |
mesh.castShadow = false; | |
mesh.receiveShadow = false; | |
mesh.position.set(cx, 0, cz); | |
setChunkBounds(mesh); | |
const m = new THREE.Matrix4(); | |
const pos = new THREE.Vector3(); | |
const quat = new THREE.Quaternion(); | |
const scl = new THREE.Vector3(); | |
const color = new THREE.Color(); | |
for (let i = 0; i < flowerCount; i++) { | |
const dx = (rng() - 0.5) * chunk; | |
const dz = (rng() - 0.5) * chunk; | |
const wx = cx + dx; | |
const wz = cz + dz; | |
const wy = getTerrainHeight(wx, wz) + 0.02; | |
const yaw = rng() * Math.PI * 2; | |
quat.setFromEuler(new THREE.Euler(0, yaw, 0)); | |
// Flowers 1.5x larger than current small baseline | |
const scale = (0.7 + rng() * 0.3) * 1.5; | |
scl.set(scale, scale, scale); | |
pos.set(dx, wy, dz); | |
m.compose(pos, quat, scl); | |
mesh.setMatrixAt(i, m); | |
// bright palette variations | |
const palettes = [ | |
new THREE.Color(0xff6fb3), // pink | |
new THREE.Color(0xffda66), // yellow | |
new THREE.Color(0x8be37c), // mint | |
new THREE.Color(0x6fc3ff), // sky | |
new THREE.Color(0xff8a6f) // peach | |
]; | |
const base = palettes[Math.floor(rng() * palettes.length)]; | |
color.copy(base).multiplyScalar(0.9 + rng()*0.2); | |
mesh.setColorAt(i, color); | |
} | |
mesh.instanceMatrix.needsUpdate = true; | |
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; | |
G.scene.add(mesh); | |
G.foliage.flowers.push(mesh); | |
} | |
// Bushes | |
if (bushesCount > 0) { | |
const geom = BASE_GEOM.bush.clone(); | |
const mesh = new THREE.InstancedMesh(geom, BUSH_MAT, bushesCount); | |
mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage); | |
mesh.castShadow = false; | |
mesh.receiveShadow = true; | |
mesh.position.set(cx, 0, cz); | |
setChunkBounds(mesh); | |
const m = new THREE.Matrix4(); | |
const pos = new THREE.Vector3(); | |
const quat = new THREE.Quaternion(); | |
const scl = new THREE.Vector3(); | |
for (let i = 0; i < bushesCount; i++) { | |
const dx = (rng() - 0.5) * chunk; | |
const dz = (rng() - 0.5) * chunk; | |
const wx = cx + dx; | |
const wz = cz + dz; | |
const wy = getTerrainHeight(wx, wz) + 0.2; | |
quat.identity(); | |
const s = 0.7 + rng() * 0.8; | |
scl.set(s, s, s); | |
pos.set(dx, wy, dz); | |
m.compose(pos, quat, scl); | |
mesh.setMatrixAt(i, m); | |
} | |
mesh.instanceMatrix.needsUpdate = true; | |
G.scene.add(mesh); | |
} | |
// Rocks | |
if (rocksCount > 0) { | |
const geom = BASE_GEOM.rock.clone(); | |
const mesh = new THREE.InstancedMesh(geom, ROCK_MAT, rocksCount); | |
mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage); | |
mesh.castShadow = true; | |
mesh.receiveShadow = true; | |
mesh.position.set(cx, 0, cz); | |
setChunkBounds(mesh); | |
const m = new THREE.Matrix4(); | |
const pos = new THREE.Vector3(); | |
const quat = new THREE.Quaternion(); | |
const scl = new THREE.Vector3(); | |
const color = new THREE.Color(); | |
for (let i = 0; i < rocksCount; i++) { | |
const dx = (rng() - 0.5) * chunk; | |
const dz = (rng() - 0.5) * chunk; | |
const wx = cx + dx; | |
const wz = cz + dz; | |
const wy = getTerrainHeight(wx, wz) + 0.05; | |
const yaw = rng() * Math.PI * 2; | |
quat.setFromEuler(new THREE.Euler(0, yaw, 0)); | |
const s = 0.4 + rng() * 0.9; | |
scl.set(s * (0.8 + rng()*0.4), s, s * (0.8 + rng()*0.4)); | |
pos.set(dx, wy, dz); | |
m.compose(pos, quat, scl); | |
mesh.setMatrixAt(i, m); | |
// slight per-rock color tint | |
color.setHSL(0.07, 0.08, THREE.MathUtils.lerp(0.32, 0.46, rng())); | |
if (mesh.instanceColor) mesh.setColorAt(i, color); | |
} | |
mesh.instanceMatrix.needsUpdate = true; | |
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; | |
G.scene.add(mesh); | |
} | |
} | |
} | |
} | |
// --- Procedural terrain helpers --- | |
// Hash-based 2D value noise for deterministic hills (pure 32-bit integer math) | |
function hash2i(xi, yi, seed) { | |
let h = Math.imul(xi, 374761393) ^ Math.imul(yi, 668265263) ^ Math.imul(seed, 2147483647); | |
h = Math.imul(h ^ (h >>> 13), 1274126177); | |
h = (h ^ (h >>> 16)) >>> 0; | |
return h / 4294967296; // [0,1) | |
} | |
function smoothstep(a, b, t) { | |
if (t <= a) return 0; | |
if (t >= b) return 1; | |
t = (t - a) / (b - a); | |
return t * t * (3 - 2 * t); | |
} | |
function lerp(a, b, t) { return a + (b - a) * t; } | |
function valueNoise2(x, z, seed) { | |
const xi = Math.floor(x); | |
const zi = Math.floor(z); | |
const xf = x - xi; | |
const zf = z - zi; | |
const s = smoothstep(0, 1, xf); | |
const t = smoothstep(0, 1, zf); | |
const v00 = hash2i(xi, zi, seed); | |
const v10 = hash2i(xi + 1, zi, seed); | |
const v01 = hash2i(xi, zi + 1, seed); | |
const v11 = hash2i(xi + 1, zi + 1, seed); | |
const x1 = lerp(v00, v10, s); | |
const x2 = lerp(v01, v11, s); | |
return lerp(x1, x2, t) * 2 - 1; // [-1,1] | |
} | |
function fbm(x, z, baseFreq, octaves, lacunarity, gain, seed) { | |
let sum = 0; | |
let amp = 1; | |
let freq = baseFreq; | |
for (let i = 0; i < octaves; i++) { | |
sum += amp * valueNoise2(x * freq, z * freq, seed); | |
freq *= lacunarity; | |
amp *= gain; | |
} | |
return sum; | |
} | |
// Exported height sampler so other systems can stick to the ground | |
export function getTerrainHeight(x, z) { | |
const seed = (CFG.seed | 0) ^ 0x9e3779b9; | |
// Gentle rolling hills with subtle detail | |
const h1 = fbm(x, z, 1 / 90, 4, 2, 0.5, seed); | |
const h2 = fbm(x, z, 1 / 28, 3, 2, 0.5, seed + 1337); | |
const h3 = fbm(x, z, 1 / 9, 2, 2, 0.5, seed + 4242); | |
let h = h1 * 3.6 + h2 * 1.7 + h3 * 0.6; // total amplitude ~ up to ~6-7 | |
// Soften near the center to keep spawn area playable | |
const r = Math.hypot(x, z); | |
const mask = smoothstep(CFG.clearRadius * 0.8, CFG.clearRadius * 1.8, r); | |
h *= mask; | |
return h; | |
} | |
// --- Procedural ground textures (albedo + normal) --- | |
function generateGroundTextures(size = 1024) { | |
const seed = (CFG.seed | 0) ^ 0x51f9ac4d; | |
const canvas = document.createElement('canvas'); | |
canvas.width = size; canvas.height = size; | |
const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
const nCanvas = document.createElement('canvas'); | |
nCanvas.width = size; nCanvas.height = size; | |
const nctx = nCanvas.getContext('2d'); | |
// Choose a periodic cell count that divides nicely for octaves | |
// Bigger = less obvious repeats; must keep perf reasonable | |
const cells = 64; // base lattice cells across the tile | |
// Periodic hash: wrap lattice coordinates to make the noise tile | |
function hash2Periodic(xi, yi, period, s) { | |
const px = ((xi % period) + period) % period; | |
const py = ((yi % period) + period) % period; | |
return hash2i(px, py, s); | |
} | |
function valueNoise2Periodic(x, z, period, s) { | |
const xi = Math.floor(x); | |
const zi = Math.floor(z); | |
const xf = x - xi; | |
const zf = z - zi; | |
const sx = smoothstep(0, 1, xf); | |
const sz = smoothstep(0, 1, zf); | |
const v00 = hash2Periodic(xi, zi, period, s); | |
const v10 = hash2Periodic(xi + 1, zi, period, s); | |
const v01 = hash2Periodic(xi, zi + 1, period, s); | |
const v11 = hash2Periodic(xi + 1, zi + 1, period, s); | |
const x1 = lerp(v00, v10, sx); | |
const x2 = lerp(v01, v11, sx); | |
return lerp(x1, x2, sz) * 2 - 1; | |
} | |
function fbmPeriodic(x, z, octaves, s) { | |
let sum = 0; | |
let amp = 0.5; | |
let freq = 1; | |
for (let i = 0; i < octaves; i++) { | |
const period = cells * freq; | |
sum += amp * valueNoise2Periodic(x * freq, z * freq, period, s + i * 1013); | |
freq *= 2; | |
amp *= 0.5; | |
} | |
return sum; // roughly [-1,1] | |
} | |
// Precompute a height-ish field for normal derivation (two-pass) | |
const H = new Float32Array(size * size); | |
const idx = (x, y) => y * size + x; | |
for (let y = 0; y < size; y++) { | |
for (let x = 0; x < size; x++) { | |
// Map pixel -> tile domain [0, cells] | |
const u = (x / size) * cells; | |
const v = (y / size) * cells; | |
const nLow = fbmPeriodic(u * 0.75, v * 0.75, 3, seed); | |
const nHi = fbmPeriodic(u * 3.0, v * 3.0, 2, seed + 999); | |
// Height proxy for normals: mix broad and fine details | |
const h = 0.6 * (nLow * 0.5 + 0.5) + 0.4 * (nHi * 0.5 + 0.5); | |
H[idx(x, y)] = h; | |
} | |
} | |
const img = ctx.createImageData(size, size); | |
const data = img.data; | |
const nimg = nctx.createImageData(size, size); | |
const ndata = nimg.data; | |
// Palettes | |
const grassDark = [0x20, 0x5a, 0x2b]; // #205a2b deep green | |
const grassLight = [0x4c, 0x9a, 0x3b]; // #4c9a3b lively green | |
const dryGrass = [0x88, 0xa0, 0x55]; // #88a055 sun-kissed | |
const dirtDark = [0x4f, 0x39, 0x2c]; // #4f392c rich soil | |
const dirtLight = [0x73, 0x5a, 0x48]; // #735a48 lighter soil | |
function mixColor(a, b, t) { | |
return [ | |
Math.round(lerp(a[0], b[0], t)), | |
Math.round(lerp(a[1], b[1], t)), | |
Math.round(lerp(a[2], b[2], t)) | |
]; | |
} | |
// Second pass: color + normal | |
const strength = 2.2; // normal intensity | |
for (let y = 0; y < size; y++) { | |
for (let x = 0; x < size; x++) { | |
const u = (x / size) * cells; | |
const v = (y / size) * cells; | |
// Patchiness control | |
const broad = fbmPeriodic(u * 0.8, v * 0.8, 3, seed + 17) * 0.5 + 0.5; | |
const detail = fbmPeriodic(u * 3.2, v * 3.2, 2, seed + 23) * 0.5 + 0.5; | |
let grassness = smoothstep(0.38, 0.62, broad); | |
grassness = lerp(grassness, grassness * (0.7 + 0.3 * detail), 0.5); | |
// Choose palette and mix for variation | |
const grassMid = mixColor(grassDark, grassLight, 0.6); | |
const grassCol = mixColor(grassMid, dryGrass, 0.25 + 0.35 * detail); | |
const dirtCol = mixColor(dirtDark, dirtLight, 0.35 + 0.4 * detail); | |
const col = mixColor(dirtCol, grassCol, grassness); | |
const p = idx(x, y) * 4; | |
data[p + 0] = col[0]; | |
data[p + 1] = col[1]; | |
data[p + 2] = col[2]; | |
data[p + 3] = 255; | |
// Normal from height field with wrapping | |
const xL = (x - 1 + size) % size, xR = (x + 1) % size; | |
const yT = (y - 1 + size) % size, yB = (y + 1) % size; | |
const hL = H[idx(xL, y)], hR = H[idx(xR, y)]; | |
const hT = H[idx(x, yT)], hB = H[idx(x, yB)]; | |
const dx = (hR - hL) * strength; | |
const dy = (hB - hT) * strength; | |
let nx = -dx, ny = -dy, nz = 1.0; | |
const invLen = 1 / Math.hypot(nx, ny, nz); | |
nx *= invLen; ny *= invLen; nz *= invLen; | |
ndata[p + 0] = Math.round((nx * 0.5 + 0.5) * 255); | |
ndata[p + 1] = Math.round((ny * 0.5 + 0.5) * 255); | |
ndata[p + 2] = Math.round((nz * 0.5 + 0.5) * 255); | |
ndata[p + 3] = 255; | |
} | |
} | |
ctx.putImageData(img, 0, 0); | |
nctx.putImageData(nimg, 0, 0); | |
const map = new THREE.CanvasTexture(canvas); | |
map.colorSpace = THREE.SRGBColorSpace; | |
map.generateMipmaps = true; | |
map.minFilter = THREE.LinearMipmapLinearFilter; | |
map.magFilter = THREE.LinearFilter; | |
map.wrapS = map.wrapT = THREE.RepeatWrapping; | |
const normalMap = new THREE.CanvasTexture(nCanvas); | |
normalMap.generateMipmaps = true; | |
normalMap.minFilter = THREE.LinearMipmapLinearFilter; | |
normalMap.magFilter = THREE.LinearFilter; | |
normalMap.wrapS = normalMap.wrapT = THREE.RepeatWrapping; | |
// Anisotropy if available | |
try { | |
const maxAniso = G.renderer && G.renderer.capabilities ? G.renderer.capabilities.getMaxAnisotropy() : 0; | |
if (maxAniso && maxAniso > 0) { | |
map.anisotropy = Math.min(8, maxAniso); | |
normalMap.anisotropy = Math.min(8, maxAniso); | |
} | |
} catch (_) {} | |
return { map, normalMap }; | |
} | |
export function setupGround() { | |
const segs = 160; // enough resolution for smooth hills | |
const geometry = new THREE.PlaneGeometry(CFG.forestSize, CFG.forestSize, segs, segs); | |
geometry.rotateX(-Math.PI / 2); | |
// Displace vertices along Y using our height function | |
const pos = geometry.attributes.position; | |
const colors = new Float32Array(pos.count * 3); | |
let minY = Infinity, maxY = -Infinity; | |
for (let i = 0; i < pos.count; i++) { | |
const x = pos.getX(i); | |
const z = pos.getZ(i); | |
const y = getTerrainHeight(x, z); | |
pos.setY(i, y); | |
if (y < minY) minY = y; | |
if (y > maxY) maxY = y; | |
} | |
pos.needsUpdate = true; | |
geometry.computeVertexNormals(); | |
// Macro vertex colors based on slope (normal.y) and height | |
const nrm = geometry.attributes.normal; | |
for (let i = 0; i < pos.count; i++) { | |
const x = pos.getX(i); | |
const z = pos.getZ(i); | |
const y = pos.getY(i); | |
const ny = nrm.getY(i); | |
const r = Math.hypot(x, z); | |
const clear = 1.0 - smoothstep(CFG.clearRadius * 0.7, CFG.clearRadius * 1.4, r); | |
const flat = smoothstep(0.6, 0.96, ny); // flat areas -> grass | |
const hNorm = (y - minY) / Math.max(1e-5, (maxY - minY)); | |
// Grass tint varies with height; dirt tint more constant | |
const grassDark = new THREE.Color(0x1f4f28); | |
const grassLight = new THREE.Color(0x3f8f3a); | |
const dirtDark = new THREE.Color(0x4a3a2e); | |
const dirtLight = new THREE.Color(0x6a5040); | |
const grassTint = grassDark.clone().lerp(grassLight, 0.35 + 0.45 * hNorm); | |
const dirtTint = dirtDark.clone().lerp(dirtLight, 0.35); | |
// Reduce grass in the central clearing for readability | |
const grassness = THREE.MathUtils.clamp(flat * (1.0 - 0.65 * clear), 0, 1); | |
const tint = dirtTint.clone().lerp(grassTint, grassness); | |
colors[i * 3 + 0] = tint.r; | |
colors[i * 3 + 1] = tint.g; | |
colors[i * 3 + 2] = tint.b; | |
} | |
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
// High-frequency detail textures (tileable) + macro vertex tint | |
const { map, normalMap } = generateGroundTextures(1024); | |
// Repeat detail across the forest (fewer repeats = larger features) | |
// Previously: forestSize / 4 (very fine, looked too uniform) | |
const repeats = Math.max(12, Math.round(CFG.forestSize / 12)); | |
map.repeat.set(repeats, repeats); | |
normalMap.repeat.set(repeats, repeats); | |
const material = new THREE.MeshStandardMaterial({ | |
color: 0xffffff, | |
map, | |
normalMap, | |
roughness: 0.95, | |
metalness: 0.0, | |
vertexColors: true | |
}); | |
// Add a subtle world-space macro variation to break tiling repetition | |
material.onBeforeCompile = (shader) => { | |
shader.uniforms.uMacroScale = { value: 0.035 }; // frequency in world units | |
shader.uniforms.uMacroStrength = { value: 0.28 }; // mix into base color | |
shader.vertexShader = ( | |
'varying vec3 vWorldPos;\n' + | |
shader.vertexShader | |
).replace( | |
'#include <worldpos_vertex>', | |
`#include <worldpos_vertex> | |
vWorldPos = worldPosition.xyz;` | |
); | |
// Cheap 2D value-noise FBM in fragment to modulate albedo in world space | |
const NOISE_CHUNK = ` | |
varying vec3 vWorldPos; | |
uniform float uMacroScale; | |
uniform float uMacroStrength; | |
float hash12(vec2 p){ | |
vec3 p3 = fract(vec3(p.xyx) * 0.1031); | |
p3 += dot(p3, p3.yzx + 33.33); | |
return fract((p3.x + p3.y) * p3.z); | |
} | |
float vnoise(vec2 p){ | |
vec2 i = floor(p); | |
vec2 f = fract(p); | |
float a = hash12(i); | |
float b = hash12(i + vec2(1.0, 0.0)); | |
float c = hash12(i + vec2(0.0, 1.0)); | |
float d = hash12(i + vec2(1.0, 1.0)); | |
vec2 u = f*f*(3.0-2.0*f); | |
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; | |
} | |
float fbm2(vec2 p){ | |
float t = 0.0; | |
float amp = 0.5; | |
for(int i=0;i<4;i++){ | |
t += amp * vnoise(p); | |
p *= 2.0; | |
amp *= 0.5; | |
} | |
return t; | |
} | |
`; | |
shader.fragmentShader = ( | |
NOISE_CHUNK + shader.fragmentShader | |
).replace( | |
'#include <map_fragment>', | |
`#include <map_fragment> | |
// World-space macro color variation to reduce visible tiling | |
vec2 st = vWorldPos.xz * uMacroScale; | |
float macro = fbm2(st); | |
macro = macro * 0.5 + 0.5; // [0,1] | |
float m = mix(0.82, 1.18, macro); | |
diffuseColor.rgb *= mix(1.0, m, uMacroStrength);` | |
); | |
}; | |
const ground = new THREE.Mesh(geometry, material); | |
ground.receiveShadow = true; | |
G.scene.add(ground); | |
G.ground = ground; | |
// With instanced trees we approximate trunk blocking via spatial grid; keep raycast blockers minimal | |
G.blockers = [ground]; | |
} | |
export function generateForest() { | |
const clearRadiusSq = CFG.clearRadius * CFG.clearRadius; | |
const halfSize = CFG.forestSize / 2; | |
let placed = 0; | |
const maxAttempts = CFG.treeCount * 3; | |
let attempts = 0; | |
// Reset data | |
G.treeColliders.length = 0; | |
if (!G.treeTrunks) G.treeTrunks = []; | |
G.treeTrunks.length = 0; // retained for compatibility; no longer populated with individual meshes | |
if (!G.treeMeshes) G.treeMeshes = []; | |
G.treeMeshes.length = 0; | |
// Prepare instanced batches (upper bound capacity = CFG.treeCount) | |
const trunkIM = new THREE.InstancedMesh(GEO_TRUNK, TRUNK_MAT, CFG.treeCount); | |
trunkIM.castShadow = true; trunkIM.receiveShadow = true; | |
const cone1IM = new THREE.InstancedMesh(GEO_CONE1, FOLIAGE_MAT, CFG.treeCount); | |
const cone2IM = new THREE.InstancedMesh(GEO_CONE2, FOLIAGE_MAT, CFG.treeCount); | |
const cone3IM = new THREE.InstancedMesh(GEO_CONE3, FOLIAGE_MAT, CFG.treeCount); | |
const crownIM = new THREE.InstancedMesh(GEO_SPH, FOLIAGE_MAT, CFG.treeCount); | |
cone1IM.castShadow = cone2IM.castShadow = cone3IM.castShadow = crownIM.castShadow = false; | |
cone1IM.receiveShadow = cone2IM.receiveShadow = cone3IM.receiveShadow = crownIM.receiveShadow = true; | |
const m = new THREE.Matrix4(); | |
const quat = new THREE.Quaternion(); | |
const scl = new THREE.Vector3(); | |
while (placed < CFG.treeCount && attempts < maxAttempts) { | |
attempts++; | |
const x = (G.random() - 0.5) * CFG.forestSize; | |
const z = (G.random() - 0.5) * CFG.forestSize; | |
// Check clearing | |
if (x * x + z * z < clearRadiusSq) continue; | |
// Check distance to other trees | |
let tooClose = false; | |
for (const collider of G.treeColliders) { | |
const dx = x - collider.x; | |
const dz = z - collider.z; | |
if (dx * dx + dz * dz < Math.pow(collider.radius * 2, 2)) { tooClose = true; break; } | |
} | |
if (tooClose) continue; | |
// Random uniform scale and rotation per tree | |
const s = 0.75 + G.random() * 0.8; // ~0.75..1.55 | |
const fScale = s * (0.9 + G.random() * 0.15); | |
const yaw = G.random() * Math.PI * 2; | |
quat.setFromEuler(new THREE.Euler(0, yaw, 0)); | |
const y = getTerrainHeight(x, z); | |
m.compose(new THREE.Vector3(x, y, z), quat, new THREE.Vector3(s, s, s)); | |
trunkIM.setMatrixAt(placed, m); | |
// Foliage uses same transform but with foliage scale factor | |
m.compose(new THREE.Vector3(x, y, z), quat, new THREE.Vector3(fScale, fScale, fScale)); | |
cone1IM.setMatrixAt(placed, m); | |
cone2IM.setMatrixAt(placed, m); | |
cone3IM.setMatrixAt(placed, m); | |
crownIM.setMatrixAt(placed, m); | |
// Collider roughly matching trunk base radius | |
const trunkBaseRadius = 1.2 * s; | |
G.treeColliders.push({ x, z, radius: trunkBaseRadius }); | |
placed++; | |
} | |
trunkIM.count = placed; cone1IM.count = placed; cone2IM.count = placed; cone3IM.count = placed; crownIM.count = placed; | |
trunkIM.instanceMatrix.needsUpdate = true; | |
cone1IM.instanceMatrix.needsUpdate = cone2IM.instanceMatrix.needsUpdate = true; | |
cone3IM.instanceMatrix.needsUpdate = crownIM.instanceMatrix.needsUpdate = true; | |
if (placed > 0) { | |
G.scene.add(trunkIM, cone1IM, cone2IM, cone3IM, crownIM); | |
} | |
// With instancing, keep blockers to ground; tree collisions handled via grid tests | |
if (G.ground) { | |
G.blockers = [G.ground]; | |
} else { | |
G.blockers = []; | |
} | |
buildTreeGrid(); | |
} | |
// ---- Spatial index for tree colliders ---- | |
// Simple uniform grid over the world to reduce O(N) scans | |
export function buildTreeGrid(cellSize = 12) { | |
const half = CFG.forestSize / 2; | |
const minX = -half, minZ = -half; | |
const cols = Math.max(1, Math.ceil(CFG.forestSize / cellSize)); | |
const rows = Math.max(1, Math.ceil(CFG.forestSize / cellSize)); | |
const cells = new Array(cols * rows); | |
for (let i = 0; i < cells.length; i++) cells[i] = []; | |
function cellIndex(ix, iz) { return iz * cols + ix; } | |
for (const t of G.treeColliders) { | |
const ix = Math.max(0, Math.min(cols - 1, Math.floor((t.x - minX) / cellSize))); | |
const iz = Math.max(0, Math.min(rows - 1, Math.floor((t.z - minZ) / cellSize))); | |
cells[cellIndex(ix, iz)].push(t); | |
} | |
G.treeGrid = { cellSize, minX, minZ, cols, rows, cells }; | |
} | |
const _nearTrees = []; | |
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); } | |
// Gathers tree colliders around a point within a given radius (XZ plane) | |
export function getNearbyTrees(x, z, radius = 4) { | |
_nearTrees.length = 0; | |
const grid = G.treeGrid; | |
if (!grid) return _nearTrees; | |
const { cellSize, minX, minZ, cols, rows, cells } = grid; | |
const r = Math.max(radius, 0); | |
const minIx = clamp(Math.floor((x - r - minX) / cellSize), 0, cols - 1); | |
const maxIx = clamp(Math.floor((x + r - minX) / cellSize), 0, cols - 1); | |
const minIz = clamp(Math.floor((z - r - minZ) / cellSize), 0, rows - 1); | |
const maxIz = clamp(Math.floor((z + r - minZ) / cellSize), 0, rows - 1); | |
for (let iz = minIz; iz <= maxIz; iz++) { | |
for (let ix = minIx; ix <= maxIx; ix++) { | |
const cell = cells[iz * cols + ix]; | |
for (let k = 0; k < cell.length; k++) _nearTrees.push(cell[k]); | |
} | |
} | |
return _nearTrees; | |
} | |
const _aabbTrees = []; | |
export function getTreesInAABB(minX, minZ, maxX, maxZ) { | |
_aabbTrees.length = 0; | |
const grid = G.treeGrid; | |
if (!grid) return _aabbTrees; | |
const { cellSize, minX: gx, minZ: gz, cols, rows, cells } = grid; | |
const minIx = clamp(Math.floor((minX - gx) / cellSize), 0, cols - 1); | |
const maxIx = clamp(Math.floor((maxX - gx) / cellSize), 0, cols - 1); | |
const minIz = clamp(Math.floor((minZ - gz) / cellSize), 0, rows - 1); | |
const maxIz = clamp(Math.floor((maxZ - gz) / cellSize), 0, rows - 1); | |
for (let iz = minIz; iz <= maxIz; iz++) { | |
for (let ix = minIx; ix <= maxIx; ix++) { | |
const cell = cells[iz * cols + ix]; | |
for (let k = 0; k < cell.length; k++) _aabbTrees.push(cell[k]); | |
} | |
} | |
return _aabbTrees; | |
} | |
// Fast 2D segment-vs-circle test | |
function segIntersectsCircle(x1, z1, x2, z2, cx, cz, r) { | |
const vx = x2 - x1, vz = z2 - z1; | |
const wx = cx - x1, wz = cz - z1; | |
const vv = vx * vx + vz * vz; | |
if (vv <= 1e-6) return false; | |
let t = (wx * vx + wz * vz) / vv; | |
if (t < 0) t = 0; else if (t > 1) t = 1; | |
const px = x1 + t * vx, pz = z1 + t * vz; | |
const dx = cx - px, dz = cz - pz; | |
return (dx * dx + dz * dz) <= r * r; | |
} | |
// Approximate line-of-sight using tree cylinders and terrain samples (no raycaster) | |
export function hasLineOfSight(from, to) { | |
// Terrain occlusion: sample a few points along the ray | |
const steps = 6; | |
for (let i = 1; i < steps; i++) { | |
const t = i / steps; | |
const x = from.x + (to.x - from.x) * t; | |
const z = from.z + (to.z - from.z) * t; | |
const y = from.y + (to.y - from.y) * t; | |
const gy = getTerrainHeight(x, z) + 0.1; | |
if (y <= gy) return false; | |
} | |
// Tree occlusion using grid-restricted set | |
const minX = Math.min(from.x, to.x) - 2.0; | |
const maxX = Math.max(from.x, to.x) + 2.0; | |
const minZ = Math.min(from.z, to.z) - 2.0; | |
const maxZ = Math.max(from.z, to.z) + 2.0; | |
const candidates = getTreesInAABB(minX, minZ, maxX, maxZ); | |
for (let i = 0; i < candidates.length; i++) { | |
const t = candidates[i]; | |
// Use a slightly inflated radius to account for foliage | |
const r = t.radius + 0.3; | |
if (segIntersectsCircle(from.x, from.z, to.x, to.z, t.x, t.z, r)) { | |
// If the segment is sufficiently high (e.g., arrow arc), allow pass | |
// Estimate height at closest approach t in [0,1] | |
const vx = to.x - from.x, vz = to.z - from.z; | |
const wx = t.x - from.x, wz = t.z - from.z; | |
const vv = vx * vx + vz * vz; | |
let u = (wx * vx + wz * vz) / (vv || 1); | |
if (u < 0) u = 0; else if (u > 1) u = 1; | |
const yAt = from.y + (to.y - from.y) * u; | |
if (yAt < 8) return false; // below canopy -> blocked | |
} | |
} | |
return true; | |
} | |