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,944 @@
1
+ // WebGL2 terrain RENDER layer: compiles and executes src/shaders/terrain.glsl
2
+ // (spherical deformation VS + CLOD blend + lit FS) per frame. Per-quad deformation
3
+ // uniforms (screenQuadCorners C / verticals N / cornerNorms L / offset / camera /
4
+ // blending / localToWorld) are computed in JS. No WebGPU.
5
+
6
+ // ---- minimal column-major mat4 helpers (no gl-matrix dep) ----------------------------
7
+ const M4 = {
8
+ mul(a, b) { // a*b, column-major (OpenGL convention)
9
+ const o = new Float32Array(16);
10
+ for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
11
+ let s = 0; for (let k = 0; k < 4; k++) s += a[k*4+r] * b[c*4+k];
12
+ o[c*4+r] = s;
13
+ }
14
+ return o;
15
+ },
16
+ perspective(fovy, aspect, near, far) {
17
+ const f = 1 / Math.tan(fovy/2), nf = 1/(near-far);
18
+ return new Float32Array([ f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0 ]);
19
+ },
20
+ // column-major translation matrix translate(t): maps p -> p + t
21
+ translate(t) {
22
+ return new Float32Array([ 1,0,0,0, 0,1,0,0, 0,0,1,0, t[0],t[1],t[2],1 ]);
23
+ },
24
+ // lookAt view matrix (world->camera), column-major
25
+ lookAt(eye, center, up) {
26
+ const z0=eye[0]-center[0], z1=eye[1]-center[1], z2=eye[2]-center[2];
27
+ let zl=Math.hypot(z0,z1,z2); const zx=z0/zl, zy=z1/zl, zz=z2/zl;
28
+ let x0=up[1]*zz-up[2]*zy, x1=up[2]*zx-up[0]*zz, x2=up[0]*zy-up[1]*zx;
29
+ let xl=Math.hypot(x0,x1,x2);
30
+ if (xl < 1e-4) { // up parallel to view dir (poles) -> pick another up
31
+ const au=[0,0,1]; x0=au[1]*zz-au[2]*zy; x1=au[2]*zx-au[0]*zz; x2=au[0]*zy-au[1]*zx;
32
+ xl=Math.hypot(x0,x1,x2);
33
+ if (xl < 1e-4){ x0=zy*1-zz*0; x1=zz*0-zx*1; x2=zx*0-zy*0; xl=Math.hypot(x0,x1,x2); } // up=[1,0,0]
34
+ }
35
+ xl = xl || 1; x0/=xl; x1/=xl; x2/=xl;
36
+ const y0=zy*x2-zz*x1, y1=zz*x0-zx*x2, y2=zx*x1-zy*x0;
37
+ return new Float32Array([
38
+ x0,y0,zx,0, x1,y1,zy,0, x2,y2,zz,0,
39
+ -(x0*eye[0]+x1*eye[1]+x2*eye[2]), -(y0*eye[0]+y1*eye[1]+y2*eye[2]), -(zx*eye[0]+zy*eye[1]+zz*eye[2]), 1
40
+ ]);
41
+ },
42
+ };
43
+
44
+ export async function initMapspinnerRender(gl, opts = {}) {
45
+ const R = opts.radius || 6360000.0;
46
+ const TILE_W = opts.tileW || 25; // mesh-coord tile width (was producer.TILE_W; producer gone)
47
+ // GRID 24 -> 16 (FPS lever, measured browser-18: pxPerPoly median 2.4px@40km / 0.45px@8km at GRID 24
48
+ // = SUB-PIXEL over-tessellation, only 40%/24% in the 4-50px band). GRID 16 cuts verts/quad 676->324
49
+ // (-52%) and tris/quad 1152->512 (-55%), so the per-vertex 14-oct broadShapeM VS (browser-9: 95% of
50
+ // the low-alt frame) runs on ~half the vertices. median scales ~24/16 -> ~3.6px, far closer to the
51
+ // band; the fine relief is carried per-pixel by the FS dFdx normal, not the mesh tessellation.
52
+ const GRID = opts.gridMeshSize || 16; // mesh quads per edge
53
+ // Expose the LIVE mesh grid so screen-space-error diagnostics (planet.html __diag.pxPerPoly)
54
+ // divide by the real polys/tile instead of a stale literal. Any future GRID change self-corrects
55
+ // the metric (the 24->16 lever left pxPerPoly defaulting to 24 = 1.5x wrong band fraction).
56
+ if (typeof window !== 'undefined') window.__glGrid = GRID;
57
+ const BORDER = 2;
58
+ const USABLE = TILE_W - 2*BORDER; // 21 interior samples spanned by the mesh
59
+
60
+ // HPF (hierarchical parameter field) continental texture -- set by the orchestrator via
61
+ // setHpf(). The terrain VS samples it by world dir for the continental elevation bias
62
+ // (seaBias), replacing the old hardcoded lobe. null until set (VS falls back to 0 bias).
63
+ let _hpfTex = null, _hpfTex2 = null, _hpfRes = 0; // _hpfTex RG16F(seaBias,elevAmp), _hpfTex2 RG8(temp,humid) -- W12 pack
64
+
65
+ // ---- compile terrain.glsl ----
66
+ let src = await (await fetch('/src/shaders/terrain.glsl')).text();
67
+ // Analytic Bruneton-style atmosphere helpers, shared by terrain FS + sky pass.
68
+ let atmoSrc = await (await fetch('/src/shaders/atmosphere.glsl')).text();
69
+
70
+ // NON-BLOCKING COMPILE (user 2026-06-02: 'startup takes really long'). The terrain shader's
71
+ // first (cold-cache) compile can take tens of seconds; querying COMPILE_STATUS/LINK_STATUS
72
+ // BLOCKS the main thread until the driver finishes -> the page freezes for the whole compile.
73
+ // KHR_parallel_shader_compile lets the driver compile on a worker thread; we poll the
74
+ // non-blocking COMPLETION_STATUS_KHR and yield to the event loop between polls, so the page
75
+ // stays responsive (and can show a loading state) during a cold compile instead of freezing.
76
+ const _parExt = gl.getExtension('KHR_parallel_shader_compile');
77
+ const COMPLETION_STATUS_KHR = 0x91B1;
78
+ // Await a program's link completion without blocking the main thread. With the parallel ext we
79
+ // poll COMPLETION_STATUS_KHR (true once the driver is done); without it we fall back to one
80
+ // yield then the (blocking) status read. Throws on compile/link failure, same as before.
81
+ async function awaitProgramLink(p, vs, fs, label){
82
+ if (_parExt) {
83
+ // poll until the driver reports completion, yielding each tick. NOT rAF when the tab is hidden
84
+ // (2026-06-12: background tabs throttle rAF to ~1/min, so a backgrounded compile sat at 'init'
85
+ // for 10+ minutes -- the recurring stuck-at-init mechanism); setTimeout keeps polling at ~8ms.
86
+ const yield_ = () => new Promise(res => (typeof requestAnimationFrame !== 'undefined'
87
+ && typeof document !== 'undefined' && !document.hidden
88
+ ? requestAnimationFrame(() => res()) : setTimeout(res, 8)));
89
+ while (!gl.getProgramParameter(p, COMPLETION_STATUS_KHR)) { await yield_(); }
90
+ }
91
+ // now the status reads return immediately (compile/link already finished)
92
+ if (vs && !gl.getShaderParameter(vs, gl.COMPILE_STATUS)) throw new Error(label+' vs: '+gl.getShaderInfoLog(vs));
93
+ if (fs && !gl.getShaderParameter(fs, gl.COMPILE_STATUS)) throw new Error(label+' fs: '+gl.getShaderInfoLog(fs));
94
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(label+' link: '+gl.getProgramInfoLog(p));
95
+ }
96
+ // PRECISION: global default HIGHP float. The mediump default (a speculative mobile-ALU lever) kept
97
+ // causing recurring UV SCRAMBLES -- any world-scale noise UV (normalize(worldPos)*freq, freq up to
98
+ // ~9000) whose snoise3 arg was evaluated in mediump (fp16 mantissa ~2048) lost lattice precision and
99
+ // scrambled at close range, and chasing every per-site highp island kept missing sites (multiple
100
+ // commits: f8550b2 et al). HIGHP-DEFAULT eliminates the entire class in one line (P2 simplicity +
101
+ // P8 make-misuse-impossible: a mediump world-scale UV can no longer be reintroduced by omission).
102
+ // Float WIDTH is not our measured frontier (octave count + LOD vertex count are), so the ALU cost is
103
+ // acceptable; correctness + simplicity win over a micro-optimization that keeps breaking. The explicit
104
+ // highp islands left in the shader are now redundant-but-harmless. int + sampler2DArray stay highp.
105
+ const hdr = '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2DArray;\n';
106
+ // Build (or rebuild) the terrain program from the current src/atmoSrc. Factored so the
107
+ // shader can be HOT-RELOADED in place (recompile()) without a page reload -- the biggest
108
+ // single cut to the shader-edit debug loop. On compile/link failure it throws WITHOUT
109
+ // disturbing the live program, so a bad edit is reported inline and the old shader keeps
110
+ // running (no broken page).
111
+ // Kick off compile+link WITHOUT reading status (non-blocking with KHR_parallel_shader_compile).
112
+ // Returns {p, vs, fs}; the caller awaits awaitProgramLink() to validate once the driver is done.
113
+ // fsDefs lets the caller add FS-only #defines (e.g. _DEBUGVIEW_ for the lazy debug program that
114
+ // carries the diagnostic displayModes). The render program passes '' so the diagnostic blocks are
115
+ // #ifdef'd OUT (the 7132-char / 25% cold-compile cut, browser-1590); the debug program passes
116
+ // ' _DEBUGVIEW_' to compile them in. The VS is identical for both (no debug branches in the VS).
117
+ function buildTerrainProgram(terrainSrc, atmo, fsDefs){
118
+ fsDefs = fsDefs || ''; // space-separated extra FS defines, e.g. '_DEBUGVIEW_'
119
+ function shader(type, def){ const s=gl.createShader(type);
120
+ // Inject atmosphere.glsl into the FRAGMENT stage only (it's pure functions; the VS
121
+ // doesn't need it). It must appear before terrain.glsl's FS uses the helpers.
122
+ const body = (type===gl.FRAGMENT_SHADER) ? (atmo+'\n'+terrainSrc) : terrainSrc;
123
+ // Each token gets its OWN `#define` line -- a single `#define _FRAGMENT_ _DEBUGVIEW_` would make
124
+ // _FRAGMENT_ a macro that EXPANDS to _DEBUGVIEW_ (and never DEFINE _DEBUGVIEW_), so the debug
125
+ // blocks stayed #ifdef'd out (witnessed browser-1609: debugFS==renderFS). Split into lines.
126
+ const tokens = [def].concat(
127
+ (type===gl.FRAGMENT_SHADER && fsDefs) ? fsDefs.trim().split(/\s+/) : []);
128
+ const defLines = tokens.map(t => '#define '+t+'\n').join('');
129
+ gl.shaderSource(s, hdr+defLines+body); gl.compileShader(s); return s; }
130
+ const vs = shader(gl.VERTEX_SHADER,'_VERTEX_'), fs = shader(gl.FRAGMENT_SHADER,'_FRAGMENT_');
131
+ const p = gl.createProgram();
132
+ gl.attachShader(p, vs); gl.attachShader(p, fs);
133
+ gl.bindAttribLocation(p, 0, 'vertex');
134
+ gl.linkProgram(p); // kicks off the (parallel) link; do NOT read status here
135
+ return { p, vs, fs };
136
+ }
137
+ // COLD COMPILE: only the render program is built on the cold startup path now. The collision PROBE
138
+ // program is LAZY (ensureProbe, built on first sampleGroundM) and the DEBUG program is lazy
139
+ // (ensureDebug) -- both off the cold path. KHR_parallel_shader_compile keeps the render link
140
+ // non-blocking so the page shows a loading state instead of freezing.
141
+ let _b = buildTerrainProgram(src, atmoSrc);
142
+ // LAZY PROBE (build-time pivot 2026-06-09): the collision-height probe program is NO LONGER built on
143
+ // the cold startup path. Measured: the probe (composeHeight + 5 carves FS) was a co-equal cold-compile
144
+ // pole, but sampleGroundM only runs on free-fly collision NEAR GROUND -- never at startup. So it is now
145
+ // built on first sampleGroundM() call (ensureProbe, mirroring the lazy debug program). Removes the probe
146
+ // VS+FS from the cold compile with ZERO functionality loss (collision still GPU-exact, just compiled the
147
+ // first time the user needs it). sampleGroundM returns null until the first build finishes (caller falls
148
+ // back to no-collision, same as the long-standing probe-unavailable path).
149
+ await awaitProgramLink(_b.p, _b.vs, _b.fs, 'terrain'); // non-blocking poll, then validate (render only now)
150
+ let prog = _b.p;
151
+ // LAZY DEBUG PROGRAM: the diagnostic displayModes (1,5,6,7,8,9,10,11,12) live behind _DEBUGVIEW_,
152
+ // compiled into this SEPARATE program only when the user first selects such a mode -- it is NEVER
153
+ // on the cold startup path (the render program above excludes them). Built on demand by ensureDebug();
154
+ // null until then. Its own uniform-location cache (_dbgUloc) since locations are per-program.
155
+ let debugProg = null, _dbgBuilding = null;
156
+ const _dbgUloc = new Map();
157
+ // The diagnostic-only modes that REQUIRE the debug program. Modes 0 (lit), 2 (albedo), 4 (biome
158
+ // ramp) render correctly in the hot program, so they never trigger a debug-program build.
159
+ const DEBUG_MODES = new Set([1,5,6,7,8,9,10,11,12]);
160
+ function ensureDebug(){
161
+ if (debugProg || _dbgBuilding) return; // already built or in-flight
162
+ // VISIBLE STATE (2026-06-12 'total clarity' tooling): a failed/slow debug compile used to fall
163
+ // back to the lit view FOREVER with no signal (witnessed: displayMode 11 silently rendered lit;
164
+ // a whole diagnostic session trusted a view that never engaged). __debugProgState is the witness:
165
+ // 'compiling' -> 'ready' | 'failed: <log>'; planet.html shows it in the HUD while a debug mode
166
+ // is requested but not yet served.
167
+ if (typeof window !== 'undefined') window.__debugProgState = 'compiling';
168
+ _dbgBuilding = (async () => {
169
+ try {
170
+ const nb = buildTerrainProgram(src, atmoSrc, ' _DEBUGVIEW_');
171
+ await awaitProgramLink(nb.p, nb.vs, nb.fs, 'debug');
172
+ debugProg = nb.p; _dbgUloc.clear();
173
+ if (typeof window !== 'undefined') window.__debugProgState = 'ready';
174
+ } catch(e){ try { if(typeof window!=='undefined') { window.__debugProgErr = String(e.message||e); window.__debugProgState = 'failed: ' + String(e.message||e).slice(0,120); } } catch(_){} }
175
+ finally { _dbgBuilding = null; }
176
+ })();
177
+ }
178
+ // ACTIVE PROGRAM indirection: U() resolves locations against whichever program is bound this frame
179
+ // (render prog by default; the debug prog while a diagnostic displayMode is active). Each program
180
+ // keeps its own location cache. _activeProg/_activeUloc are swapped in render() per frame.
181
+ let _activeProg = null, _activeUloc = null;
182
+ function setActiveProgram(p, cache){ _activeProg = p; _activeUloc = cache; }
183
+ // MEMOIZE uniform locations: U() was calling gl.getUniformLocation EVERY time, and the
184
+ // per-quad path (setQuadUniforms + 3x setTileCoords) hit it ~15x per quad per frame ->
185
+ // ~3000 synchronous driver round-trips/frame at 200 quads = the ~4fps stall. Cache by
186
+ // name; getUniformLocation is then called once per name. Cleared on recompile().
187
+ const _uloc = new Map();
188
+ const U = n => { const cache = _activeUloc || _uloc; const p = _activeProg || prog;
189
+ let l = cache.get(n); if (l === undefined) { l = gl.getUniformLocation(p, n); cache.set(n, l); } return l; };
190
+ // PROBE uniform-location cache (ESE 2026-06-10): sampleGroundM ran ~18 synchronous
191
+ // gl.getUniformLocation(probeProg,...) per call (8 inline + ~10 via setComposeHeightUniforms),
192
+ // hit once/frame on the near-ground collision path = ~18 driver round-trips/frame where it hurts
193
+ // most. Mirror _uloc: memoize per name, cleared when the probe program is (re)built.
194
+ let _probeUloc = new Map();
195
+ const PU = n => { let l = _probeUloc.get(n); if (l === undefined) { l = gl.getUniformLocation(probeProg, n); _probeUloc.set(n, l); } return l; };
196
+ // HOT-RELOAD: re-fetch both shader files (cache-busted), rebuild the terrain program,
197
+ // and swap it in atomically. Returns {ok:true} or {ok:false, error} -- never leaves the
198
+ // renderer in a broken state (a failed build throws before `prog` is reassigned).
199
+ async function recompile(){
200
+ try {
201
+ const ns = await (await fetch('/src/shaders/terrain.glsl?t='+(performance.now()|0))).text();
202
+ const na = await (await fetch('/src/shaders/atmosphere.glsl?t='+(performance.now()|0))).text();
203
+ const nb = buildTerrainProgram(ns, na);
204
+ await awaitProgramLink(nb.p, nb.vs, nb.fs, 'terrain'); // throws on compile/link error
205
+ const newProg = nb.p;
206
+ const old = prog; prog = newProg; src = ns; atmoSrc = na; _uloc.clear();
207
+ gl.deleteProgram(old);
208
+ // invalidate the lazy debug program so it rebuilds from the new source on the next debug-mode frame.
209
+ if (debugProg) { gl.deleteProgram(debugProg); debugProg = null; _dbgUloc.clear(); }
210
+ // invalidate the lazy probe program too (perf sweep 2026-06-11): it was leaked AND kept running
211
+ // the OLD shader source after a hot-reload -- collision silently diverged from the new geometry.
212
+ if (probeProg) { gl.deleteProgram(probeProg); probeProg = null; _probeUloc.clear(); }
213
+ return { ok: true };
214
+ } catch (e) { return { ok: false, error: String(e.message || e) }; }
215
+ }
216
+
217
+ // ---- HEIGHT PROBE program (collision): render the EXACT terrain height for ONE world dir
218
+ // to a 1x1 R32F target, then readPixels it. The free-fly collision floor reads this so it can
219
+ // never diverge from the rendered surface (user-chosen GPU readback, not a CPU mirror). The
220
+ // probe FS (#define _PROBE_) reuses terrain.glsl's hpfSample + broadShapeM; the VS emits one
221
+ // point at clip (0,0). Tiny 4-byte readback per call (collision once/frame).
222
+ let probeProg = null, probeFbo = null, probeTex = null, _probeBuilding = null;
223
+ // ensureProbe(): build the collision-height probe program + its 1x1 R32F FBO on first need (lazy).
224
+ // Idempotent + in-flight-guarded (mirrors ensureDebug). Off the cold startup path.
225
+ function ensureProbe(){
226
+ if (probeProg || _probeBuilding) return;
227
+ _probeBuilding = (async () => {
228
+ try {
229
+ const pvs = hdr + 'void main(){ gl_Position = vec4(0.0,0.0,0.0,1.0); gl_PointSize = 1.0; }';
230
+ const pfs = hdr + atmoSrc + '\n#define _PROBE_\n' + src;
231
+ const pv = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(pv, pvs); gl.compileShader(pv);
232
+ const pf = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(pf, pfs); gl.compileShader(pf);
233
+ const pp = gl.createProgram(); gl.attachShader(pp, pv); gl.attachShader(pp, pf); gl.linkProgram(pp);
234
+ await awaitProgramLink(pp, pv, pf, 'probe');
235
+ const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex);
236
+ gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R32F, 1, 1);
237
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
238
+ const fbo = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
239
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
240
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
241
+ _probeUloc.clear(); // stale locations from any prior probe program are invalid for the new one
242
+ probeTex = tex; probeFbo = fbo; probeProg = pp; // assign LAST so a half-built probe is never used
243
+ } catch(e){ probeProg = null; try { if(typeof window!=='undefined') window.__probeErr = String(e.message||e); } catch(_){} }
244
+ finally { _probeBuilding = null; }
245
+ })();
246
+ }
247
+ const probeVao = gl.createVertexArray();
248
+ // sampleGroundM(dir): rendered terrain height (metres) at world direction dir. Returns null if
249
+ // the probe is unavailable (caller falls back). One 4-byte readPixels.
250
+ function sampleGroundM(dir) {
251
+ if (!probeProg) { ensureProbe(); return null; } // lazy: kick off the build on first need, fall back to null until ready
252
+ const pl = Math.hypot(dir[0],dir[1],dir[2])||1;
253
+ gl.bindFramebuffer(gl.FRAMEBUFFER, probeFbo);
254
+ gl.viewport(0,0,1,1);
255
+ gl.useProgram(probeProg);
256
+ gl.bindVertexArray(probeVao);
257
+ if (_hpfTex) { gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D_ARRAY, _hpfTex); gl.uniform1i(PU('hpfPool'),3); }
258
+ if (_hpfTex2) { gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D_ARRAY, _hpfTex2); gl.uniform1i(PU('hpfPool2'),5); }
259
+ gl.uniform1i(PU('hasHpf'), _hpfTex?1:0);
260
+ // SAME shape-control + HPF-sampler congruence as render() (setComposeHeightUniforms): the probe runs
261
+ // composeHeight for sampleGroundM (collision/camera height) so collision matches the rendered surface.
262
+ // If uHiFreqCut/vtxDetail were unset (0.0) here the probe's height would omit all fine relief and
263
+ // diverge from the rendered geometry = the camera stops short of the visible surface. Match render().
264
+ setComposeHeightUniforms(PU);
265
+ gl.uniform3f(PU('probeDir'), dir[0]/pl, dir[1]/pl, dir[2]/pl);
266
+ gl.disable(gl.DEPTH_TEST);
267
+ gl.drawArrays(gl.POINTS, 0, 1);
268
+ const out = new Float32Array(4);
269
+ gl.readPixels(0,0,1,1, gl.RGBA, gl.FLOAT, out);
270
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
271
+ gl.bindVertexArray(null);
272
+ return out[0];
273
+ }
274
+
275
+ // FLOAT-LINEAR FORMAT PROBE (NOT a quality tier): OES_texture_float_linear lets the HPF atlas pools
276
+ // filter LINEAR in hardware -> hpfSample collapses to one texture() call. 0 = manual 4-tap fallback.
277
+ const _halfFloatLinearOK = !!gl.getExtension('OES_texture_float_linear') || !!gl.getExtension('OES_texture_half_float_linear');
278
+ // Diagnostics-only readout (NOT a branch): exposes the float-linear probe outcome for a witness/CLI.
279
+ try { if (typeof window !== 'undefined') window.__terrainConfig = { floatLinearOK: _halfFloatLinearOK }; } catch(_){}
280
+
281
+ // ONE SOURCE OF TRUTH for composeHeight's shape-control + HPF-sampler uniforms: every program that runs
282
+ // composeHeight (render, _PROBE_) calls this with its own uniform-locator so they CANNOT diverge.
283
+ function setComposeHeightUniforms(loc) {
284
+ const g = (n,d)=> (typeof window!=='undefined' && window['__'+n]!=null) ? +window['__'+n] : d;
285
+ gl.uniform1f(loc('uHiFreqCut'), g('hiFreqCut', 0.25)); // DECISIVE: ungated *= at terrain.glsl fine octaves; 0.5->0.25 (2026-06-10 'blotchy': the 4x fine band read as leopard dapple at altitude -- live-isolated, hiFreqCut=0 removed it entirely)
286
+ gl.uniform1f(loc('uDetailOverlay'), g('detailOverlay', 6.0)); // perlin-everywhere ELEVATION term in composeHeight -- probe must match the VS or collision diverges
287
+ gl.uniform1f(loc('vtxDetail'), g('vtxDetail', 1.0)); // DECISIVE: vtxDisplace strength (early-return on 0)
288
+ gl.uniform1f(loc('canyonDepthMul'), g('canyonDepth', 1.0));
289
+ gl.uniform1f(loc('cliffAmt'), g('cliffAmt', 1.0));
290
+ gl.uniform1i(loc('uFloatLinearOK'), _halfFloatLinearOK ? 1 : 0);
291
+ // FXC unroll-defeat (2026-06-12 AMD d3d11 fix): runtime octave bound for broadShapeM; the shader
292
+ // guards uOctMax<=0 -> 12, so this set is belt-and-braces. Live dial: window.__octMax.
293
+ gl.uniform1i(loc('uOctMax'), (typeof window!=='undefined' && window.__octMax!=null) ? (window.__octMax|0) : 12);
294
+ // FXC fold-defeat (2026-06-12, the rock-on-flat patches): the lit-normal FD step is uniform-fed
295
+ // so d3d11/FXC cannot constant-fold the 150/R offset. Live dial: window.__nrmStepM.
296
+ gl.uniform1f(loc('uNrmStepM'), g('nrmStepM', 150.0));
297
+ gl.uniform1f(loc('uHpfInset'), (typeof window!=='undefined' && window.__hpfInset === false) ? 0.0 : 1.0); // SEAM FIX: inset sampler is the permanent default (matches bakeFace fu=x/(RES-1)); window.__hpfInset===false rolls back
298
+ // ANCHOR-STEP A/B TOGGLES (per-area stairstep, wrxo0rr7a). Default 0 = current; set window.__<name>=1
299
+ // to widen that anchor-keyed band. Set HERE so BOTH render and the _PROBE_ collision see them (parity).
300
+ gl.uniform1f(loc('uMtnBandWide'), g('mtnBandWide', 0.0));
301
+ gl.uniform1f(loc('uClimateRelief'), g('climateRelief', 0.0));
302
+ gl.uniform1f(loc('uIsleWide'), g('isleWide', 0.0));
303
+ gl.uniform1f(loc('uCarveWide'), g('carveWide', 0.0));
304
+ }
305
+
306
+
307
+ // ---- SURFACE PHOTO-TEXTURES (user 2026-06-10): grass/rock/sand/snow color + displacement JPGs
308
+ // from /textures, packed into two mipped sampler2DArrays. Normals are SOBEL-DERIVED from the
309
+ // displacement at load (3x3, WRAPPED edges -- the textures tile, so the kernel must wrap or the
310
+ // tile border gets a seam line). uSurfAlb = sRGB color (RGB) + displacement (A, linear alpha);
311
+ // uSurfNrm = tangent normal xy 0.5-biased (RG) + displacement (B). Loaded ASYNC off the cold
312
+ // startup path; uHasSurfTex stays 0 (procedural-only) until the upload lands.
313
+ let _surfAlb = null, _surfNrm = null, _surfMeanL = [0.2, 0.2, 0.2, 0.5];
314
+ // 8-bit -> linear LUT (perf sweep 2026-06-11): gamma-2.2 on an 8-bit input has exactly 256 values;
315
+ // the per-pixel Math.pow de-shade/mean passes were ~30M transcendental calls = ~0.5s+ of main-thread
316
+ // long tasks per load. The LUT is bit-identical for every 8-bit input.
317
+ const LIN8 = new Float32Array(256);
318
+ for (let v = 0; v < 256; v++) LIN8[v] = Math.pow(v / 255, 2.2);
319
+ async function loadSurfaceTextures() {
320
+ const MATS = ['grass', 'rock', 'sand', 'snow']; // layer order: matches terrain.glsl splat
321
+ const SZ = 1024;
322
+ const img = (u) => new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => rej(new Error('load ' + u)); i.src = u; });
323
+ const cv = document.createElement('canvas'); cv.width = SZ; cv.height = SZ;
324
+ const cx = cv.getContext('2d', { willReadFrequently: true });
325
+ const px = (im) => { cx.drawImage(im, 0, 0, SZ, SZ); return cx.getImageData(0, 0, SZ, SZ).data; };
326
+ const albAll = new Uint8Array(SZ * SZ * 4 * MATS.length);
327
+ const nrmAll = new Uint8Array(SZ * SZ * 4 * MATS.length);
328
+ for (let m = 0; m < MATS.length; m++) {
329
+ const [ci, di] = await Promise.all([img('/textures/' + MATS[m] + '-color.jpg'), img('/textures/' + MATS[m] + '-displacement.jpg')]);
330
+ const c = px(ci), d = px(di);
331
+ // DE-SHADE (user 2026-06-11 'flat, unangled bowls of rock'): the photos carry baked large-scale
332
+ // shading (shadowed depressions), which at a 2.4km tile pastes bowl-shaped shadows onto geometry
333
+ // with no matching shape. Divide each pixel by a wrapped-bilinear 32x32 blur of the photo's own
334
+ // linear luminance (renormalized to the photo mean) -- kills the bowl-scale light, keeps detail.
335
+ // PER-CHANNEL (user 2026-06-11 'rocky patches on flat ground, no steep slopes': the grass
336
+ // photo carries large grey-brown bare-dirt patches that differ in CHROMA, not just luminance;
337
+ // at the 2.4km tile they become hundred-metre grey blotches that read as rock. Per-channel
338
+ // division flattens large-scale COLOR blotches too; fine grain untouched.)
339
+ { const G = 32, cell = SZ / G, grid = new Float32Array(G * G * 3), cnt = cell * cell;
340
+ const lin = (v) => LIN8[v], delin = (v) => Math.pow(v, 1 / 2.2) * 255;
341
+ for (let y = 0; y < SZ; y++) for (let x = 0; x < SZ; x++) {
342
+ const i = (y * SZ + x) * 4, g = (((y / cell) | 0) * G + ((x / cell) | 0)) * 3;
343
+ grid[g] += LIN8[c[i]]; grid[g + 1] += LIN8[c[i + 1]]; grid[g + 2] += LIN8[c[i + 2]];
344
+ }
345
+ const gMean = [0, 0, 0];
346
+ for (let g = 0; g < G * G; g++) for (let ch = 0; ch < 3; ch++) { grid[g * 3 + ch] /= cnt; gMean[ch] += grid[g * 3 + ch]; }
347
+ for (let ch = 0; ch < 3; ch++) gMean[ch] /= G * G;
348
+ for (let y = 0; y < SZ; y++) for (let x = 0; x < SZ; x++) {
349
+ const gx = x / cell - 0.5, gy = y / cell - 0.5;
350
+ const x0 = (Math.floor(gx) + G) % G, y0 = (Math.floor(gy) + G) % G;
351
+ const x1 = (x0 + 1) % G, y1 = (y0 + 1) % G;
352
+ const fx = gx - Math.floor(gx), fy = gy - Math.floor(gy);
353
+ const i = (y * SZ + x) * 4;
354
+ for (let ch = 0; ch < 3; ch++) {
355
+ const blur = (grid[(y0 * G + x0) * 3 + ch] * (1 - fx) + grid[(y0 * G + x1) * 3 + ch] * fx) * (1 - fy)
356
+ + (grid[(y1 * G + x0) * 3 + ch] * (1 - fx) + grid[(y1 * G + x1) * 3 + ch] * fx) * fy;
357
+ // pow 0.8: stronger than the old 0.65 luma-only pass -- the patches must GO; fine
358
+ // (sub-64px) structure is untouched by construction.
359
+ const s = Math.min(2.5, Math.max(0.4, Math.pow(gMean[ch] / Math.max(blur, 1e-4), 0.8)));
360
+ // FINE-CONTRAST RESTORE (user 2026-06-11 'grass texture... not on grassy areas'): the
361
+ // de-shade flattened large blotches but also left flats reading textureless once the
362
+ // shade-match lands the average on the macro color. Stretch the remaining (fine-grain)
363
+ // deviation around the channel mean x1.35 so the texture stays visible on flat ground.
364
+ const v = lin(c[i + ch]) * s;
365
+ c[i + ch] = Math.min(255, Math.max(0, Math.round(delin(Math.max(0, gMean[ch] + (v - gMean[ch]) * 1.35)))));
366
+ }
367
+ }
368
+ }
369
+ // yield between the de-shade and Sobel passes too (perf sweep 2026-06-11): one material was a
370
+ // single contiguous main-thread block; splitting halves the worst long task.
371
+ await new Promise(res => setTimeout(res, 0));
372
+ const base = m * SZ * SZ * 4;
373
+ const S = 2.2; // gradient gain: 1024px tile reads as believable relief at the default repeat
374
+ for (let y = 0; y < SZ; y++) {
375
+ const ym = (y + SZ - 1) % SZ, yp = (y + 1) % SZ;
376
+ for (let x = 0; x < SZ; x++) {
377
+ const xm = (x + SZ - 1) % SZ, xp = (x + 1) % SZ;
378
+ const i = y * SZ + x, o = base + i * 4;
379
+ const r = (X, Y) => d[(Y * SZ + X) * 4];
380
+ const gx = (r(xp, ym) + 2 * r(xp, y) + r(xp, yp) - r(xm, ym) - 2 * r(xm, y) - r(xm, yp)) / (8 * 255);
381
+ const gy = (r(xm, yp) + 2 * r(x, yp) + r(xp, yp) - r(xm, ym) - 2 * r(x, ym) - r(xp, ym)) / (8 * 255);
382
+ // COARSE octave (user 2026-06-11 'normals seem missing for textures'): the 1px Sobel is
383
+ // pure fine detail and mips to flat at any distance; a +/-6px central diff carries the
384
+ // mid-scale relief through the mip chain.
385
+ const x6p = (x + 6) % SZ, x6m = (x + SZ - 6) % SZ, y6p = (y + 6) % SZ, y6m = (y + SZ - 6) % SZ;
386
+ const gx2 = (r(x6p, y) - r(x6m, y)) / (2 * 255), gy2 = (r(x, y6p) - r(x, y6m)) / (2 * 255);
387
+ // LARGE octave (user 2026-06-11, repeated displacement->normals ask -- the gap was SPECTRAL:
388
+ // a km-scale displacement feature spans hundreds of px, so its per-texel gradient (~0.004)
389
+ // never registered in the 1px/6px diffs = the BIG relief had zero normal response, and the
390
+ // fine detail that did register mips away at flight altitude. A +/-48px diff at gain 8
391
+ // makes the large features tilt the normal AND survive the mip chain.)
392
+ const x48p = (x + 48) % SZ, x48m = (x + SZ - 48) % SZ, y48p = (y + 48) % SZ, y48m = (y + SZ - 48) % SZ;
393
+ const gx3 = (r(x48p, y) - r(x48m, y)) / (2 * 255), gy3 = (r(x, y48p) - r(x, y48m)) / (2 * 255);
394
+ // gain 8 -> 2 + total tilt CAP (user: 'rock blotches reappear, normal data gone again' --
395
+ // gain 8 saturated the tangential normal (gradients ~0.3 on ~100px features x8 = full
396
+ // sideways tilt) = the scramble class, self-inflicted). Cap |xy| at 0.9 so no texel ever
397
+ // encodes a >42deg tilt regardless of octave stacking.
398
+ let nx = -(gx * S + gx2 * 2.5 + gx3 * 2.0), ny = -(gy * S + gy2 * 2.5 + gy3 * 2.0);
399
+ const tm = Math.hypot(nx, ny);
400
+ if (tm > 0.9) { nx *= 0.9 / tm; ny *= 0.9 / tm; }
401
+ const il = 1 / Math.hypot(nx, ny, 1);
402
+ albAll[o] = c[i * 4]; albAll[o + 1] = c[i * 4 + 1]; albAll[o + 2] = c[i * 4 + 2]; albAll[o + 3] = d[i * 4];
403
+ nrmAll[o] = Math.round((nx * il * 0.5 + 0.5) * 255);
404
+ nrmAll[o + 1] = Math.round((ny * il * 0.5 + 0.5) * 255);
405
+ nrmAll[o + 2] = d[i * 4]; nrmAll[o + 3] = 255;
406
+ }
407
+ }
408
+ // yield between materials so the ~4x 1M-px Sobel never blocks a whole frame budget at once
409
+ await new Promise(res => setTimeout(res, 0));
410
+ }
411
+ const aniso = gl.getExtension('EXT_texture_filter_anisotropic');
412
+ function mkArray(data, internal) {
413
+ const t = gl.createTexture();
414
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, t);
415
+ gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 11, internal, SZ, SZ, MATS.length); // 11 = full 1024 mip chain
416
+ gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, 0, SZ, SZ, MATS.length, gl.RGBA, gl.UNSIGNED_BYTE, data);
417
+ gl.generateMipmap(gl.TEXTURE_2D_ARRAY);
418
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
419
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
420
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT);
421
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT);
422
+ if (aniso) gl.texParameterf(gl.TEXTURE_2D_ARRAY, aniso.TEXTURE_MAX_ANISOTROPY_EXT,
423
+ Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
424
+ return t;
425
+ }
426
+ // mean LINEAR color of the rock photo (layer 1): the far-field macro bcRock defaults to this so
427
+ // the >20km rock shade matches the near-field photo rock (no color pop across the fade).
428
+ { let r = 0, g = 0, b = 0; const base = 1 * SZ * SZ * 4, n = SZ * SZ;
429
+ for (let i = 0; i < n; i++) { r += albAll[base + i * 4]; g += albAll[base + i * 4 + 1]; b += albAll[base + i * 4 + 2]; }
430
+ const lin = (v) => Math.pow(v / n / 255, 2.2);
431
+ if (typeof window !== 'undefined') window.__surfRockMean = [lin(r), lin(g), lin(b)];
432
+ }
433
+ // mean LINEAR luminance per layer (user 2026-06-11 'terrain gets darker' + 'dont see grass/snow
434
+ // textures'): the shader shade-matches the photo by dividing out its LAYER-MEAN luminance (not
435
+ // per-pixel, which cancelled all structure; not raw photo, which shifted the shade).
436
+ { const meanL = [0, 0, 0, 0];
437
+ for (let m = 0; m < MATS.length; m++) {
438
+ let s = 0; const base = m * SZ * SZ * 4, n = SZ * SZ;
439
+ for (let i = 0; i < n; i++) {
440
+ s += 0.2126 * LIN8[albAll[base + i * 4]] + 0.7152 * LIN8[albAll[base + i * 4 + 1]] + 0.0722 * LIN8[albAll[base + i * 4 + 2]];
441
+ }
442
+ meanL[m] = s / n;
443
+ }
444
+ _surfMeanL = meanL;
445
+ if (typeof window !== 'undefined') window.__surfMeanL = meanL;
446
+ }
447
+ _surfAlb = mkArray(albAll, gl.SRGB8_ALPHA8); // sRGB decode in hardware (color); A (displacement) stays linear
448
+ _surfNrm = mkArray(nrmAll, gl.RGBA8); // normals/displacement are data, NOT color -> linear
449
+ if (typeof window !== 'undefined') window.__surfTexReady = true;
450
+ }
451
+ if (typeof document !== 'undefined') {
452
+ loadSurfaceTextures().catch(e => { try { window.__surfTexErr = String(e.message || e); } catch (_) {} });
453
+ }
454
+
455
+ // ---- fullscreen SKY pass program (atmospheric limb/halo behind the terrain) ----
456
+ // VS emits a fullscreen triangle; FS reconstructs the world-space view ray from the
457
+ // inverse view-projection and calls atm_skyRadiance. Drawn before terrain (depth
458
+ // writes off) so terrain overdraws where the planet is, leaving sky on the limb.
459
+ const skyVsSrc = hdr + `out vec2 vNdc;
460
+ void main(){ vec2 p = vec2((gl_VertexID==1)?3.0:-1.0, (gl_VertexID==2)?3.0:-1.0);
461
+ vNdc = p; gl_Position = vec4(p, 1.0, 1.0); }`;
462
+ const skyFsSrc = hdr + atmoSrc + `
463
+ in vec2 vNdc;
464
+ layout(location=0) out vec4 fragColor;
465
+ uniform mat3 camRot; // world<-view rotation (columns = view basis in world)
466
+ uniform vec2 projDiag; // (proj[0][0], proj[1][1]) for NDC->view-ray
467
+ uniform vec3 skyCamWorld; // camera world pos (meters)
468
+ uniform vec3 skySunDir; // world sun dir (normalized)
469
+ uniform float skyR; // sphere radius (meters)
470
+ void main(){
471
+ // Reconstruct the world-space view ray from NDC, like the WebGPU skyFs: undo the
472
+ // projection (divide by the proj diagonal) to get a view-space dir, then rotate
473
+ // into world with the camera basis. Robust (no near-far matrix inverse).
474
+ vec3 dirView = normalize(vec3(vNdc.x/projDiag.x, vNdc.y/projDiag.y, -1.0));
475
+ vec3 viewRay = normalize(camRot * dirView);
476
+ vec3 camAtm = atmPos(skyCamWorld, skyR);
477
+ vec3 t;
478
+ vec3 radiance = atm_skyRadiance(camAtm, viewRay, skySunDir, t);
479
+
480
+ // ---- Explicit limb/halo glow (guarantees a visible atmosphere ring from orbit).
481
+ // The physical single-scatter limb is sub-pixel thin at orbital range, so we add
482
+ // an analytic glow keyed on the ray's IMPACT PARAMETER b = perpendicular distance
483
+ // of the view ray from the planet centre. b in [BOTTOM, ~BOTTOM+halo] -> bright
484
+ // blue rim that fades outward; lit only on the sun-facing side, scaled by a soft
485
+ // forward-scatter term. This is a deliberate visual augmentation of the analytic
486
+ // single-scatter model (documented simplification).
487
+ {
488
+ float rc = length(camAtm);
489
+ float muc = dot(camAtm, viewRay) / rc;
490
+ float b = rc * sqrt(max(1.0 - muc*muc, 0.0)); // impact parameter (km)
491
+ // Only for rays passing in FRONT of the planet (muc<0) and outside the surface.
492
+ float halo = 0.0;
493
+ if (muc < 0.0) {
494
+ float t0 = (b - ATM_BOTTOM) / (ATM_TOP - ATM_BOTTOM); // 0 at surface -> 1 at top
495
+ // Inner rim brightest, fading to the shell top; zero below surface / above top.
496
+ halo = smoothstep(0.0, 0.06, t0) * (1.0 - smoothstep(0.25, 1.6, t0));
497
+ }
498
+ // Daylight side weighting from the sun's relation to the limb point direction.
499
+ vec3 limbDir = normalize(camAtm + viewRay * (-rc*muc)); // closest-approach dir
500
+ // Day-side rim brightest; keep a small floor so the whole ring stays visible.
501
+ float lit = 0.25 + 0.75 * smoothstep(-0.5, 0.6, dot(limbDir, skySunDir));
502
+ vec3 haloColor = vec3(0.32, 0.55, 1.0); // Rayleigh-blue rim
503
+ radiance += haloColor * (halo * lit) * 0.03;
504
+ }
505
+ // Sun disc through the view transmittance.
506
+ float cosVS = dot(viewRay, skySunDir);
507
+ if (cosVS > cos(ATM_SUN_ANGULAR_RADIUS)) {
508
+ radiance += t * ATM_SOLAR_IRRADIANCE * 6.0; // sun disc
509
+ }
510
+ // The analytic single-scatter radiance is HDR with small magnitudes; lift then
511
+ // ACES tonemap (matches the WebGPU sky pass family). EXPOSURE tuned so the limb
512
+ // glow + daylit sky read as an atmosphere without blowing out.
513
+ vec3 c = radiance * 120.0;
514
+ vec3 mapped = clamp((c*(2.51*c+0.03))/(c*(2.43*c+0.59)+0.14), 0.0, 1.0); // ACES
515
+ fragColor = vec4(pow(mapped, vec3(1.0/2.2)), 1.0);
516
+ }`;
517
+ function rawShader(type, source){ const s=gl.createShader(type); gl.shaderSource(s, source); gl.compileShader(s);
518
+ if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)) throw new Error('sky '+type+': '+gl.getShaderInfoLog(s)); return s; }
519
+ const skyProg = gl.createProgram();
520
+ gl.attachShader(skyProg, rawShader(gl.VERTEX_SHADER, skyVsSrc));
521
+ gl.attachShader(skyProg, rawShader(gl.FRAGMENT_SHADER, skyFsSrc));
522
+ gl.linkProgram(skyProg);
523
+ if(!gl.getProgramParameter(skyProg, gl.LINK_STATUS)) throw new Error('sky link: '+gl.getProgramInfoLog(skyProg));
524
+ const _usloc = new Map();
525
+ const SU = n => { let l = _usloc.get(n); if (l === undefined) { l = gl.getUniformLocation(skyProg, n); _usloc.set(n, l); } return l; };
526
+ const skyVao = gl.createVertexArray();
527
+
528
+ // ---- mesh grid: OVERLAP-RING tessellation (replaces the old dropped-skirt curtain).
529
+ // The mesh spans (GRID+2) cells in each axis: the INTERIOR GRID cells cover the tile's
530
+ // usable region in param coord [0,1] exactly as before, plus ONE EXTRA RING of cells on
531
+ // every side reaching param coord [-1/GRID, 1+1/GRID]. The extra ring extends the surface
532
+ // one cell INTO the neighbor tile's territory (a real, continuous part of the elevation
533
+ // field -- the atlas carries BORDER=2 texels of valid margin, so uv just outside [0,1]
534
+ // samples genuine neighbor-edge texels, NOT garbage). At a coarse/fine LOD T-junction the
535
+ // coarse tile's overlap ring covers the crack the skirt used to hide; at a same-LOD seam
536
+ // both neighbors overlap into each other and overdraw a COPLANAR surface (both compute
537
+ // near-identical world height from the continuous field, so no z-fight). The neighbor's
538
+ // own interior overdraws the overlap, so the visible surface still ends at the true tile
539
+ // boundary -- the outer ring is the "hidden last ring". vertex.z is always 0 (no skirt).
540
+ const g2 = GRID+2; // cells per axis (GRID interior + 1 ring each side)
541
+ const n2 = g2+1; // verts per axis
542
+ const du = 1.0/GRID; // param step = one interior cell
543
+ // SKIRT not OVERLAP (fix-visible-overlap-ring): the outer ring used to extend one cell INTO the
544
+ // neighbor [-du, 1+du] and rasterize a FLAT flap there -> a visible band at every patch edge (user:
545
+ // 'ring polys visible'). Instead, CLAMP each outer-ring vertex's xy to the true interior edge [0,1]
546
+ // and flag it (z=1) as a SKIRT: the VS drops it radially below the surface, forming a near-vertical
547
+ // curtain at the tile boundary. The skirt fills any T-junction crack (so no seam, unlike deleting
548
+ // the ring) but is hidden behind the surface (so no visible flat band, unlike the overlap).
549
+ const vlist = []; // x, y (param coord clamped to [0,1]), z = skirt flag (0 surface, 1 skirt)
550
+ for (let y=0;y<n2;y++) for (let x=0;x<n2;x++){
551
+ const isRing = (x===0 || x===n2-1 || y===0 || y===n2-1);
552
+ const px = Math.min(Math.max((x-1)*du, 0.0), 1.0); // clamp ring xy onto the true edge
553
+ const py = Math.min(Math.max((y-1)*du, 0.0), 1.0);
554
+ vlist.push(px, py, isRing ? 1.0 : 0.0);
555
+ }
556
+ const idx = []; for (let y=0;y<g2;y++) for (let x=0;x<g2;x++){ const a=y*n2+x,b=a+1,c=a+n2,d=c+1; idx.push(a,c,b, b,c,d); }
557
+ const verts = new Float32Array(vlist);
558
+ const indices = new Uint32Array(idx);
559
+ const vbo=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,vbo); gl.bufferData(gl.ARRAY_BUFFER,verts,gl.STATIC_DRAW);
560
+ const ibo=gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ibo); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,indices,gl.STATIC_DRAW);
561
+ const instBuf=gl.createBuffer(); // per-instance [ox,oy,l,level,face] (filled per frame in render())
562
+
563
+ // per-face local->world (cube face -> sphere local frame). Column-major mat3 packed
564
+ // into a Float32Array(9). Matches prolandProducers.buildLocalToWorld convention:
565
+ // col0 = U/rs, col1 = faceCenter, col2 = V/rs. rootQuadSize=2 -> face spans [-1,1].
566
+ function localToWorld3(face) {
567
+ // face axes (cube): for face 3 (+Z) U=+X, V=+Y, center=+Z. Generic table:
568
+ const F = [
569
+ {c:[ 1,0,0], u:[0,0,-1], v:[0,1,0]}, // +X
570
+ {c:[-1,0,0], u:[0,0, 1], v:[0,1,0]}, // -X
571
+ {c:[0, 1,0], u:[1,0,0], v:[0,0,-1]}, // +Y
572
+ {c:[0,-1,0], u:[1,0,0], v:[0,0, 1]}, // -Y
573
+ {c:[0,0, 1], u:[1,0,0], v:[0,1,0]}, // +Z
574
+ {c:[0,0,-1], u:[-1,0,0],v:[0,1,0]}, // -Z
575
+ ][face];
576
+ // local plane coords (ox,oy) in [-1,1]; the VS builds P=(ox',oy',R) then normalizes
577
+ // *defLocalToWorld* P. So localToWorld maps the local (x,y,z=R) basis to the face.
578
+ // col0<-U, col1<-V, col2<-center (z axis = outward). Column-major 3x3.
579
+ return new Float32Array([ F.u[0],F.u[1],F.u[2], F.v[0],F.v[1],F.v[2], F.c[0],F.c[1],F.c[2] ]);
580
+ }
581
+
582
+ // Compute & set the per-quad deformation uniforms (SphericalDeformation::setScreenUniforms).
583
+ // quad = {level, tx, ty, ox, oy, l}; localCam = camera in this face's local plane coords.
584
+ // (setQuadUniforms DELETED 2026-06-11 dead-code sweep: the single instanced draw replaced the
585
+ // per-quad uniform path -- defOffset/defLocalToWorld are VS locals from iOffset/iFace now, and
586
+ // the defViewProjRel/defOffset/defLocalToWorld uniforms no longer exist in the shader.)
587
+
588
+ // textureTile coords for a tile resident at `layer` of the elev/normal atlas.
589
+ // vertex.xy in [0,1] must sweep the tile INTERIOR (skip the BORDER): base = border/W,
590
+ // span = (USABLE)/W. pixelScale carried in .z (unused by the simple textureTile).
591
+ // ANCESTOR-FALLBACK: an optional `sub` = {ox,oy,scale} restricts sampling to a
592
+ // sub-rectangle of the (ancestor) tile's USABLE interior. The mesh uv [0,1] then sweeps
593
+ // only [ox,ox+scale] x [oy,oy+scale] of the usable region: base shifts by ox/oy of the
594
+ // usable span, span shrinks by `scale`. With sub=null this is the full-tile interior.
595
+ function setTileCoords(prefix, pool, layer, sub) {
596
+ const fullSpan = (USABLE-1)/TILE_W;
597
+ // EDGE-INSET fix lever (window.__elevEdgeInset, default 0.0): the texel-center offset of the
598
+ // mesh-edge sample inside the BORDER. At 0.5 the edge vertex sampled texel (BORDER+0.5) so the
599
+ // LINEAR filter blended the last INTERIOR texel with the adjacent SEAM BORDER texel -- a faint
600
+ // per-tile-edge height kink that projected into a streak at grazing angle (the user's 'subtle
601
+ // mip-bleed at medium distance', visible only when a tile edge aligned near-parallel to the
602
+ // view ray). At 0.0 the edge vertex lands on the integer interior edge texel (= BORDER), the
603
+ // shared-edge value both neighbours agree on, so there is NO border blend -> the static streak
604
+ // floor is removed. VALIDATED (browser-401/402, this session): at the oblique heading where the
605
+ // streak appeared, subtleFrac 0.0113->0.0027 + bestVertRun 6->2 (3-4x drop); harmless (0->0) at
606
+ // clean oblique + closeup nadir (411/412/413/414), so it does not regress the ff5c8ba dim-line
607
+ // fix. Default is now 0.0; the global stays as a live A/B lever.
608
+ const inset = (typeof window.__elevEdgeInset === 'number') ? window.__elevEdgeInset : 0.5;
609
+ const base0 = (BORDER + inset) / TILE_W;
610
+ if (sub) {
611
+ gl.uniform3f(U(prefix+'.tileCoords'), base0 + sub.ox*fullSpan, base0 + sub.oy*fullSpan, layer);
612
+ gl.uniform3f(U(prefix+'.tileSize'), fullSpan*sub.scale, fullSpan*sub.scale, 1.0/TILE_W);
613
+ } else {
614
+ gl.uniform3f(U(prefix+'.tileCoords'), base0, base0, layer);
615
+ gl.uniform3f(U(prefix+'.tileSize'), fullSpan, fullSpan, 1.0/TILE_W);
616
+ }
617
+ }
618
+
619
+ // SINGLE SOURCE OF TRUTH for the camera-relative clip matrix + near/far. Both render()
620
+ // and the orchestrator's frustum cull use this so the cull can never disagree with the
621
+ // draw (a divergence would cull on-screen quads or keep off-screen ones).
622
+ function cullMatrix(cam) {
623
+ const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
624
+ const camDist = Math.hypot(cam.eye[0], cam.eye[1], cam.eye[2]);
625
+ const alt = Math.max(0.0, camDist - R);
626
+ const horizon = Math.sqrt(Math.max(0.0, camDist*camDist - R*R));
627
+ const near = Math.max(1.0, alt * 0.1);
628
+ const far = Math.max(horizon * 1.5, alt * 8.0) + R * 0.25;
629
+ const proj = M4.perspective(cam.fovy||0.785, aspect, near, far);
630
+ const eye = cam.eye;
631
+ const viewRel = M4.lookAt([0,0,0], [cam.center[0]-eye[0], cam.center[1]-eye[1], cam.center[2]-eye[2]], cam.up||[0,1,0]);
632
+ const viewProjRel = M4.mul(M4.mul(proj, viewRel), M4.translate([-eye[0],-eye[1],-eye[2]]));
633
+ // viewProjNoEye = proj*viewRel WITHOUT the translate(-eye). The frustum cull must feed it
634
+ // corners ALREADY made camera-relative (corner-eye, subtracted in JS double precision) --
635
+ // folding translate(-eye) into the matrix and feeding ABSOLUTE ~6.37e6 m corners suffers
636
+ // fp32 cancellation at ground level (eye~=world), garbaging the projection and blanking the
637
+ // footprint. Subtracting in JS doubles first keeps the cull's projection precise near ground.
638
+ const viewProjNoEye = M4.mul(proj, viewRel);
639
+ return { viewProjRel, viewProjNoEye, eye, near, far, proj };
640
+ }
641
+
642
+ // Render a set of quads. quads: [{quad, face, elevLayer, normalLayer}], cam: {eye, center, up, fovy}
643
+ function render(quads, cam, sunDir, time) {
644
+ const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
645
+ // ADAPTIVE near/far (altitude-tied). A fixed near=1 / far=R*8 (~5e7) at a 50km eye
646
+ // pushed ALL near-surface geometry to NDC z~=1 (the far-plane limit), collapsing depth
647
+ // precision so most near quads z-fought / clamped off -> only one screen rectangle
648
+ // survived. Tie the planes to altitude: near ~= alt*0.1 (a few km up high, meters near
649
+ // the ground), far ~= the visible horizon distance (so the whole near surface fits the
650
+ // [near,far] range with usable z precision) plus a margin to keep the far hemisphere /
651
+ // limb. From space (alt >> R) this widens back out to ~R*8, preserving the full-globe
652
+ // view. Clamped so near>=1 and far>near.
653
+ const camDist = Math.hypot(cam.eye[0], cam.eye[1], cam.eye[2]);
654
+ const alt = Math.max(0.0, camDist - R);
655
+ const horizon = Math.sqrt(Math.max(0.0, camDist*camDist - R*R));
656
+ const near = Math.max(1.0, alt * 0.1);
657
+ const far = Math.max(horizon * 1.5, alt * 8.0) + R * 0.25;
658
+ const proj = M4.perspective(cam.fovy||0.785, aspect, near, far);
659
+ const view = M4.lookAt(cam.eye, cam.center, cam.up||[0,1,0]);
660
+ const viewProj = M4.mul(proj, view);
661
+ // CAMERA-RELATIVE projection path (fp32 precision fix). At close range the world
662
+ // coords (~6.36e6 m) and the eye (~9.5e6 m) are huge & nearly equal; view*world
663
+ // suffers catastrophic fp32 cancellation, throwing gl_Position off-screen and
664
+ // blanking the terrain. Build the projection so geometry is expressed RELATIVE to
665
+ // the eye: place the eye at the origin (lookAt center-eye) and pre-translate world
666
+ // corners by -eye. Then view*world differences are computed in fp32 BEFORE the big
667
+ // magnitudes appear, so the small near-camera coords keep their precision. The
668
+ // atmosphere/lighting path (camWorld, vWorld) stays ABSOLUTE -- only gl_Position is
669
+ // relative.
670
+ const eye = cam.eye;
671
+ const _cm = cullMatrix(cam);
672
+ const viewProjRel = _cm.viewProjRel; // same matrix the frustum cull uses
673
+ const viewProjNoEye = _cm.viewProjNoEye; // proj*viewRel WITHOUT folded translate(-eye) -- for the
674
+ // camera-relative VS path (vertex-jitter fix): the VS forms a SMALL camera-relative position so the
675
+ // big ~6.4e6 radial magnitude never enters fp32 -> no ~0.5m quantization step = no vertex jitter.
676
+ const _camDist = Math.hypot(eye[0], eye[1], eye[2]) || 1;
677
+ const camDir = [eye[0]/_camDist, eye[1]/_camDist, eye[2]/_camDist];
678
+ const camAlt = _camDist - R;
679
+ // Expose the ACTUAL draw matrix + a finite-check for the motion debug probes. A NaN
680
+ // viewProj (degenerate lookAt: fwd parallel up) is the classic disappear-on-move
681
+ // signature -- all gl_Position go NaN and nothing draws. The probe reads __lastVP /
682
+ // __lastVPFinite instead of guessing from a black screenshot.
683
+ if (typeof window !== 'undefined') {
684
+ window.__lastVP = viewProjRel;
685
+ window.__lastVPFinite = viewProjRel.every(v => Number.isFinite(v));
686
+ window.__deviceLost = gl.isContextLost();
687
+ }
688
+
689
+ gl.viewport(0,0,gl.drawingBufferWidth,gl.drawingBufferHeight);
690
+ gl.clearColor(0.0,0.0,0.0,1); gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
691
+
692
+ // SKY/ATMOSPHERE PASS REMOVED (user: 'get rid of all non-terrain material for now like haze').
693
+ // The background stays the plain clear color; only the terrain (lit by the sun) is drawn.
694
+
695
+ gl.enable(gl.DEPTH_TEST);
696
+ // Back-face culling: the globe's FAR (back) hemisphere quads were drawing over the
697
+ // near hemisphere (black faces that scramble on pan, worse at grazing angles). Cull
698
+ // back-facing triangles so only the near hemisphere renders. windingCull selects
699
+ // which winding is "front" (set from the witness; the deformed mesh + face frames
700
+ // can flip it). Sky pass above is left unculled (fullscreen triangle).
701
+ // Back-face cull mode. DEFAULT 'none': the GL winding-based cull is UNRELIABLE for the
702
+ // spherically-deformed mesh -- the near-hemisphere patch winds GL-front at orbit but the
703
+ // winding INVERTS at very low altitude, so a fixed cullFace(FRONT) renders the terrain
704
+ // directly under the camera BLACK on descent / forward-flight (witnessed: cullFront cov
705
+ // 1.0 at orbit but 0.17 at 2.5km; cullBack the opposite; cull NONE 1.0 at BOTH). The
706
+ // depth buffer (enabled above) already resolves the far hemisphere correctly -- the near
707
+ // surface always has smaller depth -- so no winding cull is needed. Override via
708
+ // window.__cullMode = 'front'|'back' for diagnostics.
709
+ const cm = window.__cullMode || 'none';
710
+ if (cm === 'none') { gl.disable(gl.CULL_FACE); }
711
+ else { gl.enable(gl.CULL_FACE);
712
+ gl.cullFace((cm === 'back') ? gl.BACK : gl.FRONT);
713
+ gl.frontFace(gl.CCW); }
714
+ // ACTIVE PROGRAM select: a diagnostic displayMode needs the lazily-built debug program (which
715
+ // carries the _DEBUGVIEW_ blocks). Build it on first request; until it finishes linking, fall
716
+ // back to the render program (the lit view) for that frame -- no black flash, just one frame of
717
+ // lit before the debug view appears. Modes 0/2/4 always use the render program.
718
+ const _dm = cam.displayMode||0;
719
+ if (DEBUG_MODES.has(_dm)) {
720
+ ensureDebug();
721
+ if (debugProg) setActiveProgram(debugProg, _dbgUloc); else setActiveProgram(prog, _uloc);
722
+ } else { setActiveProgram(prog, _uloc); }
723
+ gl.useProgram(_activeProg);
724
+ gl.uniform3f(U('camWorld'), cam.eye[0], cam.eye[1], cam.eye[2]);
725
+ gl.uniform1f(U('terrainR'), R);
726
+ // camera-relative VS projection uniforms (vertex-jitter fix): the VS builds vRel = (dir0-camDir)*R
727
+ // + dir0*h - camDir*camAlt (no 6.4e6 intermediate) and projects with defViewProjNoEye.
728
+ gl.uniformMatrix4fv(U('defViewProjNoEye'), false, viewProjNoEye);
729
+ gl.uniform3f(U('defCamDir'), camDir[0], camDir[1], camDir[2]);
730
+ gl.uniform1f(U('defCamAlt'), camAlt);
731
+ gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,3,gl.FLOAT,false,0,0);
732
+ gl.vertexAttribDivisor(0, 0); // per-vertex
733
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
734
+ // No elevation/normal/ortho atlas: terrain shape+normal+material come from the GPU fractal +
735
+ // biome ramp (the atlas producer is removed). Only the HPF continental field is sampled.
736
+ // HPF continental field (TEXTURE3): sampled in the VS by world dir for the continental
737
+ // elevation bias. hasHpf=0 -> VS uses 0 bias (graceful fallback before setHpf()).
738
+ const hasHpf = !!_hpfTex;
739
+ if (hasHpf) { gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D_ARRAY, _hpfTex); gl.uniform1i(U('hpfPool'), 3); }
740
+ if (hasHpf && _hpfTex2) { gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D_ARRAY, _hpfTex2); gl.uniform1i(U('hpfPool2'), 5); }
741
+ gl.uniform1i(U('hasHpf'), hasHpf ? 1 : 0);
742
+ // aerial-perspective haze strength -- live A/B lever (1=on default, 0=off) to isolate/kill the
743
+ // 'haze that melts into the land' on descent. The glancing-only graze weight in the shader
744
+ // confines haze to the limb; this lever lets the witness compare with/without.
745
+ // (aerialAmt setter DELETED 2026-06-11 dead-code sweep: the uniform left the shader earlier.)
746
+ // FS-derivative normal lever. Default OFF: pure cross(dFdx,dFdy) of vWorld removes the tile
747
+ // seam but exposes the coarse GRID mesh as facets -> moire (measured worse than the seam).
748
+ // Kept as a lever; the seamless fix is the HYBRID in the FS (atlas detail + continuous base).
749
+ // W5: fsNormal + pvNormal uniforms removed -- the shader no longer has them (THC Sobel is the sole
750
+ // lit normal). The old per-vertex/dFdx normal levers are deleted.
751
+ // per-vertex micro-displacement (coplanar-quads fix): unique sub-mesh-cell height per vertex
752
+ // from world-continuous face-local fBm, so the surface isn't capped at the 21-texel atlas. default 1.
753
+ // vtxDetail DEFAULT 0: the LOD-RELATIVE per-vertex micro-displacement popped between 1500/1200km
754
+ // (amplitude scaled with tile size + faded in by altitude). The continuous broadShape (12 octaves,
755
+ // absolute world wavelengths) now carries fine relief LOD-invariantly. Live re-enable via __vtxDetail.
756
+ gl.uniform1f(U('vtxDetail'), (typeof window!=='undefined' && window.__vtxDetail!=null) ? +window.__vtxDetail : 1.0);
757
+ // CLIFF / CANYON levers (live-tunable): canyon depth multiplier, cliff terrace strength (VS shape)
758
+ // + strata band thickness and cliff-strata material strength (FS texturing). Defaults = the tuned
759
+ // literals so the look is unchanged until the user dials a window global.
760
+ const _g = (n,d)=> (typeof window!=='undefined' && window['__'+n]!=null) ? +window['__'+n] : d;
761
+ gl.uniform1f(U('canyonDepthMul'), _g('canyonDepth', 1.0));
762
+ gl.uniform1f(U('uHiFreqCut'), _g('hiFreqCut', 0.25)); // 0.5->0.25 (2026-06-10 'blotchy' -- see setComposeHeightUniforms)
763
+ gl.uniform1f(U('uVertexAO'), _g('vertexAO', 1.0)); // per-vertex shading/AO strength (DEFECT 2, 2026-06-06)
764
+ gl.uniform1f(U('cliffAmt'), _g('cliffAmt', 1.0));
765
+ gl.uniform1f(U('uAoAmt'), _g('aoAmt', 1.0));
766
+ gl.uniform1f(U('uHpfInset'), (typeof window!=='undefined' && window.__hpfInset === false) ? 0.0 : 1.0); // SEAM FIX: inset sampler permanent default (matches bakeFace fu=x/(RES-1)); window.__hpfInset===false rolls back. PROBE+RENDER flip together.
767
+ // uFloatLinearOK lets hpfSample collapse the manual 4-tap bilinear to a single hardware texture().
768
+ gl.uniform1i(U('uFloatLinearOK'), _halfFloatLinearOK ? 1 : 0);
769
+ gl.uniform1f(U('uWireframe'), (typeof window!=='undefined' && window.__wireframe) ? 1.0 : 0.0);
770
+ gl.uniform1f(U('uFsCheap'), (typeof window!=='undefined' && window.__fsCheap) ? 1.0 : 0.0); // GPU-timer VS-isolation frame (window.__gpuTimer)
771
+ gl.uniform1f(U('uBiomeBandBias'), _g('biomeBandBias', 0.5)); // 1.0->0.5 (2026-06-10 'entire terrain white': elevCool h/4500 maxed the alpine temp-drop by 4.5km on the 11.6km terrain -> ice biome over all highland; halving via the uniform = effective h/9000, no shader-cache bust)
772
+ // REAL-WORLD LOOK overhaul (live-tunable via window globals / DEFAULTS.look). Beer-Lambert ocean
773
+ // extinction, biome saturation pull, intra-biome mottle, sky-fill relief, terminator sunset glow,
774
+ // night floor + earthshine, exposure + post-ACES Look (sat/contrast). Defaults = the tuned look.
775
+ gl.uniform1f(U('uBiomeSat'), _g('biomeSat', 0.72));
776
+ gl.uniform1f(U('uVariationAmt'), _g('variationAmt', 0.04)); // 0.08->0.04 (2026-06-10 'blotchy': mottle patches across the 4x massifs)
777
+ gl.uniform1f(U('uDetailOverlay'), _g('detailOverlay', 6.0)); // perlin-everywhere albedo+elevation fbm (2026-06-10; user-tuned 6)
778
+ gl.uniform1f(U('uHazeMul'), _g('hazeMul', 0.65)); // aerial-perspective strength (2026-06-10 'pale hazy': 1.0 milked the midground)
779
+ // uDiffWrap lives in ATMOSPHERE.glsl (atm_sunSkyIrradiance), not terrain.glsl -- the 2026-06-11
780
+ // dead-code scan only covered terrain.glsl and wrongly deleted this setter (wrap silently -> 0,
781
+ // restoring the very view-angle darkening b990add fixed). Scan BOTH shader files before
782
+ // declaring a uniform dead.
783
+ gl.uniform1f(U('uDiffWrap'), _g('diffWrap', 0.5)); // diffuse wrap: 0.7 flattened ALL slope shading; 0.5 = grazing lift without killing the N.L relief keytion'); 0.5 = grazing lift without killing the N.L relief key
784
+ // SURFACE PHOTO-TEXTURES (TEXTURE6/7): triplanar grass/rock/sand/snow splat. hasSurfTex stays 0
785
+ // until the async loader uploads (procedural-only fallback, no flash -- the splat fades in).
786
+ const hasSurf = !!_surfAlb;
787
+ if (hasSurf) {
788
+ gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D_ARRAY, _surfAlb); gl.uniform1i(U('uSurfAlb'), 6);
789
+ gl.activeTexture(gl.TEXTURE7); gl.bindTexture(gl.TEXTURE_2D_ARRAY, _surfNrm); gl.uniform1i(U('uSurfNrm'), 7);
790
+ }
791
+ gl.uniform1f(U('uHasSurfTex'), hasSurf ? 1.0 : 0.0);
792
+ gl.uniform1f(U('uTexTileM'), _g('texTile', 2400.0)); // metres per repeat (user: 24m read as noise/rock -- 100x bigger)
793
+ gl.uniform1f(U('uTexNrmK'), _g('texNrmK', 3.0)); // 0.8 -> 3.0 (user 2026-06-11 'displacement normals must be THE texture normals': A/B-measured -- 0.8 was visually NIL (0% of px changed >10 on toggle), 2.0 gave 1.3%, 5.0 gave 22%; the normalize(nLit+texDn) compresses the response so visibility is non-linear in K. 3.0 makes the displacement-derived relief clearly read; the loader's per-texel 0.9 tilt cap keeps the historical saturation/scramble class structurally impossible at any apply strength)
794
+ gl.uniform1f(U('uTexMix'), _g('texMix', 0.85)); // splat blend amount (0 = off)
795
+ gl.uniform1f(U('uTexWarp'), _g('texWarp', 0.125)); // anti-repetition warp amplitude (user 2026-06-11: halved a third time from 1.0)
796
+ gl.uniform1f(U('uTexPhoto'), _g('texPhoto', 0.0)); // raw photo-color fraction (0 = patch matches the macro shade exactly)
797
+ gl.uniform1f(U('uTexPhotoNear'), _g('texPhotoNear', 0.45)); // near-field material identity (photo hue at macro luminance; user 2026-06-12 'must be either grass or sand')
798
+ gl.uniform4f(U('uSurfMeanL'), _surfMeanL[0], _surfMeanL[1], _surfMeanL[2], _surfMeanL[3]); // per-layer mean linear luminance (shade-match divisor)
799
+ // LIVE A/B ISOLATION TOGGLES (window.__rockBump / __chroma / __strata, default 1 = no change). Flip one
800
+ // to 0 in the console to disable that detail layer and see which produces the close-up uv scramble.
801
+ gl.uniform1f(U('uFlatNormal'), _g('flatNormal', 0.0)); // 1 = smooth analytic normal (isolate the geometric-normal scramble)
802
+ gl.uniform1f(U('uReliefShade'), _g('reliefShade', 1.5)); // 2.4 -> 1.5 (user 'normals unusually darker': 2.4x tilt pushed ordinary 0.3-0.5 mountain slopes toward fully-shaded normals = broad darkening, not legible relief)
803
+ gl.uniform1f(U('uSkyFill'), _g('skyFill', 0.45));
804
+ gl.uniform1f(U('uTerminatorGlow'), _g('terminatorGlow', 0.30));
805
+ gl.uniform1f(U('uNightLights'), _g('nightLights', 1.0)); // night/shadow FILL intensity (dim ambient lift so dark areas are not black); 0 = off
806
+ gl.uniform1f(U('uNightFloor'), _g('nightFloor', 0.16)); // night-longitude terminator floor RAISED 0.05->0.16 (no black night terrain)
807
+ gl.uniform1f(U('uTermWidth'), _g('termWidth', 0.25));
808
+ gl.uniform1f(U('uExposure'), _g('exposure', 1.0));
809
+ gl.uniform1f(U('uLookSat'), _g('lookSat', 1.15));
810
+ gl.uniform1f(U('uLookContrast'), _g('lookContrast', 1.08));
811
+ { const o3=(n,d)=>{ const w=(typeof window!=='undefined'&&window['__'+n])||null; const v=(Array.isArray(w)&&w.length===3)?w:d; gl.uniform3f(U(n), v[0],v[1],v[2]); };
812
+ o3('uOceanDeep',[0.008,0.025,0.06]); o3('uOceanShallow',[0.07,0.22,0.26]); o3('uOceanK',[0.030,0.012,0.0045]); }
813
+ // (the continuous broad-shape field is now always on - the single terrain shape source -
814
+ // so its old on/off lever uniform was removed from terrain.glsl; nothing to set here.)
815
+ // LIVE biome ramp (window.__gen.state.biome, else tuned defaults) -- full-adjustability.
816
+ { const bm = (typeof window!=='undefined' && window.__gen && window.__gen.state && window.__gen.state.biome) || null;
817
+ const C = (k,d)=> (bm && bm[k]) ? bm[k] : d;
818
+ const c3 = (n,d)=>{ const v=C(n,d); gl.uniform3f(U(n), v[0],v[1],v[2]); };
819
+ c3('bcDeepSea',[0.04,0.10,0.28]); c3('bcSea',[0.10,0.22,0.42]); c3('bcShore',[0.52,0.46,0.33]);
820
+ c3('bcLowland',[0.24,0.42,0.18]); c3('bcGrass',[0.30,0.46,0.20]);
821
+ // bcRock follows the ROCK PHOTO mean once loaded (user 2026-06-10 'replace the original rock
822
+ // completely'): the far-field macro rock shade matches the near-field photo so the 15-20km
823
+ // fade has no color pop. Falls back to the tuned grey-tan until the loader lands.
824
+ c3('bcRock', (typeof window!=='undefined' && window.__surfRockMean) || [0.55,0.50,0.45]);
825
+ c3('bcSnow',[0.92,0.94,0.97]);
826
+ const e=C('bandEdgesLo',[150.0,1200.0]); gl.uniform2f(U('bandEdgesLo'), e[0],e[1]);
827
+ const eh=C('bandEdgesHi',[3500.0,6500.0]); gl.uniform2f(U('bandEdgesHi'), eh[0],eh[1]); // [1600,3200]->[3500,6500] (2026-06-10 'rockface everywhere': tuned pre-4x; with 11.6km peaks everything above 3200m was height-rock -- rescale the treeline)
828
+ const sn=C('snowEdges',[6000.0,8500.0]); gl.uniform2f(U('snowEdges'), sn[0],sn[1]); // 8000/10500->6000/8500 (user 2026-06-11 'all the snowy mountains have disappeared': only ~1% of land tops 8km (probe 3000-dir sweep, over7k 1.3%), so the whiteout-era snowline left virtually every massif bare; the whiteout's other sources (pre-rescale rock gates, alpine ice bias, tundra grey) are fixed independently, so 6km onset re-caps the real mountains without re-whitening the terrain)
829
+ gl.uniform1f(U('seaDepthM'), C('seaDepthM',3000.0));
830
+ const sr=C('slopeRock',[0.25,0.5]); gl.uniform2f(U('slopeRock'), sr[0],sr[1]); } // [0.25,0.5] USER-SET 2026-06-11 (matches terrain-gen-controls persisted default)
831
+ gl.uniform3f(U('sunDir'), sunDir[0],sunDir[1],sunDir[2]);
832
+ gl.uniform1i(U('displayMode'), cam.displayMode||0);
833
+ // ---- animated ocean uniforms. time advances the Gerstner waves; amp/choppy read
834
+ // from the HUD ocean sliders (window.__cam) with sane defaults for v1.
835
+ const oc = (typeof window !== 'undefined' && window.__cam) || {};
836
+ gl.uniform1f(U('oceanTime'), time || 0.0);
837
+ gl.uniform1f(U('oceanAmp'), (oc.oceanAmplitude != null) ? oc.oceanAmplitude : 1.0);
838
+ gl.uniform1f(U('oceanChoppy'), (oc.oceanChoppiness != null) ? oc.oceanChoppiness : 0.5);
839
+ gl.uniform1f(U('oceanFoam'), (oc.oceanFoam != null) ? oc.oceanFoam : 0.5);
840
+ gl.uniform1f(U('uBeachTopM'), _g('beachTop', 30.0)); // beach ceiling: grass stops, sand to the waterline + under it
841
+
842
+ // SINGLE INSTANCED DRAW: the deform params that were per-quad uniforms (ox,oy,l,level + face)
843
+ // are now PER-INSTANCE attributes. Build one interleaved instance buffer [ox,oy,l,level,face]
844
+ // (5 floats/instance) from the visible leaf set and issue ONE gl.drawElementsInstanced -- no
845
+ // per-quad uniform churn, no N draw calls. defViewProjRel is one uniform shared by all instances.
846
+ gl.uniformMatrix4fv(U('defViewProjRel'), false, viewProjRel);
847
+ gl.uniform1f(U('defRadius'), R);
848
+ const n = quads.length;
849
+ // SINGLE-SOURCE HEIGHT: every leaf is the pure per-vertex composeHeight() function, evaluated every
850
+ // frame -- no bake cache. The instance buffer carries [ox,oy,l,level,face] (5 floats); the VS computes
851
+ // h = composeHeight(dir) for each vertex.
852
+ const FLOATS = 5; // [ox,oy,l,level,face]
853
+ if (n > 0) {
854
+ const inst = new Float32Array(n * FLOATS);
855
+ for (let i = 0; i < n; i++) {
856
+ const q = quads[i].quad;
857
+ inst[i*FLOATS+0] = q.ox; inst[i*FLOATS+1] = q.oy; inst[i*FLOATS+2] = q.l; inst[i*FLOATS+3] = q.level;
858
+ inst[i*FLOATS+4] = quads[i].face;
859
+ }
860
+ gl.bindBuffer(gl.ARRAY_BUFFER, instBuf);
861
+ gl.bufferData(gl.ARRAY_BUFFER, inst, gl.DYNAMIC_DRAW);
862
+ const STRIDE = FLOATS * 4;
863
+ gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, STRIDE, 0); gl.vertexAttribDivisor(1, 1); // iOffset
864
+ gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 1, gl.FLOAT, false, STRIDE, 4 * 4); gl.vertexAttribDivisor(2, 1); // iFace
865
+ gl.uniform1f(U('uIsWater'), 0.0);
866
+ gl.drawElementsInstanced(gl.TRIANGLES, indices.length, gl.UNSIGNED_INT, 0, n);
867
+ // SEPARATE WATER SURFACE (user 2026-06-11): second instanced draw with uIsWater=1 -- the VS
868
+ // pins the mesh to sea level, the FS shades animated water and alpha-blends it over the
869
+ // just-rendered seabed. Depth test keeps it behind land; depthMask off so the transparent
870
+ // surface never occludes later passes. One program, one uniform flip.
871
+ // OWN GEOMETRY, NOT THE TERRAIN TILES (user 2026-06-11 'the terrain tiles should not be
872
+ // used for water'): the water sphere needs no terrain LOD -- deep leaves are wasted vertices
873
+ // (each runs composeHeight) and re-tessellate with terrain detail the flat surface never
874
+ // shows. Cap every visible leaf at level WCAP and DEDUP to its ancestor tile: a coarse,
875
+ // LOD-churn-free cover of the same footprint, typically ~10-50x fewer water vertices.
876
+ // __waterSurface=0 disables live.
877
+ if (typeof window === 'undefined' || window.__waterSurface !== false) {
878
+ // WCAP 7 -> 9 (coast witness caught it: a level-7 tile's 16-cell mesh chord sags
879
+ // A_cell^2/(8R) ~ 0.8m below the true sphere mid-cell -- BELOW the metres-deep shelf
880
+ // seabed, so the depth test culled the water across entire shorelines. Level-9 cells
881
+ // (~1.6km) sag ~5cm, far under any visible bathymetry, still ~16-64x fewer water verts
882
+ // than the deep terrain leaves.
883
+ const WCAP = 9;
884
+ const seen = new Set(); const wl = [];
885
+ for (let i = 0; i < n; i++) {
886
+ const q = quads[i].quad; let ox = q.ox, oy = q.oy, l = q.l, lv = q.level;
887
+ if (lv > WCAP) { const A = l * Math.pow(2, lv - WCAP); ox = Math.floor(ox / A) * A; oy = Math.floor(oy / A) * A; l = A; lv = WCAP; }
888
+ const key = quads[i].face + ':' + ox + ':' + oy + ':' + l;
889
+ if (seen.has(key)) continue; seen.add(key);
890
+ wl.push(ox, oy, l, lv, quads[i].face);
891
+ }
892
+ const wn = wl.length / FLOATS;
893
+ gl.bindBuffer(gl.ARRAY_BUFFER, instBuf);
894
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(wl), gl.DYNAMIC_DRAW);
895
+ gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, STRIDE, 0); gl.vertexAttribDivisor(1, 1);
896
+ gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 1, gl.FLOAT, false, STRIDE, 4 * 4); gl.vertexAttribDivisor(2, 1);
897
+ gl.enable(gl.BLEND);
898
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
899
+ gl.depthMask(false);
900
+ gl.uniform1f(U('uIsWater'), 1.0);
901
+ gl.drawElementsInstanced(gl.TRIANGLES, indices.length, gl.UNSIGNED_INT, 0, wn);
902
+ gl.uniform1f(U('uIsWater'), 0.0);
903
+ gl.depthMask(true);
904
+ gl.disable(gl.BLEND);
905
+ if (typeof window !== 'undefined') window.__lastWaterQuads = wn;
906
+ }
907
+ }
908
+ if (typeof window !== 'undefined') window.__lastDrawCalls = (n > 0) ? 2 : 0;
909
+ return gl.getError();
910
+ }
911
+
912
+ // ---- DEBUG PROBE: replicate the VS clip-space transform on the CPU for a quad's 4
913
+ // corners (vertex.xy in {0,1}^2) so we can see which quads project off-screen.
914
+ function probe(quads, cam) {
915
+ const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
916
+ const near = (cam.near!=null)?cam.near:1.0, far=(cam.far!=null)?cam.far:R*8;
917
+ const proj = M4.perspective(cam.fovy||0.785, aspect, near, far);
918
+ const eye = cam.eye;
919
+ const viewRel = M4.lookAt([0,0,0], [cam.center[0]-eye[0], cam.center[1]-eye[1], cam.center[2]-eye[2]], cam.up||[0,1,0]);
920
+ const viewProjRel = M4.mul(M4.mul(proj, viewRel), M4.translate([-eye[0],-eye[1],-eye[2]]));
921
+ const out = [];
922
+ for (const q of quads) {
923
+ const w3 = localToWorld3(q.face);
924
+ const w4 = new Float32Array([ w3[0],w3[1],w3[2],0, w3[3],w3[4],w3[5],0, w3[6],w3[7],w3[8],0, 0,0,0,1 ]);
925
+ const localToScreen = M4.mul(viewProjRel, w4);
926
+ const {ox,oy,l} = q.quad;
927
+ const cs = [[ox,oy],[ox+l,oy],[ox,oy+l],[ox+l,oy+l]];
928
+ const v=[],L=[];
929
+ for (let i=0;i<4;i++){ const px=cs[i][0],py=cs[i][1]; const len=Math.hypot(px,py,R); L.push(len); v.push([px/len,py/len,R/len]); }
930
+ // C and N matrices (4x4) as in setQuadUniforms
931
+ const dCorners = new Float32Array([ v[0][0]*R,v[0][1]*R,v[0][2]*R,1, v[1][0]*R,v[1][1]*R,v[1][2]*R,1, v[2][0]*R,v[2][1]*R,v[2][2]*R,1, v[3][0]*R,v[3][1]*R,v[3][2]*R,1 ]);
932
+ const C = M4.mul(localToScreen, dCorners);
933
+ // For each of the 4 mesh corners, alphaPrime picks out one column => clip = column i (h=0 baseline)
934
+ const ndc = [];
935
+ for (let i=0;i<4;i++){ const x=C[i*4],y=C[i*4+1],z=C[i*4+2],w=C[i*4+3];
936
+ ndc.push({x:+(x/w).toFixed(3),y:+(y/w).toFixed(3),z:+(z/w).toFixed(3),w:+w.toFixed(1),
937
+ off: (w<=0)||Math.abs(x/w)>1||Math.abs(y/w)>1||(z/w)<-1||(z/w)>1}); }
938
+ out.push({face:q.face, level:q.quad.level, ox:+ox.toFixed(0), oy:+oy.toFixed(0), l:+l.toFixed(0), ndc});
939
+ }
940
+ return out;
941
+ }
942
+ function setHpf(tex, res, tex2) { _hpfTex = tex; _hpfRes = res|0; _hpfTex2 = tex2 || null; } // tex2 = RG8(temp,humid) pack (W12)
943
+ return { get prog(){ return prog; }, render, probe, sampleGroundM, cullMatrix, recompile, setHpf, GRID, indexCount: indices.length, M4 };
944
+ }