File size: 20,718 Bytes
84bc42a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
/* ============================================================================

   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)<dot(b,b)?a:b;

  float d=0.5-max(abs(g.x)*0.8660254+abs(g.y)*0.5,abs(g.y));

  float w=max(fwidth(d)*1.35,0.012);

  return 1.0-smoothstep(0.0,w,d);

}



void main(){

  float age=vParam.x, speed=vParam.y, seed=vParam.z, kind=vParam.w;

  vec3 col=vTint.rgb;

  float alpha=0.0;

  if(kind<0.5){

    /* ONE readable soft annulus with a bright LEADING edge.

       u runs 0 at the inner rim to 1 at the outer rim, so the front of the

       wave is the incandescent part and everything behind it falls off into

       the burn. That asymmetry is what makes it read as a travelling shock

       front rather than a drawn circle, and there is exactly one of it. */

    float d=length(vLocal.xz);

    float u=clamp((d-0.72)/0.44,0.0,1.0);

    float ang=atan(vLocal.z,vLocal.x)*0.15915494+0.5;

    float turbulence=texture(uNoise,vec2(ang*2.0+seed,age*.18+seed*.071)).a;

    float body=smoothstep(0.0,0.42,u)*(1.0-smoothstep(0.62,1.0,u));

    float lead=exp(-pow((u-0.80)/0.115,2.0));

    float rough=0.72+0.28*turbulence;

    float fade=(1.0-age)*(1.0-age*0.55);

    alpha=(body*0.42+lead*0.95)*rough*fade*vTint.a;

    vec3 hot=mix(col,vec3(1.0),0.72);

    col=mix(col*0.85,hot,clamp(lead*1.15,0.0,1.0));

  }else{

    /* Forcefield dome. */

    vec3 V=normalize(uEye-vWorld);

    float rim=pow(1.0-clamp(abs(dot(normalize(vNrm),V)),0.0,1.0),2.6);

    float foot=1.0-smoothstep(0.012,0.115,vLocal.y);

    float cell=hexEdge((vLocal.xz+vec2(vLocal.y*0.17,-vLocal.y*0.11))*mix(6.0,14.0,uHex));

    float energyPulse=sin(vLocal.y*14.0-uTime*4.2+vWorld.x*0.05+vWorld.z*0.04)*0.5+0.5;

    float hexGlow=cell*(0.08+0.24*energyPulse);

    float breathe=0.94+0.06*sin(uTime*2.5+vWorld.x*0.02+vWorld.z*0.015);

    /* Faction-coloured rim, white core, hex lattice */

    vec3 rimCol=mix(col,vec3(0.88,0.96,1.0),rim*0.55+hexGlow*uHex*0.28);

    alpha=(rim*0.38+foot*0.18+uHex*(0.022+hexGlow))*vTint.a*breathe;

    col=mix(rimCol,vec3(1.0),rim*rim*0.4+foot*0.3);

  }

  if(alpha<0.004) discard;

  o=vec4(col,alpha);

}`;

function mfSwNow(){ return (typeof performance!=='undefined'&&performance.now?performance.now()*0.001:Date.now()*0.001); }

function mfSwMesh(){
  const out=[];
  const put=(p,n)=>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;i<seg;i++){
    const t0=i/seg*Math.PI*2, t1=(i+1)/seg*Math.PI*2;
    const c0=Math.cos(t0), s0=Math.sin(t0), c1=Math.cos(t1), s1=Math.sin(t1);
    const A=[c0*R_IN,0,s0*R_IN],  B=[c0*R_OUT,0,s0*R_OUT];
    const C=[c1*R_IN,0,s1*R_IN],  D=[c1*R_OUT,0,s1*R_OUT];
    put(A,NUP); put(B,NUP); put(D,NUP);
    put(A,NUP); put(D,NUP); put(C,NUP);
  }
  mfSwRingVerts=out.length/6;
  mfSwDomeFirst=out.length/6;
  /* Dome: hemisphere. */
  const lat=8, lng=20;
  for(let iy=0;iy<lat;iy++){
    const a0=iy/lat*Math.PI*0.5, a1=(iy+1)/lat*Math.PI*0.5;
    const r0=Math.cos(a0), r1=Math.cos(a1), h0=Math.sin(a0), h1=Math.sin(a1);
    for(let ix=0;ix<lng;ix++){
      const t0=ix/lng*Math.PI*2, t1=(ix+1)/lng*Math.PI*2;
      const P00=[r0*Math.cos(t0),h0,r0*Math.sin(t0)];
      const P01=[r1*Math.cos(t0),h1,r1*Math.sin(t0)];
      const P11=[r1*Math.cos(t1),h1,r1*Math.sin(t1)];
      const P10=[r0*Math.cos(t1),h0,r0*Math.sin(t1)];
      put(P00,P00); put(P01,P01); put(P11,P11);
      put(P00,P00); put(P11,P11); put(P10,P10);
    }
  }
  mfSwDomeVerts=out.length/6-mfSwDomeFirst;
  return new Float32Array(out);
}

function mfShockwaveBoot(){
  if(mfSwInitFailed) return false;
  if(typeof gl==='undefined'||!gl||typeof mkProg!=='function'){
    MF_SW_TELEM.lastError='GL or mkProg unavailable'; return false;
  }
  if(mfSwProg&&mfSwVAO&&mfSwInstVBO) return true;
  const wasVAO=gl.getParameter(gl.VERTEX_ARRAY_BINDING);
  const wasArr=gl.getParameter(gl.ARRAY_BUFFER_BINDING);
  try{
    mfSwProg=mkProg(MF_SW_VS,MF_SW_FS,'shockwave');
    /* mkProg() defers link validation (mesh.js) — ask explicitly. */
    if(!mfProgOk(mfSwProg)) throw new Error('shockwave shader did not link');
    mfSwU.uVP=gl.getUniformLocation(mfSwProg,'uVP');
    mfSwU.uEye=gl.getUniformLocation(mfSwProg,'uEye');
    mfSwU.uTime=gl.getUniformLocation(mfSwProg,'uTime');
    mfSwU.uNoise=gl.getUniformLocation(mfSwProg,'uNoise');
    mfSwU.uHex=gl.getUniformLocation(mfSwProg,'uHex');

    mfSwMeshVBO=gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER,mfSwMeshVBO);
    gl.bufferData(gl.ARRAY_BUFFER,mfSwMesh(),gl.STATIC_DRAW);

    mfSwInstVBO=gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER,mfSwInstVBO);
    gl.bufferData(gl.ARRAY_BUFFER,MF_SW_CAP*MF_SW_FLOATS*4,gl.DYNAMIC_DRAW);

    mfSwVAO=gl.createVertexArray();
    gl.bindVertexArray(mfSwVAO);
    gl.bindBuffer(gl.ARRAY_BUFFER,mfSwMeshVBO);
    gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,3,gl.FLOAT,false,24,0);
    gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,3,gl.FLOAT,false,24,12);
    gl.bindBuffer(gl.ARRAY_BUFFER,mfSwInstVBO);
    const st=MF_SW_FLOATS*4;
    gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,4,gl.FLOAT,false,st,0); gl.vertexAttribDivisor(2,1);
    gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3,4,gl.FLOAT,false,st,16); gl.vertexAttribDivisor(3,1);
    gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4,4,gl.FLOAT,false,st,32); gl.vertexAttribDivisor(4,1);
    gl.bindVertexArray(null);

    /* The annulus samples a narrow 1D-looking band on a mesh; 512 seamless

       noise texels exceed its projected detail while avoiding a first-impact

       2K CPU generation hitch on High/Cinematic. */
    mfSwTex=mfNoiseUpload('shockwave',{size:512,seed:42,hot:[255,230,180]});
    MF_SW_TELEM.ready=true;
    return true;
  }catch(e){
    MF_SW_TELEM.ready=false;
    MF_SW_TELEM.lastError=String(e&&e.message||e).slice(0,180);
    console.warn('shockwave boot failed',e);
    mfSwProg=null; mfSwVAO=null; mfSwMeshVBO=null; mfSwInstVBO=null; mfSwTex=null;
    mfSwInitFailed=true;
    return false;
  }finally{
    gl.bindVertexArray(wasVAO);
    gl.bindBuffer(gl.ARRAY_BUFFER,wasArr);
  }
}

function mfShockwaveGLReset(){
  mfSwProg=null; mfSwVAO=null; mfSwMeshVBO=null; mfSwInstVBO=null; mfSwTex=null;
  mfSwInitFailed=false; MF_SW_TELEM.ready=false;
}

function mfShockwaveReset(){ mfSwN=0;mfSwPresented.fill(0); }

function mfShockwavePresentedAt(x,y,targetRadius){
  x=Number(x);y=Number(y);targetRadius=Math.max(1,Number(targetRadius)||1);
  for(let i=0;i<mfSwN;i++){
    if(!mfSwPresented[i]||mfSwKind[i]!==0) continue;
    const dx=mfSwX[i]-x,dy=mfSwY[i]-y,reach=Math.max(3,targetRadius*.12);
    if(dx*dx+dy*dy>reach*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<mfSwN;i++){
    mfSwAge[i]+=dt/mfSwLife[i];
    if(mfSwAge[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;i<mfSwN;i++) mfSwPresented[i]=0;
  mfSwN=write;
  MF_SW_TELEM.rings=0; MF_SW_TELEM.domes=0;
  for(let i=0;i<mfSwN;i++) if(mfSwKind[i]===0) MF_SW_TELEM.rings++; else MF_SW_TELEM.domes++;
}

function mfShockwaveDraw(S_nA,culler){
  mfSwPresented.fill(0);
  if(mfSwN<=0) return;
  if(!mfShockwaveBoot()||!mfSwProg) return false;
  const wasProg=gl.getParameter(gl.CURRENT_PROGRAM);
  const wasVAO=gl.getParameter(gl.VERTEX_ARRAY_BINDING);
  const wasArr=gl.getParameter(gl.ARRAY_BUFFER_BINDING);
  const wasActive=gl.getParameter(gl.ACTIVE_TEXTURE);
  const wasActiveTex=gl.getParameter(gl.TEXTURE_BINDING_2D);
  gl.activeTexture(gl.TEXTURE0);
  const wasTex0=gl.getParameter(gl.TEXTURE_BINDING_2D);
  const wasBlend=gl.isEnabled(gl.BLEND),wasCull=gl.isEnabled(gl.CULL_FACE),wasDepth=gl.isEnabled(gl.DEPTH_TEST);
  const wasMask=gl.getParameter(gl.DEPTH_WRITEMASK),wasDepthFunc=gl.getParameter(gl.DEPTH_FUNC),wasCullMode=gl.getParameter(gl.CULL_FACE_MODE);
  const srcRGB=gl.getParameter(gl.BLEND_SRC_RGB),dstRGB=gl.getParameter(gl.BLEND_DST_RGB);
  const srcA=gl.getParameter(gl.BLEND_SRC_ALPHA),dstA=gl.getParameter(gl.BLEND_DST_ALPHA);
  const eqRGB=gl.getParameter(gl.BLEND_EQUATION_RGB),eqA=gl.getParameter(gl.BLEND_EQUATION_ALPHA);
  try{
    let n=0,nd=0;
    for(let i=0;i<mfSwN;i++){
      const r=Math.max(2,mfSwR[i]);
      if(typeof fogFxFootprintVisible==='function'&&!fogFxFootprintVisible(mfSwX[i],mfSwY[i],r)) continue;
      /* `vis` is a render-local closure. Looking it up from this classic

         script always failed, so off-camera annuli were uploaded and shaded

         until expiry. The renderer now passes that exact culler; the bounds

         fallback keeps boot probes and direct diagnostic calls safe. */
      if(typeof culler==='function'){
        if(!culler(mfSwX[i],mfSwY[i],r*1.2)) continue;
      }else if(typeof camBounds==='function'){
        const B=camBounds(),pad=r*1.2;
        if(B&&(mfSwX[i]<B.x0-pad||mfSwX[i]>B.x1+pad||mfSwY[i]<B.y0-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<n;i++) mfSwPresented[MF_SW_RING_MAP[i]]=1;
  }catch(e){
    mfSwPresented.fill(0);
    MF_SW_TELEM.lastError='draw: '+String(e&&e.message||e).slice(0,150);
    return false;
  }finally{
    gl.bindVertexArray(wasVAO);gl.bindBuffer(gl.ARRAY_BUFFER,wasArr);
    gl.blendFuncSeparate(srcRGB,dstRGB,srcA,dstA);gl.blendEquationSeparate(eqRGB,eqA);
    gl.depthFunc(wasDepthFunc);gl.depthMask(wasMask);gl.cullFace(wasCullMode);
    if(wasDepth)gl.enable(gl.DEPTH_TEST);else gl.disable(gl.DEPTH_TEST);
    if(wasCull)gl.enable(gl.CULL_FACE);else gl.disable(gl.CULL_FACE);
    if(wasBlend)gl.enable(gl.BLEND);else gl.disable(gl.BLEND);
    gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D,wasTex0);
    gl.activeTexture(wasActive);gl.bindTexture(gl.TEXTURE_2D,wasActiveTex);
    try{
      if(typeof begin3D==='function') begin3D(Number.isFinite(Number(S_nA))?Number(S_nA):0);
      else gl.useProgram(wasProg);
    }catch(_){gl.useProgram(wasProg);}
  }
  return true;
}

window.mfShockwaveBoot=mfShockwaveBoot;
window.mfShockwaveHit=mfShockwaveHit;
window.mfShockwaveForcefield=mfShockwaveForcefield;
window.mfShockwaveRemove=mfShockwaveRemove;
window.mfShockwaveUpdate=mfShockwaveUpdate;
window.mfShockwaveDraw=mfShockwaveDraw;
window.mfShockwaveTick=mfShockwaveTick;
window.mfShockwaveGLReset=mfShockwaveGLReset;
window.mfShockwaveReset=mfShockwaveReset;
window.mfShockwavePresentedAt=mfShockwavePresentedAt;
window.MF_SW_TELEM=MF_SW_TELEM;
})();