CREATORJD's picture
Publish MASSFRONT web 1.33.51
84bc42a verified
Raw History Blame Contribute Delete
24.1 kB
/* ============================================================================
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, gpfxFullWindow=false;
let gpfxUup={}, gpfxUr={};
let gpfxAttr=[0,0,0,0]; // set by the sim while a singularity is live
/* WHICH GL CONTEXT THESE HANDLES BELONG TO, and whether the build already
failed on it. Both exist because gpfxInit()'s only guard used to be
`if(gpfxProgU) return`, and that single line carried two real defects:
1. A LOST CONTEXT LEAVES gpfxProgU TRUTHY. glrecover.js rebuilds the renderer
in place after Android reclaims the GPU, and every other subsystem is on
its list; this one was not, and gpfxGLReset() β€” which exists precisely for
this β€” had no caller anywhere in the tree. So after a restore the guard
saw a dead WebGLProgram, refused to re-init, and the transform-feedback
pass kept driving dead VAOs: GL errors every frame until gpfxAge passed
4 s, then silence. No sparks, embers, impact spray or superweapon
fragments for the rest of the session.
2. A FAILED BUILD RETRIED PER BURST. On failure the guard stayed null, so the
NEXT gpfxBurst re-entered gpfxInit β€” and gpfxBurst is called by
addMuzzleFlash. On a device where this program will not compile, every
shot fired compiled four shaders and linked two programs, then dropped
them undeleted. Latch the failure per epoch instead. */
let gpfxEpoch=-1, gpfxInitFailed=false;
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 float vSoft;
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; vSoft=0.0; return;
}
gl_Position=uVP*vec4(aP.xyz,1.0);
/* Ortho: pixels = worldSize * (drawingBufferHeight / orthoSpan).
A 3-unit spark at tactical zoom was ~9 px, then the 1.6 px death
floor turned the field into 1px grain. Fade by alpha, not size.
Sprite stays circular β€” no velocity ellipse. */
float t=clamp(life*1.55,0.0,1.0);
float sz=aV.w*uPx;
gl_PointSize=clamp(sz*(0.88+0.16*t),5.0,max(uCap,24.0));
/* Size threshold picks puff vs spark in the fragment. Layout stays
12 floats β€” do not add a 13th channel. */
vSoft=smoothstep(4.8,8.2,aV.w);
/* Contained glow. 2.7Γ— colour * 2.3Γ— kernel * additive * bloom
painted the close-up white. C&C puffs are dense, not a flashbang. */
vC=aC.rgb*(1.00+0.55*t*t);
vA=min(0.78,life*3.2);
}`;
const GPFX_FSR=`#version 300 es
precision highp float;
in vec3 vC; in float vA; in float vSoft; out vec4 o;
void main(){
vec2 q=gl_PointCoord*2.0-1.0;
float d2=dot(q,q);
if(d2>1.0) discard;
/* Soft circular volume. Spark keeps a hotter core; puff is a disc.
Peak k stays under ~1.6 so a stack of 30 does not blow the buffer. */
float body=1.0-smoothstep(0.0,mix(0.38,0.80,vSoft),d2);
float core=1.0-smoothstep(0.0,mix(0.11,0.26,vSoft),d2);
float halo=1.0-smoothstep(mix(0.18,0.36,vSoft),1.0,d2);
float k=core*mix(0.92,0.42,vSoft)+body*mix(0.62,0.88,vSoft)+halo*mix(0.22,0.38,vSoft);
vec3 hot=mix(vC,vec3(1.0),core*mix(0.55,0.22,vSoft));
o=vec4(hot*k*vA,k*vA);
}`;
/* Call before touching any gpfx handle. A new GL epoch (initGL3D bumps it, on
cold boot AND on every context restore) invalidates every handle this module
owns, so drop them and allow exactly one rebuild attempt on the new context. */
function gpfxCtxCheck(){
const ep=(typeof glEpoch!=='undefined')?glEpoch:0;
if(gpfxEpoch===ep) return;
gpfxEpoch=ep;
gpfxInitFailed=false;
gpfxGLReset();
}
function gpfxInit(){
if(gpfxProgU||gpfxInitFailed||typeof gl==='undefined'||!gl) return;
/* TWO-PHASE BUILD. compileShader()/linkProgram() are asynchronous in the
driver; it is getShaderParameter(COMPILE_STATUS) / getProgramParameter(
LINK_STATUS) that block until that program is ready. Querying inside a
single mk() made the second program wait for the first to finish. Build
BOTH first, then read both statuses, so the driver can link them in
parallel (KHR_parallel_shader_compile). Same restructure as mkProg() in
mesh.js; measured ~240 ms recovered at load.
Verify with: node tools/probe-shader-compile.mjs */
const build=(vs,fs,tf)=>{
const p=gl.createProgram();
const shaders=[];
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);
shaders.push(sh);
gl.attachShader(p,sh);
}
/* Must precede linkProgram(), so it stays in the build phase. */
if(tf) gl.transformFeedbackVaryings(p,['tfP','tfV','tfC'],gl.INTERLEAVED_ATTRIBS);
gl.linkProgram(p);
return {p:p,shaders:shaders};
};
const finish=(built)=>{
let ok=true;
for(const sh of built.shaders){
if(!gl.getShaderParameter(sh,gl.COMPILE_STATUS)){
console.error('gpufx shader',gl.getShaderInfoLog(sh)); ok=false; }
}
if(!gl.getProgramParameter(built.p,gl.LINK_STATUS)){
console.error('gpufx link',gl.getProgramInfoLog(built.p)); ok=false; }
/* Shaders are refcounted by the program once attached, so deleting them
here frees them with it instead of stranding them for the session β€” and
on the failure path below they are the objects that used to leak. */
for(const sh of built.shaders) gl.deleteShader(sh);
if(!ok){ gl.deleteProgram(built.p); return null; }
return built.p;
};
const builtU=build(GPFX_VSU,GPFX_FSU,true);
const builtR=build(GPFX_VSR,GPFX_FSR,false);
gpfxProgU=finish(builtU);
gpfxProgR=finish(builtR);
if(!gpfxProgU||!gpfxProgR){
/* One of the two may have linked. Release it rather than orphaning it, and
latch so the next muzzle flash does not recompile the whole pair. */
if(gpfxProgU) gl.deleteProgram(gpfxProgU);
if(gpfxProgR) gl.deleteProgram(gpfxProgR);
gpfxProgU=gpfxProgR=null; gpfxInitFailed=true; 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. The
High/Cinematic path returns to the full ring after it fills, so every
unused slot still must begin as a dead particle rather than a noisy speck
or coloured streak 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; gpfxFullWindow=false; }
/* 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 own the 6144-slot ring, but can still use its populated prefix
before that ring wraps. MEDIUM/LOW use their smaller prefix throughout. */
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 is a first-class mobile target. 0.62 vs HIGH 1.12 was a sparse
spark rain. Share the look; fillrate is the cheaper point cap / window. */
const k=p>=1.25?1.55:p>=0.95?1.12:p>=0.65?0.98:0.42;
return Math.max(lo==null?2:lo, Math.round(n*k));
}
/* Energy / impact: overlapping soft discs first, then a few hot sparks.
Dark additive "debris" was invisible (SRC_ALPHA,ONE * rgb*0.38 β‰ˆ nothing)
and the leftover budget went to 9 px needles. Skip the water stamp β€”
lifting every land hit to Y=16 floated the puff off the hull. */
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 terr=(typeof terrainH==='function'?terrainH(x,y):0);
const hh=Math.max(h==null?0:h, Math.max(0.4, terr+1.15));
const sp=o.speed||88, lf=o.life||0.88, sz=o.size||6.8;
const jit=o.jit==null?2.0:o.jit;
/* Volume is fewer, slower discs β€” stacking the full nn at 8+ wu
plus a white needle layer is what washed the close-up. */
const vn=Math.max(3,(nn*0.62)|0);
gpfxBurst(x,y,hh,vn,{speed:sp*0.40,up:o.up==null?0.55:o.up,life:lf*1.08,
col:c,size:sz,drag:o.drag==null?0.960:o.drag,jit:jit,dir:o.dir,skipWater:1});
if(p>=0.65)
gpfxBurst(x,y,hh+1.2,Math.max(3,nn>>2),{speed:sp*0.95,up:0.18,life:lf*0.48,
col:[255,250,232],size:sz*0.46,drag:0.90,jit:Math.min(1.8,jit),dir:o.dir,skipWater:1});
if(p>=0.95)
gpfxBurst(x,y,hh+1.8,Math.max(2,(nn*0.22)|0),{speed:sp*1.28,up:0.10,life:lf*0.34,
col:c,size:sz*0.36,drag:0.87,jit:1.1,dir:o.dir,spread:0.16,skipWater:1});
}
/* 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){
gpfxCtxCheck();
if(!gpfxProgU){ gpfxInit(); if(!gpfxProgU) return; }
n=Math.min(n|0,512); if(n<=0) return;
const o=opts||{};
/* Superweapon / crater spray over authored water. Energy / muzzle pass
skipWater so a land hit does not write a crater splash. The splash
waterFxImpact itself emits is n=12 and wfxSplashing, so it cannot loop. */
if(!o.skipWater && n>=20 && (h==null||h<16) && !(typeof wfxSplashing!=='undefined'&&wfxSplashing)
&& typeof stampWaterRipple==='function')
stampWaterRipple(x,y, Math.min(1.25, n/52));
/* 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||(o.r!=null?[o.r,o.g!=null?o.g:o.r,o.b!=null?o.b:o.r]:[255,190,90]);
const size=o.size||5.8, 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);
/* Until the full High/Cinematic ring has been filled/overwritten, every
slot above gpfxHi is the initialization zero and transforms into the same
zero. Skipping it changes no particle state or image. Once the full ring
is reached, retain the original all-capacity path: a later wrap can carry
live particles on both sides of the head. */
if(work===GPFX_CAP&&(wrapped||gpfxHi>=GPFX_CAP)) gpfxFullWindow=true;
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){
/* Before the live check, not after: a context loss during combat leaves
gpfxLive non-zero, and without this the first frames after a restore ran
transform feedback through dead VAOs. */
gpfxCtxCheck();
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; gpfxFullWindow=false;
return;
}
const p=mfVfxQ();
const work=gpfxWorkCap();
if(gpfxHi>work) gpfxHi=work;
if(gpfxLive>work) gpfxLive=work;
/* High/Cinematic preserve the historical all-capacity update after their
ring first fills/wraps. Before then, and at every lower preset, transform
feedback and the draw pass touch only the contiguous populated prefix. */
const nDraw=p>=0.95&&gpfxFullWindow?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);
/* Pixels-per-world, not raw view height. drawingBufferHeight is what
gl_PointSize is measured in; CSS innerHeight * 1/w was the smear. */
const bufH=(gl&&gl.drawingBufferHeight)||viewH||900;
const span=Math.max(1,typeof orthoSpan==='number'?orthoSpan:480);
const qMul=p>=1.25?1.20:p>=0.95?1.12:p>=0.65?1.08:0.88;
gl.uniform1f(gpfxUr.px,(bufH/span)*qMul);
/* Cap is fillrate. 52 px * 3k additive discs + bloom = white frame.
36/30 still reads as a puff, not a 1px grain. */
const cap=p>=1.25?48:p>=0.95?36:p>=0.65?30:18;
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);
}
/* Airframe smoke trail. Billboard puffs (addAirPuff) are the smoke; GPU
motes are HIGH-only soot, not gpfxEnergyBlast. Additive mid-grey read as
a white energy ribbon on the first pass β€” do not put the trail on GPU.
The crash recipe owns the eventual core/ring/debris group, so this lead-in
must not leave an extra amber coal stamp on the ground at impact. */
function gpfxAirSmoke(x,y,h,vx,vy,opts){
const o=opts||{}, p=mfVfxQ();
const sz=Math.max(o.contrail?2.4:4.2,(o.size||14)*(o.contrail?.24:o.crash?0.65:0.32));
const backX=-(vx||0)*0.18, backY=-(vy||0)*0.18;
if(typeof addAirPuff==='function'){
/* Trail shape is sampled on fixed simulation ticks. Use the air authority's
seeded lane hash when available so replayed contrails and damage smoke
do not depend on unrelated Math.random consumers. */
const owner=Number.isInteger(o.owner)?o.owner:0;
const rv=typeof mfAirCrashValue==='function'?(lane)=>mfAirCrashValue(owner,240+lane):
(lane)=>(((Math.imul((owner+1)^(lane*8191)^((typeof tick==='number'?tick:0)<<1),1103515245)+12345)>>>0)/4294967296);
const jitter=o.contrail?.22:(p>=.95?.9:.5),j0=rv(0)*2-1,j1=rv(1)*2-1,j2=rv(2),j3=rv(3);
addAirPuff(x+j0*jitter,y+j1*jitter,h,
backX+j1*(o.contrail?.18:.6),backY-(o.contrail?.35:2.2+j2*1.5),
o.contrail?(p>=.95?.62:.48):p>=.95?(o.crash?1.00:.88):.72,sz*(.9+j3*.14),
o.contrail?214:o.crash?42:54,o.contrail?222:o.crash?38:50,o.contrail?228:o.crash?34:46,o.owner);
}
}