Spaces:
Running
Running
Download src/engine/gpufx.js from CREATORJD/massfront-playtest: direct link, hf CLI and curl.
- Browser
- Download file 24.1 kB
-
https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/engine/gpufx.js
- Command line
-
hf download hf://spaces/CREATORJD/massfront-playtest/src/engine/gpufx.js
-
curl -L -o gpufx.js https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/engine/gpufx.js
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); | |
| } | |
| } | |