Spaces:
Running
Running
import * as THREE from 'three'; | |
import { G } from './globals.js'; | |
import { CLOUDS } from './config.js'; | |
const COLOR_N = new THREE.Color(CLOUDS.colorNight); | |
const COLOR_D = new THREE.Color(CLOUDS.colorDay); | |
const TMP_COLOR = new THREE.Color(); | |
// Lightweight value-noise + FBM for soft, natural cloud edges | |
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, y, seed) { | |
const xi = Math.floor(x); | |
const yi = Math.floor(y); | |
const xf = x - xi; | |
const yf = y - yi; | |
const sx = smoothstep(0, 1, xf); | |
const sy = smoothstep(0, 1, yf); | |
const v00 = hash2i(xi, yi, seed); | |
const v10 = hash2i(xi + 1, yi, seed); | |
const v01 = hash2i(xi, yi + 1, seed); | |
const v11 = hash2i(xi + 1, yi + 1, seed); | |
const ix0 = lerp(v00, v10, sx); | |
const ix1 = lerp(v01, v11, sx); | |
return lerp(ix0, ix1, sy) * 2 - 1; // [-1,1] | |
} | |
function fbm2(x, y, 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, y * freq, seed + i * 1013); | |
freq *= lacunarity; | |
amp *= gain; | |
} | |
return sum; // ~[-ampSum, ampSum] | |
} | |
function makeCloudTexture(size = 256, puffCount = 10) { | |
const canvas = document.createElement('canvas'); | |
canvas.width = canvas.height = size; | |
const ctx = canvas.getContext('2d'); | |
if (!ctx) return null; | |
ctx.clearRect(0, 0, size, size); | |
// Draw several soft circles to form a cloud shape | |
const r = size / 2; | |
ctx.fillStyle = 'white'; | |
ctx.globalCompositeOperation = 'source-over'; | |
for (let i = 0; i < puffCount; i++) { | |
const pr = r * (0.42 + Math.random() * 0.38); | |
const px = r + (Math.random() * 2 - 1) * r * 0.48; | |
const py = r + (Math.random() * 2 - 1) * r * 0.22; // slightly flatter vertically | |
const grad = ctx.createRadialGradient(px, py, pr * 0.18, px, py, pr); | |
grad.addColorStop(0, 'rgba(255,255,255,0.95)'); | |
grad.addColorStop(1, 'rgba(255,255,255,0.0)'); | |
ctx.fillStyle = grad; | |
ctx.beginPath(); | |
ctx.arc(px, py, pr, 0, Math.PI * 2); | |
ctx.fill(); | |
} | |
// Apply subtle FBM noise to alpha for irregular, more realistic edges | |
const img = ctx.getImageData(0, 0, size, size); | |
const data = img.data; | |
const seed = 1337; | |
for (let y = 0; y < size; y++) { | |
for (let x = 0; x < size; x++) { | |
const idx = (y * size + x) * 4; | |
const a = data[idx + 3] / 255; // base alpha from puffs | |
if (a <= 0) continue; | |
// FBM noise in [0..1] | |
const nx = x / size; | |
const ny = y / size; | |
const n = fbm2(nx, ny, 8.0, 3, 2.0, 0.5, seed) * 0.5 + 0.5; | |
// Edge breakup and slight interior variation | |
let alpha = a * (0.78 + 0.35 * n); | |
// Gentle bottom shading (darker underside) | |
const shade = 0.90 + 0.10 * (1.0 - ny); // 1.0 at top -> 0.90 at bottom | |
data[idx] = Math.min(255, data[idx] * shade); | |
data[idx+1] = Math.min(255, data[idx+1] * shade); | |
data[idx+2] = Math.min(255, data[idx+2] * shade); | |
// Contrast alpha for crisper silhouettes | |
alpha = Math.pow(alpha, 0.85); | |
// Hard clip tiny alphas to help alphaTest (reduces overdraw) | |
if (alpha < 0.02) alpha = 0; | |
data[idx + 3] = Math.round(alpha * 255); | |
} | |
} | |
ctx.putImageData(img, 0, 0); | |
const tex = new THREE.CanvasTexture(canvas); | |
tex.generateMipmaps = true; | |
tex.anisotropy = 2; | |
tex.minFilter = THREE.LinearMipmapLinearFilter; | |
tex.magFilter = THREE.LinearFilter; | |
return tex; | |
} | |
export function setupClouds() { | |
if (!CLOUDS.enabled) return; | |
// Create shared texture | |
const tex = makeCloudTexture(256, 12); | |
if (!tex) return; | |
// Wind vector | |
const ang = THREE.MathUtils.degToRad(CLOUDS.windDeg || 0); | |
const wind = new THREE.Vector3(Math.cos(ang), 0, Math.sin(ang)); | |
for (let i = 0; i < CLOUDS.count; i++) { | |
const mat = new THREE.SpriteMaterial({ | |
map: tex, | |
color: new THREE.Color(CLOUDS.colorDay), | |
transparent: true, | |
opacity: CLOUDS.opacityDay, | |
alphaTest: 0.03, // discard near-transparent pixels, reduces fill cost | |
depthTest: true, | |
depthWrite: false, | |
fog: false | |
}); | |
const sp = new THREE.Sprite(mat); | |
// Randomize in-texture rotation for variety without extra draw cost | |
sp.material.rotation = Math.random() * Math.PI * 2; | |
const size = THREE.MathUtils.lerp(CLOUDS.sizeMin, CLOUDS.sizeMax, Math.random()); | |
sp.scale.set(size, size * 0.6, 1); // a bit flattened | |
sp.castShadow = false; sp.receiveShadow = false; | |
// Position in ring | |
const t = Math.random() * Math.PI * 2; | |
const r = CLOUDS.radius * (0.6 + Math.random() * 0.4); | |
sp.position.set(Math.cos(t) * r, CLOUDS.height + (Math.random() - 0.5) * 10, Math.sin(t) * r); | |
sp.renderOrder = 0; | |
G.scene.add(sp); | |
const speed = CLOUDS.speed * (0.6 + Math.random() * 0.8); | |
G.clouds.push({ sprite: sp, speed, wind: wind.clone(), size }); | |
} | |
} | |
export function updateClouds(delta) { | |
if (!CLOUDS.enabled || G.clouds.length === 0) return; | |
// Day factor for opacity/color blending | |
const dayF = 0.5 - 0.5 * Math.cos(2 * Math.PI * (G.timeOfDay || 0)); | |
for (const c of G.clouds) { | |
// Drift | |
c.sprite.position.addScaledVector(c.wind, c.speed * delta); | |
// Wrap around ring bounds | |
const p = c.sprite.position; | |
const r = Math.hypot(p.x, p.z); | |
const minR = CLOUDS.radius * 0.5; | |
const maxR = CLOUDS.radius * 1.1; | |
if (r < minR || r > maxR) { | |
// Reposition opposite side keeping height | |
const ang = Math.atan2(p.z, p.x) + Math.PI; | |
p.x = Math.cos(ang) * CLOUDS.radius; | |
p.z = Math.sin(ang) * CLOUDS.radius; | |
} | |
// Blend opacity and color via day/night | |
const op = CLOUDS.opacityNight * (1 - dayF) + CLOUDS.opacityDay * dayF; | |
c.sprite.material.opacity = op; | |
TMP_COLOR.copy(COLOR_N).lerp(COLOR_D, dayF); | |
c.sprite.material.color.copy(TMP_COLOR); | |
} | |
} | |