mapspinner 0.1.38 → 0.1.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mapspinner",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "WebGL2 Earth-scale terrain rendering SDK for interactive globe applications",
5
5
  "main": "src/index.js",
6
6
  "exports": {
package/src/gl-render.js CHANGED
@@ -272,6 +272,140 @@ export async function initMapspinnerRender(gl, opts = {}) {
272
272
  return out[0];
273
273
  }
274
274
 
275
+ // ===== THC HEIGHT-CACHE BAKE (2026-06-14, NON-DESTRUCTIVE) =====
276
+ // A separate program renders composeHeight for one tile into an R32F grid (a fullscreen tri; each
277
+ // fragment = one tile parametric texel). Used FIRST as a readback witness (bake vs procedural
278
+ // sampleGroundM) to prove the bake matches the geometry; the pool/LRU + the VS-sample switch are
279
+ // later DAG nodes. _faceFrames mirror terrain.glsl faceFrame() columns (column-major mat3).
280
+ const THC_BAKE_RES = 130;
281
+ const _faceFrames = [
282
+ [0,0,-1, 0,1,0, 1,0,0], [0,0,1, 0,1,0, -1,0,0],
283
+ [1,0,0, 0,0,-1, 0,1,0], [1,0,0, 0,0,1, 0,-1,0],
284
+ [1,0,0, 0,1,0, 0,0,1], [-1,0,0, 0,1,0, 0,0,-1],
285
+ ];
286
+ let bakeProg=null, bakeTex=null, bakeFbo=null, _bakeBuilding=null; const _bakeUloc=new Map();
287
+ const BU = n => { let l=_bakeUloc.get(n); if(l===undefined){ l=gl.getUniformLocation(bakeProg,n); _bakeUloc.set(n,l);} return l; };
288
+ function ensureBake(){
289
+ if (bakeProg || _bakeBuilding) return;
290
+ _bakeBuilding = (async () => {
291
+ try {
292
+ const bvs = hdr + 'void main(){ vec2 p=vec2((gl_VertexID==1)?3.0:-1.0,(gl_VertexID==2)?3.0:-1.0); gl_Position=vec4(p,0.0,1.0); }';
293
+ const bfs = hdr + '\n#define _HEIGHTBAKE_\n' + src;
294
+ const bv=gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(bv,bvs); gl.compileShader(bv);
295
+ const bf=gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(bf,bfs); gl.compileShader(bf);
296
+ const bp=gl.createProgram(); gl.attachShader(bp,bv); gl.attachShader(bp,bf); gl.linkProgram(bp);
297
+ await awaitProgramLink(bp, bv, bf, 'bake');
298
+ const tex=gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex);
299
+ gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R32F, THC_BAKE_RES, THC_BAKE_RES);
300
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
301
+ const fbo=gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
302
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
303
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
304
+ _bakeUloc.clear(); bakeTex=tex; bakeFbo=fbo; bakeProg=bp;
305
+ } catch(e){ bakeProg=null; try{ if(typeof window!=='undefined') window.__bakeErr=String(e.message||e); }catch(_){} }
306
+ finally { _bakeBuilding=null; }
307
+ })();
308
+ }
309
+ const bakeVao = gl.createVertexArray();
310
+ // bake ONE tile into bakeTex + read it back (Float32Array of THC_BAKE_RES^2 heights). Returns null
311
+ // until the program is built (lazy). NON-DESTRUCTIVE: does not touch the live render path.
312
+ function bakeTileReadback(face, ox, oy, l, level){
313
+ if (!bakeProg){ ensureBake(); return null; }
314
+ gl.bindFramebuffer(gl.FRAMEBUFFER, bakeFbo);
315
+ gl.viewport(0,0,THC_BAKE_RES,THC_BAKE_RES);
316
+ gl.useProgram(bakeProg);
317
+ gl.bindVertexArray(bakeVao);
318
+ if (_hpfTex){ gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D_ARRAY,_hpfTex); gl.uniform1i(BU('hpfPool'),3); }
319
+ if (_hpfTex2){ gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D_ARRAY,_hpfTex2); gl.uniform1i(BU('hpfPool2'),5); }
320
+ gl.uniform1i(BU('hasHpf'), _hpfTex?1:0);
321
+ setComposeHeightUniforms(BU);
322
+ gl.uniform1f(BU('defRadius'), R);
323
+ gl.uniformMatrix3fv(BU('uBakeFrame'), false, new Float32Array(_faceFrames[face|0]));
324
+ gl.uniform4f(BU('uBakeOffset'), ox, oy, l, level);
325
+ gl.uniform1f(BU('uBakeRes'), THC_BAKE_RES);
326
+ gl.disable(gl.DEPTH_TEST);
327
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
328
+ const buf = new Float32Array(THC_BAKE_RES*THC_BAKE_RES*4);
329
+ gl.readPixels(0,0,THC_BAKE_RES,THC_BAKE_RES, gl.RGBA, gl.FLOAT, buf);
330
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
331
+ gl.bindVertexArray(null);
332
+ const out = new Float32Array(THC_BAKE_RES*THC_BAKE_RES);
333
+ for (let i=0;i<out.length;i++) out[i]=buf[i*4];
334
+ let dbg=null; try{ dbg={ offLoc: BU('uBakeOffset')!=null, resLoc: BU('uBakeRes')!=null, frameLoc: BU('uBakeFrame')!=null,
335
+ offRead: BU('uBakeOffset')?Array.from(gl.getUniform(bakeProg, BU('uBakeOffset'))):null,
336
+ resRead: BU('uBakeRes')?gl.getUniform(bakeProg, BU('uBakeRes')):null }; }catch(e){ dbg={err:String(e)}; }
337
+ return { heights: out, res: THC_BAKE_RES, dbg };
338
+ }
339
+ if (typeof window !== 'undefined') { window.__thcBakeReadback = bakeTileReadback; window.__thcEnsureBake = ensureBake; }
340
+
341
+ // ===== THC HEIGHT POOL + LRU (the VS-sample consumer; the FPS win) =====
342
+ // The VS samples a baked per-tile height (O(1) texture fetch) instead of composeHeight 5x/vertex,
343
+ // when window.__thc is on. A 2D-array pool holds one BAKE_RES^2 R32F layer per live tile; a leaf
344
+ // gets a layer (baked once) on first sight, LRU-evicted when the pool is full. Default OFF -> the
345
+ // live render is unchanged (composeHeight), so this is safe to ship behind the toggle.
346
+ const THC_POOL_LAYERS = 512;
347
+ let heightPool=null, poolFbo=null;
348
+ const _tcMap = new Map(); // tileKey -> layer
349
+ const _tcLayerKey = new Array(THC_POOL_LAYERS).fill(null); // layer -> tileKey (evict bookkeeping)
350
+ const _tcUsed = new Int32Array(THC_POOL_LAYERS); // layer -> last-used frame
351
+ let _tcFrame = 0, _tcNextFree = 0, _tcBakesThisFrame = 0;
352
+ // BAKE-ON-EDIT: terraform/HPF changes make every baked layer stale -> drop the whole map so each
353
+ // visible tile re-bakes on next sight (synchronously, before its draw -> no black/stale frame).
354
+ function invalidatePool(){ _tcMap.clear(); _tcLayerKey.fill(null); _tcNextFree = 0; }
355
+ function ensurePool(){
356
+ if (heightPool) return;
357
+ heightPool = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D_ARRAY, heightPool);
358
+ gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.R32F, THC_BAKE_RES, THC_BAKE_RES, THC_POOL_LAYERS);
359
+ const lin = _halfFloatLinearOK ? gl.LINEAR : gl.NEAREST; // R32F LINEAR needs OES_texture_float_linear; else VS does manual bilinear
360
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, lin); gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, lin);
361
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
362
+ poolFbo = gl.createFramebuffer();
363
+ }
364
+ function bakeTileToLayer(face,ox,oy,l,level,layer){
365
+ gl.bindFramebuffer(gl.FRAMEBUFFER, poolFbo);
366
+ gl.framebufferTextureLayer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, heightPool, 0, layer);
367
+ gl.viewport(0,0,THC_BAKE_RES,THC_BAKE_RES);
368
+ gl.useProgram(bakeProg); gl.bindVertexArray(bakeVao);
369
+ if (_hpfTex){ gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D_ARRAY,_hpfTex); gl.uniform1i(BU('hpfPool'),3); }
370
+ if (_hpfTex2){ gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D_ARRAY,_hpfTex2); gl.uniform1i(BU('hpfPool2'),5); }
371
+ gl.uniform1i(BU('hasHpf'), _hpfTex?1:0);
372
+ setComposeHeightUniforms(BU);
373
+ gl.uniform1f(BU('defRadius'), R);
374
+ gl.uniformMatrix3fv(BU('uBakeFrame'), false, new Float32Array(_faceFrames[face|0]));
375
+ gl.uniform4f(BU('uBakeOffset'), ox, oy, l, level);
376
+ gl.uniform1f(BU('uBakeRes'), THC_BAKE_RES);
377
+ gl.disable(gl.DEPTH_TEST);
378
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
379
+ _tcBakesThisFrame++;
380
+ }
381
+ // pool layer for a tile, baked on first sight; LRU-evicts when full. Returns -1 if not yet bakeable.
382
+ function ensureTileLayer(face,ox,oy,l,level){
383
+ const key = face+':'+ox+':'+oy+':'+l;
384
+ let layer = _tcMap.get(key);
385
+ if (layer === undefined){
386
+ if (_tcNextFree < THC_POOL_LAYERS){ layer = _tcNextFree++; }
387
+ else { let lru=0, lruF=_tcUsed[0]; for(let k=1;k<THC_POOL_LAYERS;k++) if(_tcUsed[k]<lruF){lruF=_tcUsed[k];lru=k;} layer=lru; const old=_tcLayerKey[lru]; if(old!=null) _tcMap.delete(old); }
388
+ _tcMap.set(key, layer); _tcLayerKey[layer]=key;
389
+ bakeTileToLayer(face,ox,oy,l,level,layer);
390
+ }
391
+ _tcUsed[layer]=_tcFrame;
392
+ return layer;
393
+ }
394
+ // THC active = toggle on AND both programs/pool ready. Builds them lazily; returns false until ready
395
+ // so the first frames fall back to composeHeight (uThc=0) with no garbage.
396
+ let _tcInvSeen = 0;
397
+ function thcActive(){
398
+ if (typeof window==='undefined' || !window.__thc) return false;
399
+ if (!bakeProg){ ensureBake(); return false; }
400
+ ensurePool();
401
+ // live re-bake hook: window.__thcInvalidate() bumps __thcInval; any composeHeight-shaping edit
402
+ // (e.g. __gen biome/relief dials) should call it so the baked pool refreshes.
403
+ const inv = (window.__thcInval|0);
404
+ if (inv !== _tcInvSeen){ _tcInvSeen = inv; invalidatePool(); }
405
+ return !!heightPool;
406
+ }
407
+ if (typeof window !== 'undefined') window.__thcInvalidate = () => { window.__thcInval = (window.__thcInval|0) + 1; };
408
+
275
409
  // FLOAT-LINEAR FORMAT PROBE (NOT a quality tier): OES_texture_float_linear lets the HPF atlas pools
