File size: 17,620 Bytes
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/* ============================================================================

   GPU PARTICLES β€” transform feedback

   ----------------------------------------------------------------------------

   The CPU particle pool is fine for forty smoke puffs; it is not fine for the

   three thousand white-hot fragments a superweapon owes the player. This is

   the industry-standard mobile answer: particles live entirely on the GPU β€”

   position, velocity, life, colour in one interleaved buffer, advanced by a

   transform-feedback pass (rasterizer discarded), rendered as additive point

   sprites in the same draw state as every other glow. The CPU's only job is

   writing newborn particles into a ring window of the buffer.



   12 floats per particle: pos(3) life(1) vel(3) size(1) rgb(3) drag(1).

   Capacity 6144 β€” a full superweapon plus every beam on screen, one draw.



   ROUTING RULE (hybrid by design β€” keep it this way):

     CPU pool (addParticle)  β†’ light, few, authored: smoke puffs, muzzle

       flashes, shock rings, flipbook fireballs, mushroom columns, debris

       chunks that need per-particle gameplay logic.

     GPU (gpfxBurst)         β†’ heavy, many, dynamic: spark fountains, ember

       storms, beam impact spray, singularity infall β€” anything where the

       COUNT is the effect. If an effect wants >24 particles a burst or

       continuous emission, it belongs here; if it wants bespoke behaviour

       per particle, it belongs on the CPU.

   ============================================================================ */
const GPFX_CAP=6144, GPFX_FLOATS=12;
let gpfxA=null, gpfxB=null, gpfxVAO_A=null, gpfxVAO_B=null, gpfxTF=null;
let gpfxProgU=null, gpfxProgR=null, gpfxHead=0, gpfxFlip=0, gpfxLive=0;
let gpfxHi=0, gpfxAge=0, gpfxDrawN=0;
let gpfxUup={}, gpfxUr={};
let gpfxAttr=[0,0,0,0];      // set by the sim while a singularity is live
const GPFX_SCRATCH=new Float32Array(GPFX_FLOATS*512);

const GPFX_VSU=`#version 300 es

layout(location=0) in vec4 aP;   // xyz + life

layout(location=1) in vec4 aV;   // vel + size

layout(location=2) in vec4 aC;   // rgb + drag

uniform float uDt; uniform float uGrav;

uniform vec4 uAttr;          // xyz = singularity core, w = strength (0 = off)

/* Height on unit 10 (terrain/water already own it). Never 0/4/5/6.

   Seabed samples are negative; max with 0.4 keeps water spray on the plane. */

uniform sampler2D uHeight;

uniform float uMap;

uniform float uHasH;

out vec4 tfP; out vec4 tfV; out vec4 tfC;

void main(){

  float life=aP.w-uDt;

  vec3 v=aV.xyz;

  v.y-=uGrav*uDt;

  /* SINGULARITY PULL. Inverse-distance attraction plus a tangential term, so

     matter SPIRALS in along an accretion path instead of falling straight β€”

     the swirl is what reads as gravity rather than suction. Anything that

     crosses the horizon is consumed. */

  if(uAttr.w>0.0){

    vec3 dvec=uAttr.xyz-aP.xyz;

    float dl=length(dvec)+3.0;

    vec3 dir=dvec/dl;

    float pull=uAttr.w*2400.0/dl;

    v+=dir*pull*uDt;

    v+=vec3(dir.z,0.0,-dir.x)*pull*0.42*uDt;

    if(dl<7.5) life=0.0;

  }

  v*=pow(aC.w,uDt*60.0);

  vec3 p=aP.xyz+v*uDt;

  /* Grounding: an ember that reaches the floor dies there in a brief flare

     rather than sinking through the world. y<0.4 is sea-level β€” hills sit

     much higher, so sample heightTex (world Y) when the terrain pass left it. */

  float floorY=0.4;

  if(uHasH>0.5&&uMap>1.0){

    vec2 uv=clamp(p.xz/uMap,0.0,1.0);

    floorY=max(0.4, texture(uHeight,uv).r+0.5);

  }

  if(p.y<floorY&&v.y<0.0){ p.y=floorY; v*=0.22; life=min(life,0.22); }

  tfP=vec4(p,life); tfV=vec4(v,aV.w); tfC=aC;

}`;
const GPFX_FSU=`#version 300 es

precision lowp float; out vec4 o; void main(){ o=vec4(0.0); }`;

