CREATORJD's picture
Publish MASSFRONT web 1.33.33 (part 3)
3597f5a verified
Raw
History Blame Contribute Delete
17.6 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;
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);
}