Skip to content

Commit 9783cee

Browse files
committed
feat: add cloud shadows and ocean specular to earth shader
Cloud shadows use ray-sphere intersection to trace from each surface point toward the sun, finding the exact cloud intersection point and converting to equirectangular UV for accurate shadow placement. Shadow strength scales with bloom (100% with bloom, 80% without). Ocean specular uses two-lobe Blinn-Phong with Schlick Fresnel (F0=0.02) and blue-channel water masking. Cloud shadows suppress glare under overcast areas. Glare only renders when bloom is enabled.
1 parent a895526 commit 9783cee

4 files changed

Lines changed: 102 additions & 6 deletions

File tree

src/app.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ export class App {
408408
this.setLoading(0.5, 'Building scene...');
409409

410410
// Earth
411-
this.earth = new Earth(dayTex, nightTex, earthNormal ?? null, earthDisp ?? null);
411+
this.earth = new Earth(dayTex, nightTex, earthNormal ?? null, earthDisp ?? null, cloudTex);
412412
this.scene3d.add(this.earth.mesh);
413413

414414
// Sky-view ground disc: flat plane with projected Earth texture for realistic ground
@@ -1611,7 +1611,8 @@ export class App {
16111611
if (this.viewMode === ViewMode.VIEW_3D || isSkyView) {
16121612
// Update 3D scene (sky view shares the 3D scene but hides ground objects)
16131613
if (!this.orreryCtrl.isOrreryMode && !isSkyView) {
1614-
this.earth.update(epoch, gmstDeg, this.cfg.earthRotationOffset, this.cfg.showNightLights);
1614+
this.earth.update(epoch, gmstDeg, this.cfg.earthRotationOffset, this.cfg.showNightLights, this.cfg.showClouds, this.camera3d.position);
1615+
this.earth.setShowGlare(this.bloomEnabled);
16151616
if (earthMode) this.cloudLayer.update(epoch, gmstDeg, this.cfg.earthRotationOffset, this.cfg.showClouds, this.cfg.showNightLights);
16161617
}
16171618
// Moon + sun update in both orbital and sky view

src/scene/earth.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import earthFragSrc from '../shaders/earth-daynight.frag.glsl?raw';
88

99
const _yAxis = new THREE.Vector3(0, 1, 0);
1010
const _tmpSun = new THREE.Vector3();
11+
const _tmpView = new THREE.Vector3();
1112

1213
export class Earth {
1314
mesh: THREE.Mesh;
@@ -16,7 +17,7 @@ export class Earth {
1617
private bumpEnabled = true;
1718
private aoEnabledState = true;
1819

19-
constructor(dayTex: THREE.Texture, nightTex: THREE.Texture, normalTex: THREE.Texture | null, displacementTex: THREE.Texture | null) {
20+
constructor(dayTex: THREE.Texture, nightTex: THREE.Texture, normalTex: THREE.Texture | null, displacementTex: THREE.Texture | null, cloudTex: THREE.Texture | null) {
2021
const radius = EARTH_RADIUS_KM / DRAW_SCALE;
2122
const geometry = this.genEarthGeometry(radius, 256, 256);
2223

@@ -28,6 +29,7 @@ export class Earth {
2829
nightTexture: { value: nightTex },
2930
normalMap: { value: normalTex },
3031
displacementMap: { value: displacementTex },
32+
cloudTexture: { value: cloudTex },
3133
sunDir: { value: new THREE.Vector3(1, 0, 0) },
3234
moonPos: { value: new THREE.Vector3(0, 0, 0) },
3335
moonRadius: { value: MOON_RADIUS_KM },
@@ -37,6 +39,10 @@ export class Earth {
3739
aoEnabled: { value: 1.0 },
3840
displacementScale: { value: 0.007 },
3941
hasDisplacement: { value: displacementTex ? 1.0 : 0.0 },
42+
viewPos: { value: new THREE.Vector3() },
43+
cloudUVOffset: { value: 0.0 },
44+
showClouds: { value: 0.0 },
45+
showGlare: { value: 1.0 },
4046
},
4147
vertexShader: earthVertSrc,
4248
fragmentShader: earthFragSrc,
@@ -98,19 +104,35 @@ export class Earth {
98104
return geo;
99105
}
100106

101-
update(currentEpoch: number, gmstDeg: number, earthOffset: number, showNightLights: boolean) {
102-
this.mesh.rotation.y = (gmstDeg + earthOffset) * DEG2RAD;
107+
update(currentEpoch: number, gmstDeg: number, earthOffset: number, showNightLights: boolean, showClouds: boolean, cameraPosition: THREE.Vector3) {
108+
const earthRotRad = (gmstDeg + earthOffset) * DEG2RAD;
109+
this.mesh.rotation.y = earthRotRad;
103110
this.material.uniforms.showNight.value = showNightLights ? 1.0 : 0.0;
111+
this.material.uniforms.showClouds.value = showClouds ? 1.0 : 0.0;
104112

105113
const sunEci = calculateSunPosition(currentEpoch);
106-
const earthRotRad = (gmstDeg + earthOffset) * DEG2RAD;
107114
_tmpSun.copy(sunEci).applyAxisAngle(_yAxis, -earthRotRad);
108115
this.material.uniforms.sunDir.value.copy(_tmpSun);
109116

110117
// Moon position in ECEF for solar eclipse shadow
111118
const moonRender = calculateMoonPosition(currentEpoch);
112119
moonRender.applyAxisAngle(_yAxis, -earthRotRad);
113120
this.material.uniforms.moonPos.value.copy(moonRender);
121+
122+
// View position in ECEF for specular
123+
_tmpView.copy(cameraPosition).applyAxisAngle(_yAxis, -earthRotRad);
124+
// Scale from draw units to km for the shader
125+
_tmpView.multiplyScalar(DRAW_SCALE);
126+
this.material.uniforms.viewPos.value.copy(_tmpView);
127+
128+
// Cloud UV offset: difference between earth and cloud rotation in UV space
129+
const cloudAngle = ((gmstDeg + earthOffset + currentEpoch * 360.0 * 0.04) % 360.0) * DEG2RAD;
130+
const uvOffset = (earthRotRad - cloudAngle) / (2.0 * Math.PI);
131+
this.material.uniforms.cloudUVOffset.value = uvOffset;
132+
}
133+
134+
setShowGlare(on: boolean) {
135+
this.material.uniforms.showGlare.value = on ? 1.0 : 0.0;
114136
}
115137

116138
setNightEmission(value: number) {

src/shaders/earth-daynight.frag.glsl

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ uniform sampler2D dayTexture;
22
uniform sampler2D nightTexture;
33
uniform sampler2D normalMap;
44
uniform sampler2D displacementMap;
5+
uniform sampler2D cloudTexture;
56
uniform vec3 sunDir;
67
uniform vec3 moonPos;
78
uniform float moonRadius;
@@ -10,15 +11,21 @@ uniform float nightEmission;
1011
uniform float hasNormalMap;
1112
uniform float aoEnabled;
1213
uniform float hasDisplacement;
14+
uniform vec3 viewPos;
15+
uniform float cloudUVOffset;
16+
uniform float showClouds;
17+
uniform float showGlare;
1318

1419
varying vec2 vUv;
20+
varying vec3 vWorldPos;
1521

1622
const float PI = 3.14159265359;
1723
const float TWO_PI = 6.28318530718;
1824
const float TEX_STEP = 1.0 / 2048.0;
1925
const float AO_STRENGTH = 8.0;
2026
const float EARTH_R = 6371.0;
2127
const float SUN_ANG_R = 0.00465;
28+
const float CLOUD_ALT = 25.0;
2229

2330
void main() {
2431
vec4 day = texture2D(dayTexture, vUv);
@@ -88,6 +95,70 @@ void main() {
8895
float surfaceHaze = pow(max(1.0 - abs(intensity), 0.0), 2.0) * smoothstep(-0.1, 0.2, intensity) * 0.1;
8996
scatteredDay = mix(scatteredDay, hazeColor, surfaceHaze);
9097

98+
// --- Cloud shadows via ray-sphere intersection ---
99+
float cloudShadow = 1.0;
100+
if (showClouds > 0.5 && rawIntensity > -0.1) {
101+
// Trace ray from surface point toward sun, find where it hits the cloud sphere
102+
vec3 surfPos = baseNormal * EARTH_R;
103+
float cloudR = EARTH_R + CLOUD_ALT;
104+
105+
// Solve |surfPos + t*sunDir|² = cloudR²
106+
float b = 2.0 * dot(surfPos, sunDir);
107+
float c = EARTH_R * EARTH_R - cloudR * cloudR;
108+
float disc = b * b - 4.0 * c;
109+
110+
// disc is always positive (surface is inside the cloud sphere)
111+
float t = (-b + sqrt(disc)) * 0.5;
112+
vec3 cloudHit = surfPos + t * sunDir;
113+
114+
// Convert cloud intersection to equirectangular UV
115+
vec3 cn = normalize(cloudHit);
116+
float cloudTheta = atan(-cn.z, cn.x);
117+
float cloudPhi = acos(clamp(cn.y, -1.0, 1.0));
118+
vec2 cloudUV = vec2(cloudTheta / TWO_PI + 0.5, 1.0 - cloudPhi / PI);
119+
120+
// Apply the rotation offset between earth and cloud layer
121+
cloudUV.x = fract(cloudUV.x + cloudUVOffset);
122+
123+
float cloudAlpha = texture2D(cloudTexture, cloudUV).a;
124+
125+
// Softer shadows on the day side, fade out near terminator
126+
float shadowDayMask = smoothstep(-0.05, 0.25, rawIntensity);
127+
float shadowStrength = showGlare > 0.5 ? 1.0 : 0.8;
128+
cloudShadow = 1.0 - cloudAlpha * shadowStrength * shadowDayMask;
129+
scatteredDay *= cloudShadow;
130+
}
131+
132+
// --- Ocean specular (water glare / sun glint) ---
133+
if (showGlare > 0.5 && rawIntensity > 0.0) {
134+
vec3 viewDir = normalize(viewPos - baseNormal * EARTH_R);
135+
vec3 halfVec = normalize(sunDir + viewDir);
136+
137+
float NdotV = max(dot(baseNormal, viewDir), 0.0);
138+
float NdotH = max(dot(normal, halfVec), 0.0);
139+
float NdotL = max(rawIntensity, 0.0);
140+
141+
// Fresnel (Schlick) — water F0 ≈ 0.02
142+
float fresnel = 0.02 + 0.98 * pow(1.0 - NdotV, 5.0);
143+
144+
// Ocean mask: blue-dominant pixels with low green/red
145+
float waterMask = clamp((day.b - max(day.r, day.g)) * 3.0, 0.0, 1.0);
146+
147+
// Two-lobe specular: tight sun disk + broad Fresnel rim
148+
float specTight = pow(NdotH, 256.0);
149+
float specBroad = pow(NdotH, 16.0);
150+
float spec = specTight * 3.0 + specBroad * 0.15;
151+
152+
// Sun color: warm white at high angles, reddish near horizon
153+
vec3 sunColor = mix(vec3(1.0, 0.7, 0.4), vec3(1.0, 0.95, 0.9), NdotL);
154+
155+
// Cloud shadow suppresses glare — no sun reflection under clouds
156+
vec3 glare = sunColor * spec * fresnel * waterMask * NdotL * cloudShadow;
157+
158+
// Roughness from normal map breaks up the glare naturally
159+
scatteredDay += glare;
160+
}
161+
91162
// Boost night emission for bloom (HDR values > 1.0)
92163
vec4 boostedNight = vec4(night.rgb * nightEmission, night.a);
93164
// Eclipse: blend day toward night lights instead of black

src/shaders/earth-daynight.vert.glsl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ uniform float displacementScale;
33
uniform float hasDisplacement;
44

55
varying vec2 vUv;
6+
varying vec3 vWorldPos;
67

78
void main() {
89
vUv = uv;
@@ -13,5 +14,6 @@ void main() {
1314
pos += normal * height * displacementScale;
1415
}
1516

17+
vWorldPos = (modelMatrix * vec4(pos, 1.0)).xyz;
1618
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
1719
}

0 commit comments

Comments
 (0)