const GPFX_VSR=`#version 300 es

layout(location=0) in vec4 aP;

layout(location=1) in vec4 aV;

layout(location=2) in vec4 aC;

uniform mat4 uVP; uniform float uPx; uniform float uCap;

out vec3 vC; out float vA; out vec2 vDir; out float vStreak;

void main(){

  float life=max(aP.w,0.0);

  if(aP.w<=0.0){

    gl_Position=vec4(2.0,2.0,2.0,1.0);

    gl_PointSize=0.0; vC=vec3(0.0); vA=0.0; vDir=vec2(1.0,0.0); vStreak=0.0; return;

  }

  gl_Position=uVP*vec4(aP.xyz,1.0);

  /* Hot when young. Fast sparks stretch along velocity so a blast reads as

     streaks, not a circular confetti puff. Screen-space dir is from a short

     look-ahead so the ellipse follows the shot even as the camera tilts. */

  float t=clamp(life*1.85,0.0,1.0);

  float spd=length(aV.xyz);

  float streak=clamp(spd*0.017,0.0,3.8);

  vStreak=streak;

  vec4 p1=uVP*vec4(aP.xyz+aV.xyz*0.018,1.0);

  vec2 nd=p1.xy/max(p1.w,0.05)-gl_Position.xy/max(gl_Position.w,0.05);

  float nl=length(nd);

  vDir=nl>1e-5?nd/nl:vec2(1.0,0.0);

  float sz=aV.w*uPx/max(gl_Position.w,0.55);

  gl_PointSize=clamp(sz*(1.0+streak*1.18),2.8,max(uCap,28.0))*(0.72+0.58*t);

  vC=aC.rgb*(1.08+2.95*t*t);

  vA=min(1.0,life*4.6);

}`;
const GPFX_FSR=`#version 300 es

precision highp float;

in vec3 vC; in float vA; in vec2 vDir; in float vStreak; out vec4 o;

void main(){

  vec2 q=gl_PointCoord*2.0-1.0;

  /* PointCoord Y is down; NDC Y is up. Flip so the streak follows velocity. */

  vec2 d=normalize(vec2(vDir.x,-vDir.y));

  vec2 n=vec2(-d.y,d.x);

  float along=dot(q,d), across=dot(q,n);

  float stretch=1.0+vStreak*1.65;

  float d2=(along*along)/(stretch*stretch)+across*across;

  if(d2>1.0) discard;

  float core=1.0-smoothstep(0.0,0.10,d2);

  float mid=1.0-smoothstep(0.03,0.48,d2);

  float halo=1.0-smoothstep(0.18,1.0,d2);

  float k=core*1.55+mid*0.78+halo*0.36;

  vec3 hot=mix(vC,vec3(1.0),core*0.90);

  o=vec4(hot*k*vA,k*vA);

}`;

