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