diff --git a/index.html b/index.html
index 3c759ff..b75b627 100644
--- a/index.html
+++ b/index.html
@@ -156,6 +156,27 @@
background: #4a9eff;
cursor: pointer;
}
+
+ .control-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: rgba(255,255,255,0.06);
+ margin-bottom: 8px;
+ }
+
+ .control-toggle label {
+ font-size: 12px;
+ opacity: 0.9;
+ }
+
+ .control-toggle input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: #5da9ff;
+ }
.control-value {
text-align: right;
@@ -285,6 +306,36 @@
+
+
FPS: 60
diff --git a/src/OceanScene.js b/src/OceanScene.js
index 58518aa..79b6838 100644
--- a/src/OceanScene.js
+++ b/src/OceanScene.js
@@ -5,6 +5,7 @@ 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 { TerrainGenerator } from './TerrainGenerator.js';
import { VegetationSystem } from './VegetationSystem.js';
@@ -27,6 +28,7 @@ export class OceanScene {
this.vegetationFillLight = null;
this.composer = null;
this.bloomPass = null;
+ this.rainPass = null;
this.cloudGroup = null;
this.cloudMaterials = [];
this.cloudLayers = [];
@@ -50,6 +52,11 @@ export class OceanScene {
fogDensity: 0.42,
fogHeight: 0.32,
fogRange: 0.55,
+ rainEnabled: false,
+ rainScreenIntensity: 0.41,
+ rainVeilIntensity: 1.15,
+ rainDropSize: 1.0,
+ rainSpeed: 1.0,
mieCoefficient: 0.005,
mieDirectionalG: 0.8
};
@@ -148,6 +155,245 @@ export class OceanScene {
this.params.bloomThreshold
);
this.composer.addPass(this.bloomPass);
+
+ 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);
+ }
+
+ 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);
+ }
+ `
+ };
}
async initSky() {
@@ -636,6 +882,9 @@ export class OceanScene {
if (this.composer) {
this.composer.setSize(window.innerWidth, window.innerHeight);
}
+ if (this.rainPass) {
+ this.rainPass.material.uniforms.resolution.value.set(window.innerWidth, window.innerHeight);
+ }
}
setSunElevation(value) {
@@ -718,6 +967,41 @@ export class OceanScene {
this.updateFog();
}
+ setRainEnabled(value) {
+ this.params.rainEnabled = value;
+ if (this.rainPass) {
+ this.rainPass.enabled = value;
+ }
+ }
+
+ 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;
+ }
+ }
+
updateClouds() {
if (!this.cloudGroup) return;
@@ -823,6 +1107,10 @@ export class OceanScene {
if (this.vegetationSystem) {
this.vegetationSystem.update(time);
}
+
+ if (this.rainPass) {
+ this.rainPass.material.uniforms.time.value = time;
+ }
this.controls.update();
if (this.composer) {
diff --git a/src/main.js b/src/main.js
index 9c48ea3..7ab6508 100644
--- a/src/main.js
+++ b/src/main.js
@@ -42,6 +42,15 @@ function setupControls(oceanScene) {
});
};
+ const bindCheckbox = (id, setter) => {
+ const checkbox = document.getElementById(id);
+ if (!checkbox) return;
+
+ checkbox.addEventListener('change', (e) => {
+ setter(e.target.checked);
+ });
+ };
+
bindSlider('sun-elevation', (value) => `${value.toFixed(1)}°`, (value) => oceanScene.setSunElevation(value));
bindSlider('sun-azimuth', (value) => `${value.toFixed(1)}°`, (value) => oceanScene.setSunAzimuth(value));
bindSlider('exposure', (value) => value.toFixed(2), (value) => oceanScene.setExposure(value));
@@ -55,6 +64,11 @@ function setupControls(oceanScene) {
bindSlider('fog-density', (value) => value.toFixed(2), (value) => oceanScene.setFogDensity(value));
bindSlider('fog-height', (value) => value.toFixed(2), (value) => oceanScene.setFogHeight(value));
bindSlider('fog-range', (value) => value.toFixed(2), (value) => oceanScene.setFogRange(value));
+ bindSlider('rain-screen-intensity', (value) => value.toFixed(2), (value) => oceanScene.setRainScreenIntensity(value));
+ bindSlider('rain-veil-intensity', (value) => value.toFixed(2), (value) => oceanScene.setRainVeilIntensity(value));
+ bindSlider('rain-drop-size', (value) => value.toFixed(2), (value) => oceanScene.setRainDropSize(value));
+ bindSlider('rain-speed', (value) => value.toFixed(2), (value) => oceanScene.setRainSpeed(value));
+ bindCheckbox('rain-enabled', (value) => oceanScene.setRainEnabled(value));
}
main().catch(console.error);