276
410
  // filter LINEAR in hardware -> hpfSample collapses to one texture() call. 0 = manual 4-tap fallback.
277
411
  const _halfFloatLinearOK = !!gl.getExtension('OES_texture_float_linear') || !!gl.getExtension('OES_texture_half_float_linear');
@@ -576,7 +710,7 @@ export async function initMapspinnerRender(gl, opts = {}) {
576
710
  // the instance data is identical -> skip the Float32Array build + bufferData + water dedup Set-loop
577
711
  // and just rebind+draw. Pure CPU/GC win (GPU is vertex-bound, the upload is off the critical path).
578
712
  const instBufWater=gl.createBuffer();
579
- let _instQuadsRef=null, _instWaterRef=null, _instWaterN=0;
713
+ let _instQuadsRef=null, _instWaterRef=null, _instWaterN=0, _lastThc=false;
580
714
 
581
715
  // per-face local->world (cube face -> sphere local frame). Column-major mat3 packed
582
716
  // into a Float32Array(9). Matches localToWorld3 convention:
@@ -896,14 +1030,27 @@ export async function initMapspinnerRender(gl, opts = {}) {
896
1030
  gl.uniformMatrix4fv(U('defViewProjRel'), false, viewProjRel);
897
1031
  gl.uniform1f(U('defRadius'), R);
898
1032
  const n = quads.length;
899
- // SINGLE-SOURCE HEIGHT: every leaf is the pure per-vertex composeHeight() function, evaluated every
900
- // frame -- no bake cache. The instance buffer carries [ox,oy,l,level,face] (5 floats); the VS computes
901
- // h = composeHeight(dir) for each vertex.
902
- const FLOATS = 5; // [ox,oy,l,level,face]
1033
+ // Instance buffer: [ox,oy,l,level,face, iLayer] (6 floats). iLayer = the THC pool layer for this
1034
+ // tile (when __thc on); the VS samples the baked height there instead of composeHeight.
1035
+ const FLOATS = 6; // [ox,oy,l,level,face, iLayer]
903
1036
  if (n > 0) {
904
- // STATIC-FRAME SKIP: only rebuild + re-upload the instance data when the quad SET changed (the
905
- // orchestrator passes the SAME quads array object on a static camera, a fresh array on rebuild).
906
- const _dirty = (quads !== _instQuadsRef);
1037
+ // THC: when active, ensure every visible tile has a baked pool layer (bake on first sight). The
1038
+ // bakes clobber the FBO/program/viewport -> restore the canvas render state afterward.
1039
+ const _thc = thcActive();
1040
+ let _layers = null;
1041
+ if (_thc) {
1042
+ _tcFrame++; _tcBakesThisFrame = 0;
1043
+ _layers = new Float32Array(n);
1044
+ for (let i = 0; i < n; i++) { const q = quads[i].quad; _layers[i] = ensureTileLayer(quads[i].face, q.ox, q.oy, q.l, q.level); }
1045
+ gl.bindVertexArray(null); // bakeTileToLayer left bakeVao bound; the main path uses the default VAO
1046
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1047
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
1048
+ gl.enable(gl.DEPTH_TEST); // bakeTileToLayer disabled depth
1049
+ gl.useProgram(_activeProg);
1050
+ if (typeof window !== 'undefined') window.__thcBakes = _tcBakesThisFrame;
1051
+ }
1052
+ // STATIC-FRAME SKIP: rebuild only when the quad set changed OR the toggle flipped (iLayer needs writing).
1053
+ const _dirty = (quads !== _instQuadsRef) || (_thc !== _lastThc);
907
1054
  gl.bindBuffer(gl.ARRAY_BUFFER, instBuf);
908
1055
  if (_dirty) {
909
1056
  const inst = new Float32Array(n * FLOATS);
@@ -911,12 +1058,17 @@ export async function initMapspinnerRender(gl, opts = {}) {
911
1058
  const q = quads[i].quad;
912
1059
  inst[i*FLOATS+0] = q.ox; inst[i*FLOATS+1] = q.oy; inst[i*FLOATS+2] = q.l; inst[i*FLOATS+3] = q.level;
913
1060
  inst[i*FLOATS+4] = quads[i].face;
1061
+ inst[i*FLOATS+5] = _layers ? _layers[i] : 0.0;
914
1062
  }
915
1063
  gl.bufferData(gl.ARRAY_BUFFER, inst, gl.DYNAMIC_DRAW);
916
1064
  }
1065
+ _lastThc = _thc;
917
1066
  const STRIDE = FLOATS * 4;
918
1067
  gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, STRIDE, 0); gl.vertexAttribDivisor(1, 1); // iOffset
919
1068
  gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 1, gl.FLOAT, false, STRIDE, 4 * 4); gl.vertexAttribDivisor(2, 1); // iFace
1069
+ gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 1, gl.FLOAT, false, STRIDE, 5 * 4); gl.vertexAttribDivisor(3, 1); // iLayer (THC pool layer)
1070
+ gl.uniform1f(U('uThc'), _thc ? 1.0 : 0.0);
1071
+ if (_thc) { gl.activeTexture(gl.TEXTURE8); gl.bindTexture(gl.TEXTURE_2D_ARRAY, heightPool); gl.uniform1i(U('uHeightPool'), 8); gl.uniform1f(U('uPoolRes'), THC_BAKE_RES); gl.uniform1f(U('uPoolLinear'), _halfFloatLinearOK ? 1.0 : 0.0); }
920
1072
  gl.uniform1f(U('uIsWater'), 0.0);
