mapspinner 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ // quadtree.js -- the cube-face terrain quadtree LOD selection, ported from the deleted
2
+ // proland_terrain.cpp (TerrainNode/TerrainQuad subdivision + SphericalDeformation + the
3
+ // pixel-calibrated split distance). This is the ONE piece of the old C++/wasm the render
4
+ // needed; everything else (the elevation/normal/ortho atlas producer + cascade) is gone --
5
+ // terrain shape is the single GPU fractal (broadShapeM in terrain.glsl), evaluated per-vertex.
6
+ //
7
+ // Pure JS, no wasm, no allocation per frame beyond the leaf array. One Quadtree instance per
8
+ // run; call setConfig once, computeSplitDist when viewport/fov change, updateQuadtree per frame
9
+ // with the camera in LOCAL (undeformed cube-face) space -> returns leaf quads [{level,tx,ty,ox,oy,l}].
10
+
11
+ export class Quadtree {
12
+ constructor() {
13
+ this.size = 6360000.0; // root half-extent / planet radius (m)
14
+ this.maxLevel = 20;
15
+ this.splitDist = 1.1; // computed from splitFactor+viewport+fov
16
+ this.distFactor = 2.0; // altitude weighting
17
+ this._cam = [0, 0, 0]; // camera in LOCAL (undeformed) space (aim-shifted LOD reference)
18
+ this._camAlt = 0.0; // true altitude above the sphere (|localCam|-R), same on every face
19
+ this._nadir = [0, 0]; // TRUE camera nadir (face-local x,y) for the far-LOD foreground protect
20
+ this._aim = null; // AIM ground point (face-local x,y), 2nd foreground-protect center (or null)
21
+ this._leaves = [];
22
+ }
23
+
24
+ setConfig(size, maxLevel, distFactor) {
25
+ this.size = size; this.maxLevel = maxLevel; this.distFactor = distFactor;
26
+ }
27
+
28
+ // splitDist = splitFactor * viewportH/1024 * tan(40deg)/tan(fov/2), clamped >= 1.1.
29
+ computeSplitDist(splitFactor, viewportH, fovRad) {
30
+ let sd = splitFactor * viewportH / 1024.0 * Math.tan(40.0 / 180.0 * Math.PI) / Math.tan(fovRad / 2.0);
31
+ if (!(sd >= 1.1) || !isFinite(sd)) sd = 1.1;
32
+ this.splitDist = sd;
33
+ return sd;
34
+ }
35
+
36
+ // TerrainNode::getCameraDist: local-space max-distance metric. The altitude term uses the TRUE
37
+ // camera altitude above the sphere (same on every face -> adjacent faces subdivide by real
38
+ // proximity, the fix for the turn-around-unpatched bug).
39
+ _cameraDist(ox, oy, l) {
40
+ const dz = Math.max(this._camAlt / this.distFactor, 0.0);
41
+ const dx = Math.min(Math.abs(this._cam[0] - ox), Math.abs(this._cam[0] - (ox + l)));
42
+ const dy = Math.min(Math.abs(this._cam[1] - oy), Math.abs(this._cam[1] - (oy + l)));
43
+ return Math.max(dz, Math.max(dx, dy));
44
+ }
45
+
46
+ // TerrainQuad subdivision: subdivide while dist < l*splitDist AND level < maxLevel; emit leaves.
47
+ // DISTANCE FALLOFF (user 2026-06-01j: 'when close to terrain, far-away quads should LOD better').
48
+ // The plain dist<l*splitDist gives constant screen-space leaf size (leaf ~ dist/splitDist), so when
49
+ // the camera is LOW the horizon-distant terrain still subdivides fine = wasted quads. Tighten the
50
+ // effective splitDist with the quad's LATERAL distance from the camera: far quads need to be much
51
+ // bigger to keep splitting, so distant terrain stays coarse while the near/under-camera stays fine.
52
+ // Falloff scaled by altitude so high views (where 'far' is the whole visible disc) are unaffected.
53
+ _recurse(level, tx, ty, ox, oy, l) {
54
+ const dist = this._cameraDist(ox, oy, l);
55
+ // DISTANCE FALLOFF: coarsen far quads when low. Use the lateral distance to the quad CENTER
56
+ // (NOT its nearest edge -- a big near quad has a far edge, which must NOT penalize it, or the
57
+ // root never splits). Only quads whose CENTRE sits well beyond the near footprint coarsen.
58
+ const cxv = ox + l * 0.5, cyv = oy + l * 0.5;
59
+ // latC = lateral distance from the TRUE camera NADIR (this._nadir), NOT the aim-shifted LOD ref.
60
+ // This decouples foreground-protection from the aim-shift: the foreground (near the true nadir)
61
+ // always has latC~0 -> fall=1.0 -> full LOD, no matter how low the floor. So a LOW floor can
62
+ // reduce the far horizon hard WITHOUT collapsing nearby terrain (user: 'nearby reduced all the
63
+ // way when going to land' was the aim-shifted ref penalizing the foreground).
64
+ // latC = lateral distance from the nadir to the quad's NEAREST POINT (clamp nadir into the quad's
65
+ // [ox,ox+l]x[oy,oy+l] extent), NOT its CENTER. THE CENTER WAS THE BUG (user 2026-06-02: 'dense LOD
66
+ // off to the side, not under the camera; moving toward a face edge it wraps'): a LARGE quad that
67
+ // CONTAINS the camera has its center offset by up to l/2, so a center-distance falloff penalized
68
+ // the camera's own containing quads and they never subdivided -- the only fine quads landed where a
69
+ // quad CENTER happened to align with nadir (off toward the face centre). Nearest-point distance is
70
+ // ~0 for any quad containing the nadir, so the footprint refines UNDER the camera at any face pos.
71
+ const nx0 = Math.max(ox, Math.min(this._nadir[0], ox + l));
72
+ const ny0 = Math.max(oy, Math.min(this._nadir[1], oy + l));
73
+ let latC = Math.max(Math.abs(this._nadir[0] - nx0), Math.abs(this._nadir[1] - ny0));
74
+ // Protect the foreground around the AIM ground point too (the pitched-down bottom-of-screen band):
75
+ // nearest-point distance to the aim box as well; take the NEARER so a quad close to EITHER is fine.
76
+ if (this._aim !== null) {
77
+ const ax0 = Math.max(ox, Math.min(this._aim[0], ox + l));
78
+ const ay0 = Math.max(oy, Math.min(this._aim[1], oy + l));
79
+ const latA = Math.max(Math.abs(this._aim[0] - ax0), Math.abs(this._aim[1] - ay0));
80
+ if (latA < latC) latC = latA;
81
+ }
82
+ // penalty-free near radius. At very low alt the ON-SCREEN foreground extends to the HORIZON
83
+ // (~sqrt(2*R*alt): 1km->113km), so a fixed 20km near coarsened the visible field on sea-level
84
+ // landing (user 2026-06-02: 'at sea level LOD reduces when landing'). Scale near to the horizon
85
+ // distance so the whole visible field out to ~0.6*horizon stays full-LOD at any landing altitude;
86
+ // camAlt*6 dominates at higher alt where the horizon is far. size == R.
87
+ // near = penalty-free radius around the foreground. It must cover the WHOLE on-screen field so
88
+ // detail keeps INCREASING all the way to the ground (user 2026-06-02: 'LOD not increasing under
89
+ // 500km'). The visible field reaches the HORIZON (~sqrt(2*R*alt)); protect the FULL horizon (was
90
+ // 0.6) so nothing on-screen is coarsened by the far-LOD falloff -- the falloff then only trims
91
+ // terrain BEYOND the horizon (off-screen waste), never visible detail.
92
+ const horizon = Math.sqrt(2.0 * this.size * Math.max(this._camAlt, 0.0));
93
+ // W2 SINGLE-VERSION near-radius tighten (mob-w2, unconditional): max(camAlt*6, horizon*0.9,
94
+ // 20km). Shrinking the penalty-free near radius lets the far-LOD falloff coarsen MORE of the
95
+ // off-screen / past-horizon field, cutting peak visible leaves toward ~600-900 (precondition
96
+ // for the 512 layer cap). These are THE only values -- no device tier.
97
+ const near = Math.max(this._camAlt * 6.0, horizon * 0.9, 20000.0);
98
+ // floor 0.5 (was 0.18): past the horizon the split may HALVE, not crush to ~1/5 (which was
99
+ // collapsing near-field detail on descent). Foreground (latC<near) stays fall=1 -> full LOD.
100
+ const fall = 1.0 / (1.0 + Math.max(0.0, latC - near) / (near * 2.0));
101
+ // ALTITUDE-DETAIL-GRADIENT-SWAP (user: 'above fps height, swap more near detail for far detail so the
102
+ // higher we get the less of a detail gradient there is to the land center'). The far-coarsening floor
103
+ // is normally 0.5 (far quads split at half the near rate = a radial detail gradient around the
104
+ // camera). Above the fps height (~5km AGL = first-person ground play) raise that floor toward 1.0 as
105
+ // altitude climbs, so far and near refine at the same rate = the gradient FLATTENS the higher we get
106
+ // (detail budget spreads from the near foreground out to the far field). Below fps height the gradient
107
+ // stays sharp (floor 0.5) so the deck keeps its near-field detail. fpsAltM live via this.fpsAltM.
108
+ const fpsAltM = this.fpsAltM || 5000.0;
109
+ const aboveFps = Math.min(1.0, Math.max(0.0, (this._camAlt - fpsAltM) / (fpsAltM * 8.0))); // 0 at fps height -> 1 by ~45km
110
+ const floor = 0.5 + 0.5 * (aboveFps * aboveFps * (3 - 2 * aboveFps)); // 0.5 (fps deck) -> 1.0 (high alt, flat gradient)
111
+ const effSplit = this.splitDist * Math.max(floor, fall);
112
+ if (dist < l * effSplit && level < this.maxLevel) {
113
+ const hl = l / 2.0;
114
+ this._recurse(level + 1, 2 * tx, 2 * ty, ox, oy, hl);
115
+ this._recurse(level + 1, 2 * tx + 1, 2 * ty, ox + hl, oy, hl);
116
+ this._recurse(level + 1, 2 * tx, 2 * ty + 1, ox, oy + hl, hl);
117
+ this._recurse(level + 1, 2 * tx + 1, 2 * ty + 1, ox + hl, oy + hl, hl);
118
+ } else {
119
+ this._leaves.push({ level, tx, ty, ox, oy, l });
120
+ }
121
+ }
122
+
123
+ // Run the quadtree for the current camera (LOCAL cube-face coords); returns the leaf-quad array.
124
+ // The root quad spans [-size, size] in x and y (one cube face).
125
+ // camX/Y/Z = the (aim-shifted) LOD reference; nadirX/nadirY (optional) = the TRUE camera nadir in
126
+ // face-local coords, used by the far-LOD falloff to protect the real foreground (decoupled from the
127
+ // aim-shift). Defaults to the LOD ref's x,y when not supplied (back-compat). aimX/aimY (optional) =
128
+ // the look ray's ground-hit in face-local coords; a SECOND foreground-protect center so the
129
+ // pitched-down bottom-of-screen band stays full-LOD in every azimuth (null/omitted -> nadir only).
130
+ updateQuadtree(camX, camY, camZ, nadirX, nadirY, aimX, aimY, camAlt) {
131
+ this._cam[0] = camX; this._cam[1] = camY; this._cam[2] = camZ;
132
+ // TRUE ALTITUDE (fix off-center LOD stall, user 2026-06-03 'LOD dense only at the start point').
133
+ // camX,camY are the atan-WARPED face-local lateral coords (from worldToFaceLocal); off the face
134
+ // centre they are large (up to ~R near the edge), so sqrt(camX^2+camY^2+camZ^2) hugely
135
+ // OVERESTIMATES altitude -> the altitude term dominates the split metric -> LOD stalls everywhere
136
+ // but the face centre (where camX=camY=0 makes the hypot correct). Use the caller's TRUE altitude
137
+ // (|camWorld|-R) when supplied; fall back to the old hypot only for back-compat.
138
+ this._camAlt = (camAlt !== undefined && camAlt !== null)
139
+ ? camAlt
140
+ : Math.sqrt(camX * camX + camY * camY + camZ * camZ) - this.size;
141
+ this._nadir[0] = (nadirX !== undefined) ? nadirX : camX;
142
+ this._nadir[1] = (nadirY !== undefined) ? nadirY : camY;
143
+ this._aim = (aimX !== undefined && aimY !== undefined) ? [aimX, aimY] : null;
144
+ this._leaves.length = 0;
145
+ this._recurse(0, 0, 0, -this.size, -this.size, 2.0 * this.size);
146
+ return this._leaves;
147
+ }
148
+ }
149
+
150
+ // SphericalDeformation::localToDeformed with the tangent-adjusted (equal-area-ish) cube->sphere
151
+ // remap: warp the normalized face coord s=x/R by tan(s*pi/4) before the radial projection so cell
152
+ // area is near-uniform (seam-safe: identity at s=+-1). MUST match terrain.glsl faceWarp + the VS.
153
+ // q = (R+z) * normalize(R*tan(x/R*pi/4), R*tan(y/R*pi/4), R).
154
+ export function localToDeformed(x, y, z, R) {
155
+ const k = Math.PI / 4.0;
156
+ const wx = R * Math.tan((x / R) * k);
157
+ const wy = R * Math.tan((y / R) * k);
158
+ const inv = (z + R) / Math.sqrt(wx * wx + wy * wy + R * R);
159
+ return [wx * inv, wy * inv, R * inv];
160
+ }
@@ -0,0 +1,215 @@
1
+ // src/shaders/atmosphere.glsl (#version 300 es is prepended by the JS loader)
2
+ // -----------------------------------------------------------------------------
3
+ // WebGL2 analytic Rayleigh/Mie single-scatter atmosphere for the Proland planet.
4
+ //
5
+ // PORT NOTE — what this is vs. the WebGPU path:
6
+ // planet.html runs the AUTHORITATIVE Bruneton model: it BAKES the transmittance/
7
+ // scattering/irradiance LUTs at runtime with WebGPU COMPUTE shaders (3D textures,
8
+ // multiple-scattering orders 2..4) and then samples them with a_GetSkyRadiance().
9
+ // WebGL2 has NO compute shaders, and the baked LUTs live in a *separate* WebGPU
10
+ // GPUDevice/context that cannot be shared into the WebGL2 context. Re-baking the
11
+ // full 3D multiple-scattering LUTs with WebGL2 fragment passes is a large, fragile
12
+ // undertaking. Per the task's explicit fallback, this file implements a FAITHFUL
13
+ // analytic single-scatter atmosphere instead: same atmosphere geometry (bottom
14
+ // 6360 km, top 6420 km), the same Rayleigh/Mie scattering coefficients and phase
15
+ // functions and solar irradiance Bruneton uses, and an analytic optical-depth
16
+ // transmittance. It produces a real limb/halo, sky color gradient, a sun disc,
17
+ // and sun+sky irradiance on the terrain. SIMPLIFIED vs. baked Bruneton:
18
+ // - single scattering only (no orders 2..4 multiple scattering),
19
+ // - analytic exponential optical depth (chapman-ish) instead of LUT transmittance,
20
+ // - sky ambient from a hemispheric Rayleigh approximation instead of the irradiance LUT.
21
+ //
22
+ // Units: positions passed in are ATMOSPHERE-SPACE kilometres (surface at ATM_BOTTOM
23
+ // = 6360). atmPos() maps the meter-scale render world (R = 6360000 m) into this space.
24
+ // -----------------------------------------------------------------------------
25
+
26
+ const float ATM_PI = 3.14159265358979;
27
+
28
+ // Atmosphere geometry (km) — matches ATMO_DEF used to bake the WebGPU LUTs.
29
+ const float ATM_BOTTOM = 6360.0;
30
+ // Visible shell thickened to ~140 km (vs Bruneton's 60 km) so the limb/halo reads
31
+ // as a real atmosphere ring from orbit instead of a sub-pixel sliver. SIMPLIFICATION
32
+ // vs. the baked Bruneton LUTs (which use 6420). Scale heights raised to match so the
33
+ // density still falls off smoothly across the thicker shell.
34
+ const float ATM_TOP = 6500.0;
35
+ const float ATM_RAYLEIGH_H = 18.0; // Rayleigh scale height (km), inflated for the halo
36
+ const float ATM_MIE_H = 4.0; // Mie scale height (km)
37
+ const float ATM_MIE_G = 0.8;
38
+
39
+ // Bruneton scattering coefficients (per-km) and solar irradiance (W/m^2/nm-ish).
40
+ const vec3 ATM_RAYLEIGH = vec3(0.005802, 0.013558, 0.0331);
41
+ const vec3 ATM_MIE = vec3(0.003996, 0.003996, 0.003996);
42
+ const vec3 ATM_MIE_EXT = vec3(0.003996) * (1.0 / 0.9); // Mie extinction = scat/ssa, ssa~0.9
43
+ const vec3 ATM_SOLAR_IRRADIANCE = vec3(1.474, 1.8504, 1.91198);
44
+ const float ATM_SUN_ANGULAR_RADIUS = 0.004675;
45
+
46
+ // Map a render-space world position (meters, surface radius R_m) into atmosphere
47
+ // space (km, surface at ATM_BOTTOM). Preserves altitude fractions across the shell.
48
+ // W7: worldMeters ~6.4e6 m + R_m ~6.4e6 m both overflow fp16 -> highp (terrain.glsl injects this file
49
+ // into the FS, whose global default precision is now mediump). The km-scale result narrows naturally.
50
+ vec3 atmPos(highp vec3 worldMeters, highp float R_m) {
51
+ return worldMeters * (ATM_BOTTOM / R_m);
52
+ }
53
+
54
+ float atm_rayleighPhase(float nu) { return (3.0/(16.0*ATM_PI)) * (1.0 + nu*nu); }
55
+ float atm_miePhase(float g, float nu) {
56
+ float k = 3.0/(8.0*ATM_PI) * (1.0-g*g)/(2.0+g*g);
57
+ return k * (1.0+nu*nu) / pow(max(1.0 + g*g - 2.0*g*nu, 1e-4), 1.5);
58
+ }
59
+
60
+ // Distance from a point at radius r along direction with cosine mu to the top shell.
61
+ // Returns -1 if the ray escapes without entering the atmosphere from outside.
62
+ // W7: r ~6360 km so r*r ~4e7 OVERFLOWS fp16 (max 65504) -> r + the disc quadratic MUST be highp.
63
+ float atm_distToTop(highp float r, float mu) {
64
+ highp float disc = r*r*(mu*mu - 1.0) + ATM_TOP*ATM_TOP;
65
+ if (disc < 0.0) return -1.0;
66
+ return max(-r*mu + sqrt(disc), 0.0);
67
+ }
68
+ // Distance to ground sphere (ATM_BOTTOM), or -1 if no hit ahead.
69
+ float atm_distToGround(highp float r, float mu) {
70
+ highp float disc = r*r*(mu*mu - 1.0) + ATM_BOTTOM*ATM_BOTTOM;
71
+ if (disc < 0.0) return -1.0;
72
+ highp float d = -r*mu - sqrt(disc);
73
+ return d >= 0.0 ? d : -1.0;
74
+ }
75
+
76
+ // Analytic densities at radius r (altitude above ATM_BOTTOM).
77
+ // W7: r ~6360 (km-scale planet radius) and the (r - ATM_BOTTOM) altitude cancellation are highp -- at
78
+ // mediump fp16 the ~6360 radius resolves to ~4 km steps and the density profile (scale-height tens of
79
+ // km) would band/collapse. The exp() RESULT narrows to the mediump default.
80
+ float atm_rayleighDensity(highp float r) { return exp(-(r - ATM_BOTTOM)/ATM_RAYLEIGH_H); }
81
+ float atm_mieDensity(highp float r) { return exp(-(r - ATM_BOTTOM)/ATM_MIE_H); }
82
+
83
+ // Optical depth (Rayleigh,Mie line integral) from point p0 along dir for distance d.
84
+ // Cheap fixed-step trapezoid; cheap enough for a fullscreen pass.
85
+ void atm_opticalDepth(highp vec3 p0, vec3 dir, float d, out float odR, out float odM) { // W7: km-scale p0 -> highp
86
+ const int N = 8;
87
+ float dt = d / float(N);
88
+ odR = 0.0; odM = 0.0;
89
+ for (int i = 0; i < N; i++) {
90
+ highp vec3 p = p0 + dir * (dt * (float(i) + 0.5)); // W7: km-scale march point
91
+ highp float r = length(p);
92
+ odR += atm_rayleighDensity(r) * dt;
93
+ odM += atm_mieDensity(r) * dt;
94
+ }
95
+ }
96
+
97
+ // Transmittance over a segment of length d from p0 along dir.
98
+ vec3 atm_transmittanceSeg(highp vec3 p0, vec3 dir, float d) { // W7: km-scale p0 -> highp
99
+ float odR, odM;
100
+ atm_opticalDepth(p0, dir, d, odR, odM);
101
+ return exp(-(ATM_RAYLEIGH * odR + ATM_MIE_EXT * odM));
102
+ }
103
+
104
+ // Transmittance from point p to the top of the atmosphere along sun direction.
105
+ vec3 atm_transmittanceToSun(highp vec3 p, vec3 sun) { // W7: km-scale p -> highp
106
+ highp float r = length(p);
107
+ float mu = dot(p, sun) / r;
108
+ float d = atm_distToTop(r, mu);
109
+ if (d < 0.0) return vec3(1.0);
110
+ // SOFT sea-level horizon (user 2026-06-11 'shading indicates depth not slope' -- THE math bug):
111
+ // the old branch returned a BINARY vec3(0) whenever the sun ray intersected the ATM_BOTTOM
112
+ // sphere. ATM_BOTTOM is SEA LEVEL, so at low sun the direct light became a hard step function
113
+ // of ELEVATION (every point below a sun-dependent altitude got zero sun, every point above got
114
+ // full sun) -- slope-blind, elevation-keyed dark patches that read as 'depth shading' + dark
115
+ // rocky blobs. Replace with a smooth attenuation over ~2 deg of sun dip below the sea horizon:
116
+ // night stays dark, the elevation step is gone, slopes drive the shading again.
117
+ float muHoriz = -sqrt(max(0.0, 1.0 - (ATM_BOTTOM * ATM_BOTTOM) / (r * r)));
118
+ float soft = smoothstep(muHoriz - 0.035, muHoriz + 0.005, mu);
119
+ if (soft <= 0.0) return vec3(0.0);
120
+ return atm_transmittanceSeg(p, sun, d) * soft;
121
+ }
122
+
123
+ // Sky in-scatter radiance + view transmittance along a view ray from cameraIn.
124
+ // camera/view in atmosphere-space km. Single-scatter Rayleigh+Mie integration.
125
+ vec3 atm_skyRadiance(highp vec3 cameraIn, vec3 viewRay, vec3 sun, out vec3 transmittance) { // W7: km-scale camera -> highp
126
+ highp vec3 camera = cameraIn;
127
+ highp float r = length(camera);
128
+ float mu = dot(camera, viewRay) / r;
129
+
130
+ // March-start: if outside the atmosphere, advance to the top shell.
131
+ if (r > ATM_TOP) {
132
+ float dt = atm_distToTop(r, mu);
133
+ if (dt < 0.0) { transmittance = vec3(1.0); return vec3(0.0); } // misses atmosphere
134
+ camera = camera + viewRay * dt;
135
+ r = length(camera);
136
+ mu = dot(camera, viewRay) / r;
137
+ }
138
+
139
+ // March-end: ground hit, else exit at top shell.
140
+ float dGround = atm_distToGround(r, mu);
141
+ float dTop = atm_distToTop(r, mu);
142
+ bool ground = dGround > 0.0;
143
+ float dEnd = ground ? dGround : max(dTop, 0.0);
144
+ if (dEnd <= 0.0) { transmittance = vec3(1.0); return vec3(0.0); }
145
+
146
+ const int N = 16;
147
+ float dt = dEnd / float(N);
148
+ vec3 inscatR = vec3(0.0);
149
+ vec3 inscatM = vec3(0.0);
150
+ float odR = 0.0, odM = 0.0; // accumulated optical depth from camera to sample
151
+
152
+ for (int i = 0; i < N; i++) {
153
+ highp vec3 p = camera + viewRay * (dt * (float(i) + 0.5)); // W7: km-scale march point -> highp
154
+ highp float pr = length(p);
155
+ float dR = atm_rayleighDensity(pr) * dt;
156
+ float dM = atm_mieDensity(pr) * dt;
157
+ odR += dR; odM += dM;
158
+ // transmittance camera->sample
159
+ vec3 tView = exp(-(ATM_RAYLEIGH * odR + ATM_MIE_EXT * odM));
160
+ // transmittance sample->sun
161
+ vec3 tSun = atm_transmittanceToSun(p, sun);
162
+ vec3 t = tView * tSun;
163
+ inscatR += t * dR;
164
+ inscatM += t * dM;
165
+ }
166
+ float nu = dot(viewRay, sun);
167
+ transmittance = exp(-(ATM_RAYLEIGH * odR + ATM_MIE_EXT * odM));
168
+ if (ground) transmittance = vec3(0.0);
169
+
170
+ vec3 radiance = ATM_SOLAR_IRRADIANCE * (
171
+ inscatR * ATM_RAYLEIGH * atm_rayleighPhase(nu) +
172
+ inscatM * ATM_MIE * atm_miePhase(ATM_MIE_G, nu));
173
+ return radiance;
174
+ }
175
+
176
+ // Sun + sky irradiance reaching a surface point with given normal.
177
+ // Returns DIRECT sun irradiance (already * N.L); sky_irradiance is the ambient term.
178
+ // DIFFUSE WRAP (user 2026-06-10 'looking down the horizon at grass it gets darker'): pure Lambert
179
+ // kills slopes tilted a few degrees off the sun, so a downward view (which preferentially shows
180
+ // ripple backsides) reads darker than the grazing view of the same field. Wrap lifts away-facing
181
+ // gentle slopes (N.L -> (N.L+w)/(1+w)) -- soft-knee terrain diffuse, view-angle gradient gone.
182
+ // Unset by programs that do not bind it (GL zero-default) -> plain Lambert there (sky pass).
183
+ uniform float uDiffWrap; // 0 = pure Lambert; ~0.35 = soft wrap (terrain render program)
184
+ vec3 atm_sunSkyIrradiance(highp vec3 point, vec3 normal, vec3 sun, out vec3 sky_irradiance) { // W7: km-scale point -> highp
185
+ highp float r = length(point);
186
+ vec3 up = point / r;
187
+ float muS = dot(up, sun);
188
+ // ELEVATION-INDEPENDENT direct sun (user 2026-06-11 'normals appear elevation based again' --
189
+ // the recurring class, previously narrowed by 1263c51 but not killed): BOTH the sea-horizon cut
190
+ // (muHoriz is a function of r) and the sun-path optical depth key on the POINT'S RADIUS, so at
191
+ // low sun the direct light is a smooth function of terrain ELEVATION -- slope-blind brightness
192
+ // gradients across whole regions that read as 'height-based normals'. Evaluate the sun
193
+ // transmittance on a FIXED shell (sea level + 0.5 km): day/night and sunset reddening still
194
+ // follow the sun's local altitude (mu via `up`), but terrain elevation can no longer modulate
195
+ // direct light -- slope (N.sun) owns the shading. The sky/AP marches keep their per-point
196
+ // physics (airborne sample points genuinely differ); only the SURFACE direct term is pinned.
197
+ vec3 tSun = atm_transmittanceToSun(up * (ATM_BOTTOM + 0.5), sun);
198
+ vec3 direct = ATM_SOLAR_IRRADIANCE * tSun * clamp((dot(normal, sun) + uDiffWrap) / (1.0 + uDiffWrap), 0.0, 1.0);
199
+ // Sky (ambient) dome: Rayleigh-tinted, scaled by how much sky the surface sees
200
+ // and by daylight (smoothstep over the terminator). Hemispheric weight (1+N.up)/2.
201
+ float day = smoothstep(-0.10, 0.25, muS);
202
+ // Desaturate the sky-ambient tint toward white: raw Rayleigh (b~4x r) washed lit land
203
+ // fully blue. mostly-white ambient lights terrain without recoloring it blue.
204
+ vec3 rayTint = ATM_RAYLEIGH / (ATM_RAYLEIGH.x);
205
+ vec3 skyTint = mix(vec3(1.0), rayTint, 0.4);
206
+ // SUN-vs-SKY BALANCE (user 2026-06-02: 'normals arent affecting the lit view properly'). The
207
+ // sky ambient is weighted by N.up (near-directionless), so a too-large ambient washes out the
208
+ // directional N.sun sun term and opposing slopes barely differ -> normals look like they dont
209
+ // shade. Cut the ambient coefficient (0.14 -> 0.075) so the DIRECT sun (which carries the normal
210
+ // via N.sun) dominates and slopes self-shade; the (1+N.up)/2 dome weight stays so it still fills
211
+ // shadowed faces without flattening them. An ambient floor downstream keeps shadows non-black.
212
+ sky_irradiance = ATM_SOLAR_IRRADIANCE * 0.075 * day * skyTint
213
+ * (0.5 * (1.0 + dot(normal, up)));
214
+ return direct;
215
+ }