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.
- package/.claude/workflows/fps-perf.js +147 -0
- package/.claude/workflows/optimize-dna.js +201 -0
- package/.claude/workflows/shader-bottleneck-dna.js +168 -0
- package/.claude/workflows/speed-dna.js +209 -0
- package/.claude/workflows/startup-perf.js +117 -0
- package/.github/workflows/publish.yml +43 -0
- package/AGENTS.md +265 -0
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +1 -0
- package/README.md +82 -0
- package/examples/basic-sdk-usage.html +114 -0
- package/package.json +28 -0
- package/planet.html +2181 -0
- package/planet.zip +0 -0
- package/scripts/backend-ab.mjs +88 -0
- package/scripts/dev-chrome.cmd +22 -0
- package/scripts/verify.mjs +69 -0
- package/server.js +127 -0
- package/src/anchor-field.js +559 -0
- package/src/gl-render.js +944 -0
- package/src/index.js +41 -0
- package/src/planet-orchestrator.js +790 -0
- package/src/quadtree.js +160 -0
- package/src/shaders/atmosphere.glsl +215 -0
- package/src/shaders/terrain.glsl +2109 -0
- package/src/terrain-gen-controls.js +122 -0
- package/tests/run.js +58 -0
- package/textures/grass-color.jpg +0 -0
- package/textures/grass-displacement.jpg +0 -0
- package/textures/rock-color.jpg +0 -0
- package/textures/rock-displacement.jpg +0 -0
- package/textures/sand-color.jpg +0 -0
- package/textures/sand-displacement.jpg +0 -0
- package/textures/snow-color.jpg +0 -0
- package/textures/snow-displacement.jpg +0 -0
|
@@ -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
|
+
}
|