921
1073
  // UNDERWATER DETECTION: camera below sea level enables underwater shading + water surface
922
1074
  // rendering from below. Set before the terrain draw so the FS can apply underwater fog.
@@ -951,7 +1103,7 @@ export async function initMapspinnerRender(gl, opts = {}) {
951
1103
  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; }
952
1104
  const key = quads[i].face + ':' + ox + ':' + oy + ':' + l;
953
1105
  if (seen.has(key)) continue; seen.add(key);
954
- wl.push(ox, oy, l, lv, quads[i].face);
1106
+ wl.push(ox, oy, l, lv, quads[i].face, 0); // iLayer unused for water (VS pins sea level)
955
1107
  }
956
1108
  _instWaterN = wl.length / FLOATS;
957
1109
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(wl), gl.DYNAMIC_DRAW);
@@ -1014,6 +1166,6 @@ export async function initMapspinnerRender(gl, opts = {}) {
1014
1166
  }
1015
1167
  return out;
1016
1168
  }
1017
- function setHpf(tex, res, tex2) { _hpfTex = tex; _hpfRes = res|0; _hpfTex2 = tex2 || null; } // tex2 = RG8(temp,humid) pack (W12)
1169
+ function setHpf(tex, res, tex2) { _hpfTex = tex; _hpfRes = res|0; _hpfTex2 = tex2 || null; invalidatePool(); } // tex2 = RG8(temp,humid) pack (W12); HPF change -> re-bake THC tiles
1018
1170
  return { get prog(){ return prog; }, render, checkGlError, probe, sampleGroundM, cullMatrix, recompile, setHpf, GRID, indexCount: indices.length, M4 };