function gpfxInit(){
  if(gpfxProgU||typeof gl==='undefined'||!gl) return;
  const mk=(vs,fs,tf)=>{
    const p=gl.createProgram();
    for(const [ty,src] of [[gl.VERTEX_SHADER,vs],[gl.FRAGMENT_SHADER,fs]]){
      const sh=gl.createShader(ty); gl.shaderSource(sh,src); gl.compileShader(sh);
      if(!gl.getShaderParameter(sh,gl.COMPILE_STATUS)){
        console.error('gpufx shader',gl.getShaderInfoLog(sh)); return null; }
      gl.attachShader(p,sh);
    }
    if(tf) gl.transformFeedbackVaryings(p,['tfP','tfV','tfC'],gl.INTERLEAVED_ATTRIBS);
    gl.linkProgram(p);
    if(!gl.getProgramParameter(p,gl.LINK_STATUS)){
      console.error('gpufx link',gl.getProgramInfoLog(p)); return null; }
    return p;
  };
  gpfxProgU=mk(GPFX_VSU,GPFX_FSU,true);
  gpfxProgR=mk(GPFX_VSR,GPFX_FSR,false);
  if(!gpfxProgU||!gpfxProgR){ gpfxProgU=gpfxProgR=null; return; }
  gpfxUup={dt:gl.getUniformLocation(gpfxProgU,'uDt'),grav:gl.getUniformLocation(gpfxProgU,'uGrav'),
           attr:gl.getUniformLocation(gpfxProgU,'uAttr'),
           height:gl.getUniformLocation(gpfxProgU,'uHeight'),
           map:gl.getUniformLocation(gpfxProgU,'uMap'),
           hasH:gl.getUniformLocation(gpfxProgU,'uHasH')};
  const wasP=gl.getParameter(gl.CURRENT_PROGRAM);
  gl.useProgram(gpfxProgU);
  if(gpfxUup.height) gl.uniform1i(gpfxUup.height,10);
  if(wasP) gl.useProgram(wasP);
  gpfxUr={vp:gl.getUniformLocation(gpfxProgR,'uVP'),px:gl.getUniformLocation(gpfxProgR,'uPx'),
           cap:gl.getUniformLocation(gpfxProgR,'uCap')};
  /* DYNAMIC_COPY with a byte length leaves GPU memory uninitialized. HIGH/CINE

     still draw GPFX_CAP once any particle is live, so garbage life/pos became

     noisy specks and coloured streaks across the ground. */
  const zero=new Float32Array(GPFX_CAP*GPFX_FLOATS);
  const mkBuf=()=>{ const b=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,b);
    gl.bufferData(gl.ARRAY_BUFFER,zero,gl.DYNAMIC_COPY); return b; };
  gpfxA=mkBuf(); gpfxB=mkBuf();
  const mkVao=(buf)=>{ const v=gl.createVertexArray(); gl.bindVertexArray(v);
    gl.bindBuffer(gl.ARRAY_BUFFER,buf);
    gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,4,gl.FLOAT,false,48,0);
    gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,4,gl.FLOAT,false,48,16);
    gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,4,gl.FLOAT,false,48,32);
    gl.bindVertexArray(null); return v; };
  gpfxVAO_A=mkVao(gpfxA); gpfxVAO_B=mkVao(gpfxB);
  gpfxTF=gl.createTransformFeedback();
}
function gpfxGLReset(){ gpfxProgU=gpfxProgR=gpfxA=gpfxB=gpfxVAO_A=gpfxVAO_B=gpfxTF=null; gpfxHead=0; gpfxLive=0; gpfxHi=0; gpfxAge=0; gpfxDrawN=0; }

/* Preset scale for DRAW-side FX. GFX.particles is 0.5/0.75/1/1.5.

   Do not multiply by perfScale β€” main.js already folds particles into it. */
function mfVfxQ(){
  return (typeof GFX!=='undefined'&&GFX.particles!=null)?GFX.particles:1;
}
/* HIGH/CINE keep the 6144 window. MEDIUM/LOW only TF+draw the prefix they

   can fill β€” mid-tier was paying 6144 verts for a handful of sparks. */
function gpfxWorkCap(){
  const p=mfVfxQ();
  if(p>=0.95) return GPFX_CAP;
  return p>=0.65?2048:1024;
}
function gpfxN(n,lo){
  const p=mfVfxQ();
  /* MEDIUM stays ~half of HIGH. CINE gets the expensive fountain. */
  const k=p>=1.25?1.55:p>=0.95?1.12:p>=0.65?0.62:0.32;
  return Math.max(lo==null?2:lo, Math.round(n*k));
}
/* Energy blast: faction spray + white needles on MED+, debris ring on HIGH.

   Spawn Y is lifted past the water-ripple stamp (n>=20 && h<16) so a land

   hit does not write a crater splash. Water fluid is another agent's file. */
