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); } } }