/* ============================================================================ SHOCKWAVE + FORCEFIELD SYSTEM ---------------------------------------------------------------------------- Real mesh-based shockwaves for impacts and forcefields, not camera-facing quads. Each event spawns an expanding ring on the ground plane; forcefields are domes with faction-tinted energy. The shader samples procedural 4K noise so the ring never pixelates at cinematic zoom. INTEGRATION CONTRACT mfShockwaveBoot() after initGL3D() mfShockwaveHit(x,y,radius,energy,faction, opts) mfShockwaveForcefield(x,y,radius,faction, opts) mfShockwaveDraw(S_nA, culler) once per frame after opaque, before bloom mfShockwaveTick(dt) mfShockwaveGLReset() on GL context rebuild mfShockwaveReset() on resetWorld() Globals: uses window.gl, window.mkProg, window.matVP, window.cam, etc. ============================================================================ */ (function(){ 'use strict'; const MF_SW_CAP=64; const MF_SW_FLOATS=12; const MF_SW_RING_INST=new Float32Array(MF_SW_CAP*MF_SW_FLOATS); const MF_SW_DOME_INST=new Float32Array(MF_SW_CAP*MF_SW_FLOATS); const MF_SW_RING_MAP=new Int16Array(MF_SW_CAP); const mfSwPresented=new Uint8Array(MF_SW_CAP); /* Ring instance data: x,y,z, radius, age01, opacity, r,g,b, speed, kind, seed */ const mfSwX=new Float32Array(MF_SW_CAP); const mfSwY=new Float32Array(MF_SW_CAP); const mfSwR=new Float32Array(MF_SW_CAP); const mfSwAge=new Float32Array(MF_SW_CAP); const mfSwLife=new Float32Array(MF_SW_CAP); const mfSwOp=new Float32Array(MF_SW_CAP); const mfSwR0=new Float32Array(MF_SW_CAP); const mfSwR1=new Float32Array(MF_SW_CAP); const mfSwColR=new Uint8Array(MF_SW_CAP); const mfSwColG=new Uint8Array(MF_SW_CAP); const mfSwColB=new Uint8Array(MF_SW_CAP); const mfSwSpeed=new Float32Array(MF_SW_CAP); const mfSwKind=new Uint8Array(MF_SW_CAP); // 0=impact ring, 1=forcefield dome const mfSwSeed=new Float32Array(MF_SW_CAP); let mfSwN=0; const MF_SW_TELEM={ready:false, rings:0, domes:0, dropped:0, lastError:''}; let mfSwProg=null, mfSwVAO=null, mfSwMeshVBO=null, mfSwInstVBO=null; let mfSwU={}; let mfSwTex=null, mfSwEpoch=-1, mfSwInitFailed=false; let mfSwRingVerts=0, mfSwDomeFirst=0, mfSwDomeVerts=0; const MF_SW_VS=`#version 300 es layout(location=0) in vec3 aPos; layout(location=1) in vec3 aNrm; layout(location=2) in vec4 aI0; // x,y,z,radius layout(location=3) in vec4 aI1; // age, opacity, speed, seed layout(location=4) in vec4 aI2; // r,g,b, kind uniform mat4 uVP; uniform float uTime; out vec3 vWorld; out vec3 vLocal; out vec3 vNrm; flat out vec4 vTint; flat out vec4 vParam; void main(){ float kind=aI2.w; vec3 p=aPos; vec3 n=aNrm; float age=clamp(aI1.x,0.0,1.0); float rad=aI0.w; if(kind<0.5){ /* Ground ring: scale on XZ, keep Y at ground. */ p=vec3(p.x*rad, p.y*0.12, p.z*rad); }else{ /* Forcefield dome. */ p=vec3(p.x*rad, p.y*rad*0.65, p.z*rad); n=aNrm; } vWorld=aI0.xyz+p; /* Normalized mesh coordinates stay independent of world radius. The old radius-scaled value made every ring test outside its shader's 0..1 band. */ vLocal=aPos; vNrm=n; vTint=vec4(aI2.rgb/255.0, aI1.y); vParam=vec4(age, aI1.z, aI1.w, kind); gl_Position=uVP*vec4(vWorld,1.0); }`; const MF_SW_FS=`#version 300 es precision highp float; in vec3 vWorld; in vec3 vLocal; in vec3 vNrm; flat in vec4 vTint; flat in vec4 vParam; uniform vec3 uEye; uniform float uTime; uniform sampler2D uNoise; uniform float uHex; out vec4 o; float hexEdge(vec2 p){ const vec2 hs=vec2(1.0,1.7320508); vec2 h=hs*0.5; vec2 a=mod(p,hs)-h; vec2 b=mod(p-h,hs)-h; vec2 g=dot(a,a)out.push(p[0],p[1],p[2],n[0],n[1],n[2]); /* Ring: a FLAT annulus on the XZ plane, normal up. This was a torus tube, and a tube is why impacts showed nested rings: its surface crosses the shader's |d-1| band TWICE — once over the top of the tube and once underneath it — and with additive blending and depth writes off, both crossings composite, so one shock front drew as two concentric bright circles. One annulus is one front. It is also a quarter of the triangles (96*2 vs 64*8*2). */ const seg=96, R_IN=0.72, R_OUT=1.16, NUP=[0,1,0]; for(let i=0;ireach*reach) continue; const ratio=mfSwR1[i]/targetRadius; if(ratio>.68&&ratio<1.48) return true; } return false; } function mfShockwaveHit(x,y,radius,energy,faction,opts){ const o=opts||{}; if(mfSwN>=MF_SW_CAP){ MF_SW_TELEM.dropped++; return -1; } const q=typeof mfVfxQ==='function'?mfVfxQ():1; if(q<0.35) return -1; const prof=mfEnergyProfile(faction||'nova'); const col=prof.impact.shockwaveColor; const i=mfSwN++; mfSwPresented[i]=0; // a reused slot must not inherit a stale bit mfSwX[i]=x; mfSwY[i]=y; mfSwR0[i]=radius||18; mfSwR1[i]=o.maxRadius||Math.max(radius*3,60); mfSwR[i]=mfSwR0[i]; mfSwAge[i]=0; /* ONE faction-speed division, not two. vfxRecipe already derives shockLife as 0.34/shockwaveSpeed and mfEmitMacroFx passes that through as `life`, so dividing again here squared the faction term: a fast profile (shockwaveSpeed ~1.6) got 0.34/1.6/1.6 = 0.13 s and the annulus was gone about three frames after the detonation it belongs to. An explicit caller life is now taken as the FINAL duration; only the default derives from the profile. speedMul shortens the ring, which is what "faster" means. */ const baseLife=o.life>0?o.life:(0.7/Math.max(0.72,prof.impact.shockwaveSpeed)); mfSwLife[i]=Math.max(0.05,baseLife/Math.max(0.35,o.speedMul||1.0)); mfSwOp[i]=o.opacity||1.0; mfSwColR[i]=col[0]; mfSwColG[i]=col[1]; mfSwColB[i]=col[2]; mfSwSpeed[i]=prof.impact.shockwaveSpeed*(o.speedMul||1.0); mfSwKind[i]=0; mfSwSeed[i]=Math.random()*64; return i; } function mfShockwaveForcefield(x,y,radius,faction,opts){ const o=opts||{}; if(mfSwN>=MF_SW_CAP){ MF_SW_TELEM.dropped++; return -1; } const prof=mfEnergyProfile(faction||'nova'); const col=prof.forcefield.color; const i=mfSwN++; mfSwPresented[i]=0; mfSwX[i]=x; mfSwY[i]=y; mfSwR0[i]=radius||30; mfSwR1[i]=radius||30; mfSwR[i]=mfSwR0[i]; mfSwAge[i]=0; mfSwLife[i]=o.life||999999; /* persistent until removed */ mfSwOp[i]=(o.opacity!=null?o.opacity:prof.forcefield.opacity)*0.65; mfSwColR[i]=col[0]; mfSwColG[i]=col[1]; mfSwColB[i]=col[2]; mfSwSpeed[i]=0; mfSwKind[i]=1; mfSwSeed[i]=Math.random()*64; return i; } function mfShockwaveRemove(id){ if(id<0||id>=mfSwN) return false; /* Swap-last removal, keep prefix dense. */ const j=mfSwN-1; if(id!==j){ mfSwX[id]=mfSwX[j]; mfSwY[id]=mfSwY[j]; mfSwR[id]=mfSwR[j]; mfSwAge[id]=mfSwAge[j]; mfSwLife[id]=mfSwLife[j]; mfSwOp[id]=mfSwOp[j]; mfSwR0[id]=mfSwR0[j]; mfSwR1[id]=mfSwR1[j]; mfSwColR[id]=mfSwColR[j]; mfSwColG[id]=mfSwColG[j]; mfSwColB[id]=mfSwColB[j]; mfSwSpeed[id]=mfSwSpeed[j]; mfSwKind[id]=mfSwKind[j]; mfSwSeed[id]=mfSwSeed[j]; mfSwPresented[id]=mfSwPresented[j]; // the bit belongs to the event, not the slot } mfSwPresented[j]=0; mfSwN--; return true; } function mfShockwaveUpdate(id, opts){ if(id<0||id>=mfSwN) return false; const o=opts||{}; if(o.x!=null) mfSwX[id]=o.x; if(o.y!=null) mfSwY[id]=o.y; if(o.radius!=null){ mfSwR0[id]=o.radius; mfSwR1[id]=o.radius; } if(o.opacity!=null) mfSwOp[id]=o.opacity; if(o.life!=null){ mfSwLife[id]=o.life; mfSwAge[id]=0; } return true; } function mfShockwaveTick(dt){ if(!(dt>0)) return; let write=0; for(let i=0;i=1.0) continue; /* Expand impact rings. */ if(mfSwKind[i]===0){ const t=mfSwAge[i]; mfSwR[i]=mfSwR0[i]+(mfSwR1[i]-mfSwR0[i])*t; }else{ mfSwR[i]=mfSwR0[i]; } if(write!==i){ mfSwX[write]=mfSwX[i]; mfSwY[write]=mfSwY[i]; mfSwR[write]=mfSwR[i]; mfSwAge[write]=mfSwAge[i]; mfSwLife[write]=mfSwLife[i]; mfSwOp[write]=mfSwOp[i]; mfSwR0[write]=mfSwR0[i]; mfSwR1[write]=mfSwR1[i]; mfSwColR[write]=mfSwColR[i]; mfSwColG[write]=mfSwColG[i]; mfSwColB[write]=mfSwColB[i]; mfSwSpeed[write]=mfSwSpeed[i]; mfSwKind[write]=mfSwKind[i]; mfSwSeed[write]=mfSwSeed[i]; mfSwPresented[write]=mfSwPresented[i]; } write++; } /* Compaction moved live events down; anything at or past the new end is a dead slot whose presentation bit would otherwise answer a later query. */ for(let i=write;iB.x1+pad||mfSwY[i]B.y1+pad)) continue; } const dst=mfSwKind[i]===1?MF_SW_DOME_INST:MF_SW_RING_INST; const row=mfSwKind[i]===1?nd++:n++,o=row*MF_SW_FLOATS; if(mfSwKind[i]===0) MF_SW_RING_MAP[row]=i; dst[o+0]=mfSwX[i];dst[o+1]=terrainH(mfSwX[i],mfSwY[i])+(mfSwKind[i]===1?r*.3:.3);dst[o+2]=mfSwY[i];dst[o+3]=r; dst[o+4]=Math.min(1,mfSwAge[i]);dst[o+5]=mfSwKind[i]===1?mfSwOp[i]:mfSwOp[i]*(1-mfSwAge[i]*mfSwAge[i]); dst[o+6]=mfSwSpeed[i];dst[o+7]=mfSwSeed[i]; dst[o+8]=mfSwColR[i];dst[o+9]=mfSwColG[i];dst[o+10]=mfSwColB[i];dst[o+11]=mfSwKind[i]; } if(!n&&!nd) return true; gl.useProgram(mfSwProg); gl.uniformMatrix4fv(mfSwU.uVP,false,matVP); const eye=typeof eyeX==='number'?[eyeX,eyeY,eyeZ]:[cam.x,200,cam.y]; gl.uniform3f(mfSwU.uEye,eye[0],eye[1],eye[2]); gl.uniform1f(mfSwU.uTime,typeof animT==='number'?animT:mfSwNow()); gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D,mfSwTex);gl.uniform1i(mfSwU.uNoise,0); gl.enable(gl.DEPTH_TEST);gl.depthFunc(gl.LEQUAL);gl.depthMask(false); gl.disable(gl.CULL_FACE);gl.enable(gl.BLEND);gl.blendEquation(gl.FUNC_ADD);gl.blendFunc(gl.SRC_ALPHA,gl.ONE); gl.bindVertexArray(mfSwVAO);gl.bindBuffer(gl.ARRAY_BUFFER,mfSwInstVBO); if(n){ gl.uniform1f(mfSwU.uHex,0);gl.bufferSubData(gl.ARRAY_BUFFER,0,MF_SW_RING_INST,0,n*MF_SW_FLOATS); gl.drawArraysInstanced(gl.TRIANGLES,0,mfSwRingVerts,n); } if(nd){ gl.uniform1f(mfSwU.uHex,1);gl.bufferSubData(gl.ARRAY_BUFFER,0,MF_SW_DOME_INST,0,nd*MF_SW_FLOATS); gl.drawArraysInstanced(gl.TRIANGLES,mfSwDomeFirst,mfSwDomeVerts,nd); } /* Suppress the armed legacy annulus only after the complete custom pass succeeds. A later dome/upload failure must reveal every fallback rather than leave a half-presented frame. */ for(let i=0;i