function gpfxEnergyBlast(x,y,h,n,col,opts){
  const o=opts||{}, p=mfVfxQ();
  const nn=gpfxN(n, o.min==null?3:o.min);
  if(nn<=0||typeof gpfxBurst!=='function') return;
  const c=col||[180,220,255];
  const hh=Math.max(h==null?16:h,16);
  const sp=o.speed||148, lf=o.life||0.82, sz=o.size||3.5;
  gpfxBurst(x,y,hh,nn,{speed:sp,up:o.up==null?0.50:o.up,life:lf,
    col:c,size:sz,drag:o.drag==null?0.93:o.drag,jit:o.jit==null?3.6:o.jit,dir:o.dir});
  if(p>=0.65)
    gpfxBurst(x,y,hh+2.0,Math.max(4,nn>>1),{speed:sp*1.62,up:0.14,life:lf*0.64,
      col:[255,253,246],size:sz*0.74,drag:0.89,jit:2.1,dir:o.dir});
  if(p>=0.95){
    const dark=[Math.max(48,c[0]*0.38)|0,Math.max(42,c[1]*0.34)|0,Math.max(38,c[2]*0.30)|0];
    gpfxBurst(x,y,hh+0.9,Math.max(5,nn>>1),{speed:sp*0.78,up:0.68,life:lf*1.22,
      col:dark,size:sz*1.22,drag:0.965,jit:4.4});
    gpfxBurst(x,y,hh+2.8,Math.max(4,(nn*0.38)|0),{speed:sp*1.95,up:0.08,life:lf*0.46,
      col:c,size:sz*0.52,drag:0.87,jit:1.5,dir:o.dir,spread:0.16});
  }
}

/* Emit n particles. kind of spray is caller-shaped via speed/spread/gravity:

   sparks (fast, hot, heavy), embers (slow, drifting), debris (mid, dark). */
