mapspinner 0.1.39 → 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 +1 -1
- package/src/gl-render.js +96 -10
- package/src/shaders/terrain.glsl +39 -1
package/package.json
CHANGED
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
|
-
//
|
|
966
|
-
//
|
|
967
|
-
|
|
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
|
-
//
|
|
971
|
-
//
|
|
972
|
-
const
|
|
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
|
}
|
package/src/shaders/terrain.glsl
CHANGED
|
@@ -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
|