1019
1171
  }
@@ -390,7 +390,7 @@ highp vec2 faceWarp(highp vec2 p){ return defRadius * tan((p / defRadius) * 0.78
390
390
  uniform float uDetailOverlay; // amplitude lever (user-tuned 6; 0 = off; __detailOverlay)
391
391
  uniform float uGrid; // interior cells per tile edge (GRID=16); for mesh-based vertex normals
392
392
  uniform float uNrmStepM; // lit-normal FD step in metres (150); uniform-fed to defeat FXC constant folding
393
- #if defined(_VERTEX_) || defined(_PROBE_)
393
+ #if defined(_VERTEX_) || defined(_PROBE_) || defined(_HEIGHTBAKE_)
394
394
  highp float broadShapeM(vec3 dir, float reliefMul, float ridgeMul){ // W7: returns metres (~13000) -> highp
395
395
  if (hasHpf == 0) return 0.0;
396
396
  float mtnAmp = 1.0; // mountain-amplitude (was a window.__mtnAmp uniform; inlined at neutral 1.0)
@@ -693,6 +693,25 @@ highp float composeHeight(vec3 dir0, highp vec2 faceLocal, float tileM){ // W7
693
693
  layout(location=0) in vec3 vertex; // vertex.xy in [0,1] parametric quad coord
694
694
  layout(location=1) in highp vec4 iOffset; // W7: PER-INSTANCE (ox, oy, l, level) face-local metres ~6.4e6 -> highp
695
695
  layout(location=2) in float iFace; // PER-INSTANCE cube face index 0..5
696
+ layout(location=3) in float iLayer; // THC: PER-INSTANCE height-pool array layer for this tile (uThc on)
697
+ // THC (Tile Heightmap Cache): when uThc>0.5 the VS reads the baked composeHeight from uHeightPool
698
+ // (a 2D-array, one layer per visible tile, parametric uv = vertex.xy) instead of re-evaluating
699
+ // composeHeight 5x/vertex. uPoolLinear=0 -> manual bilinear (R32F not linear-filterable).
700
+ uniform float uThc;
701
+ uniform sampler2DArray uHeightPool;
702
+ uniform float uPoolRes;
703
+ uniform float uPoolLinear;
704
+ highp float thcSample(highp vec2 uv, float layer){
705
+ highp vec2 t = clamp(uv, 0.0, 1.0) * (uPoolRes - 1.0);
706
+ if (uPoolLinear > 0.5) return texture(uHeightPool, vec3((t + 0.5) / uPoolRes, layer)).r;
707
+ highp vec2 f = floor(t); highp vec2 fr = t - f;
708
+ highp vec2 b0 = (f + 0.5) / uPoolRes, b1 = (f + 1.5) / uPoolRes;
709
+ highp float h00 = texture(uHeightPool, vec3(b0.x, b0.y, layer)).r;
710
+ highp float h10 = texture(uHeightPool, vec3(b1.x, b0.y, layer)).r;
711
+ highp float h01 = texture(uHeightPool, vec3(b0.x, b1.y, layer)).r;
712
+ highp float h11 = texture(uHeightPool, vec3(b1.x, b1.y, layer)).r;
713
+ return mix(mix(h00, h10, fr.x), mix(h01, h11, fr.x), fr.y);
714
+ }
696
715
  out highp vec3 vWorld; // W7 highp ISLAND: absolute world pos ~6.4e6 m (FS lighting/atmosphere) -- fp16 would jitter it
697
716
  out highp float vH; // W7: signed elevation (metres, ~13000) -- highp for the material ramp / strata / ocean depth
698
717
  out highp vec3 vNrm; // W8: world-space per-vertex analytic normal (fixed-step central diff of the FULL composeHeight). The SOLE FS lit normal -- replaces the jittery cross(dFdx,dFdy). highp to match vWorld on the ~6.4e6 m planet.
@@ -949,7 +968,26 @@ void main() {
949
968
  // sides of a seam share the same dir0/tangent frame -> consistent normals, no row artifact.
950
969
  highp float hN0 = 0.0;
951
970
  highp vec3 vN = dir0;
952
- if (uIsWater < 0.5) {
971
+ if (uIsWater < 0.5 && uThc > 0.5) {
972
+ // THC FAST PATH: height + symmetric central-diff normal from the baked pool. 5 bilinear texture
973
+ // taps replace 5 full composeHeight evals (the VS-bound cost). The 4 normal taps use the SAME
974
+ // parametric step as the composeHeight path; the dir at each tap is a cheap faceWarp+normalize
975
+ // (no field eval). uNrmStepM-scaled step => the same smoothed (low-pass) normal as the slow path.
976
+ hN0 = thcSample(vertex.xy, iLayer);
977
+ highp float nStepM = (uNrmStepM > 0.0) ? uNrmStepM : 300.0;
978
+ highp float duP = clamp(nStepM / max(defOffset.z, 1.0), 1.0 / ((uGrid > 0.0) ? uGrid : 16.0), 0.34);
979
+ highp float hPU = thcSample(vertex.xy + vec2(duP, 0.0), iLayer);
980
+ highp float hMU = thcSample(vertex.xy + vec2(-duP, 0.0), iLayer);
981
+ highp float hPV = thcSample(vertex.xy + vec2(0.0, duP), iLayer);
982
+ highp float hMV = thcSample(vertex.xy + vec2(0.0, -duP), iLayer);
983
+ highp vec3 dPU = normalize(defLocalToWorld * vec3(faceWarp((vertex.xy + vec2(duP,0.0)) * defOffset.z + defOffset.xy), defRadius));
984
+ highp vec3 dMU = normalize(defLocalToWorld * vec3(faceWarp((vertex.xy + vec2(-duP,0.0)) * defOffset.z + defOffset.xy), defRadius));
985
+ highp vec3 dPV = normalize(defLocalToWorld * vec3(faceWarp((vertex.xy + vec2(0.0,duP)) * defOffset.z + defOffset.xy), defRadius));
986
+ highp vec3 dMV = normalize(defLocalToWorld * vec3(faceWarp((vertex.xy + vec2(0.0,-duP)) * defOffset.z + defOffset.xy), defRadius));
987
+ vN = normalize(cross(dPU * (defRadius + hPU) - dMU * (defRadius + hMU),
988
+ dPV * (defRadius + hPV) - dMV * (defRadius + hMV)));
989
+ if (dot(vN, dir0) < 0.0) vN = -vN;
990
+ } else if (uIsWater < 0.5) {
953
991
  hN0 = composeHeight(dir0, faceLocal, defOffset.z); // center = the geometry height h
954
992
  // VERTEX NORMAL = CENTRAL DIFFERENCE in PARAMETRIC MESH SPACE over the FULL composeHeight (2026-06-14
955
993
  // jagged-normal fix). Two earlier methods both jagged: (a) interior FORWARD mesh-cell cross product
@@ -2229,3 +2267,22 @@ void main(){
2229
2267
  probeOut = vec4(h, 0.0, 0.0, 1.0);
2230
2268
  }
2231
2269
  #endif
2270
+
2271
+ #ifdef _HEIGHTBAKE_
2272
+ // THC HEIGHT-CACHE BAKE (2026-06-14): render ONE tile's composeHeight into a height-pool texel grid.
2273
+ // Each fragment = one parametric (u,v) of the tile; the dir + faceLocal mapping is IDENTICAL to the
2274
+ // mesh VS (faceWarp(vertex.xy*defOffset.z+defOffset.xy), normalize(frame*vec3(faceLocal,defRadius)))
2275
+ // so the baked height matches the procedural geometry exactly. NON-DESTRUCTIVE: this is a separate
2276
+ // program; the live VS still computes composeHeight until thc-vs-sample switches it to a pool sample.
2277
+ uniform mat3 uBakeFrame; // face-local -> world for this tile's face (== faceFrame(face))
2278
+ uniform vec4 uBakeOffset; // tile (ox, oy, l, level) face-local metres (== iOffset)
2279
+ uniform float uBakeRes; // bake grid resolution (texels per edge)
2280
+ out vec4 bakeOut;
2281
+ void main(){
2282
+ highp vec2 uv = (gl_FragCoord.xy - 0.5) / max(uBakeRes - 1.0, 1.0); // texel -> parametric [0,1]
2283
+ highp vec2 faceLocal = faceWarp(uv * uBakeOffset.z + uBakeOffset.xy);
2284
+ highp vec3 dir0 = normalize(uBakeFrame * vec3(faceLocal, defRadius));
2285
+ highp float h = composeHeight(dir0, faceLocal, uBakeOffset.z);
2286
+ bakeOut = vec4(h, 0.0, 0.0, 1.0);
2287
+ }
2288
+ #endif