function gpfxBurst(x,y,h,n,opts){
  if(!gpfxProgU){ gpfxInit(); if(!gpfxProgU) return; }
  n=Math.min(n|0,512); if(n<=0) return;
  /* Superweapon / crater spray over authored water. The splash that

     waterFxImpact itself emits is n=12 and wfxSplashing, so it cannot loop. */
  if(n>=20 && (h==null||h<16) && !(typeof wfxSplashing!=='undefined'&&wfxSplashing)
     && typeof stampWaterRipple==='function')
    stampWaterRipple(x,y, Math.min(1.25, n/52));
  const o=opts||{};
  /* Civic wreck / crater bursts pass h=5–10 (world Y at sea). Hills sit

     much higher β€” lift the spawn onto terrain without burying water spray

     (seabed samples are negative, so max with 0.4 keeps them on the plane). */
  const terr=(typeof terrainH==='function'?terrainH(x,y):0);
  h=Math.max(h==null?0:h, Math.max(0.4, terr+0.55));
  const sp=o.speed||90, up=o.up==null?0.55:o.up, life=o.life||1.1;
  const col=o.col||[255,190,90], size=o.size||3.2, drag=o.drag==null?0.985:o.drag;
  const dir=o.dir, spread=o.spread==null?0.30:o.spread;
  let dx=0,dz=0,hasDir=false;
  if(dir&&dir.length>=2){
    const dl=Math.hypot(dir[0],dir[1])||1;
    dx=dir[0]/dl; dz=dir[1]/dl; hasDir=true;
  }
  const S=GPFX_SCRATCH;
  for(let i=0;i<n;i++){
    const a=Math.random()*TAU, el=(Math.random()*2-1);
    const v=sp*(0.35+Math.random()*0.65);
    const k=i*GPFX_FLOATS;
    S[k]=x+(Math.random()*2-1)*(o.jit||2);  S[k+1]=h+(Math.random()*2-1)*(o.jit||2);
    S[k+2]=y+(Math.random()*2-1)*(o.jit||2);
    S[k+3]=life*(0.55+Math.random()*0.65);
    if(hasDir){
      S[k+4]=dx*v+(Math.random()*2-1)*v*spread;
      S[k+5]=v*up*(0.22+Math.random()*0.75);
      S[k+6]=dz*v+(Math.random()*2-1)*v*spread;
    } else {
      S[k+4]=Math.cos(a)*v*(1-Math.abs(el)*0.4);
      S[k+5]=v*up*(0.4+Math.random()*0.9);
      S[k+6]=Math.sin(a)*v*(1-Math.abs(el)*0.4);
    }
    S[k+7]=size*(0.6+Math.random()*0.8);
    S[k+8]=col[0]/255; S[k+9]=col[1]/255; S[k+10]=col[2]/255;
    S[k+11]=drag;
  }
  const buf=gpfxFlip?gpfxB:gpfxA;
  /* Sim ticks can emit bursts between draws. Stomping ARRAY_BUFFER while an

     InstMesh VAO is current retargets that VAO's attrib 0 at the particle

     buffer β€” the next model flush then uploads instance data into GPUFX

     memory and the army's albedo appears to strobe. */
  const wasVao=gl.getParameter(gl.VERTEX_ARRAY_BINDING);
  const wasArr=gl.getParameter(gl.ARRAY_BUFFER_BINDING);
  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ARRAY_BUFFER,buf);
  const work=gpfxWorkCap();
  if(gpfxHead>=work) gpfxHead=gpfxHead%work;
  const end=Math.min(work,gpfxHead+n);
  const first=end-gpfxHead;
  gl.bufferSubData(gl.ARRAY_BUFFER,gpfxHead*48,S.subarray(0,first*GPFX_FLOATS));
  if(n>first) gl.bufferSubData(gl.ARRAY_BUFFER,0,S.subarray(first*GPFX_FLOATS,n*GPFX_FLOATS));
  const wrapped=gpfxHead+n>work;
  gpfxHead=(gpfxHead+n)%work;
  gpfxLive=Math.min(work,gpfxLive+n);
  gpfxHi=wrapped?work:Math.max(gpfxHi,gpfxHead||work);
  gpfxAge=0;
  gl.bindBuffer(gl.ARRAY_BUFFER,wasArr);
  gl.bindVertexArray(wasVao);
}

