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,790 @@
1
+ // planet-orchestrator.js -- drives the GPU one-fractal planet render. Per frame it runs the
2
+ // 6-face cube-sphere quadtree (quadtree.js) with the camera in each face's local frame, collects
3
+ // the visible leaf quads, and hands them to gl-render to draw. Terrain shape is the per-vertex
4
+ // GPU fractal in terrain.glsl -- there is no tile producer, no atlas, no per-tile content gen.
5
+ //
6
+ // Coordinate facts:
7
+ // - quad coords are METERS. root l = 2*R, R = 6360000.
8
+ // - the quadtree returns leaf quads as (level,tx,ty,ox,oy,l) in METER coords; ox,oy,l pass
9
+ // straight to gl-render (which projects each vertex P=(ox+..,oy+..,R) to the sphere).
10
+ // - FACE_FRAME below is the SINGLE source of truth for the cube-face local frame (col0=U,
11
+ // col1=V, col2=center); worldToFaceLocal inverts it to put the world camera into face coords.
12
+
13
+ import { Quadtree } from '/src/quadtree.js';
14
+ import { initMapspinnerRender } from '/src/gl-render.js';
15
+ import { createAnchorField } from '/src/anchor-field.js';
16
+
17
+ // MUST match the render's localToWorld3 transformation exactly (col0=U, col1=V,
18
+ // col2=center). This is the face's local orthonormal frame: a face-local point
19
+ // (x,y,z) maps to world = x*U + y*V + z*center. The frame is orthonormal, so the
20
+ // inverse (world->local) is just the dot products onto U, V, center.
21
+ const FACE_FRAME = [
22
+ { c: [ 1, 0, 0], u: [0, 0, -1], v: [0, 1, 0] }, // +X
23
+ { c: [-1, 0, 0], u: [0, 0, 1], v: [0, 1, 0] }, // -X
24
+ { c: [0, 1, 0], u: [1, 0, 0], v: [0, 0, -1] }, // +Y
25
+ { c: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1] }, // -Y
26
+ { c: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] }, // +Z
27
+ { c: [0, 0, -1], u: [-1, 0, 0], v: [0, 1, 0] }, // -Z
28
+ ];
29
+
30
+ const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
31
+
32
+ // World camera position (meters, sphere centered at origin) -> that face's LOCAL
33
+ // plane coords, matching the render's P=(ox,oy,R) convention. The render builds each
34
+ // vertex as P = (localX, localY, R) then maps it through localToWorld3 = [U V center].
35
+ // So the face-local x is the projection onto U, local y onto V, and local z onto the
36
+ // center axis (this is the camera's signed distance along the outward face axis,
37
+ // i.e. its altitude component). Units stay in METERS, consistent with ox,oy,l.
38
+ // LOD-CENTER FIX (user 2026-06-02, after the moveTol cadence fix did NOT cure it): the quadtree
39
+ // ox/oy space is PRE-warp -- the VS maps each vertex through faceWarp(p)=R*tan((p/R)*pi/4)
40
+ // (terrain.glsl:490) before placing it on the sphere, and quadtree.js:132 localToDeformed mirrors
41
+ // that tan. The plain dot products onto (U,V,center) give the WARPED projection wx=R*tan(s); to get
42
+ // the quadtree's PRE-warp coord we must invert the tan: ox = (4/pi)*R*atan(wx/R). WITHOUT the atan,
43
+ // the quadtree _cam grew like tan(true_s) -- IDENTITY at the face centre (the only place LOD worked)
44
+ // and DIVERGING toward the edges, so the dense LOD trailed the camera ("the camera moves faster than
45
+ // the LOD center"). The atan only remaps in-face x,y; the z/center term is the altitude and stays raw.
46
+ // Identity at the seam (tan(pi/4)=1, atan(1)=pi/4) so cross-face edges still meet exactly.
47
+ // faceWarp forward: q = (R+z)*normalize(R*tan(s_x*pi/4), R*tan(s_y*pi/4), R), s=ox/R. The (R+z)/L
48
+ // scalar and L normalization cancel in the RATIO of face-frame components: dot(q,U)/dot(q,center) =
49
+ // (R*tan(s_x))/R = tan(s_x). So the pre-warp coord is ox = (4/pi)*R*atan(cu/cc), cu=dot(c,U),
50
+ // cc=dot(c,center) -- NOT atan(cu/R) (that ignores the altitude/normalize scalar). cc>0 for any camera
51
+ // over this face (it is the front face). z/altitude term stays the raw center projection.
52
+ const ATAN_INV_K = 4.0 / Math.PI; // 1/(pi/4): undo faceWarp's (p/R)*pi/4 scaling
53
+ function worldToFaceLocal(face, camWorld) {
54
+ const F = FACE_FRAME[face];
55
+ const R = 6360000.0; // == quadtree size / defRadius (the face warp radius)
56
+ const cu = dot(camWorld, F.u), cv = dot(camWorld, F.v), cc = dot(camWorld, F.c);
57
+ // GUARD: worldToFaceLocal runs for ALL 6 faces every frame. For a face the camera is NOT in
58
+ // front of, cc<=0 and cu/cc flips sign / blows up -> a garbage _cam that explodes the quadtree
59
+ // recursion (witnessed: 75s frames). The atan inverse is only meaningful on the FRONT hemisphere
60
+ // (cc>0). Off the front face, clamp the ratio to the seam (|s|=pi/4 -> ox=+-R, one face away) so
61
+ // the quad sits at the face edge and the quadtree coarsens it naturally -- the bounded behaviour
62
+ // the OLD plain-dot code had. ccSafe floors the denominator so a near-limb camera can't blow up.
63
+ const SEAM = R; // ox at the face edge (s=+-1)
64
+ let ox, oy;
65
+ if (cc > 1.0) { // camera in front of this face (cc up to ~R+alt)
66
+ ox = ATAN_INV_K * R * Math.atan(cu / cc);
67
+ oy = ATAN_INV_K * R * Math.atan(cv / cc);
68
+ } else { // back/side face: push to the edge, sign-preserving
69
+ ox = (cu >= 0 ? SEAM : -SEAM);
70
+ oy = (cv >= 0 ? SEAM : -SEAM);
71
+ }
72
+ return [ox, oy, cc];
73
+ }
74
+
75
+ // Conservative view-frustum cull: returns true ONLY if every sample point of the quad's
76
+ // deformed shell (4 corners x [R-MAX_ELEV, R+MAX_ELEV]) is beyond the SAME clip plane
77
+ // (all left / all right / all up / all down / all far). Never tests near/z<-w, so a quad
78
+ // straddling the near/limb is kept. This can't remove a quad touching the screen (such a
79
+ // quad has >=1 corner inside every plane's half-space). vpr = the SAME viewProjRel the
80
+ // render uses (render.cullMatrix), fed ABSOLUTE world coords (vpr folds translate(-eye)).
81
+ const CULL_MAX_ELEV = 12000.0; // meters: +/- elevation margin so peaks can't poke in
82
+ const CULL_NDC_MARGIN = 0.06; // NDC slack so an edge-touching quad is kept (false-keep is cheap)
83
+ // Robust screen-space-AABB frustum cull. The old 4-CORNER "all corners past one plane" test could not
84
+ // bound a spherically-bulged + tangent-warped quad's true screen extent at oblique views -- the bulge
85
+ // is maximal at the EDGE MIDPOINTS, which 4 corners miss entirely, so on-screen quads got false-culled
86
+ // (the user hit a missing-quad hole at nearly every oblique angle; margin-tuning never converged). This
87
+ // version samples a 3x3 grid (corners + edge mids + centre) at BOTH elevation shells, projects each the
88
+ // SAME way the VS does (tangent-warp px->R*tan(px/R*pi/4) + camera-relative eye-subtract in fp64), and
89
+ // builds the quad's NDC bounding box from the IN-FRONT samples. A quad is culled ONLY if that box lies
90
+ // entirely beyond one viewport edge ([-1,1] +/- a small margin) OR every sample is behind the near plane.
91
+ // AABB-vs-viewport is the correct screen-overlap test; the edge-mid samples capture the bulge the corners
92
+ // don't, so an on-screen quad's box always overlaps the viewport and is kept.
93
+ function quadOutsideFrustum(face, ox, oy, l, R, vpr, eye) {
94
+ const F = FACE_FRAME[face];
95
+ const ex = eye ? eye[0] : 0, ey = eye ? eye[1] : 0, ez = eye ? eye[2] : 0;
96
+ const WK = Math.PI / 4.0;
97
+ const hl = l * 0.5;
98
+ // 3x3 face-local sample grid: corners, edge midpoints, centre.
99
+ const sx = [ox, ox+hl, ox+l], sy = [oy, oy+hl, oy+l];
100
+ let minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity, anyFront=false, anyBehind=false, allBeyondFar=true;
101
+ for (let gx=0; gx<3; gx++) for (let gy=0; gy<3; gy++) {
102
+ const wpx = R * Math.tan((sx[gx] / R) * WK);
103
+ const wpy = R * Math.tan((sy[gy] / R) * WK);
104
+ const len = Math.hypot(wpx, wpy, R) || 1;
105
+ const dx = (wpx/len)*F.u[0]+(wpy/len)*F.v[0]+(R/len)*F.c[0];
106
+ const dy = (wpx/len)*F.u[1]+(wpy/len)*F.v[1]+(R/len)*F.c[1];
107
+ const dz = (wpx/len)*F.u[2]+(wpy/len)*F.v[2]+(R/len)*F.c[2];
108
+ for (let s=0;s<2;s++){
109
+ const rad = s===0 ? (R-CULL_MAX_ELEV) : (R+CULL_MAX_ELEV);
110
+ const X=dx*rad-ex, Y=dy*rad-ey, Z=dz*rad-ez;
111
+ const cx = vpr[0]*X+vpr[4]*Y+vpr[8]*Z+vpr[12];
112
+ const cy = vpr[1]*X+vpr[5]*Y+vpr[9]*Z+vpr[13];
113
+ const cz = vpr[2]*X+vpr[6]*Y+vpr[10]*Z+vpr[14];
114
+ const cw = vpr[3]*X+vpr[7]*Y+vpr[11]*Z+vpr[15];
115
+ if (cw <= 1e-6) { anyBehind = true; continue; } // behind near plane: can't project to a finite NDC
116
+ anyFront = true;
117
+ if (cz <= cw) allBeyondFar = false; // at least one sample in front of the far plane
118
+ const nx = cx/cw, ny = cy/cw;
119
+ if (nx < minX) minX = nx; if (nx > maxX) maxX = nx;
120
+ if (ny < minY) minY = ny; if (ny > maxY) maxY = ny;
121
+ }
122
+ }
123
+ if (!anyFront) return true; // entire quad behind the camera -> cull
124
+ // STRADDLES the near plane (some samples in front, some behind): the projected NDC AABB is only
125
+ // PARTIAL (the behind-near samples have no finite projection), so it cannot prove off-screen --
126
+ // a near-straddling quad is almost always on-screen. KEEP it (this was the over-cull at oblique
127
+ // low-alt: the far edge dipped behind the near plane, the AABB shrank to the near edge, and the
128
+ // quad got wrongly culled -> blank). Only quads FULLY in front get the AABB-vs-viewport test.
129
+ if (anyBehind) return false;
130
+ if (allBeyondFar) return true; // entire quad beyond the far plane -> cull
131
+ const M = CULL_NDC_MARGIN;
132
+ // cull only if the NDC AABB is fully off one side of the viewport.
133
+ return (maxX < -1 - M) || (minX > 1 + M) || (maxY < -1 - M) || (minY > 1 + M);
134
+ }
135
+
136
+ // Pick the cube face the camera is most directly over (largest dot of camDir with
137
+ // the face's outward center axis).
138
+ function pickFace(camWorld) {
139
+ const len = Math.hypot(camWorld[0], camWorld[1], camWorld[2]) || 1;
140
+ const dir = [camWorld[0]/len, camWorld[1]/len, camWorld[2]/len];
141
+ let best = 0, bestDot = -Infinity;
142
+ for (let f = 0; f < 6; f++) {
143
+ const d = dot(dir, FACE_FRAME[f].c);
144
+ if (d > bestDot) { bestDot = d; best = f; }
145
+ }
146
+ return best;
147
+ }
148
+
149
+ export async function initMapspinnerPlanet(gl, opts = {}) {
150
+ const R = opts.radius || 6360000.0;
151
+ // maxLevel default raised 12 -> 16: at 12 the quadtree hit its cap around ~300km altitude so
152
+ // terrain detail froze on descent. 16 lets it keep refining toward first-person (texel ~7m at
153
+ // L16 vs ~116m at L12); fps sweep (browser-1835) showed deeper levels are cheap here. Live
154
+ // override window.__maxLevel (read per-frame in the update loop below).
155
+ // maxLevel 16 = the DETAIL FLOOR: vtxDisplace now resolves to ~31m (7 octaves), and L16 cells are
156
+ // ~7m, so L16 fully samples the finest fractal octave -- subdividing past it would multiply quads
157
+ // with no new detail (user: 'below ~500m quads increase but nothing happens'). Capped here so the
158
+ // deepest LOD lands where the fractal detail ends. Live override window.__maxLevel.
159
+ // maxLevel 16 -> 14 (user 2026-06-01j: 'really slow when you get close'). vtxDisplace's finest
160
+ // octave is ~31m (wl0 2000m / 2^6); an L14 cell is ~28m, which ALREADY fully samples that floor.
161
+ // L15/L16 (cells ~14m/~7m) multiply the quad count 2-4x with NO new resolvable detail (the fractal
162
+ // has nothing below ~31m) -- pure close-approach overdraw. Cap at 14 = the real detail floor.
163
+ // maxLevel 14 -> 16 (user 2026-06-02: 'at 2m we feel bigger than the features'). vtxDisplace now
164
+ // adds octaves down to ~8m, but an L14 cell is ~28m -> those octaves undersample. L16 cells are
165
+ // ~7m, resolving the ~8m floor so the ground shows human/decametre-scale relief on close approach.
166
+ // Trade: ~2-4x more quads at the deck (the deep LOD only triggers within ~hundreds of m of ground).
167
+ // maxLevel 16 -> 13 (user 2026-06-09: 'get rid of the highest 3 stages'). Drops the three deepest
168
+ // quadtree levels (L14/L15/L16, ~28m/~14m/~7m cells) -- the close-approach overdraw tail -- so the deepest
169
+ // LOD is now L13 (~56m cell). Pairs with the LOD_STEP push (each region still resolves levels-finer-per-
170
+ // distance, just capped three steps shallower). Live override window.__maxLevel.
171
+ const maxLevel = opts.maxLevel ?? 13;
172
+ // splitFactor 2.0 over-subdivided ~5-20x: the baseline measured px/poly edge median
173
+ // 0.73 (orbit) / 1.56 (lowalt), far below the user's 4-50 px target, which also
174
+ // saturated the atlas (1920/1920) and drove tileGenMs to ~950ms. The live sweep
175
+ // (__diag.sweepSplit, browser-3) found splitFactor 1.0 puts px/poly median at 9.7
176
+ // (orbit) / 26.8 (lowalt) -- 100%/98% inside the 4-50 band -- while collapsing the quad
177
+ // count (orbit 860->20, lowalt 1272->328). 1.0 is the calibrated default; override live
178
+ // via window.__splitFactor.
179
+ const splitFactor = opts.splitFactor ?? 1.0;
180
+ const gridMeshSize = opts.gridMeshSize || 16; // 24->16 FPS lever (sub-pixel over-tessellation; see gl-render.js GRID)
181
+
182
+ gl.getExtension('EXT_color_buffer_float'); // RGBA32F atlas render targets
183
+ // OES_texture_float_linear lets the driver LINEAR-filter the RGBA32F HPF/elevation textures. When
184
+ // ABSENT, a LINEAR-flagged float texture SILENTLY falls back to NEAREST -> per-texel square steps
185
+ // ("elevations look square"). The terrain shader now does MANUAL bilinear in hpfSample so the field
186
+ // is smooth regardless, but expose whether hardware float-linear was granted so the live page can
187
+ // witness the original root (false = the single-tap path was silently NEAREST).
188
+ const _floatLinearExt = gl.getExtension('OES_texture_float_linear');
189
+ if (typeof window !== 'undefined') window.__floatLinearOK = !!_floatLinearExt;
190
+
191
+ // INIT TIMINGS (user 2026-06-02: profile the start load time). Stamp each init stage; exposed as
192
+ // window.__initTimings so the bottleneck is visible (the HPF bake = 256*256*6 sampleUV is the
193
+ // suspect). Uses performance.now() where available, Date.now() otherwise.
194
+ const _now = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
195
+ const _t = { start: _now() };
196
+ // No producer/wasm: terrain shape is the per-vertex GPU fractal (broadShapeM in terrain.glsl).
197
+ const render = await initMapspinnerRender(gl, { radius: R, gridMeshSize });
198
+ _t.shaderCompileMs = +(_now() - _t.start).toFixed(0);
199
+ // JS quadtree (replaces the deleted wasm PL.updateQuadtree/computeSplitDist/setConfig). Pure
200
+ // geometry LOD selection; terrain shape is the GPU fractal, so no wasm is needed for the mesh.
201
+ const qt = new Quadtree();
202
+
203
+ // ---- HIERARCHICAL PARAMETER FIELD (HPF) ----------------------------------------
204
+ // The anchor field drives generation as a CONTINUOUS function of world direction. To keep
205
+ // Proland upsample-coherence + seamlessness we feed it to the terrain VS as a TEXTURE (a
206
+ // continuous C0 field via LINEAR filtering) rather than a per-tile constant (a per-tile
207
+ // step would reintroduce seams -- see the CLOD mean-drift / edge-equality lessons). We bake
208
+ // the field's continental band into a per-face HPF_RES^2 RGBA32F 2D-array (seaBias, elevAmp,
209
+ // temp, humidity) once at init; the VS samples it by world dir and uses seaBias as the
210
+ // continental elevation bias (replacing the old hardcoded lobe). Live edits re-bake (cheap:
211
+ // HPF_RES^2*6 samples) so the terraform loop stays one-dispatch.
212
+ const _tHpf0 = _now();
213
+ const hpf = createAnchorField({ seed: opts.hpfSeed || 1337 });
214
+ _t.anchorFieldMs = +(_now() - _tHpf0).toFixed(0);
215
+ // ANCHOR DENSITY: 64/face was too blocky; 256/face was sharp but its bake = 256*256*6 = 393k
216
+ // hpf.sampleUV calls cost ~4s of MAIN-THREAD block at init (THE start-load-time bottleneck, profiled
217
+ // headless 2026-06-02: 4027ms@256 vs 1137ms@128). 128/face (98k samples ~1.1s) is the calibrated
218
+ // load-time fix: the field is LINEAR-filtered so continental/biome regions stay smooth (128 is still
219
+ // 4x finer than the old blocky 64), and the regional biome PATCHES are 100s of km = far above one
220
+ // 128-texel cell (~50km/cell on a 6371km face). opts.hpfTexRes overrides (256 for a sharpness A/B).
221
+ const HPF_RES = opts.hpfTexRes || 128; // per-face texels of the baked continental/biome field
222
+ // W12 PACK (mob-w12): the continental field is split from one RGBA32F (16B/texel) into TWO
223
+ // smaller textures -- hpfTex RG16F (seaBias, elevAmp; precision-sensitive geometry floats) +
224
+ // hpfTex2 RG8 (temp, humid; [0,1] climate, 8-bit ample). 62% less HPF VRAM/bandwidth, silhouettes
225
+ // intact. UNCONDITIONAL format (single version). The shader hpfSample decodes both back to the
226
+ // legacy (seaBias, elevAmp, temp, humid) vec4. Both flagged LINEAR; the shader's manual bilinear
227
+ // keeps the field smooth even where OES_texture_float_linear is absent (no quality tier).
228
+ function _mkHpfTex(internalFmt) {
229
+ const t = gl.createTexture();
230
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, t);
231
+ gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, internalFmt, HPF_RES, HPF_RES, 6);
232
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
233
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
234
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
235
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
236
+ return t;
237
+ }
238
+ const hpfTex = _mkHpfTex(gl.RG16F); // r=seaBias[m], g=elevAmp
239
+ const hpfTex2 = _mkHpfTex(gl.RG8); // r=temp[0,1], g=humid[0,1]
240
+ // bands FINER than the bake texture resolution are sub-texel (alias to noise) -> skip them in the
241
+ // bake to cut the ~1.5M-anchor local band that dominates init cost (hpf-bake-init-latency). The
242
+ // baked channels (seaBias/elevAmp/temp/humidity) change negligibly (local band touches only elevAmp
243
+ // by <=1.1x). The full field (all bands incl. local roughness) is still used live in the shader/CLI.
244
+ const BAKE_MAX_LEVEL = Math.round(Math.log2(HPF_RES)); // e.g. 256 -> 8; skips bands with level>8
245
+ const _bakeBuf = new Float32Array(HPF_RES * HPF_RES * 2); // RG16F: seaBias, elevAmp
246
+ const _bakeBuf2 = new Uint8Array(HPF_RES * HPF_RES * 2); // RG8: temp, humid (x255)
247
+ // bake ONE face into the HPF texture array (HPF_RES^2 sampleUV calls). Factored from bakeHpf so the
248
+ // init can bake the start face synchronously and BACKGROUND the rest (the cut to bakeHpfMs: the full
249
+ // 6-face bake was 1519ms = 96% of warm totalInitMs, browser-1585). texStorage3D zero-inits unbaked
250
+ // faces (0 bias = flat sea) -> we redraw as each background face lands so no face stays blank.
251
+ function bakeFace(face) {
252
+ const buf = _bakeBuf;
253
+ // HPF SEAM INSET (hpf-seam-inset-bake): the centred bake fu=(x+0.5)/HPF_RES puts face-edge texels 0.5
254
+ // texel INSIDE the face, so adjacent faces' edge texels do not land on the shared-edge world dir ->
255
+ // LINEAR+CLAMP across a shared edge interpolates two inset grids = up to 985m seam (seam-diagnostic
256
+ // gpuBilinearSeam f1|f4). The inset map fu=x/(RES-1) lands edge texels exactly on fu=0/1 = the shared
257
+ // edge -> seam collapses to 0m. MATCHED PAIR: the shader hpfSample bilinear must ALSO map uv->texel as
258
+ // uv*(sz-1) (terrain.glsl, same __hpfInset gate) or the whole field shifts 0.5 texel. Gated OFF by
259
+ // default (window.__hpfInset) until live-A/B confirms no global misplacement; flip both together.
260
+ // SEAM FIX (2026-06-09, workflow wmk2ieggi): edge-aligned inset bake is now the PERMANENT default
261
+ // (was gated OFF behind window.__hpfInset). ROOT: the centred bake fu=(x+0.5)/RES put each face's
262
+ // edge texel 0.5 texel INSIDE the face, so adjacent faces (e.g. y-/z+) sampled DIFFERENT world dirs
263
+ // at the shared edge -> LINEAR+CLAMP across the edge interpolated two offset grids = a 3770m height
264
+ // jump (code-exec browser-9317). baseParamsAt is pure world-dir (seamless by construction), so the
265
+ // inset map fu=x/(RES-1) lands edge texels EXACTLY on the shared-edge world dir -> both faces bake the
266
+ // IDENTICAL edge value -> cube-edge jump ~0. MATCHED PAIR with gl-render uHpfInset=1 + terrain.glsl
267
+ // hpfSample inset branch; all three flip together. window.__hpfInset===false still forces OFF (rollback).
268
+ const _hpfInset = (typeof window !== 'undefined' && window.__hpfInset === false) ? false : true;
269
+ const buf2 = _bakeBuf2;
270
+ bakeFaceRows(face, 0, HPF_RES, buf, buf2, _hpfInset);
271
+ uploadFace(face, buf, buf2);
272
+ }
273
+ // Row-range slice of the bake loop (perf sweep 2026-06-11): the background path chunks a face into
274
+ // row bands honoring the idle-callback deadline instead of one ~190ms synchronous block per face.
275
+ function bakeFaceRows(face, yStart, yEnd, buf, buf2, _hpfInset) {
276
+ for (let y = yStart; y < yEnd; y++) for (let x = 0; x < HPF_RES; x++) {
277
+ const fu = _hpfInset ? x / (HPF_RES - 1) : (x + 0.5) / HPF_RES;
278
+ const fv = _hpfInset ? y / (HPF_RES - 1) : (y + 0.5) / HPF_RES;
279
+ const s = hpf.sampleUV(face, fu, fv, BAKE_MAX_LEVEL);
280
+ const o = (y * HPF_RES + x) * 2;
281
+ buf[o] = s.seaBias; buf[o+1] = s.elevAmp; // RG16F float pair
282
+ buf2[o] = Math.max(0, Math.min(255, Math.round(s.temp * 255))); // RG8 quantize [0,1]
283
+ buf2[o+1] = Math.max(0, Math.min(255, Math.round(s.humidity * 255)));
284
+ }
285
+ }
286
+ function uploadFace(face, buf, buf2) {
287
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, hpfTex);
288
+ gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, face, HPF_RES, HPF_RES, 1, gl.RG, gl.FLOAT, buf);
289
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, hpfTex2);
290
+ gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, face, HPF_RES, HPF_RES, 1, gl.RG, gl.UNSIGNED_BYTE, buf2);
291
+ }
292
+ function bakeHpf() { for (let face = 0; face < 6; face++) bakeFace(face); } // full synchronous (used by rebake)
293
+ // pick the face under the start camera so the first visible face is baked synchronously; the rest
294
+ // background. camWorldPos may not be known yet at init -> default face 0 (the bake order is otherwise
295
+ // arbitrary, and the background pass fills all 6 within a few frames anyway).
296
+ const _startFace = (typeof opts.startFace === 'number') ? (opts.startFace|0) : 0;
297
+ const _tBake0 = _now();
298
+ bakeFace(_startFace); // synchronous: the start face is ready for frame 1
299
+ _t.bakeHpfMs = +(_now() - _tBake0).toFixed(0); // now ~1/6 of the old full-bake cost on the init path
300
+ _t.bakeFacesPending = 5;
301
+ _t.totalInitMs = +(_now() - _t.start).toFixed(0);
302
+ if (render.setHpf) render.setHpf(hpfTex, HPF_RES, hpfTex2);
303
+
304
+ // (height atlas removed 2026-06-10: rejected for fidelity -- bilinear texels facet the 4x peaks at any
305
+ // sane RES -- and the cold-compile motivation died with the ANGLE-backend fix (d3d11/FXC 152s -> vulkan
306
+ // 140ms, scripts/dev-chrome.cmd). Procedural broadShapeM is the ONLY height path. Cast-shadow atlas
307
+ // removed same day for bake stutters.)
308
+
309
+
310
+ // BACKGROUND-bake the remaining 5 faces one per macrotask so init returns fast and the page can paint.
311
+ // Each completed face triggers a redraw (clearCache -> the next frame re-renders with the new field).
312
+ if (typeof window !== 'undefined') {
313
+ // CHUNKED BY ROW BAND (perf sweep 2026-06-11): one whole face per idle callback was a ~190ms
314
+ // synchronous block x5 (requestIdleCallback grants ~50ms; the callback ignored it) = 5 dropped-frame
315
+ // hitches right after first paint. Now each callback bakes row bands while deadline.timeRemaining()
316
+ // holds (fallback fixed band without a deadline), uploading once per completed face as before.
317
+ // (Shared _bakeBuf: a sync __hpfRebake() mid-background-bake clobbers the partial face -- it always
318
+ // did own the buffers; the background pass re-bakes that face's remaining rows from its own state.)
319
+ const _rest = [0,1,2,3,4,5].filter(f => f !== _startFace);
320
+ // timeout 200->32 + slice 16->32 rows (2026-06-11 'rocky patches/flat again' follow-up): the first
321
+ // chunked version let the unbaked-face window stretch to ~8s on a GPU-bound tab -- an unbaked face
322
+ // renders ZERO HPF (flat land, cold-dry grey biome, height-only shading), symptom-identical to the
323
+ // old atlas-era flat/rocky class. 4 slices/face firing every <=32ms = whole planet baked in well
324
+ // under 1s worst case (the old single-block path's wall-clock) with ~50ms slices, no 190ms frame hit.
325
+ const _bgYield = (cb) => (typeof requestIdleCallback !== 'undefined') ? requestIdleCallback(cb, {timeout: 32}) : setTimeout(cb, 0);
326
+ const ROWS_PER_SLICE = 32;
327
+ let _bgFace = -1, _bgRow = 0;
328
+ const _bgInset = (typeof window !== 'undefined' && window.__hpfInset === false) ? false : true;
329
+ function _bgBakeNext(deadline) {
330
+ if (_bgFace < 0) {
331
+ if (!_rest.length) { _t.bakeFacesPending = 0; clearCache(); return; }
332
+ _bgFace = _rest.shift(); _bgRow = 0;
333
+ }
334
+ do {
335
+ const yEnd = Math.min(HPF_RES, _bgRow + ROWS_PER_SLICE);
336
+ bakeFaceRows(_bgFace, _bgRow, yEnd, _bakeBuf, _bakeBuf2, _bgInset);
337
+ _bgRow = yEnd;
338
+ } while (_bgRow < HPF_RES && deadline && deadline.timeRemaining && deadline.timeRemaining() > 8);
339
+ if (_bgRow >= HPF_RES) {
340
+ uploadFace(_bgFace, _bakeBuf, _bakeBuf2);
341
+ _bgFace = -1;
342
+ _t.bakeFacesPending = _rest.length;
343
+ clearCache(); // invalidate the static-camera cache so the next frame re-renders with the new face
344
+ }
345
+ _bgYield(_bgBakeNext);
346
+ }
347
+ _bgYield(_bgBakeNext);
348
+ } else { bakeHpf(); _t.bakeFacesPending = 0; } // headless/node: no event loop wait, bake all synchronously
349
+ if (typeof window !== 'undefined') {
350
+ window.__initTimings = _t; // {shaderCompileMs, anchorFieldMs, bakeHpfMs, totalInitMs}
351
+ window.__hpf = hpf;
352
+ window.__hpfRebake = () => { bakeHpf(); }; // call after edits (does NOT clearCache: the
353
+ // continental field is shader-side so a re-bake + redraw is enough; no tile regen needed).
354
+ }
355
+ // Vegetation is disabled in the GPU-only rewrite (it depended on the deleted producer; gated
356
+ // by window.__veg default-off anyway). Re-add later as a pure-GPU instanced pass if wanted.
357
+ let vegetation = null;
358
+
359
+ // ATLAS LRU / tile-gen / ancestor-fallback REMOVED: terrain is the per-vertex GPU fractal,
360
+ // there are no tiles to make resident, generate, or evict. _frameCache stays for the
361
+ // static-camera re-render PERF path. frameStart kept (the render loop still references it).
362
+ let _frameCache = null;
363
+ let frameStart = 0;
364
+
365
+ // ---- per-frame quadtree drive --------------------------------------------------
366
+ // camWorldPos: [x,y,z] world meters (sphere centered at origin).
367
+ // camTarget: [x,y,z] world look-at point. fovy in radians. displayMode int.
368
+ // Returns { quadCount, glError, face }.
369
+ function frame(camWorldPos, camTarget, fovy = 0.7, displayMode = 0, sunDir, time = 0, up) {
370
+ const sun = sunDir || (() => { const s = [0.4, 0.5, 0.75]; const sl = Math.hypot(...s); return [s[0]/sl, s[1]/sl, s[2]/sl]; })();
371
+ // Use the caller's up if given (planet.html keeps an orthonormal free-fly up); only
372
+ // fall back to [0,1,0] when none supplied. Hardcoding [0,1,0] broke the +Y pole view
373
+ // (up parallel to the view dir -> degenerate lookAt -> nothing rendered).
374
+ const camUp = up || opts.up || [0, 1, 0];
375
+
376
+ // PERF: rebuilding the 6-face quadtree + regenerating tiles (ensureResident: upsample/
377
+ // normal/ortho FBO passes) every frame is the dominant cost. When the camera hasn't
378
+ // moved/turned meaningfully since the last rebuild, REUSE the cached visible-quad set
379
+ // and just re-render (cheap) -- re-render still animates the ocean (time) + tracks the
380
+ // sun. Rebuild only when the camera moves > ~0.1% of altitude or turns, or display mode
381
+ // changes. This keeps a static/orbiting view at full fps and only pays tile-gen on move.
382
+ const camDist = Math.hypot(camWorldPos[0],camWorldPos[1],camWorldPos[2]);
383
+ // LOD-LAG FIX (user 2026-06-02): the OLD moveTol = 0.1% of altitude let the LOD reference
384
+ // snap only in coarse jumps while the camera glided smoothly between them, so the dense LOD
385
+ // TRAILED the camera ("the camera moves faster than the LOD center"). The rebuild must track
386
+ // the camera's per-move displacement, not 0.1% of altitude. Drop the coefficient 20x (0.001
387
+ // -> 0.00005 = 0.005% of altitude) AND cap it at 250m absolute, so even at orbit the LOD
388
+ // recomputes essentially every move-step. A perfectly STILL camera moves 0 m < moveTol and
389
+ // still hits the cached branch (fps hold preserved); only a MOVING camera rebuilds.
390
+ const moveTol = Math.min(250.0, Math.max(1.0, (camDist - R) * 0.00005));
391
+ const fwd = [camTarget[0]-camWorldPos[0], camTarget[1]-camWorldPos[1], camTarget[2]-camWorldPos[2]];
392
+ const c = _frameCache;
393
+ // Rebuild the 6-face quadtree only when the camera moves > ~0.1% of altitude, turns, or
394
+ // changes display mode; otherwise reuse the cached visible-quad set and just re-render
395
+ // (cheap -- still animates ocean + tracks sun). The per-vertex GPU fractal means there is
396
+ // no tile streaming to drain, so a static camera holds the cache indefinitely.
397
+ const moved = !c || !c.pos
398
+ || Math.hypot(camWorldPos[0]-c.pos[0], camWorldPos[1]-c.pos[1], camWorldPos[2]-c.pos[2]) > moveTol
399
+ || (fwd[0]*c.fwd[0]+fwd[1]*c.fwd[1]+fwd[2]*c.fwd[2]) < c.fwdLen2*0.99999
400
+ || displayMode !== c.displayMode;
401
+ if (!moved) {
402
+ const cam2 = { eye: camWorldPos, center: camTarget, up: camUp, fovy, displayMode };
403
+ const glError = render.render(c.quads, cam2, sun, time);
404
+ // Phase 2 fix: also re-draw vegetation on STATIC frames (the GPU instance buffer is
405
+ // still valid from the last rebuild). Without this, trees vanished whenever the
406
+ // camera held still (the static branch returned before the veg draw).
407
+ try {
408
+ if (vegetation && typeof window !== 'undefined' && window.__veg) {
409
+ vegetation.draw(cam2, sun, render.cullMatrix(cam2).viewProjRel);
410
+ }
411
+ } catch(e){}
412
+ try { if (typeof window !== 'undefined') window.__cullStats = { kept: c.quads.length, culled: -1, culledOnScreen: -1, cullActive: false, frame: 'cached', altM: Math.round(camDist - R) }; } catch(_){}
413
+ return { quadCount: c.quads.length, glError, face: c.frontFace, residentCount: 0,
414
+ fallbackCount: c.fallbackCount, maxFallbackLevel: c.maxFallbackLevel, frontFallback: c.frontFallback, cached: true };
415
+ }
416
+
417
+ // configure the authentic Proland quadtree once (meter units; root l=2R).
418
+ // splitFactor is LIVE-TUNABLE via window.__splitFactor so the px/poly target
419
+ // (4-50 px per triangle edge) can be calibrated in-browser with one dispatch
420
+ // (no wasm rebuild / reload) -- the efficiency analog of __diag.setGen. A SMALLER
421
+ // splitFactor => smaller splitDist => quads split only when much closer => COARSER
422
+ // mesh => MORE px per poly (the baseline measured px/poly median 0.73-1.56, far
423
+ // below the 4-50 band: the terrain was over-subdivided ~5-20x, which also saturated
424
+ // the atlas and drove tileGenMs to ~950ms). Clamp to a sane positive range.
425
+ let sf = (typeof window !== 'undefined' && window.__splitFactor != null)
426
+ ? Math.max(0.05, +window.__splitFactor) : splitFactor;
427
+ // ONE altitude->splitFactor relationship (skipped when window.__splitFactor pins it, so the
428
+ // live px/poly sweep stays authoritative). Two regimes, exactly ONE applies at any altitude:
429
+ // The multiplier is MONOTONE-NONDECREASING as altitude drops (detail must never decrease on
430
+ // approach -- user: 'detail decreased going under 200km'. The old bell decayed from its ~200km
431
+ // peak back to 1.0 by 50km, so subdivision fell 200->50km). Shape, high alt -> surface:
432
+ // >=800km : 1.0x (planet small in frame, base splitFactor suffices)
433
+ // 800->150km : ramp UP 1.0 -> PEAK (2.6x) -- the near surface grows in frame, fill it / no
434
+ // black corner wedges (the 'fov above 100km' problem)
435
+ // 150->50km : HOLD the peak (plateau, no decay) -- detail stays high through the descent
436
+ // <=50km : keep rising peak -> 3.1x at the surface (closeup polys grow as the eye nears the
437
+ // deck; px/poly was ~67 just past target at 5km)
438
+ // altitude above the sea-level sphere (km). Hoisted to the outer scope so the deck-cap
439
+ // gate (below, ~line 465) can read it -- it was previously block-scoped inside the
440
+ // splitFactor branch, so the deck-cap reference threw ReferenceError: altKm is not defined
441
+ // and killed the whole render loop (frozen __altM, zero quads, overlay never torn down).
442
+ const altKm = Math.max(0, (camDist - R) / 1000);
443
+ if (typeof window === 'undefined' || window.__splitFactor == null) {
444
+ const PEAK = 1.4; // 2.0 -> 1.4 (user 2026-06-04 destructive FPS run, browser-18 measured): at the
445
+ // 6km closeup the frame is 96.6% VS+raster-bound (fullMs 36.3, vsRaster 35.1, fs 1.2 = dead lever).
446
+ // A LIVE __splitFactor sweep at 6km showed PEAK 2.0 over-subdivides the low-alt footprint into a
447
+ // SUB-PIXEL tail (pxPerPoly p10 0.01, median 11px, only 37% in the 4-50 band): sf2.0=2635q/34.3ms,
448
+ // sf1.4=1480q/21.0ms (1.6x faster) and median climbs 11->22px = DEEPER into the 4-50 target band
449
+ // (fracInBand 0.37->0.41, BETTER not worse). So 1.4 removes invisible over-tessellation, not detail:
450
+ // the polys move toward the target screen size. Below 1.4 (sf1.0 median 44px) starts under-
451
+ // tessellating the band, so 1.4 is the calibrated low-alt plateau. broadShapeM octave cuts were
452
+ // rejected (change the shape/seams, refuted historically); the FS is a dead lever at 3.4%.
453
+ let mul;
454
+ if (altKm >= 700) {
455
+ // HIGH-ALT LOD SWAP CURVE -- SPREAD 700km-3Mm (user 2026-06-11: 'the lods at around 700km to
456
+ // 1Mm must be spread out from 700km to 3Mm'). The old knots stacked L6/L7/L8 onsets at
457
+ // 931/895/860km (measured node 7111); re-solved (node solver 7113, bisection onsets + random
458
+ // + coordinate search against this exact quadtree pipeline incl LOD_STEP 4.0 and the 0.35
459
+ // lean) so the swaps log-spread: L6 onset 2955km, L7 1438km, L8 686km, L5 4872km preserved,
460
+ // full descent monotone, L9-L12 close-approach unchanged. Regime boundary moved 800 -> 700km
461
+ // so the L8 swap can land at 700km (the <boundary ramp begins below the lowest knot).
462
+ // (the src/lab altSplitMul mirror was deleted 2026-06-12; this is the only copy now.)
463
+ // knots [altKm, sf]; sf log-interpolated. Outside the range clamps to the end knot.
464
+ const KN = [[700,0.865],[900,1.009],[1200,0.966],[1700,0.898],[2400,1.19],[3200,0.865],[5000,0.86],[7000,0.72],[13000,0.60],[40000,0.45]];
465
+ if (altKm <= KN[0][0]) { mul = KN[0][1]; }
466
+ else if (altKm >= KN[KN.length-1][0]) { mul = KN[KN.length-1][1]; }
467
+ else { for (let i = 1; i < KN.length; i++) { if (altKm <= KN[i][0]) {
468
+ const a0 = KN[i-1][0], v0 = KN[i-1][1], a1 = KN[i][0], v1 = KN[i][1];
469
+ const t = (Math.log(altKm) - Math.log(a0)) / (Math.log(a1) - Math.log(a0));
470
+ mul = v0 + (v1 - v0) * t; break;
471
+ } } }
472
+ } else if (altKm >= 150) {
473
+ const x = (700 - altKm) / (700 - 150); // 0 at 700km -> 1 at 150km (boundary synced to the 700km knot floor)
474
+ mul = 1.0 + (PEAK - 1.0) * (x*x*(3 - 2*x)); // smoothstep up to the peak
475
+ } else if (altKm >= 50) {
476
+ mul = PEAK; // plateau (no decay on the way down)
477
+ } else {
478
+ // <=50km: HOLD the peak, do NOT keep climbing. The old "2.6->3.1x at the deck" climb
479
+ // (user 2026-06-01j: '17k quads at 24km, really slow') compounded with distFactor=sf*3 to
480
+ // over-subdivide (browser-4582 pxPerPoly median 1.62, only 29% in the 4-50 band = ~6-10x too
481
+ // fine). Maturing detail past 50km is the mesh subdividing into a denser sample of the same
482
+ // fractal; pushing splitFactor higher just makes sub-pixel polys. Flat plateau here.
483
+ mul = PEAK;
484
+ }
485
+ // W2 SINGLE-VERSION LOD LEAN (mob-w2, unconditional -- not a device tier): scale the
486
+ // computed splitFactor (both regimes: the high-alt hold-off and the 150km ramp/plateau)
487
+ // by 0.35. This is the biggest ALU win toward the 512 layer cap: it drops peak visible
488
+ // leaves from ~1200-1950 toward ~600-900 by coarsening the mesh ~3x (fewer triangles, the
489
+ // frame is 96%+ VS+raster-bound at the deck). 0.35 is THE value, hardcoded -- there is no
490
+ // mobile-vs-desktop branch. (Pairs with the near-radius tighten in quadtree.js.)
491
+ sf = sf * mul * 0.35;
492
+ }
493
+ // LOD-STEP PUSH (user 2026-06-09: 'push back the lod area one step so everything is 1 step higher
494
+ // detail, don't add an lod'). One LOD step = a factor 2 in the screen-space split threshold (a quad
495
+ // of length l vs l/2): doubling splitDist makes every quad split at 2x its current distance, so each
496
+ // region resolves exactly one level finer and every LOD ring pushes outward by one step. Applied
497
+ // ONLY to splitDist (NOT distF below): distF = sf*8.0 is the altitude-weighting term, and scaling sf
498
+ // into BOTH compounds into ~2 levels at altitude (witnessed: base sf*2 gave mx 5->7 at 8000km). Keep
499
+ // distF on the original sf so the push is a clean ONE step. maxLevel (16) is untouched -- no new LOD.
500
+ const LOD_STEP = 4.0; // push ALL LODs out ANOTHER step (user 2026-06-09 #2, after the 1.0->2.0 first step):
501
+ qt.computeSplitDist(sf * LOD_STEP, gl.drawingBufferHeight || 480, fovy);
502
+ // DETAIL-FARTHER (user 2026-06-01h: each LOD pop should happen ~3x farther out -- 6km detail at
503
+ // 24km, etc.). A quad subdivides when camAlt < l * splitDist * distFactor, so tripling distFactor
504
+ // shifts every LOD pop ~3x higher in altitude WITHOUT densifying (leaf count stays ~same, unlike
505
+ // raising splitDist which also squares the count). distFactor was = sf (~1-3); decouple it to
506
+ // ~3x sf so the curve shifts up in altitude. Lab-tuned (terrain-lab.mjs lodReport): distFactor 6
507
+ // puts the L15 detail at 24km that distFactor 2 reached only at 6-9km. window.__distFactor overrides.
508
+ // distFactor shifts WHERE LOD pops happen in altitude (each pop ~Nx farther) WITHOUT densifying.
509
+ // sf*3 (session-h 'detail 3x farther') compounded with the low-alt sf ramp to over-subdivide at
510
+ // 24km (browser-4582). Pull it back to sf*1.8 -- LOD still pops well ahead of the old sf*1, but
511
+ // the 24km-horizon swath is no longer blanket-refined to L15. window.__distFactor overrides for
512
+ // the live px/poly sweep.
513
+ // distFactor sf*1.8 -> sf*3.6 (user 2026-06-01j: 'the LOD change at 1k should happen at 2k, 2k->4k,
514
+ // 3k->6k, 6k->12k' = pops ~2x FARTHER in altitude). distFactor scales the altitude at which each
515
+ // LOD pop fires WITHOUT densifying the leaf count; doubling it doubles the pop altitude. The
516
+ // distance-falloff (quadtree.js) handles the horizon overdraw separately, so raising this is safe.
517
+ // sf*3.6 -> sf*8.0 (lod-mip-range-altitude-2x, user 2026-06-06: 'we want the detail we see at 5.5km
518
+ // to display at 12km' = each LOD pop ~2.2x FARTHER again). Measured (quadtree, sf=1): at 3.6 the
519
+ // L15 detail pops at 6km; at 8.0 it pops at ~13km -- so 5.5km-detail now displays at ~12km, 3.5km
520
+ // at ~9km, 2.5km at ~6km, 1.5km at ~3km, matching the user's table. Leaf count is unchanged (distF
521
+ // shifts WHERE pops fire in altitude, it does not densify), so this is a pure detail-farther push.
522
+ const distF = (typeof window !== 'undefined' && window.__distFactor != null) ? +window.__distFactor : sf * 8.0;
523
+ // maxLevel LIVE-TUNABLE via window.__maxLevel. Raised default cap so the deeper (now-farther) LODs
524
+ // are not clipped before the fractal detail runs out. Clamp to a sane range.
525
+ let mxl = (typeof window !== 'undefined' && window.__maxLevel != null)
526
+ ? Math.max(2, Math.min(22, window.__maxLevel|0)) : maxLevel;
527
+ // ALTITUDE-GATED DECK CAP (user 2026-06-04, measured deck VS-bound ~27fps): on very-low
528
+ // approach the deepest levels L15/L16 (cells ~14m/~7m) are ~10-21% of the frame's quads and
529
+ // the frame is 98% vertex-shader, so capping them claws back deck FPS. The user chose to trade
530
+ // that sub-30m close-approach relief for frame rate (cap to 14 = ~28m cells, the vtxDisplace
531
+ // ~31m floor). Only fires under DECK_CAP_ALT_KM (where L15+ actually trigger, per the
532
+ // distance-falloff); above it the full cap stands so descent detail is unaffected. An explicit
533
+ // window.__maxLevel override still wins (manual tuning escape hatch). Live-tunable threshold via
534
+ // window.__deckCapAltKm (default 1km).
535
+ const DECK_CAP_ALT_KM = (typeof window !== 'undefined' && window.__deckCapAltKm != null) ? +window.__deckCapAltKm : 1.0;
536
+ // RELIEF-KEYED ADAPTIVE DECK CAP (DEFECT 1b, user 2026-06-06: 'scale to bounding areas within
537
+ // spatial indexes or otherwise increase the fidelity'). The flat L14 deck cap (FPS lever) wastes
538
+ // the deeper LODs on flat ground but ALSO starves rugged ground of the fidelity it needs. Sample
539
+ // the anchor relief UNDER the nadir (ONE hpf.sampleDir per frame, negligible) and raise the cap on
540
+ // mountainous ground (mirror the shader mtn gate smoothstep(16.8,18.6, elevAmp)) so rugged tiles
541
+ // subdivide to L16 (~7m cells, the vtxDisplace floor) while flat tiles stay capped at L14 -> the
542
+ // polygon budget follows the relief, within the existing quadtree spatial index. nadir-only sample
543
+ // so the foreground the user is descending toward drives it.
544
+ let DECK_CAP_LEVEL = 14;
545
+ if (hpf && hpf.sampleDir) {
546
+ const nA = hpf.sampleDir([camWorldPos[0]/camDist, camWorldPos[1]/camDist, camWorldPos[2]/camDist]);
547
+ const mtn = Math.max(0, Math.min(1, (nA.elevAmp - 16.8) / (18.6 - 16.8))); // mountain-belt weight 0..1
548
+ DECK_CAP_LEVEL = 14 + Math.round(2 * mtn); // flat L14 -> rugged L16, adaptive
549
+ }
550
+ const _maxLevelOverridden = (typeof window !== 'undefined' && window.__maxLevel != null);
551
+ if (!_maxLevelOverridden && altKm < DECK_CAP_ALT_KM && mxl > DECK_CAP_LEVEL) mxl = DECK_CAP_LEVEL;
552
+ qt.setConfig(R, mxl, distF);
553
+ const splitDist = sf + 1.0;
554
+
555
+ // Render ALL 6 cube faces. For each face, run the quadtree with the camera in THAT
556
+ // face's local frame, then accumulate its visible leaf quads (tagged with the face).
557
+ // Back-facing faces still subdivide coarsely (camera far on their local z) and are
558
+ // depth-culled / off-screen, so the cost is bounded; the camera-facing face gets the
559
+ // deep LOD. This makes the whole globe (silhouette + far side curving away) render,
560
+ // not just the one face. The atlas LRU keys include the face, so tiles don't collide.
561
+ const quads = [];
562
+ let fallbackCount = 0, maxFallbackLevel = -1, frontFallback = 0, culledCount = 0;
563
+ let frontFace = pickFace(camWorldPos);
564
+ // LOD-CENTER: the deepest LOD must land where the user is LOOKING, not at nadir.
565
+ // getCameraDist (wasm) measures lateral distance from g_localCam.x/y = the camera's
566
+ // PROJECTED (nadir) position, so the finest quads cluster directly under the camera. When
567
+ // the camera looks forward/oblique, the screen-centre ground point is FORWARD of nadir, so
568
+ // the user "sees detail increasing in front of us" (the high-LOD patch is below the view).
569
+ // FIX (JS-only, no wasm rebuild): feed the quadtree a LOD-REFERENCE position whose
570
+ // DIRECTION is shifted toward the aim ground point but whose MAGNITUDE stays == camDist, so
571
+ // the altitude term (g_camAlt = |localCam| - R) is unchanged while the lateral term now
572
+ // measures distance from the AIM point. At nadir-look this is identity (aimDir == camDir).
573
+ // LOD reference: a MILD aim-bias (0.3) extends the quadtree's coverage toward the look direction
574
+ // so the FORWARD ground fills the frame (fixes the forward-edge black band) WITHOUT moving the
575
+ // density peak off the standing point -- the reference stays 0.7*camera + 0.3*aim, so the deepest
576
+ // LOD is still under the camera (honors 'LOD local to standing') while the looked-toward arc gets
577
+ // enough subdivision to tile the screen with no gap. window.__lodAimBias overrides (0 = pure nadir).
578
+ let lodRefPos = camWorldPos;
579
+ // AIM GROUND POINT (the look ray's surface hit) -- captured for BOTH the LOD-ref aim-bias AND
580
+ // the far-LOD foreground protection. When the camera is pitched down, the bottom-of-screen band
581
+ // sits at the aim point, OFF the nadir; protecting only the nadir box left that band slightly
582
+ // under-covered in off-axis look azimuths (witnessed: bottom-strip coverage az90 0.964 vs az180
583
+ // 1.0). Passing the aim point as a SECOND falloff-protect center keeps the bottom band full-LOD
584
+ // in every azimuth without moving the density peak off the standing point.
585
+ let aimGroundPt = null;
586
+ {
587
+ // aimBias DEFAULT 0 (user 2026-06-02: 'the LOD center still doesnt match the position center').
588
+ // ANY aim-bias blends camDir with the look-ground-point dir, which moves the DENSITY PEAK off the
589
+ // camera nadir toward where you look (at oblique angles the aim point is far toward the horizon ->
590
+ // big shift). The peak must sit EXACTLY under the camera = the position center. So lodRefPos stays
591
+ // the pure camera (aimBias 0); the FORWARD frame is still filled by the SEPARATE aimLocal
592
+ // foreground-PROTECT passed to the quadtree (it keeps the looked-toward band full-LOD WITHOUT
593
+ // moving the peak). window.__lodAimBias>0 re-enables the shift if a forward-bias is ever wanted.
594
+ const aimBias = (typeof window !== 'undefined' && window.__lodAimBias != null) ? Math.max(0, Math.min(1, +window.__lodAimBias)) : 0.0;
595
+ const fwdLen = Math.hypot(fwd[0], fwd[1], fwd[2]) || 1;
596
+ const fx = fwd[0]/fwdLen, fy = fwd[1]/fwdLen, fz = fwd[2]/fwdLen;
597
+ // ray-sphere: nearest hit of (cam + t*fwd) with sphere radius R. b = cam.fwd, c = |cam|^2-R^2.
598
+ const b = camWorldPos[0]*fx + camWorldPos[1]*fy + camWorldPos[2]*fz;
599
+ const cc = camDist*camDist - R*R;
600
+ const disc = b*b - cc;
601
+ if (disc > 0) {
602
+ const tHit = -b - Math.sqrt(disc);
603
+ if (tHit > 0) aimGroundPt = [camWorldPos[0] + tHit*fx, camWorldPos[1] + tHit*fy, camWorldPos[2] + tHit*fz];
604
+ }
605
+ if (aimBias > 0 && disc > 0) {
606
+ const t = -b - Math.sqrt(disc); // nearest forward intersection
607
+ if (t > 0) {
608
+ // aim ground point, then its DIRECTION scaled to camDist (preserve altitude term).
609
+ const gx = camWorldPos[0] + t*fx, gy = camWorldPos[1] + t*fy, gz = camWorldPos[2] + t*fz;
610
+ const gl = Math.hypot(gx, gy, gz) || 1;
611
+ // blend camera-dir and aim-ground-dir, renormalize to camDist.
612
+ const cnx = camWorldPos[0]/camDist, cny = camWorldPos[1]/camDist, cnz = camWorldPos[2]/camDist;
613
+ let rx = cnx*(1-aimBias) + (gx/gl)*aimBias;
614
+ let ry = cny*(1-aimBias) + (gy/gl)*aimBias;
615
+ let rz = cnz*(1-aimBias) + (gz/gl)*aimBias;
616
+ const rl = Math.hypot(rx, ry, rz) || 1;
617
+ lodRefPos = [rx/rl*camDist, ry/rl*camDist, rz/rl*camDist];
618
+ }
619
+ }
620
+ }
621
+ // diag: expose the LOD reference shift (angular separation from nadir) for live tuning.
622
+ try {
623
+ if (typeof window !== 'undefined') {
624
+ const sepDot = (lodRefPos[0]*camWorldPos[0] + lodRefPos[1]*camWorldPos[1] + lodRefPos[2]*camWorldPos[2]) / (camDist*camDist);
625
+ window.__lodRefSepDeg = Math.acos(Math.max(-1, Math.min(1, sepDot))) * 57.29578;
626
+ }
627
+ } catch (e) {}
628
+ // (No tile residency to pre-seed -- terrain is the per-vertex GPU fractal, every leaf draws.)
629
+ // FRUSTUM CULL (budget optimization): skip quads fully outside the view frustum so they
630
+ // don't consume atlas layers -- freeing budget for in-frame deep LOD (fewer fallbacks /
631
+ // FRUSTUM CULL OFF BY DEFAULT (user: 'the FOV cull still omits on-screen elements at the edge').
632
+ // The conservative 4-corner shell test mis-culls large near/edge quads whose sphere-bulged
633
+ // interior is actually on-screen, so we don't run it: the GPU clips genuinely off-screen patches
634
+ // for free, and the behind-limb cull below removes the ~80% backside quads cheaply. It is purely
635
+ // a perf optimization; re-enable for diagnostics via window.__frustumCull (or opts.frustumCull).
636
+ const cullOn = (typeof window !== 'undefined' && window.__frustumCull != null) ? !!window.__frustumCull : (opts.frustumCull === true);
637
+ const cullActive = cullOn && render.cullMatrix;
638
+ // cull-debug: count frustum-culled-but-on-screen quads (false-cull signature) when enabled.
639
+ const _cullDbgOn = (typeof window !== 'undefined' && !!window.__cullDebug);
640
+ let _cullDbgOnScreen = 0;
641
+ // Use viewProjNoEye (proj*viewRel, NO translate) + pass the eye so quadOutsideFrustum can make
642
+ // each corner camera-relative in JS doubles (fp32-precision fix for the ground-nadir blank).
643
+ const _cm = cullActive ? render.cullMatrix({ eye: camWorldPos, center: camTarget, up: camUp, fovy }) : null;
644
+ const vpr = _cm ? _cm.viewProjNoEye : null;
645
+ // BEHIND-LIMB CULL (the dominant bottleneck fix). The baseline measured ~80% of all
646
+ // generated quads sitting BEHIND the planet's horizon -- they are geometrically
647
+ // occluded by the globe (depth-culled to nothing) yet each still costs 3 FBO tile-gen
648
+ // passes (elev+normal+ortho) + a draw, saturating the atlas (1920/1920) and driving
649
+ // tileGenMs to ~950ms. A quad whose deformed surface point lies beyond the horizon
650
+ // tangent can NEVER be visible (the near hemisphere always occludes it), so skip it
651
+ // before tile-gen. Horizon test: the tangent ray from the camera touches the sphere
652
+ // where the surface-point direction wp satisfies dot(wp, camDir) == R/camDist; points
653
+ // with dot < that cosine are over the horizon. A generous margin (subtract a few quad
654
+ // half-angles via the elevation+arc slack) keeps limb quads that straddle the horizon.
655
+ // Disabled for coarse quads (level<2: a single coarse quad can span the whole limb) and
656
+ // when very close to the surface (camDist<R*1.001: the horizon is degenerate/at-foot).
657
+ const camDirX = camWorldPos[0]/camDist, camDirY = camWorldPos[1]/camDist, camDirZ = camWorldPos[2]/camDist;
658
+ // (the forward-cone limb rescue was replaced by quadOutsideFrustum; its normalized look-dir
659
+ // lookX/Y/Z + _fl hypot were dead with zero readers -- removed 2026-06-10 ESE cleanup.)
660
+ const cosHorizon = Math.min(1.0, R / camDist);
661
+ // angular slack so a quad straddling the horizon (peak elevation + its own arc) is kept:
662
+ // CULL_MAX_ELEV lifts the visible point, and the quad subtends ~l/R radians of arc.
663
+ // The horizon exists at ANY altitude above the surface, so the limb cull is valid all
664
+ // the way down -- the earlier R*1.001 guard (~6.4km) wrongly DISABLED it at closeup
665
+ // (5km), leaving 1968 quads with wastedFrac 0.87 (browser-5). Lower the guard to a tiny
666
+ // epsilon above the surface; the slack term already keeps any limb-straddling quad.
667
+ // (Eye exactly at/under the surface is degenerate -- cosHorizon would be >=1 -- so skip.)
668
+ const limbCullActive = (typeof window !== 'undefined' && window.__limbCull != null ? !!window.__limbCull : true)
669
+ && camDist > R * 1.00002;
670
+ for (let face = 0; face < 6; face++) {
671
+ // LOD drive uses lodRefPos (aim-shifted, altitude preserved) so deepest LOD follows the
672
+ // look point; the quad record keeps the TRUE-camera localCam (for the VS geomorph
673
+ // defCamera uniform), and the cull below uses the TRUE camera (camDirX/Y/Z, camWorldPos).
674
+ const lodLocalCam = worldToFaceLocal(face, lodRefPos);
675
+ const localCam = worldToFaceLocal(face, camWorldPos);
676
+ // pass the TRUE camera nadir (localCam x,y) so the far-LOD falloff protects the real foreground
677
+ // (decoupled from the aim-shifted lodLocalCam) -- nearby stays fine while the far horizon trims.
678
+ // ALSO pass the AIM ground point (face-local x,y) as a SECOND foreground-protect center so the
679
+ // pitched-down bottom-of-screen band stays full-LOD in every look azimuth (fixes the small
680
+ // azimuth-asymmetric bottom-edge missing quads). null aim (look misses the sphere) -> nadir only.
681
+ const aimLocal = aimGroundPt ? worldToFaceLocal(face, aimGroundPt) : null;
682
+ // pass the TRUE altitude (|camWorldPos|-R) so the quadtree does not derive it from the WARPED
683
+ // face-local hypot (which overestimates off the face centre -> LOD stalled everywhere but the
684
+ // start point; user 2026-06-03). camDist = |camWorldPos|, R = planet radius.
685
+ const leaves = qt.updateQuadtree(lodLocalCam[0], lodLocalCam[1], lodLocalCam[2], localCam[0], localCam[1],
686
+ aimLocal ? aimLocal[0] : undefined, aimLocal ? aimLocal[1] : undefined,
687
+ camDist - R);
688
+ const n = leaves.length;
689
+ if (n <= 0) continue;
690
+ const F = FACE_FRAME[face];
691
+ for (let i = 0; i < n; i++) {
692
+ const q = leaves[i];
693
+ const level = q.level, tx = q.tx, ty = q.ty;
694
+ const ox = q.ox, oy = q.oy, l = q.l;
695
+ // behind-limb cull (cheap; before the expensive frustum cull + tile-gen).
696
+ if (limbCullActive && (level|0) >= 2) {
697
+ const cx = ox + l*0.5, cy = oy + l*0.5;
698
+ const len = Math.hypot(cx, cy, R) || 1;
699
+ const wx = (cx/len)*F.u[0]+(cy/len)*F.v[0]+(R/len)*F.c[0];
700
+ const wy = (cx/len)*F.u[1]+(cy/len)*F.v[1]+(R/len)*F.c[1];
701
+ const wz = (cx/len)*F.u[2]+(cy/len)*F.v[2]+(R/len)*F.c[2];
702
+ const dotOut = wx*camDirX + wy*camDirY + wz*camDirZ;
703
+ // TIGHT slack = elevation lift + the quad's own arc, NO fixed 0.04 floor. The old floor kept
704
+ // a ~17deg-wide RING at ALL azimuths at low alt (cosHorizon->1) -> 55% backside quads DRAWN
705
+ // (browser-4726 wastedFrac 0.55) = the close-approach FPS sink. A quad above this tight
706
+ // horizon is genuinely visible (kept); a quad below it is kept ONLY by the forward-cone
707
+ // rescue, so sideways/backside near-horizon rings are culled.
708
+ const slack = CULL_MAX_ELEV / R + (l / R);
709
+ if (dotOut < cosHorizon - slack) {
710
+ // FRUSTUM RESCUE (user 2026-06-05: 'culling nearby quads unnecessarily making land
711
+ // disappear'). The old forward-CONE rescue used a fixed ~53deg half-angle cone
712
+ // (cos(fovy*0.9+0.30)), but the real view FRUSTUM is WIDER than that cone -- especially
713
+ // horizontally with the screen aspect ratio. So an on-screen quad past the geometric
714
+ // horizon but outside the narrow cone (e.g. near-ground land off to the side of the look
715
+ // direction) got LIMB-CULLED while still visible -> land popped out of the frame. The
716
+ // authoritative on-screen test is quadOutsideFrustum (NDC AABB vs viewport, the same test
717
+ // applied below); defer to it: a past-horizon quad is culled ONLY if it is also outside the
718
+ // real frustum. The backside (genuinely off-screen) still fails the frustum test and is
719
+ // culled, so the perf intent (no backside draw) is preserved without trimming visible land.
720
+ // GUARD: vpr is null when the frustum cull is OFF (cullActive false -> line ~570). Without
721
+ // this guard quadOutsideFrustum dereferences vpr[0] = null and throws 'Cannot read
722
+ // properties of null (reading 0)' the moment the camera moves (user 2026-06-06). When there
723
+ // is no frustum matrix we cannot prove the past-horizon quad is off-screen, so KEEP it
724
+ // (conservative: a few extra backside quads when cull is off, never a crash, never lost land).
725
+ if (vpr && quadOutsideFrustum(face, ox, oy, l, R, vpr, camWorldPos)) { culledCount++; continue; }
726
+ }
727
+ }
728
+ if (cullActive && (level|0) >= 2 && quadOutsideFrustum(face, ox, oy, l, R, vpr, camWorldPos)) {
729
+ culledCount++;
730
+ // CULL DEBUG: count quads the frustum cull dropped that ACTUALLY project on-screen (the
731
+ // false-cull bug signature). Project the quad center via the cull's own camera-relative
732
+ // matrix; on-screen = in front (cw>0) and |ndc|<1. window.__cullDebug toggles this.
733
+ if (_cullDbgOn) {
734
+ // match the tangent warp (same as quadOutsideFrustum + the VS) so the on-screen test
735
+ // agrees with the rendered geometry (an un-warped center mis-counts toward face edges).
736
+ const _wk = Math.PI/4.0;
737
+ const ccx = R*Math.tan(((ox + l*0.5)/R)*_wk), ccy = R*Math.tan(((oy + l*0.5)/R)*_wk);
738
+ const cl = Math.hypot(ccx, ccy, R) || 1;
739
+ const cwx = (ccx/cl)*F.u[0]+(ccy/cl)*F.v[0]+(R/cl)*F.c[0];
740
+ const cwy = (ccx/cl)*F.u[1]+(ccy/cl)*F.v[1]+(R/cl)*F.c[1];
741
+ const cwz = (ccx/cl)*F.u[2]+(ccy/cl)*F.v[2]+(R/cl)*F.c[2];
742
+ const dX = cwx*R - camWorldPos[0], dY = cwy*R - camWorldPos[1], dZ = cwz*R - camWorldPos[2];
743
+ const px = vpr[0]*dX+vpr[4]*dY+vpr[8]*dZ+vpr[12];
744
+ const py = vpr[1]*dX+vpr[5]*dY+vpr[9]*dZ+vpr[13];
745
+ const pw = vpr[3]*dX+vpr[7]*dY+vpr[11]*dZ+vpr[15];
746
+ if (pw > 1e-3 && Math.abs(px/pw) < 1 && Math.abs(py/pw) < 1) _cullDbgOnScreen++;
747
+ }
748
+ continue;
749
+ }
750
+ // No atlas/tile generation: terrain shape is the GPU fractal evaluated per-vertex, so every
751
+ // visible leaf just draws (no resident-tile allocation, no ancestor fallback, no gen budget).
752
+ quads.push({ quad: { level, tx, ty, ox, oy, l }, face, localCam, splitDist });
753
+ }
754
+ }
755
+
756
+ // render the combined visible leaf set across all faces.
757
+ const cam = { eye: camWorldPos, center: camTarget, up: camUp, fovy, displayMode };
758
+ const glError = render.render(quads, cam, sun, time);
759
+ // CULL DEBUG stats for the live HUD: kept/culled counts + the false-cull signature
760
+ // (culledOnScreen = frustum-culled quads that actually project inside the screen) + whether
761
+ // this was a REBUILD frame (cull ran) vs a cached re-render. Read by planet.html's __cullHud.
762
+ try {
763
+ if (typeof window !== 'undefined') {
764
+ window.__cullStats = { kept: quads.length, culled: culledCount, culledOnScreen: _cullDbgOnScreen,
765
+ cullActive, frame: 'rebuild', altM: Math.round((camDist - R) ) };
766
+ }
767
+ } catch(_){}
768
+ // VEGETATION (Phase 1 billboards): build instances from the visible quads + draw after
769
+ // terrain. Gated by window.__veg (default off). Reuses render.cullMatrix's viewProjRel.
770
+ try {
771
+ if (vegetation && typeof window !== 'undefined' && window.__veg) {
772
+ const vn = vegetation.buildInstances(quads, cam);
773
+ vegetation.draw(cam, sun, render.cullMatrix(cam).viewProjRel);
774
+ try { window.__vegCount = vn; window.__grassCount = vegetation.grass; } catch(_){}
775
+ }
776
+ } catch(e){ try { window.__vegErr = String(e.message||e); } catch(_){} }
777
+ try { window.__lastGLQuads = quads; window.__lastGLCam = cam; window.__lastGLRender = render; } catch(e){}
778
+ // cache the rebuilt set for static-camera re-render (PERF). fwdLen2 = |fwd|^2 is the
779
+ // dot-product threshold reference for the "turned" check above. The per-vertex fractal has
780
+ // no tiles to stream, so the cache is always complete in one frame.
781
+ _frameCache = { pos: [camWorldPos[0],camWorldPos[1],camWorldPos[2]], fwd, fwdLen2: fwd[0]*fwd[0]+fwd[1]*fwd[1]+fwd[2]*fwd[2],
782
+ displayMode, quads, frontFace, fallbackCount, maxFallbackLevel, frontFallback };
783
+ return { quadCount: quads.length, glError, face: frontFace, residentCount: 0, fallbackCount, maxFallbackLevel, frontFallback, culledCount, cached: false };
784
+ }
785
+
786
+ // No tile atlas to drop; clearCache just invalidates the static-camera re-render cache so a
787
+ // live param change (e.g. anchor re-bake) redraws. Kept for the __diag/gen-control call sites.
788
+ function clearCache() { _frameCache = null; }
789
+ return { frame, render, clearCache };
790
+ }