Files
three-offshore-vibe/src/OceanScene.js
2026-03-28 15:14:27 +08:00

2364 lines
95 KiB
JavaScript

import * as THREE from 'three';
import { Water } from 'three/addons/objects/Water.js';
import { Sky } from 'three/addons/objects/Sky.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import Stats from 'three/addons/libs/stats.module.js';
import { TerrainGenerator } from './TerrainGenerator.js';
import { VegetationSystem } from './VegetationSystem.js';
import { DEFAULT_SCENE_PARAMS } from './weatherPresets.js';
import { OffshoreWindTurbineAsset } from './objects/OffshoreWindTurbineAsset.js';
const RAIN_AUDIO_URL = '/audio/rain-calming.mp3';
const THUNDER_AUDIO_URL = '/audio/thunder-close.mp3';
export class OceanScene {
constructor(container) {
this.container = container;
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.water = null;
this.sky = null;
this.starField = null;
this.moonSprite = null;
this.moonGlowSprite = null;
this.galaxyBand = null;
this.sun = new THREE.Vector3();
this.initialSun = new THREE.Vector3();
this.terrain = null;
this.vegetation = null;
this.vegetationSystem = null;
this.pmremGenerator = null;
this.renderTarget = null;
this.ambientLight = null;
this.sunLight = null;
this.moonLight = null;
this.vegetationFillLight = null;
this.lightningLight = null;
this.lightningCloudGlow = null;
this.composer = null;
this.bloomPass = null;
this.fogPass = null;
this.rainPass = null;
this.snowPass = null;
this.depthTarget = null;
this.stats = null;
this.cloudGroup = null;
this.cloudMaterials = [];
this.cloudLayers = [];
this.fogGroup = null;
this.fogLayers = [];
this.horizonFog = null;
this.skyHazeBand = null;
this.windTurbine = null;
this.rainAudioPool = [];
this.rainAudioActiveIndex = 0;
this.rainAudioIsPlaying = false;
this.rainAudioCrossfading = false;
this.rainAudioCrossfadeDuration = 1.6;
this.thunderAudioPool = [];
this.thunderAudioIndex = 0;
this.thunderVolume = DEFAULT_SCENE_PARAMS.thunderVolume;
this.scheduledThunder = [];
this.params = { ...DEFAULT_SCENE_PARAMS };
this.clock = new THREE.Clock();
this.lightningFlash = 0;
this.lightningLocalFlash = 0;
this.lightningBurstEnd = 0;
this.nextLightningAt = 0;
this.lightningPulseSchedule = [];
}
async init() {
this.initRenderer();
this.initStats();
this.initScene();
this.initCamera();
this.initControls();
this.initLighting();
this.initPostProcessing();
this.initAudio();
await this.initSky();
this.initStars();
this.initNightSky();
this.initClouds();
await this.initWater();
await this.initTerrain();
await this.initWindTurbine();
await this.initVegetation();
this.initSunPosition();
this.initEventListeners();
}
initRenderer() {
this.renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: 'high-performance'
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = this.params.exposure;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.container.appendChild(this.renderer.domElement);
}
initStats() {
this.stats = new Stats();
this.stats.showPanel(0);
this.stats.dom.style.position = 'fixed';
this.stats.dom.style.left = '0';
this.stats.dom.style.top = '0';
this.stats.dom.style.bottom = 'auto';
this.stats.dom.style.margin = '0';
this.stats.dom.style.zIndex = '120';
this.container.appendChild(this.stats.dom);
}
initScene() {
this.scene = new THREE.Scene();
}
initCamera() {
this.camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
1,
20000
);
this.camera.position.set(100, 50, 200);
}
initControls() {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.maxPolarAngle = Math.PI * 0.48;
this.controls.minDistance = 30;
this.controls.maxDistance = 1000;
this.controls.target.set(0, 10, 0);
this.controls.update();
}
initLighting() {
this.ambientLight = new THREE.AmbientLight(0x8ea0b7, 0.58);
this.scene.add(this.ambientLight);
this.sunLight = new THREE.DirectionalLight(0xffffff, 1.5);
this.sunLight.castShadow = true;
this.sunLight.shadow.mapSize.width = 2048;
this.sunLight.shadow.mapSize.height = 2048;
this.sunLight.shadow.camera.near = 0.5;
this.sunLight.shadow.camera.far = 500;
this.sunLight.shadow.camera.left = -100;
this.sunLight.shadow.camera.right = 100;
this.sunLight.shadow.camera.top = 100;
this.sunLight.shadow.camera.bottom = -100;
this.scene.add(this.sunLight);
this.moonLight = new THREE.DirectionalLight(0xa9c7ff, 0);
this.moonLight.castShadow = false;
this.scene.add(this.moonLight);
this.vegetationFillLight = new THREE.DirectionalLight(0xffb06a, 0.95);
this.vegetationFillLight.castShadow = false;
this.scene.add(this.vegetationFillLight);
this.lightningLight = new THREE.DirectionalLight(0xddeeff, 0);
this.lightningLight.castShadow = false;
this.lightningLight.position.set(-120, 180, 40);
this.scene.add(this.lightningLight);
}
initPostProcessing() {
this.depthTarget = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight);
this.depthTarget.depthTexture = new THREE.DepthTexture(window.innerWidth, window.innerHeight, THREE.UnsignedIntType);
this.depthTarget.depthTexture.format = THREE.DepthFormat;
this.depthTarget.texture.minFilter = THREE.NearestFilter;
this.depthTarget.texture.magFilter = THREE.NearestFilter;
this.depthTarget.texture.generateMipmaps = false;
this.composer = new EffectComposer(this.renderer);
this.composer.addPass(new RenderPass(this.scene, this.camera));
this.bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
this.params.bloomStrength,
this.params.bloomRadius,
this.params.bloomThreshold
);
this.composer.addPass(this.bloomPass);
this.fogPass = new ShaderPass(this.createVolumetricFogShader());
this.fogPass.material.uniforms.resolution.value.set(window.innerWidth, window.innerHeight);
this.fogPass.material.uniforms.tDepth.value = this.depthTarget.depthTexture;
this.fogPass.material.uniforms.cameraNear.value = this.camera.near;
this.fogPass.material.uniforms.cameraFar.value = this.camera.far;
this.composer.addPass(this.fogPass);
this.rainPass = new ShaderPass(this.createRainShader());
this.rainPass.enabled = this.params.rainEnabled;
this.rainPass.material.uniforms.resolution.value.set(window.innerWidth, window.innerHeight);
this.rainPass.material.uniforms.screenIntensity.value = this.params.rainScreenIntensity;
this.rainPass.material.uniforms.veilIntensity.value = this.params.rainVeilIntensity;
this.rainPass.material.uniforms.dropSize.value = this.params.rainDropSize;
this.rainPass.material.uniforms.rainSpeed.value = this.params.rainSpeed;
this.composer.addPass(this.rainPass);
this.snowPass = new ShaderPass(this.createSnowShader());
this.snowPass.enabled = this.params.snowEnabled;
this.snowPass.material.uniforms.resolution.value.set(window.innerWidth, window.innerHeight);
this.snowPass.material.uniforms.intensity.value = this.params.snowIntensity;
this.snowPass.material.uniforms.snowSpeed.value = this.params.snowSpeed;
this.composer.addPass(this.snowPass);
}
initAudio() {
this.rainAudioPool = Array.from({ length: 2 }, () => {
const audio = new Audio(RAIN_AUDIO_URL);
audio.loop = false;
audio.preload = 'auto';
audio.volume = 0;
audio.crossOrigin = 'anonymous';
return audio;
});
this.thunderAudioPool = Array.from({ length: 3 }, () => {
const audio = new Audio(THUNDER_AUDIO_URL);
audio.preload = 'auto';
audio.crossOrigin = 'anonymous';
return audio;
});
}
createRainShader() {
return {
uniforms: {
tDiffuse: { value: null },
time: { value: 0 },
screenIntensity: { value: this.params.rainScreenIntensity },
veilIntensity: { value: this.params.rainVeilIntensity },
dropSize: { value: this.params.rainDropSize },
rainSpeed: { value: this.params.rainSpeed },
resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float time;
uniform float screenIntensity;
uniform float veilIntensity;
uniform float dropSize;
uniform float rainSpeed;
uniform vec2 resolution;
varying vec2 vUv;
float hash11(float p) {
p = fract(p * 0.1031);
p *= p + 33.33;
p *= p + p;
return fract(p);
}
float hash21(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);
}
vec3 hash31(float p) {
vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.11369, 0.13787));
p3 += dot(p3, p3.yzx + 19.19);
return fract(vec3(
(p3.x + p3.y) * p3.z,
(p3.x + p3.z) * p3.y,
(p3.y + p3.z) * p3.x
));
}
float easeOutCubic(float x) {
float inv = 1.0 - clamp(x, 0.0, 1.0);
return 1.0 - inv * inv * inv;
}
vec2 dropLayer(vec2 uv, float t, float sizeFactor) {
vec2 grid = vec2(9.0, 4.0) / mix(0.7, 1.45, sizeFactor);
vec2 p = uv * grid;
vec2 cell = floor(p);
vec2 local = fract(p) - 0.5;
vec3 n = hash31(cell.x * 53.17 + cell.y * 417.43);
float rate = (0.45 + n.z * 0.95) * mix(0.82, 1.45, sizeFactor);
float cycle = t * rate + n.z * 11.0;
float phase = fract(cycle);
vec2 spawn = (n.xy - 0.5) * vec2(0.72, 0.28);
float onset = smoothstep(0.0, 0.07, phase);
float lifeFade = 1.0 - smoothstep(0.52, 0.82, phase);
float fall = easeOutCubic(smoothstep(0.02, 0.58, phase));
float fallDistance = mix(0.9, 2.15, n.y) * mix(0.85, 1.4, sizeFactor) * fall;
vec2 center = vec2(spawn.x, 0.62 + spawn.y - fallDistance);
vec2 delta = local - center;
float aspect = mix(0.8, 1.45, n.x);
float bodyRadius = mix(0.18, 0.3, sizeFactor);
float body = smoothstep(bodyRadius, 0.03, length(delta * vec2(aspect, 1.0)));
float tail = smoothstep(mix(0.06, 0.095, sizeFactor), 0.0, abs(delta.x)) *
smoothstep(0.04, -0.12, delta.y) *
smoothstep(-0.72, -0.04, delta.y) *
smoothstep(0.04, 0.18, phase);
float spitSeed = hash21(cell + floor(cycle) + 3.7);
float spitY = center.y + 0.18 + fract(local.y * 3.7 + spitSeed) * 0.42;
float spit = smoothstep(mix(0.06, 0.11, sizeFactor), 0.0, length(local - vec2(center.x + (spitSeed - 0.5) * 0.14, spitY)));
spit *= smoothstep(0.02, 0.14, phase) * (1.0 - smoothstep(0.18, 0.34, phase));
float impactFlash = smoothstep(0.14, 0.0, phase) * smoothstep(0.24, 0.02, length(local - vec2(spawn.x, 0.58 + spawn.y)));
float mask = (max(body, tail * 0.9) * onset + spit * 0.55 + impactFlash * 0.7) * lifeFade;
float trail = tail * onset * lifeFade;
return vec2(mask, trail);
}
vec2 drops(vec2 uv, float t, float l0, float l1, float l2, float sizeFactor) {
vec2 m1 = dropLayer(uv, t, sizeFactor) * l1;
vec2 m2 = dropLayer(uv * 1.85, t, sizeFactor * 0.82) * l2;
float c = m1.x + m2.x;
c = smoothstep(0.3, 1.0, c);
float trail = max(m1.y * l1, m2.y * l2);
return vec2(c, trail);
}
vec3 impactLayer(vec2 uv, float t, float rainAmount, float sizeFactor) {
vec2 grid = vec2(11.0, 6.0) / mix(0.75, 1.35, sizeFactor);
vec2 p = uv * grid;
vec2 cell = floor(p);
vec2 local = fract(p) - 0.5;
vec3 n = hash31(cell.x * 97.13 + cell.y * 413.71);
float rate = (1.3 + n.z * 2.4) * mix(0.95, 1.55, sizeFactor);
float cycle = t * rate + n.x * 7.13 + n.y * 3.17;
float phase = fract(cycle);
vec2 center = (n.xy - 0.5) * vec2(0.72, 0.36);
float lifeFade = 1.0 - smoothstep(0.28, 0.56, phase);
center.y += easeOutCubic(smoothstep(0.01, 0.36, phase)) * mix(0.9, 1.45, sizeFactor);
vec2 d = local - center;
float radius = mix(0.012, mix(0.18, 0.32, sizeFactor), smoothstep(0.0, 0.22, phase));
float flash = smoothstep(0.09, 0.0, phase) * smoothstep(0.18, 0.015, length(d));
float ring = smoothstep(0.025, 0.0, abs(length(d) - radius)) * (1.0 - smoothstep(0.05, 0.24, phase));
float tail = smoothstep(0.055, 0.0, abs(d.x)) *
smoothstep(0.02, -0.12, d.y) *
smoothstep(-0.6, -0.04, d.y) *
smoothstep(0.02, 0.18, phase);
float sideSplash = smoothstep(0.055, 0.0, abs(d.y + 0.02)) *
smoothstep(0.16, 0.02, abs(abs(d.x) - mix(0.06, 0.22, phase))) *
(1.0 - smoothstep(0.06, 0.2, phase));
float mask = (flash + ring * 0.55 + tail * 0.65 + sideSplash * 0.4) * step(0.64, n.z) * rainAmount * lifeFade;
vec2 dir = normalize(d + vec2(0.0001));
return vec3(mask, dir * mask * (0.018 + flash * 0.028));
}
float rainVeil(vec2 uv, float t, float rainAmount) {
float accum = 0.0;
for (int i = 0; i < 4; i++) {
float fi = float(i);
float scale = 18.0 + fi * 11.0;
float speed = 2.0 + fi * 0.9;
float slant = 0.16 + fi * 0.07;
vec2 p = uv;
p.x += p.y * slant;
p.y += t * speed;
p.x += sin(p.y * (7.0 + fi * 2.3) + fi * 11.7) * (0.015 + fi * 0.004);
vec2 layerUv = p * scale;
vec2 cell = floor(layerUv);
vec2 local = fract(layerUv) - 0.5;
vec3 n = hash31(cell.x * (41.0 + fi * 13.0) + cell.y * (289.0 + fi * 71.0) + fi * 19.0);
float lane = (n.x - 0.5) * 0.62;
float width = mix(0.01, 0.045, n.y * n.y);
float length = mix(0.24, 0.95, n.x);
float bend = sin((cell.y + t * speed * 8.0) * (0.9 + n.z * 1.6) + n.x * 6.2831) * 0.18;
float dx = local.x - lane - bend * (0.15 + fi * 0.05);
float dy = local.y + (n.z - 0.5) * 0.3;
float streak = smoothstep(width, width * 0.15, abs(dx));
streak *= smoothstep(length, length * 0.12, abs(dy) * 2.0);
float breakup = smoothstep(0.2, 0.85, sin((dy + n.y * 2.0) * 18.0 + n.z * 12.0) * 0.5 + 0.5);
float flicker = 0.35 + 0.65 * fract(n.z * 31.7 + t * (4.0 + n.x * 3.0) + fi * 2.1);
float density = step(0.34 - fi * 0.05, n.y);
streak *= mix(1.0, breakup, 0.55) * flicker * density;
accum += streak * (0.52 - fi * 0.08);
}
return accum * smoothstep(0.08, 0.95, rainAmount);
}
void main() {
vec2 uv = vUv;
vec2 centeredUv = (uv - 0.5) * vec2(resolution.x / max(resolution.y, 1.0), 1.0);
float screenAmount = clamp(screenIntensity / 1.5, 0.0, 1.0);
float veilAmount = clamp(veilIntensity / 1.5, 0.0, 1.0);
float sizeFactor = clamp((dropSize - 0.4) / 1.4, 0.0, 1.0);
float rainAmount = max(screenAmount, veilAmount);
float t = time * rainSpeed * 0.18;
float staticLayer = 0.0;
float densityScale = mix(1.2, 0.55, sizeFactor);
float layer1 = smoothstep(0.08, 0.55, screenAmount) * densityScale;
float layer2 = smoothstep(0.25, 0.95, screenAmount) * densityScale;
vec2 c = drops(centeredUv * 0.9, t, staticLayer, layer1, layer2, sizeFactor);
vec3 impact = impactLayer(centeredUv * vec2(1.0, 1.2), t * 2.2, screenAmount, sizeFactor);
float veil = rainVeil(centeredUv * vec2(1.0, 1.4), time * rainSpeed * 0.55, veilAmount);
vec2 e = vec2(0.0015, 0.0);
float cx = drops(centeredUv + e, t, staticLayer, layer1, layer2, sizeFactor).x;
float cy = drops(centeredUv + e.yx, t, staticLayer, layer1, layer2, sizeFactor).x;
vec2 normal = vec2(cx - c.x, cy - c.x) + impact.yz;
float maxBlur = mix(0.002, 0.009, rainAmount);
float minBlur = 0.001;
float focus = mix(maxBlur - c.y * 0.5, minBlur, smoothstep(0.05, 0.2, c.x + impact.x * 0.5));
vec2 texel = 1.0 / max(resolution, vec2(1.0));
vec2 distortion = normal * (0.35 + focus * 48.0 + impact.x * 1.6);
vec3 base =
texture2D(tDiffuse, uv + distortion).rgb * 0.4 +
texture2D(tDiffuse, uv + distortion + vec2(texel.x * focus * 24.0, 0.0)).rgb * 0.15 +
texture2D(tDiffuse, uv + distortion - vec2(texel.x * focus * 24.0, 0.0)).rgb * 0.15 +
texture2D(tDiffuse, uv + distortion + vec2(0.0, texel.y * focus * 24.0)).rgb * 0.15 +
texture2D(tDiffuse, uv + distortion - vec2(0.0, texel.y * focus * 24.0)).rgb * 0.15;
vec3 sharp = texture2D(tDiffuse, uv + distortion * 0.6).rgb;
vec3 col = mix(base, sharp, smoothstep(0.04, 0.22, c.x));
vec3 rainTint = vec3(0.86, 0.91, 0.95);
col = mix(col, col * 0.88 + rainTint * 0.12, clamp(c.x * 0.65 + c.y * 0.3 + impact.x * 0.4, 0.0, 1.0));
col += c.y * rainTint * 0.08 * screenAmount;
col += impact.x * rainTint * 0.18 * screenAmount;
col += veil * rainTint * (0.12 + veilAmount * 0.18);
col = mix(col, col * 0.94 + rainTint * 0.06, veil * 0.18);
col = mix(texture2D(tDiffuse, uv).rgb, col, clamp(rainAmount * 1.1, 0.0, 1.0));
gl_FragColor = vec4(col, 1.0);
}
`
};
}
createVolumetricFogShader() {
return {
uniforms: {
tDiffuse: { value: null },
tDepth: { value: null },
resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
cameraNear: { value: this.camera?.near ?? 1 },
cameraFar: { value: this.camera?.far ?? 20000 },
projectionMatrixInverse: { value: new THREE.Matrix4() },
viewMatrixInverse: { value: new THREE.Matrix4() },
cameraWorldPosition: { value: new THREE.Vector3() },
fogColor: { value: new THREE.Color(0x9ec5db) },
horizonColor: { value: new THREE.Color(0xcfe0ee) },
fogDensity: { value: 0.0 },
fogHeight: { value: this.params.fogHeight },
fogRange: { value: this.params.fogRange },
time: { value: 0 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform sampler2D tDepth;
uniform vec2 resolution;
uniform mat4 projectionMatrixInverse;
uniform mat4 viewMatrixInverse;
uniform vec3 cameraWorldPosition;
uniform vec3 fogColor;
uniform vec3 horizonColor;
uniform float fogDensity;
uniform float fogHeight;
uniform float fogRange;
uniform float time;
varying vec2 vUv;
float hash21(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);
}
vec3 reconstructWorldPosition(vec2 uv, float depth) {
float z = depth * 2.0 - 1.0;
vec4 clipPosition = vec4(uv * 2.0 - 1.0, z, 1.0);
vec4 viewPosition = projectionMatrixInverse * clipPosition;
viewPosition /= max(viewPosition.w, 0.0001);
vec4 worldPosition = viewMatrixInverse * viewPosition;
return worldPosition.xyz;
}
float sampleMediumDensity(vec3 samplePosition, float traveledDistance) {
float seaLevel = 2.0;
float verticalOffset = max(samplePosition.y - seaLevel, 0.0);
float heightFalloff = mix(0.08, 0.02, fogHeight);
float heightMask = exp(-verticalOffset * heightFalloff);
float seaMask = smoothstep(210.0, -12.0, samplePosition.y);
float distanceMask = smoothstep(120.0, mix(900.0, 5400.0, fogRange), traveledDistance);
float upperFade = 1.0 - smoothstep(
mix(120.0, 260.0, fogHeight),
mix(360.0, 760.0, fogHeight),
samplePosition.y
);
float windNoise = hash21(samplePosition.xz * 0.0008 + vec2(time * 0.012, -time * 0.008));
float breakup = mix(0.82, 1.14, windNoise);
return fogDensity * heightMask * seaMask * distanceMask * upperFade * breakup;
}
void main() {
vec3 base = texture2D(tDiffuse, vUv).rgb;
float depth = texture2D(tDepth, vUv).x;
if (fogDensity <= 0.00001) {
gl_FragColor = vec4(base, 1.0);
return;
}
float sceneDepth = depth < 0.99999 ? depth : 0.99999;
vec3 endPosition = reconstructWorldPosition(vUv, sceneDepth);
vec3 ray = endPosition - cameraWorldPosition;
float rayLength = length(ray);
if (depth >= 0.99999) {
vec3 farPosition = reconstructWorldPosition(vUv, 0.99999);
ray = farPosition - cameraWorldPosition;
rayLength = min(length(ray), mix(2200.0, 6400.0, fogRange));
}
if (rayLength <= 0.0001) {
gl_FragColor = vec4(base, 1.0);
return;
}
vec3 rayDirection = ray / rayLength;
const int STEP_COUNT = 12;
float stepLength = rayLength / float(STEP_COUNT);
float transmittance = 1.0;
vec3 inscattering = vec3(0.0);
for (int i = 0; i < STEP_COUNT; i++) {
float jitter = hash21(gl_FragCoord.xy + float(i) * 13.37 + time * 24.0);
float traveled = (float(i) + 0.35 + jitter * 0.45) * stepLength;
vec3 samplePosition = cameraWorldPosition + rayDirection * traveled;
float localDensity = sampleMediumDensity(samplePosition, traveled);
vec3 localTint = mix(horizonColor, fogColor, smoothstep(-8.0, 140.0, samplePosition.y));
float extinction = 1.0 - exp(-localDensity * stepLength);
inscattering += transmittance * localTint * extinction;
transmittance *= exp(-localDensity * stepLength);
}
vec3 color = base * transmittance + inscattering;
gl_FragColor = vec4(color, 1.0);
}
`
};
}
createSnowShader() {
return {
uniforms: {
tDiffuse: { value: null },
time: { value: 0 },
intensity: { value: this.params.snowIntensity },
snowSpeed: { value: this.params.snowSpeed },
resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float time;
uniform float intensity;
uniform float snowSpeed;
uniform vec2 resolution;
varying vec2 vUv;
float rnd(float x) {
return fract(sin(dot(vec2(x + 47.49, 38.2467 / (x + 2.3)), vec2(12.9898, 78.233))) * 43758.5453);
}
float drawCircle(vec2 uv, vec2 center, float radius) {
return 1.0 - smoothstep(0.0, radius, length(uv - center));
}
float snowField(vec2 uv, float t, float amount, float aspect) {
float snow = 0.0;
float blizzardFactor = mix(0.08, 0.3, amount);
int flakeCount = 220;
for (int i = 0; i < 220; i++) {
if (i >= flakeCount) break;
float j = float(i);
float baseRnd = rnd(cos(j));
float speed = (0.3 + baseRnd * (0.7 + 0.5 * cos(j / 55.0))) * mix(0.7, 1.65, amount);
float radius = (0.001 + speed * 0.012) * mix(0.7, 1.18, amount);
vec2 center = vec2(
((0.25 - uv.y) * blizzardFactor + rnd(j) + 0.08 * cos(t * 0.7 + sin(j))) * aspect,
mod(sin(j) - speed * (t * 1.5 * (0.1 + blizzardFactor)), 1.35) - 0.25
);
float flake = drawCircle(uv, center, radius);
snow += flake * (0.035 + speed * 0.04);
}
return snow;
}
void main() {
vec2 uv = vUv;
float aspect = resolution.x / max(resolution.y, 1.0);
vec2 snowUv = vec2(vUv.x * aspect, vUv.y);
float snowAmount = clamp(intensity / 1.5, 0.0, 1.0);
float t = time * snowSpeed;
float snow = snowField(snowUv, t, snowAmount, aspect);
float snowMask = clamp(snow * mix(0.45, 1.15, snowAmount), 0.0, 1.0);
float atmosphere = (1.0 - vUv.y) * 0.12 * snowAmount;
vec3 base = texture2D(tDiffuse, vUv).rgb;
vec3 snowTint = vec3(0.92, 0.95, 1.0);
base = mix(base, base * 0.96 + snowTint * 0.04, snowAmount * 0.1);
base += snowTint * snowMask;
base += vec3(0.16, 0.28, 0.4) * atmosphere;
gl_FragColor = vec4(base, 1.0);
}
`
};
}
async initSky() {
this.sky = new Sky();
this.sky.scale.setScalar(10000);
this.sky.rotation.y = Math.PI;
this.scene.add(this.sky);
const skyUniforms = this.sky.material.uniforms;
skyUniforms['turbidity'].value = this.params.turbidity;
skyUniforms['rayleigh'].value = this.params.rayleigh;
skyUniforms['mieCoefficient'].value = this.params.mieCoefficient;
skyUniforms['mieDirectionalG'].value = this.params.mieDirectionalG;
this.pmremGenerator = new THREE.PMREMGenerator(this.renderer);
}
initStars() {
const starCount = 8000;
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const sizes = new Float32Array(starCount);
const color = new THREE.Color();
for (let i = 0; i < starCount; i++) {
const radius = THREE.MathUtils.randFloat(2200, 4200);
const theta = Math.random() * Math.PI * 2.0;
const phi = THREE.MathUtils.randFloat(0.015, Math.PI * 0.49);
const sinPhi = Math.sin(phi);
const x = radius * sinPhi * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * sinPhi * Math.sin(theta);
positions[i * 3] = x;
positions[i * 3 + 1] = y;
positions[i * 3 + 2] = z;
color.setHSL(
THREE.MathUtils.randFloat(0.52, 0.64),
THREE.MathUtils.randFloat(0.15, 0.45),
THREE.MathUtils.randFloat(0.72, 0.96)
);
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
sizes[i] = Math.pow(Math.random(), 1.9);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('sizeNoise', new THREE.BufferAttribute(sizes, 1));
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
intensity: { value: this.params.starIntensity }
},
vertexShader: `
attribute float sizeNoise;
varying vec3 vColor;
varying float vPulse;
uniform float time;
uniform float intensity;
void main() {
vColor = color;
vPulse = fract(sizeNoise * 17.0 + time * (0.015 + sizeNoise * 0.035));
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
float starSize = 2.8 + sizeNoise * 6.4;
float projectedSize = starSize * (6500.0 / -mvPosition.z) * (0.65 + intensity * 0.55);
gl_PointSize = max(1.6, projectedSize);
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vPulse;
uniform float intensity;
void main() {
vec2 p = gl_PointCoord - vec2(0.5);
float d = length(p);
float core = smoothstep(0.26, 0.0, d);
float glow = smoothstep(0.58, 0.06, d);
float halo = smoothstep(0.82, 0.16, d);
float twinkle = 0.8 + 0.2 * sin(vPulse * 6.2831);
float alpha = (core * 1.2 + glow * 0.65 + halo * 0.18) * twinkle * intensity;
if (alpha <= 0.001) discard;
gl_FragColor = vec4(vColor, alpha);
}
`,
transparent: true,
depthTest: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexColors: true
});
this.starField = new THREE.Points(geometry, material);
this.starField.frustumCulled = false;
this.scene.add(this.starField);
this.updateStars();
}
initNightSky() {
const moonTexture = this.createMoonTexture();
const moonMaterial = new THREE.SpriteMaterial({
map: moonTexture,
color: 0xe9f0ff,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.NormalBlending
});
this.moonSprite = new THREE.Sprite(moonMaterial);
this.moonSprite.scale.setScalar(490);
this.scene.add(this.moonSprite);
const moonGlowTexture = this.createMoonGlowTexture();
const moonGlowMaterial = new THREE.SpriteMaterial({
map: moonGlowTexture,
color: 0xc6dbff,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending
});
this.moonGlowSprite = new THREE.Sprite(moonGlowMaterial);
this.moonGlowSprite.scale.setScalar(980);
this.scene.add(this.moonGlowSprite);
const galaxyTexture = this.createGalaxyTexture();
const galaxyMaterial = new THREE.SpriteMaterial({
map: galaxyTexture,
color: 0xa7bedf,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending
});
this.galaxyBand = new THREE.Sprite(galaxyMaterial);
this.galaxyBand.scale.set(2600, 760, 1);
this.scene.add(this.galaxyBand);
}
createMoonTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(128, 128);
ctx.beginPath();
ctx.arc(-8, 0, 76, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
const moonFill = ctx.createLinearGradient(-76, -48, 26, 44);
moonFill.addColorStop(0, 'rgba(250,252,255,1)');
moonFill.addColorStop(0.55, 'rgba(232,239,250,1)');
moonFill.addColorStop(1, 'rgba(198,212,232,1)');
ctx.fillStyle = moonFill;
ctx.beginPath();
ctx.arc(-8, 0, 76, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(42, 0, 76, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = 'rgba(138,152,182,0.12)';
ctx.beginPath();
ctx.arc(-40, -18, 7, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(-34, 20, 9, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
createMoonGlowTexture() {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const glow = ctx.createRadialGradient(256, 256, 0, 256, 256, 256);
glow.addColorStop(0, 'rgba(255,255,255,0.62)');
glow.addColorStop(0.12, 'rgba(236,242,255,0.46)');
glow.addColorStop(0.28, 'rgba(198,216,255,0.26)');
glow.addColorStop(0.52, 'rgba(148,184,255,0.11)');
glow.addColorStop(0.78, 'rgba(120,160,255,0.05)');
glow.addColorStop(1, 'rgba(120,160,255,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
createGalaxyTexture() {
const canvas = document.createElement('canvas');
canvas.width = 1024;
canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const base = ctx.createLinearGradient(0, canvas.height * 0.5, canvas.width, canvas.height * 0.5);
base.addColorStop(0, 'rgba(0,0,0,0)');
base.addColorStop(0.16, 'rgba(110,130,170,0.04)');
base.addColorStop(0.35, 'rgba(165,180,215,0.1)');
base.addColorStop(0.5, 'rgba(235,235,255,0.16)');
base.addColorStop(0.68, 'rgba(165,180,215,0.1)');
base.addColorStop(0.84, 'rgba(110,130,170,0.04)');
base.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = base;
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < 1800; i++) {
const x = Math.random() * canvas.width;
const yCenter = canvas.height * 0.52 + Math.sin(x * 0.008) * 22.0;
const y = yCenter + (Math.random() - 0.5) * 120;
const alpha = Math.random() * Math.random() * 0.7;
const size = Math.random() < 0.06 ? 2.2 : 1.0;
ctx.fillStyle = `rgba(255,255,255,${alpha})`;
ctx.fillRect(x, y, size, size);
}
const blur = ctx.createRadialGradient(canvas.width * 0.5, canvas.height * 0.5, 10, canvas.width * 0.5, canvas.height * 0.5, canvas.width * 0.5);
blur.addColorStop(0, 'rgba(210,220,255,0.09)');
blur.addColorStop(0.5, 'rgba(150,170,220,0.04)');
blur.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = blur;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
initClouds() {
this.cloudGroup = new THREE.Group();
this.cloudGroup.position.y = 40;
this.addCloudPlaneLayer({
radius: 5200,
y: 120,
opacity: 0.42,
scale: 1450,
detailScale: 3.4,
softness: 0.19,
edgeFade: 0.2,
shadowStrength: 0.36,
highlightStrength: 0.18,
erosionStrength: 0.2,
ridgeStrength: 0.08,
driftX: 0.0045,
driftY: 0.0012,
rotationZ: 0.06
});
this.addCloudPlaneLayer({
radius: 4300,
y: 250,
opacity: 0.28,
scale: 980,
detailScale: 4.1,
softness: 0.17,
edgeFade: 0.24,
shadowStrength: 0.42,
highlightStrength: 0.24,
erosionStrength: 0.28,
ridgeStrength: 0.12,
driftX: -0.0032,
driftY: 0.0018,
rotationZ: -0.04
});
this.addCloudPlaneLayer({
radius: 3400,
y: 360,
opacity: 0.18,
scale: 760,
detailScale: 4.8,
softness: 0.16,
edgeFade: 0.28,
shadowStrength: 0.46,
highlightStrength: 0.3,
erosionStrength: 0.34,
ridgeStrength: 0.16,
driftX: 0.0021,
driftY: -0.0014,
rotationZ: 0.1
});
this.scene.add(this.cloudGroup);
this.initLightningCloudGlow();
this.setCloudElevation(this.params.cloudElevation);
this.setCloudCoverage(this.params.cloudCoverage);
this.updateClouds();
}
initLightningCloudGlow() {
const glowTexture = this.createLightningGlowTexture();
const material = new THREE.SpriteMaterial({
map: glowTexture,
color: 0xddeeff,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
fog: false,
blending: THREE.AdditiveBlending
});
this.lightningCloudGlow = new THREE.Sprite(material);
this.lightningCloudGlow.scale.set(900, 900, 1);
this.lightningCloudGlow.position.set(-420, 210, -760);
this.lightningCloudGlow.visible = false;
this.scene.add(this.lightningCloudGlow);
}
addCloudPlaneLayer(config) {
const material = new THREE.ShaderMaterial({
uniforms: {
tintColor: { value: new THREE.Color(0xffffff) },
layerOpacity: { value: config.opacity },
time: { value: 0 },
cloudCoverage: { value: this.params.cloudCoverage },
cloudDensity: { value: this.params.cloudDensity },
scale: { value: config.scale },
detailScale: { value: config.detailScale },
softness: { value: config.softness },
edgeFade: { value: config.edgeFade },
drift: { value: new THREE.Vector2(config.driftX, config.driftY) },
seed: { value: Math.random() * 100.0 },
lightDir: { value: new THREE.Vector2(1, 0) },
shadowStrength: { value: config.shadowStrength ?? 0.42 },
highlightStrength: { value: config.highlightStrength ?? 0.26 },
erosionStrength: { value: config.erosionStrength ?? 0.24 },
ridgeStrength: { value: config.ridgeStrength ?? 0.12 }
},
vertexShader: `
varying vec2 vPlaneUv;
varying vec3 vWorldPos;
void main() {
vPlaneUv = uv;
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 tintColor;
uniform float layerOpacity;
uniform float time;
uniform float cloudCoverage;
uniform float cloudDensity;
uniform float scale;
uniform float detailScale;
uniform float softness;
uniform float edgeFade;
uniform vec2 drift;
uniform float seed;
uniform vec2 lightDir;
uniform float shadowStrength;
uniform float highlightStrength;
uniform float erosionStrength;
uniform float ridgeStrength;
varying vec2 vPlaneUv;
varying vec3 vWorldPos;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 78.233);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 5; i++) {
value += amplitude * noise(p);
p = p * 2.03 + vec2(11.7, 7.3);
amplitude *= 0.5;
}
return value;
}
void main() {
vec2 flow = vWorldPos.xz / scale + drift * time + vec2(seed);
float base = fbm(flow);
float detail = fbm(flow * detailScale + vec2(0.0, time * 0.01));
float wisps = fbm(flow * (detailScale * 0.55) - vec2(time * 0.008, 0.0));
float billow = fbm(flow * 0.62 + vec2(seed * 0.37, -seed * 0.21));
float erosion = fbm(flow * (detailScale * 1.8) + vec2(seed * 1.3, -seed * 0.9));
float ridge = 1.0 - abs(fbm(flow * (detailScale * 0.85) - vec2(seed * 0.6, seed * 0.45)) * 2.0 - 1.0);
float shape = base * 0.5 + billow * 0.24 + detail * 0.14 + wisps * 0.12;
shape -= erosion * erosionStrength;
shape += ridge * ridgeStrength;
float densityBoost = mix(0.78, 1.28, clamp(cloudDensity, 0.0, 1.0));
float coverageThreshold = mix(0.84, 0.34, clamp(cloudCoverage, 0.0, 1.0));
float alpha = smoothstep(
coverageThreshold + softness,
coverageThreshold - softness,
shape * densityBoost
);
float core = smoothstep(
coverageThreshold + softness * 0.4,
coverageThreshold - softness * 1.4,
shape * densityBoost
);
vec2 eps = vec2(0.03, 0.0);
float sampleA = fbm((flow + lightDir * eps.x) * 0.62 + vec2(seed * 0.37, -seed * 0.21));
float sampleB = fbm((flow - lightDir * eps.x) * 0.62 + vec2(seed * 0.37, -seed * 0.21));
float lightEdge = clamp((sampleA - sampleB) * 3.2 + 0.5, 0.0, 1.0);
float underside = 1.0 - smoothstep(0.32, 0.88, shape);
float radial = distance(vPlaneUv, vec2(0.5)) * 2.0;
float edgeMask = 1.0 - smoothstep(1.0 - edgeFade, 1.0, radial);
alpha *= edgeMask * layerOpacity;
if (alpha <= 0.001) discard;
vec3 shadowColor = tintColor * (1.0 - shadowStrength);
vec3 litColor = mix(shadowColor, tintColor, core);
litColor += tintColor * lightEdge * core * highlightStrength;
litColor = mix(litColor, shadowColor, underside * shadowStrength * 0.7);
gl_FragColor = vec4(litColor, alpha);
}
`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
fog: false
});
const geometry = new THREE.CircleGeometry(config.radius, 96);
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2;
mesh.rotation.z = config.rotationZ ?? 0;
mesh.position.y = config.y;
this.cloudLayers.push({
mesh,
material,
baseOpacity: config.opacity,
baseY: config.y,
baseScale: config.scale,
baseSoftness: config.softness,
baseShadowStrength: config.shadowStrength ?? 0.42,
baseHighlightStrength: config.highlightStrength ?? 0.26,
baseErosionStrength: config.erosionStrength ?? 0.24,
baseRidgeStrength: config.ridgeStrength ?? 0.12
});
this.cloudGroup.add(mesh);
}
initFog() {
const fogTexture = this.createFogTexture();
const lowFogTexture = this.createLowFogTexture();
this.fogGroup = new THREE.Group();
this.fogLayers = [];
const layerConfigs = [
{ width: 5200, height: 1800, y: 5, opacity: 0.18, speedX: 0.00022, speedY: 0.00004, rotation: 0.03, scale: 3.2, texture: lowFogTexture, low: true },
{ width: 4300, height: 1500, y: 11, opacity: 0.14, speedX: -0.00018, speedY: 0.00005, rotation: -0.04, scale: 2.8, texture: lowFogTexture, low: true },
{ width: 4600, height: 2400, y: 22, opacity: 0.2, speedX: 0.00045, speedY: 0.0001, rotation: 0.08, scale: 2.4, texture: fogTexture, low: false },
{ width: 3900, height: 1900, y: 52, opacity: 0.14, speedX: -0.00028, speedY: 0.00014, rotation: -0.05, scale: 2.1, texture: fogTexture, low: false }
];
layerConfigs.forEach((config) => {
const texture = config.texture.clone();
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(config.scale, config.low ? 1.1 : 1.4);
texture.needsUpdate = true;
const material = new THREE.MeshBasicMaterial({
map: texture,
alphaMap: texture,
color: 0xdbe7ef,
transparent: true,
opacity: config.opacity,
depthWrite: false,
fog: false,
side: THREE.DoubleSide,
blending: THREE.NormalBlending
});
const geometry = new THREE.PlaneGeometry(config.width, config.height, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2;
mesh.rotation.z = config.rotation;
mesh.position.y = config.y;
this.fogLayers.push({
mesh,
texture,
baseY: config.y,
baseOpacity: config.opacity,
isLowLayer: config.low,
speedX: config.speedX,
speedY: config.speedY
});
this.fogGroup.add(mesh);
});
this.horizonFog = this.createHorizonFog();
this.fogGroup.add(this.horizonFog);
this.skyHazeBand = this.createSkyHazeBand();
this.fogGroup.add(this.skyHazeBand);
this.scene.add(this.fogGroup);
this.updateFog();
}
createLightningGlowTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const context = canvas.getContext('2d');
const gradient = context.createRadialGradient(128, 128, 0, 128, 128, 128);
gradient.addColorStop(0, 'rgba(255,255,255,0.95)');
gradient.addColorStop(0.18, 'rgba(220,236,255,0.85)');
gradient.addColorStop(0.42, 'rgba(180,205,255,0.35)');
gradient.addColorStop(0.72, 'rgba(120,155,255,0.08)');
gradient.addColorStop(1, 'rgba(120,155,255,0)');
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
createFogTexture() {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < 42; i++) {
const x = 30 + Math.random() * 452;
const y = 40 + Math.random() * 432;
const radius = 34 + Math.random() * 88;
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, 'rgba(255,255,255,0.82)');
gradient.addColorStop(0.22, 'rgba(255,255,255,0.58)');
gradient.addColorStop(0.58, 'rgba(255,255,255,0.16)');
gradient.addColorStop(1, 'rgba(255,255,255,0)');
context.fillStyle = gradient;
context.fillRect(x - radius, y - radius, radius * 2, radius * 2);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
createLowFogTexture() {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 256;
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < 30; i++) {
const x = Math.random() * canvas.width;
const y = 30 + Math.random() * 150;
const radiusX = 80 + Math.random() * 140;
const radiusY = 14 + Math.random() * 28;
const gradient = context.createRadialGradient(x, y, 0, x, y, radiusX);
gradient.addColorStop(0, 'rgba(255,255,255,0.58)');
gradient.addColorStop(0.22, 'rgba(255,255,255,0.36)');
gradient.addColorStop(0.6, 'rgba(255,255,255,0.12)');
gradient.addColorStop(1, 'rgba(255,255,255,0)');
context.save();
context.translate(x, y);
context.scale(1.0, radiusY / radiusX);
context.translate(-x, -y);
context.fillStyle = gradient;
context.fillRect(x - radiusX, y - radiusX, radiusX * 2, radiusX * 2);
context.restore();
}
const verticalFade = context.createLinearGradient(0, 0, 0, canvas.height);
verticalFade.addColorStop(0, 'rgba(255,255,255,0)');
verticalFade.addColorStop(0.18, 'rgba(255,255,255,0.55)');
verticalFade.addColorStop(0.52, 'rgba(255,255,255,1)');
verticalFade.addColorStop(0.86, 'rgba(255,255,255,0.42)');
verticalFade.addColorStop(1, 'rgba(255,255,255,0)');
context.globalCompositeOperation = 'destination-in';
context.fillStyle = verticalFade;
context.fillRect(0, 0, canvas.width, canvas.height);
context.globalCompositeOperation = 'source-over';
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
createHorizonFogTexture() {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 512;
const context = canvas.getContext('2d');
const gradient = context.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, 'rgba(255,255,255,0)');
gradient.addColorStop(0.08, 'rgba(255,255,255,0.02)');
gradient.addColorStop(0.2, 'rgba(255,255,255,0.12)');
gradient.addColorStop(0.35, 'rgba(255,255,255,0.38)');
gradient.addColorStop(0.5, 'rgba(255,255,255,0.55)');
gradient.addColorStop(0.65, 'rgba(255,255,255,0.38)');
gradient.addColorStop(0.8, 'rgba(255,255,255,0.12)');
gradient.addColorStop(0.92, 'rgba(255,255,255,0.02)');
gradient.addColorStop(1, 'rgba(255,255,255,0)');
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.repeat.set(4, 1);
texture.needsUpdate = true;
return texture;
}
createSkyHazeTexture() {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 512;
const context = canvas.getContext('2d');
const gradient = context.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, 'rgba(255,255,255,0)');
gradient.addColorStop(0.06, 'rgba(255,255,255,0.01)');
gradient.addColorStop(0.15, 'rgba(255,255,255,0.06)');
gradient.addColorStop(0.28, 'rgba(255,255,255,0.18)');
gradient.addColorStop(0.42, 'rgba(255,255,255,0.45)');
gradient.addColorStop(0.55, 'rgba(255,255,255,0.62)');
gradient.addColorStop(0.7, 'rgba(255,255,255,0.45)');
gradient.addColorStop(0.85, 'rgba(255,255,255,0.12)');
gradient.addColorStop(0.94, 'rgba(255,255,255,0.02)');
gradient.addColorStop(1, 'rgba(255,255,255,0)');
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.repeat.set(3, 1);
texture.needsUpdate = true;
return texture;
}
createHorizonFog() {
const texture = this.createHorizonFogTexture();
const material = new THREE.MeshBasicMaterial({
map: texture,
alphaMap: texture,
color: 0xdde8f2,
transparent: true,
opacity: 0.38,
fog: false,
depthWrite: false,
side: THREE.BackSide
});
const geometry = new THREE.CylinderGeometry(4700, 4700, 900, 72, 1, true);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.y = 130;
mesh.renderOrder = -1;
mesh.userData.texture = texture;
return mesh;
}
createSkyHazeBand() {
const texture = this.createSkyHazeTexture();
const material = new THREE.MeshBasicMaterial({
map: texture,
alphaMap: texture,
color: 0xdde8f2,
transparent: true,
opacity: 0.32,
fog: false,
depthWrite: false,
side: THREE.BackSide,
blending: THREE.NormalBlending
});
const geometry = new THREE.CylinderGeometry(5200, 5200, 1600, 72, 1, true);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.y = 420;
mesh.renderOrder = -2;
mesh.userData.texture = texture;
return mesh;
}
async initWater() {
const waterGeometry = new THREE.PlaneGeometry(10000, 10000, 128, 128);
const waterNormals = await new Promise((resolve) => {
new THREE.TextureLoader().load(
'/textures/waternormals.jpg',
(texture) => {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
resolve(texture);
}
);
});
this.water = new Water(waterGeometry, {
textureWidth: 512,
textureHeight: 512,
waterNormals: waterNormals,
sunDirection: new THREE.Vector3(),
sunColor: 0xffffff,
waterColor: this.params.waterColor,
distortionScale: 3.7,
fog: true
});
this.water.rotation.x = -Math.PI / 2;
this.water.position.y = -0.15;
this.setWaterColor(this.params.waterColor);
const baseWaterOnBeforeRender = this.water.onBeforeRender.bind(this.water);
this.water.onBeforeRender = (...args) => {
const starsWereVisible = this.starField?.visible ?? false;
const moonWasVisible = this.moonSprite?.visible ?? false;
const moonGlowWasVisible = this.moonGlowSprite?.visible ?? false;
const galaxyWasVisible = this.galaxyBand?.visible ?? false;
if (this.starField) {
this.starField.visible = false;
}
if (this.moonSprite) {
this.moonSprite.visible = false;
}
if (this.moonGlowSprite) {
this.moonGlowSprite.visible = false;
}
if (this.galaxyBand) {
this.galaxyBand.visible = false;
}
try {
baseWaterOnBeforeRender(...args);
} finally {
if (this.starField) {
this.starField.visible = starsWereVisible;
}
if (this.moonSprite) {
this.moonSprite.visible = moonWasVisible;
}
if (this.moonGlowSprite) {
this.moonGlowSprite.visible = moonGlowWasVisible;
}
if (this.galaxyBand) {
this.galaxyBand.visible = galaxyWasVisible;
}
}
};
this.scene.add(this.water);
}
async initTerrain() {
const terrainGen = new TerrainGenerator({
size: 1200, // 地形平面尺寸
segments: 200, // 网格细分数,越高细节越多
maxHeight: 34, // 海面以上地形的最大起伏高度
waterLevel: 0, // 海平面基准高度
underwaterDepthBias: 4.5, // 整体压低海面以下地形,避免浅滩露出
underwaterBiasFadeWidth: 8, // 水下额外下沉的渐变宽度,越大近岸越平滑
landBias: 0.2, // 整体抬升陆地倾向,增大陆地露出面积
falloffStartRatio: 0.22, // 从中心向外开始下沉的起始比例
maxLandRatio: 0.46, // 大陆海岸线的大致外缘比例
edgeDepth: 12, // 海岸外侧向海底下沉的强度
coreRadiusRatio: 0.24, // 大陆核心高地区域半径比例
continentLift: 0.55, // 核心大陆的额外抬升强度
coastVariance: 0.05, // 海岸线形状起伏幅度,越大越不规则
outerShelfDepth: 4, // 大陆外侧陆架的额外下沉深度
coastlineBlendWidth: 42, // 海岸线外侧过渡到海底的缓冲宽度
seed: 23 // 固定随机种子,保证地形稳定复现
});
this.terrain = terrainGen.generate();
this.scene.add(this.terrain);
this.terrainGenerator = terrainGen;
}
async initWindTurbine() {
this.windTurbine = new OffshoreWindTurbineAsset({
position: new THREE.Vector3(280, 0, -2350),
yaw: 0,
scale: 0.68,
rotorSpeed: 0.24
});
await this.windTurbine.load();
this.windTurbine.addToScene(this.scene);
this.windTurbine.faceDirection(this.sun);
}
async initVegetation() {
const vegSystem = new VegetationSystem(this.terrainGenerator, {
grassCount: 0, // 随机草簇数量,设为 0 时只使用 grassAreas
shrubCount: 0, // 随机灌木数量,设为 0 时只使用 shrubPlacements
lowPlantCount: 0, // 随机低矮植物数量,设为 0 时只使用 lowPlantPlacements
treeCount: 0, // 随机树木数量,设为 0 时只使用 treePlacements
terrainSize: 1200, // 随机植被允许分布的地形范围
waterLevel: 1, // 植被生成时参考的水位,避免贴近海边
treePlacements: [ // 手动指定树木坐标
{ x: 0, z: 50, rotation: 0.4, scale: 1.6 },
{ x: 21, z: 32, rotation: 1.2, scale: 1.35 },
// { x: -40, z: -150, rotation: 2.1, scale: 1.75 },
// { x: 70, z: -70, rotation: 2.8, scale: 1.45 },
// { x: 135, z: 15, rotation: 4.1, scale: 1.55 },
// { x: 30, z: 120, rotation: 5.2, scale: 1.7 }
],
shrubPlacements: [ // 手动指定灌木坐标
{ x: -210, z: -65, rotation: 0.3, scale: 1.05 },
{ x: -195, z: -75, rotation: 1.4, scale: 0.95 },
// { x: -20, z: -95, rotation: 2.2, scale: 1.1 },
// { x: 55, z: -5, rotation: 3.6, scale: 0.9 },
// { x: 150, z: -55, rotation: 4.5, scale: 1.15 },
// { x: 185, z: 75, rotation: 5.4, scale: 1.0 }
],
lowPlantPlacements: [ // 手动指定低矮植物坐标
{ x: -235, z: -20, rotation: 0.6, scale: 0.58 },
{ x: -205, z: 15, rotation: 1.8, scale: 0.52 },
// { x: -10, z: -20, rotation: 2.7, scale: 0.48 },
// { x: 82, z: -132, rotation: 3.4, scale: 0.62 },
// { x: 118, z: 58, rotation: 4.2, scale: 0.56 },
// { x: 225, z: 18, rotation: 5.1, scale: 0.6 }
],
grassAreas: [ // 手动指定草簇生成区域
{ centerX: -140, centerZ: -10, width: 220, depth: 170, count: 4200 },
{ centerX: 110, centerZ: 65, width: 210, depth: 160, count: 3800 }
]
});
this.vegetation = vegSystem.generate();
this.vegetationSystem = vegSystem;
vegSystem.addToScene(this.scene);
}
initSunPosition() {
this.initialSun.copy(this.sun);
this.updateSun();
}
updateSun() {
const phi = THREE.MathUtils.degToRad(90 - this.params.elevation);
const theta = THREE.MathUtils.degToRad(this.params.azimuth);
this.sun.setFromSphericalCoords(1, phi, theta);
if (this.initialSun.lengthSq() === 0) {
this.initialSun.copy(this.sun);
}
this.sky.material.uniforms['sunPosition'].value.copy(this.sun);
this.water.material.uniforms['sunDirection'].value.copy(this.sun).normalize();
if (this.sunLight) {
const sunDistance = 100;
this.sunLight.position.set(
this.sun.x * sunDistance,
this.sun.y * sunDistance,
this.sun.z * sunDistance
);
const dayMix = THREE.MathUtils.clamp((this.sun.y + 0.06) / 0.52, 0, 1);
this.sunLight.intensity = THREE.MathUtils.lerp(0.0, 1.5, dayMix);
}
const nightMix = THREE.MathUtils.clamp((-this.sun.y + 0.02) / 0.72, 0, 1);
if (this.ambientLight) {
this.ambientLight.color.set(0x8ea0b7).lerp(new THREE.Color(0x425a77), nightMix);
this.ambientLight.intensity = THREE.MathUtils.lerp(0.66, 1.0, nightMix);
}
if (this.moonLight) {
const moonDistance = 115;
this.moonLight.position.set(
-this.sun.x * moonDistance,
Math.max(34, -this.sun.y * moonDistance * 0.5 + 42),
-this.sun.z * moonDistance
);
this.moonLight.intensity = 1.15 * nightMix;
}
if (this.vegetationFillLight) {
const fillDistance = 90;
this.vegetationFillLight.position.set(
-this.sun.x * fillDistance * 0.45 + 35,
Math.max(18, this.sun.y * fillDistance * 0.28 + 24),
-this.sun.z * fillDistance * 0.35 + 28
);
const fillDayMix = THREE.MathUtils.clamp((this.sun.y + 0.2) / 0.9, 0, 1);
this.vegetationFillLight.intensity = THREE.MathUtils.lerp(0.5, 1.15, fillDayMix) + nightMix * 0.16;
}
if (this.renderTarget) {
this.renderTarget.dispose();
}
const sceneEnv = new THREE.Scene();
sceneEnv.add(this.sky);
this.renderTarget = this.pmremGenerator.fromScene(sceneEnv);
this.scene.environment = this.renderTarget.texture;
this.scene.add(this.sky);
this.updateClouds();
this.updateStars();
}
getFogColor() {
const elevation = this.params.elevation;
if (elevation < -4) {
return 0x121c2c;
} else if (elevation < 0) {
return 0x1d2b40;
} else if (elevation < 10) {
return 0x4a5a6a;
} else if (elevation < 20) {
return 0x8cb8d4;
} else if (elevation < 45) {
return 0x9ec5db;
} else if (elevation < 70) {
return 0xb8d4e8;
} else {
return 0xd4e8f4;
}
}
getAtmosphereColors() {
const sunMix = THREE.MathUtils.clamp((this.params.elevation + 10) / 100, 0, 1);
const fogColor = new THREE.Color(this.getFogColor());
const nightMix = THREE.MathUtils.clamp((-this.params.elevation + 1.0) / 12.0, 0, 1);
const warmHorizon = new THREE.Color(0xf0c7a3);
const coolHorizon = new THREE.Color(0xcfe0ee);
const nightHorizon = new THREE.Color(0x27415f);
const horizonColor = warmHorizon.clone().lerp(coolHorizon, sunMix).lerp(nightHorizon, nightMix);
const warmSkyBase = new THREE.Color(0xf6d7b8);
const coolSkyBase = new THREE.Color(0xbfd8eb);
const nightSkyBase = new THREE.Color(0x08111f);
const nightSkyBlend = new THREE.Color(0x182940);
const skyBaseColor = warmSkyBase.clone().lerp(coolSkyBase, sunMix * 0.92).lerp(nightSkyBase, nightMix);
const skyBlendColor = skyBaseColor.clone().lerp(fogColor, 0.42).lerp(nightSkyBlend, nightMix * 0.78);
return { sunMix, fogColor, horizonColor, skyBaseColor, skyBlendColor };
}
initEventListeners() {
window.addEventListener('resize', () => this.onWindowResize());
}
onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
if (this.depthTarget) {
this.depthTarget.setSize(window.innerWidth, window.innerHeight);
}
if (this.composer) {
this.composer.setSize(window.innerWidth, window.innerHeight);
}
if (this.fogPass) {
this.fogPass.material.uniforms.resolution.value.set(window.innerWidth, window.innerHeight);
this.fogPass.material.uniforms.cameraNear.value = this.camera.near;
this.fogPass.material.uniforms.cameraFar.value = this.camera.far;
}
if (this.rainPass) {
this.rainPass.material.uniforms.resolution.value.set(window.innerWidth, window.innerHeight);
}
if (this.snowPass) {
this.snowPass.material.uniforms.resolution.value.set(window.innerWidth, window.innerHeight);
}
}
setSunElevation(value) {
this.params.elevation = value;
this.updateSun();
}
setSunAzimuth(value) {
this.params.azimuth = value;
this.updateSun();
}
setExposure(value) {
this.params.exposure = value;
this.renderer.toneMappingExposure = value;
}
setTurbidity(value) {
this.params.turbidity = value;
this.sky.material.uniforms['turbidity'].value = value;
this.updateSun();
}
setRayleigh(value) {
this.params.rayleigh = value;
this.sky.material.uniforms['rayleigh'].value = value;
this.updateSun();
}
setBloomStrength(value) {
this.params.bloomStrength = value;
if (this.bloomPass) {
this.bloomPass.strength = value;
}
}
setBloomRadius(value) {
this.params.bloomRadius = value;
if (this.bloomPass) {
this.bloomPass.radius = value;
}
}
setBloomThreshold(value) {
this.params.bloomThreshold = value;
if (this.bloomPass) {
this.bloomPass.threshold = value;
}
}
setWaterColor(value) {
this.params.waterColor = value;
if (this.water?.material?.uniforms?.waterColor?.value) {
this.water.material.uniforms.waterColor.value.set(value);
}
}
setCloudCoverage(value) {
this.params.cloudCoverage = value;
this.updateClouds();
}
setCloudDensity(value) {
this.params.cloudDensity = value;
this.updateClouds();
}
setCloudElevation(value) {
this.params.cloudElevation = value;
if (this.cloudGroup) {
this.cloudGroup.position.y = THREE.MathUtils.lerp(-160, 260, value);
}
}
setFogEnabled(value) {
this.params.fogEnabled = value;
this.updateFog();
}
setFogDensity(value) {
this.params.fogDensity = THREE.MathUtils.clamp(value, 0, 2);
this.updateFog();
}
setFogHeight(value) {
this.params.fogHeight = value;
this.updateFog();
}
setFogRange(value) {
this.params.fogRange = value;
this.updateFog();
}
setRainEnabled(value) {
this.params.rainEnabled = value;
if (this.rainPass) {
this.rainPass.enabled = value;
}
this.updateRainAudioState();
if (!value) {
this.lightningFlash = 0;
this.lightningLocalFlash = 0;
this.lightningBurstEnd = 0;
this.nextLightningAt = 0;
this.lightningPulseSchedule = [];
this.scheduledThunder = [];
this.applyLightningState(0);
this.stopThunderAudio();
}
}
setRainScreenIntensity(value) {
this.params.rainScreenIntensity = value;
if (this.rainPass) {
this.rainPass.material.uniforms.screenIntensity.value = value;
}
}
setRainVeilIntensity(value) {
this.params.rainVeilIntensity = value;
if (this.rainPass) {
this.rainPass.material.uniforms.veilIntensity.value = value;
}
}
setRainDropSize(value) {
this.params.rainDropSize = value;
if (this.rainPass) {
this.rainPass.material.uniforms.dropSize.value = value;
}
}
setRainSpeed(value) {
this.params.rainSpeed = value;
if (this.rainPass) {
this.rainPass.material.uniforms.rainSpeed.value = value;
}
}
setRainAudioEnabled(value) {
this.params.rainAudioEnabled = value;
this.updateRainAudioState();
}
setRainAudioVolume(value) {
this.params.rainAudioVolume = value;
this.updateRainAudioState();
}
setSnowEnabled(value) {
this.params.snowEnabled = value;
if (this.snowPass) {
this.snowPass.enabled = value;
}
}
setSnowIntensity(value) {
this.params.snowIntensity = value;
if (this.snowPass) {
this.snowPass.material.uniforms.intensity.value = value;
}
}
setSnowSpeed(value) {
this.params.snowSpeed = value;
if (this.snowPass) {
this.snowPass.material.uniforms.snowSpeed.value = value;
}
}
setStarEnabled(value) {
this.params.starEnabled = value;
this.updateStars();
}
setStarIntensity(value) {
this.params.starIntensity = value;
this.updateStars();
}
updateRainAudioState() {
if (this.rainAudioPool.length === 0) return;
const shouldPlay = this.params.rainEnabled && this.params.rainAudioEnabled && this.params.rainAudioVolume > 0.001;
if (shouldPlay) {
if (!this.rainAudioIsPlaying) {
const active = this.rainAudioPool[this.rainAudioActiveIndex];
active.currentTime = 0;
active.volume = this.params.rainAudioVolume;
const playPromise = active.play();
if (playPromise?.catch) {
playPromise.catch(() => {});
}
this.rainAudioIsPlaying = true;
this.rainAudioCrossfading = false;
}
} else {
for (const audio of this.rainAudioPool) {
audio.pause();
audio.currentTime = 0;
audio.volume = 0;
}
this.rainAudioIsPlaying = false;
this.rainAudioCrossfading = false;
}
}
updateRainAudioLoop() {
if (!this.rainAudioIsPlaying || this.rainAudioPool.length < 2) return;
const active = this.rainAudioPool[this.rainAudioActiveIndex];
const next = this.rainAudioPool[(this.rainAudioActiveIndex + 1) % this.rainAudioPool.length];
const duration = Number.isFinite(active.duration) ? active.duration : 0;
if (duration <= 0) {
active.volume = this.params.rainAudioVolume;
return;
}
const timeLeft = duration - active.currentTime;
if (!this.rainAudioCrossfading && timeLeft <= this.rainAudioCrossfadeDuration) {
next.currentTime = 0;
next.volume = 0;
const playPromise = next.play();
if (playPromise?.catch) {
playPromise.catch(() => {});
}
this.rainAudioCrossfading = true;
}
if (this.rainAudioCrossfading) {
const progress = THREE.MathUtils.clamp(1.0 - timeLeft / this.rainAudioCrossfadeDuration, 0, 1);
active.volume = this.params.rainAudioVolume * (1.0 - progress);
next.volume = this.params.rainAudioVolume * progress;
if (progress >= 0.999 || active.ended) {
active.pause();
active.currentTime = 0;
active.volume = 0;
this.rainAudioActiveIndex = (this.rainAudioActiveIndex + 1) % this.rainAudioPool.length;
this.rainAudioCrossfading = false;
}
} else {
active.volume = this.params.rainAudioVolume;
}
}
setLightningEnabled(value) {
this.params.lightningEnabled = value;
if (!value) {
this.lightningFlash = 0;
this.lightningBurstEnd = 0;
this.nextLightningAt = 0;
this.lightningPulseSchedule = [];
this.scheduledThunder = [];
this.applyLightningState(0);
this.stopThunderAudio();
}
}
setLightningIntensity(value) {
this.params.lightningIntensity = value;
}
setThunderVolume(value) {
const nextValue = THREE.MathUtils.clamp(value, 0, 1);
this.params.thunderVolume = nextValue;
this.thunderVolume = nextValue;
}
applyParams(nextParams = {}) {
const mergedParams = { ...DEFAULT_SCENE_PARAMS, ...nextParams };
this.setSunElevation(mergedParams.elevation);
this.setSunAzimuth(mergedParams.azimuth);
this.setExposure(mergedParams.exposure);
this.setTurbidity(mergedParams.turbidity);
this.setRayleigh(mergedParams.rayleigh);
this.setBloomStrength(mergedParams.bloomStrength);
this.setBloomRadius(mergedParams.bloomRadius);
this.setWaterColor(mergedParams.waterColor);
this.setCloudCoverage(mergedParams.cloudCoverage);
this.setCloudDensity(mergedParams.cloudDensity);
this.setCloudElevation(mergedParams.cloudElevation);
this.setFogEnabled(mergedParams.fogEnabled ?? true);
this.setFogDensity(mergedParams.fogDensity);
this.setFogHeight(mergedParams.fogHeight);
this.setFogRange(mergedParams.fogRange);
this.setRainScreenIntensity(mergedParams.rainScreenIntensity);
this.setRainVeilIntensity(mergedParams.rainVeilIntensity);
this.setRainDropSize(mergedParams.rainDropSize);
this.setRainSpeed(mergedParams.rainSpeed);
this.setRainAudioVolume(mergedParams.rainAudioVolume);
this.setRainAudioEnabled(mergedParams.rainAudioEnabled);
this.setSnowIntensity(mergedParams.snowIntensity);
this.setSnowSpeed(mergedParams.snowSpeed);
this.setSnowEnabled(mergedParams.snowEnabled);
this.setStarIntensity(mergedParams.starIntensity);
this.setStarEnabled(mergedParams.starEnabled);
this.setLightningIntensity(mergedParams.lightningIntensity);
this.setThunderVolume(mergedParams.thunderVolume);
this.setLightningEnabled(mergedParams.lightningEnabled);
this.setRainEnabled(mergedParams.rainEnabled);
}
scheduleNextLightning(time) {
const rainActivity = Math.max(this.params.rainVeilIntensity, this.params.rainScreenIntensity);
const densityBias = THREE.MathUtils.clamp(rainActivity / 1.5, 0, 1);
const delay = THREE.MathUtils.lerp(7.5, 3.0, densityBias) + Math.random() * THREE.MathUtils.lerp(8.0, 4.0, densityBias);
this.nextLightningAt = time + delay;
}
startLightningBurst(time) {
const pulseCountRoll = Math.random();
const pulseCount = pulseCountRoll > 0.8 ? 3 : pulseCountRoll > 0.45 ? 2 : 1;
this.lightningPulseSchedule = [];
let pulseTime = time;
for (let i = 0; i < pulseCount; i++) {
const duration = 0.05 + Math.random() * 0.07;
const amplitude = this.params.lightningIntensity * (1.0 - i * 0.14) * (0.85 + Math.random() * 0.35);
this.lightningPulseSchedule.push({
time: pulseTime,
duration,
amplitude
});
pulseTime += 0.06 + Math.random() * 0.12;
}
this.lightningBurstEnd = pulseTime + 0.12;
const flashX = THREE.MathUtils.randFloat(-1600, 1600);
const flashY = THREE.MathUtils.randFloat(120, 340);
const flashZ = THREE.MathUtils.randFloat(-1800, -420);
this.lightningLight.position.set(flashX * 0.22, flashY, flashZ * 0.08);
if (this.lightningCloudGlow) {
this.lightningCloudGlow.position.set(flashX, flashY, flashZ);
const size = THREE.MathUtils.randFloat(720, 1480);
this.lightningCloudGlow.scale.set(size, size * THREE.MathUtils.randFloat(0.72, 1.08), 1);
}
this.scheduleThunderBurst(flashX, flashY, flashZ);
}
scheduleThunderBurst(flashX, flashY, flashZ) {
const distanceNorm = THREE.MathUtils.clamp(
(Math.abs(flashX) / 1600) * 0.35 + (Math.abs(flashZ) / 1800) * 0.65,
0,
1
);
const delay = THREE.MathUtils.lerp(0.65, 2.4, distanceNorm) + Math.random() * 0.45;
const volume = this.params.lightningIntensity * THREE.MathUtils.lerp(1.0, 0.58, distanceNorm) * 1.12;
this.scheduledThunder.push({
playAt: this.lightningBurstEnd + delay,
volume,
playbackRate: THREE.MathUtils.randFloat(0.94, 1.03)
});
}
stopThunderAudio() {
for (const audio of this.thunderAudioPool) {
audio.pause();
audio.currentTime = 0;
}
}
playThunder(volume, playbackRate) {
if (!this.params.rainEnabled || !this.params.lightningEnabled || this.thunderAudioPool.length === 0) return;
const audio = this.thunderAudioPool[this.thunderAudioIndex % this.thunderAudioPool.length];
this.thunderAudioIndex += 1;
audio.pause();
audio.currentTime = 0;
audio.volume = THREE.MathUtils.clamp(volume * this.thunderVolume, 0, 1);
audio.playbackRate = playbackRate;
const playPromise = audio.play();
if (playPromise?.catch) {
playPromise.catch(() => {});
}
}
updateThunder(time) {
if (!this.params.rainEnabled || !this.params.lightningEnabled || this.scheduledThunder.length === 0) return;
const pending = [];
for (const thunder of this.scheduledThunder) {
if (time >= thunder.playAt) {
this.playThunder(thunder.volume, thunder.playbackRate);
} else {
pending.push(thunder);
}
}
this.scheduledThunder = pending;
}
updateLightning(time) {
if (!this.params.rainEnabled || !this.params.lightningEnabled) return;
if (this.nextLightningAt === 0) {
this.scheduleNextLightning(time);
}
if (time >= this.nextLightningAt && time >= this.lightningBurstEnd) {
this.startLightningBurst(time);
this.scheduleNextLightning(time);
}
let flash = 0;
let localFlash = 0;
for (const pulse of this.lightningPulseSchedule) {
const dt = time - pulse.time;
if (dt < -0.001 || dt > pulse.duration * 2.8) continue;
const attack = Math.exp(-Math.pow((dt - pulse.duration * 0.12) / Math.max(pulse.duration * 0.32, 0.001), 2.0));
const decay = Math.exp(-Math.max(dt, 0.0) / Math.max(pulse.duration * 1.45, 0.001));
const pulseFlash = pulse.amplitude * attack * decay;
flash += pulseFlash;
localFlash += pulseFlash * 1.35;
}
if (flash > 0.001) {
this.lightningFlash = Math.max(this.lightningFlash * 0.7, flash);
this.lightningLocalFlash = Math.max(this.lightningLocalFlash * 0.62, localFlash);
} else {
this.lightningFlash *= 0.82;
if (this.lightningFlash < 0.002) this.lightningFlash = 0;
this.lightningLocalFlash *= 0.74;
if (this.lightningLocalFlash < 0.002) this.lightningLocalFlash = 0;
}
this.applyLightningState(this.lightningFlash, this.lightningLocalFlash);
}
applyLightningState(flash, localFlash = 0) {
if (this.lightningLight) {
this.lightningLight.intensity = flash * 5.5;
}
if (this.lightningCloudGlow) {
this.lightningCloudGlow.visible = localFlash > 0.002;
this.lightningCloudGlow.material.opacity = THREE.MathUtils.clamp(localFlash * 0.95, 0, 0.95);
}
if (this.renderer) {
this.renderer.toneMappingExposure = this.params.exposure * (1.0 + flash * 1.6);
}
if (this.bloomPass) {
this.bloomPass.strength = this.params.bloomStrength + flash * 0.35;
}
}
updateClouds() {
if (!this.cloudGroup) return;
const sunMix = THREE.MathUtils.clamp((this.params.elevation + 10) / 100, 0, 1);
const dawnMix = 1.0 - THREE.MathUtils.smoothstep(this.params.elevation, 8, 34);
const rainMix = this.params.rainEnabled ? THREE.MathUtils.clamp(this.params.rainVeilIntensity / 2.0, 0.35, 1.0) : 0;
const snowMix = this.params.snowEnabled ? THREE.MathUtils.clamp(this.params.snowIntensity / 1.5, 0.35, 1.0) : 0;
const stormMix = Math.max(rainMix, snowMix);
const warmCloud = new THREE.Color(0xdab188);
const dayCloud = new THREE.Color(0xd1dbe6);
const cloudColor = warmCloud.lerp(dayCloud, sunMix);
const lightningMix = THREE.MathUtils.clamp(this.lightningFlash * 0.32, 0, 1);
cloudColor.lerp(new THREE.Color(0xbfcad6), stormMix * 0.45);
cloudColor.lerp(new THREE.Color(0xe9f3ff), lightningMix);
const lightDir = new THREE.Vector2(this.sun.x, this.sun.z).normalize();
this.cloudLayers.forEach((layer, index) => {
const coverageFactor = 0.15 + this.params.cloudCoverage * 1.15;
const densityFactor = 0.2 + this.params.cloudDensity * 1.35;
const layerDepth = index / Math.max(this.cloudLayers.length - 1, 1);
const weatherOpacityBoost = THREE.MathUtils.lerp(1.0, 1.42 - layerDepth * 0.16, rainMix);
const snowOpacityBoost = THREE.MathUtils.lerp(1.0, 1.22 - layerDepth * 0.08, snowMix);
const opacity = layer.baseOpacity * coverageFactor * densityFactor * weatherOpacityBoost * snowOpacityBoost;
const heightDrop = THREE.MathUtils.lerp(0, 120 + index * 55, rainMix);
const snowLift = THREE.MathUtils.lerp(0, 40 + index * 24, snowMix);
const layerScale = layer.baseScale * THREE.MathUtils.lerp(1.18 - index * 0.06, 0.9 + index * 0.04, rainMix);
const softness = layer.baseSoftness * THREE.MathUtils.lerp(1.15, 0.82, rainMix) * THREE.MathUtils.lerp(1.0, 1.08, snowMix);
const erosion = layer.baseErosionStrength * THREE.MathUtils.lerp(0.72 + index * 0.08, 1.28 + index * 0.1, rainMix);
const ridge = layer.baseRidgeStrength * THREE.MathUtils.lerp(1.24, 0.78, rainMix) * THREE.MathUtils.lerp(1.0, 0.86, snowMix);
const highlight = layer.baseHighlightStrength * THREE.MathUtils.lerp(1.45 - index * 0.12, 0.62, rainMix) * THREE.MathUtils.lerp(1.12, 0.84, snowMix);
const shadow = layer.baseShadowStrength * THREE.MathUtils.lerp(0.94, 1.36, stormMix);
layer.material.uniforms.layerOpacity.value = opacity;
layer.material.uniforms.cloudCoverage.value = this.params.cloudCoverage;
layer.material.uniforms.cloudDensity.value = this.params.cloudDensity;
layer.material.uniforms.scale.value = layerScale;
layer.material.uniforms.softness.value = softness;
layer.material.uniforms.erosionStrength.value = erosion;
layer.material.uniforms.ridgeStrength.value = ridge;
layer.material.uniforms.highlightStrength.value = highlight;
layer.material.uniforms.shadowStrength.value = shadow;
layer.material.uniforms.tintColor.value.copy(cloudColor);
layer.material.uniforms.lightDir.value.copy(lightDir);
layer.mesh.position.y = layer.baseY - heightDrop + snowLift + dawnMix * index * 18.0;
layer.mesh.visible = opacity > 0.015;
});
}
updateStars() {
if (!this.starField) return;
const nightFactor = THREE.MathUtils.clamp((12.0 - this.params.elevation) / 16.0, 0.0, 1.0);
const weatherFade = this.params.rainEnabled ? 0.08 : this.params.snowEnabled ? 0.55 : 1.0;
const opacity = (this.params.starEnabled ? this.params.starIntensity : 0.0) * nightFactor * weatherFade;
const isBlackNight = this.params.elevation < -1.0;
this.starField.visible = opacity > 0.001;
this.starField.material.uniforms.intensity.value = opacity;
if (this.moonSprite) {
const moonAngle = Math.atan2(this.initialSun.z, this.initialSun.x);
const moonDistance = 3200;
this.moonSprite.position.set(
Math.cos(moonAngle) * moonDistance,
THREE.MathUtils.lerp(260, 520, nightFactor),
Math.sin(moonAngle) * moonDistance
);
const moonGlow = isBlackNight ? THREE.MathUtils.clamp((nightFactor - 0.18) / 0.72, 0, 1) : 0.0;
this.moonSprite.visible = isBlackNight && moonGlow > 0.01;
this.moonSprite.material.opacity = THREE.MathUtils.lerp(0.0, 2.4, moonGlow);
const moonScale = THREE.MathUtils.lerp(410, 730, moonGlow);
this.moonSprite.scale.setScalar(moonScale);
if (this.moonGlowSprite) {
this.moonGlowSprite.position.copy(this.moonSprite.position);
this.moonGlowSprite.visible = this.moonSprite.visible;
this.moonGlowSprite.material.opacity = moonGlow * 1.18;
this.moonGlowSprite.scale.setScalar(THREE.MathUtils.lerp(980, 1680, moonGlow));
}
}
if (this.galaxyBand) {
const angle = THREE.MathUtils.degToRad(this.params.azimuth + 36);
const radius = 3000;
this.galaxyBand.position.set(
Math.cos(angle) * radius,
THREE.MathUtils.lerp(920, 1500, nightFactor),
Math.sin(angle) * radius
);
this.galaxyBand.material.rotation = THREE.MathUtils.degToRad(-24);
const galaxyFade = isBlackNight ? THREE.MathUtils.clamp((opacity - 0.18) / 1.1, 0, 1) * weatherFade : 0.0;
this.galaxyBand.material.opacity = galaxyFade * 0.92;
this.galaxyBand.visible = isBlackNight && this.galaxyBand.material.opacity > 0.01;
}
}
updateFog() {
if (!this.fogPass) return;
const { fogColor, horizonColor } = this.getAtmosphereColors();
const lightningMix = THREE.MathUtils.clamp(this.lightningFlash * 0.42, 0, 1);
fogColor.lerp(new THREE.Color(0xdbe8f5), lightningMix);
horizonColor.lerp(new THREE.Color(0xe5eef9), lightningMix);
const uniforms = this.fogPass.material.uniforms;
uniforms.fogColor.value.copy(fogColor);
uniforms.horizonColor.value.copy(horizonColor);
uniforms.fogDensity.value = this.params.fogEnabled
? THREE.MathUtils.lerp(0.00002, 0.00078, this.params.fogDensity / 2.0)
: 0.0;
uniforms.fogHeight.value = this.params.fogHeight;
uniforms.fogRange.value = this.params.fogRange;
uniforms.projectionMatrixInverse.value.copy(this.camera.projectionMatrixInverse);
uniforms.viewMatrixInverse.value.copy(this.camera.matrixWorld);
uniforms.cameraWorldPosition.value.copy(this.camera.position);
}
updateDepthTarget() {
if (!this.depthTarget) return;
const hiddenObjects = [
this.sky,
this.starField,
this.moonSprite,
this.moonGlowSprite,
this.galaxyBand
].filter(Boolean);
const previousVisibility = hiddenObjects.map((object) => object.visible);
hiddenObjects.forEach((object) => {
object.visible = false;
});
const previousRenderTarget = this.renderer.getRenderTarget();
this.renderer.setRenderTarget(this.depthTarget);
this.renderer.clear();
this.renderer.render(this.scene, this.camera);
this.renderer.setRenderTarget(previousRenderTarget);
hiddenObjects.forEach((object, index) => {
object.visible = previousVisibility[index];
});
}
animate() {
requestAnimationFrame(() => this.animate());
this.stats?.begin();
const delta = this.clock.getDelta();
const time = this.clock.elapsedTime;
this.updateLightning(time);
this.updateThunder(time);
this.updateRainAudioLoop();
if (this.water) {
this.water.material.uniforms['time'].value += 1.0 / 60.0;
}
if (this.cloudGroup) {
this.cloudLayers.forEach((layer) => {
layer.material.uniforms.time.value = time;
});
}
if (this.starField) {
this.starField.material.uniforms.time.value = time;
}
if (this.vegetationSystem) {
this.vegetationSystem.update(time);
}
if (this.windTurbine) {
this.windTurbine.faceDirection(this.sun);
this.windTurbine.update(time, delta);
}
if (this.lightningFlash > 0.001) {
this.updateClouds();
this.updateFog();
this.updateStars();
}
if (this.rainPass) {
this.rainPass.material.uniforms.time.value = time;
}
if (this.snowPass) {
this.snowPass.material.uniforms.time.value = time;
}
if (this.fogPass) {
this.fogPass.material.uniforms.time.value = time;
}
this.controls.update();
this.updateFog();
if (this.composer) {
this.updateDepthTarget();
this.composer.render();
} else {
this.renderer.render(this.scene, this.camera);
}
this.stats?.end();
}
hideLoading() {
const loading = document.getElementById('loading');
if (loading) {
loading.style.opacity = '0';
loading.style.transition = 'opacity 0.5s ease';
setTimeout(() => {
loading.style.display = 'none';
}, 500);
}
}
}