mapspinner 0.1.39 → 0.1.41

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.39",
3
+ "version": "0.1.41",
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
@@ -338,6 +338,74 @@ export async function initMapspinnerRender(gl, opts = {}) {
338
338
  }
339
339
  if (typeof window !== 'undefined') { window.__thcBakeReadback = bakeTileReadback; window.__thcEnsureBake = ensureBake; }
340
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
+
341
409
  // FLOAT-LINEAR FORMAT PROBE (NOT a quality tier): OES_texture_float_linear lets the HPF atlas pools
342
410
  // filter LINEAR in hardware -> hpfSample collapses to one texture() call. 0 = manual 4-tap fallback.
343
411
  const _halfFloatLinearOK = !!gl.getExtension('OES_texture_float_linear') || !!gl.getExtension('OES_texture_half_float_linear');
@@ -642,7 +710,7 @@ export async function initMapspinnerRender(gl, opts = {}) {
642
710
  // the instance data is identical -> skip the Float32Array build + bufferData + water dedup Set-loop
643
711
  // and just rebind+draw. Pure CPU/GC win (GPU is vertex-bound, the upload is off the critical path).
644
712
  const instBufWater=gl.createBuffer();
645
- let _instQuadsRef=null, _instWaterRef=null, _instWaterN=0;
713
+ let _instQuadsRef=null, _instWaterRef=null, _instWaterN=0, _lastThc=false;
646
714
 
647
715
  // per-face local->world (cube face -> sphere local frame). Column-major mat3 packed
648
716
  // into a Float32Array(9). Matches localToWorld3 convention:
@@ -962,14 +1030,27 @@ export async function initMapspinnerRender(gl, opts = {}) {
962
1030
  gl.uniformMatrix4fv(U('defViewProjRel'), false, viewProjRel);
963
1031
  gl.uniform1f(U('defRadius'), R);
964
1032
  const n = quads.length;
965
- // SINGLE-SOURCE HEIGHT: every leaf is the pure per-vertex composeHeight() function, evaluated every
966
- // frame -- no bake cache. The instance buffer carries [ox,oy,l,level,face] (5 floats); the VS computes
967
- // h = composeHeight(dir) for each vertex.
968
- 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]
969
1036
  if (n > 0) {
970
- // STATIC-FRAME SKIP: only rebuild + re-upload the instance data when the quad SET changed (the
971
- // orchestrator passes the SAME quads array object on a static camera, a fresh array on rebuild).
972
- 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);
973
1054
  gl.bindBuffer(gl.ARRAY_BUFFER, instBuf);
974
1055
  if (_dirty) {
975
1056
  const inst = new Float32Array(n * FLOATS);
@@ -977,12 +1058,17 @@ export async function initMapspinnerRender(gl, opts = {}) {
977
1058
  const q = quads[i].quad;
978
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;
979
1060
  inst[i*FLOATS+4] = quads[i].face;
1061
+ inst[i*FLOATS+5] = _layers ? _layers[i] : 0.0;
980
1062
  }
981
1063
  gl.bufferData(gl.ARRAY_BUFFER, inst, gl.DYNAMIC_DRAW);
982
1064
  }
1065
+ _lastThc = _thc;
983
1066
  const STRIDE = FLOATS * 4;
984
1067
  gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, STRIDE, 0); gl.vertexAttribDivisor(1, 1); // iOffset
985
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); }
986
1072
  gl.uniform1f(U('uIsWater'), 0.0);
987
1073
  // UNDERWATER DETECTION: camera below sea level enables underwater shading + water surface
988
1074
  // rendering from below. Set before the terrain draw so the FS can apply underwater fog.
@@ -1017,7 +1103,7 @@ export async function initMapspinnerRender(gl, opts = {}) {
1017
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; }
1018
1104
  const key = quads[i].face + ':' + ox + ':' + oy + ':' + l;
1019
1105
  if (seen.has(key)) continue; seen.add(key);
1020
- 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)
1021
1107
  }
1022
1108
  _instWaterN = wl.length / FLOATS;
1023
1109
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(wl), gl.DYNAMIC_DRAW);
@@ -1080,6 +1166,6 @@ export async function initMapspinnerRender(gl, opts = {}) {
1080
1166
  }
1081
1167
  return out;
1082
1168
  }
1083
- 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
1084
1170
  return { get prog(){ return prog; }, render, checkGlError, probe, sampleGroundM, cullMatrix, recompile, setHpf, GRID, indexCount: indices.length, M4 };
1085
1171
  }
@@ -222,7 +222,7 @@ uniform float uMtnBandWide; // widen mtn=smoothstep(16.8,18.6,ele
222
222
  uniform float uClimateRelief; // widen wetLowFlat(0.66,0.9,humid) + coldFlat(0.18,0.34,temp) reliefMul gates
223
223
  uniform float uIsleWide; // widen isleZone seaBias gates (50,350)+(900,1600) -> (30,600)+(600,2200)
224
224
  uniform float uCarveWide; // widen the river/canyon/lake/dune CLIMATE gates so carve depth fades in over a wide span
225
- float canyonRidgeField(vec3 dir){ return inciseRidgeField(normalize(dir) + vec3(13.7, -4.2, 8.9), 96.0, 2.07); } // phase offset matches FS canyonMask. baseFreq 48->96 (user 2026-06-14: double canyon frequency = 2x finer/denser gorge network, more visible at the deck). Shared VS carve + FS mask -> congruent by construction.
225
+ float canyonRidgeField(vec3 dir){ return inciseRidgeField(normalize(dir) + vec3(13.7, -4.2, 8.9), 380.0, 2.07); } // phase offset matches FS canyonMask. baseFreq 380 = ~100km gorge network (user 2026-06-14: doubled 380->760 to restore deck-visible canyons after a 380->96 regression that put gorges ~400km apart = 'missing', then HALVED 760->380 back to the ~100km baseline). Shared VS carve + FS mask -> congruent by construction.
226
226
  // CANYON cross-section now reads as a CANYON, not a V-notch: STEEP WALLS + a FLAT FLOOR.
227
227
  // wall = a sharp smoothstep band -> the carve drops fast over a narrow ridge interval (the cliff
228
228
  // walls), instead of the old gentle bench+gorge blend that made shallow V-troughs.
@@ -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