/* One call per frame from the additive pass: advance, then draw. */
function gpfxFrame(dt,matVP,viewH){
  if(!gpfxProgU||!gpfxLive){ gpfxDrawN=0; return; }
  /* Longest authored life is ~2.8Γ—1.2. After that the field is dead β€” do not

     keep transform-feedbacking the buffer. HIGH also drops; a quiet map is

     not a permanent 6144-point tax. */
  gpfxAge+=Math.min(dt>0?dt:0,0.05);
  if(gpfxAge>4.0){
    gpfxLive=0; gpfxHead=0; gpfxHi=0; gpfxDrawN=0;
    return;
  }
  const p=mfVfxQ();
  const work=gpfxWorkCap();
  if(gpfxHi>work) gpfxHi=work;
  if(gpfxLive>work) gpfxLive=work;
  /* HIGH/CINE: full buffer. MEDIUM/LOW: live high-water only. */
  const nDraw=p>=0.95?GPFX_CAP:Math.max(1,Math.min(work,gpfxHi||gpfxLive));
  gpfxDrawN=nDraw;
  const src=gpfxFlip?gpfxVAO_B:gpfxVAO_A, dst=gpfxFlip?gpfxA:gpfxB;
  /* Combat is the only caller. Leaving TF / RASTERIZER_DISCARD / ARRAY_BUFFER

     dirty made the NEXT frame's model pass sample the wrong buffer as albedo

     β€” every hull strobing for as long as any GPU particle was alive. */
  const wasProg=gl.getParameter(gl.CURRENT_PROGRAM);
  const wasVao=gl.getParameter(gl.VERTEX_ARRAY_BINDING);
  const wasArr=gl.getParameter(gl.ARRAY_BUFFER_BINDING);
  const wasDiscard=gl.isEnabled(gl.RASTERIZER_DISCARD);
  const wasTex=gl.getParameter(gl.ACTIVE_TEXTURE);
  const wasBlend=gl.isEnabled(gl.BLEND), wasCull=gl.isEnabled(gl.CULL_FACE);
  const wasDepth=gl.isEnabled(gl.DEPTH_TEST), wasMask=gl.getParameter(gl.DEPTH_WRITEMASK);
  gl.useProgram(gpfxProgU);
  gl.uniform1f(gpfxUup.dt,Math.min(dt,0.05));
  gl.uniform1f(gpfxUup.grav,gpfxAttr[3]>0?30.0:150.0);
  gl.uniform4f(gpfxUup.attr,gpfxAttr[0],gpfxAttr[1],gpfxAttr[2],gpfxAttr[3]);
  /* Height lives on 10. Bind, sample, restore β€” never unit 0, never 4/5/6,

     never bindTexture(null). */
  const hasH=typeof heightTex!=='undefined'&&heightTex;
  gl.activeTexture(gl.TEXTURE10);
  const prev10=gl.getParameter(gl.TEXTURE_BINDING_2D);
  if(hasH) gl.bindTexture(gl.TEXTURE_2D,heightTex);
  if(gpfxUup.hasH) gl.uniform1f(gpfxUup.hasH,hasH?1:0);
  if(gpfxUup.map) gl.uniform1f(gpfxUup.map,typeof MAP!=='undefined'?MAP:3200);
  gl.enable(gl.RASTERIZER_DISCARD);
  gl.bindVertexArray(src);
  gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK,gpfxTF);
  gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER,0,dst);
  gl.beginTransformFeedback(gl.POINTS);
  gl.drawArrays(gl.POINTS,0,nDraw);
  gl.endTransformFeedback();
  gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER,0,null);
  gl.bindBuffer(gl.TRANSFORM_FEEDBACK_BUFFER,null);
  gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK,null);
  gl.disable(gl.RASTERIZER_DISCARD);
  gl.activeTexture(gl.TEXTURE10);
  if(prev10) gl.bindTexture(gl.TEXTURE_2D,prev10);
  else if(hasH) gl.bindTexture(gl.TEXTURE_2D,heightTex);
  gpfxFlip^=1;
  gl.useProgram(gpfxProgR);
  gl.uniformMatrix4fv(gpfxUr.vp,false,matVP);
  /* MEDIUM dprCap 1.25 β€” keep the point cap under HIGH so fillrate stays cheaper. */
  const cap=p>=1.25?78:p>=0.95?66:p>=0.65?36:24;
  gl.uniform1f(gpfxUr.px,(viewH||900)*(p>=1.25?1.20:p>=0.95?1.12:0.88));
  if(gpfxUr.cap) gl.uniform1f(gpfxUr.cap,cap);
  gl.bindVertexArray(gpfxFlip?gpfxVAO_B:gpfxVAO_A);
  gl.drawArrays(gl.POINTS,0,nDraw);
  /* bindBuffer(ARRAY_BUFFER) AFTER restoring a VAO retargets that VAO's attrib

     0 β€” the exact leak gpfxBurst used to cause. Restore the default bind with

     VAO unbound, then put the caller's VAO back. */
  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ARRAY_BUFFER,wasArr);
  gl.bindVertexArray(wasVao);
  if(wasDiscard) gl.enable(gl.RASTERIZER_DISCARD); else gl.disable(gl.RASTERIZER_DISCARD);
  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.depthMask(wasMask);
  gl.activeTexture(wasTex);
  if(wasProg) gl.useProgram(wasProg);
}