Spaces:
Running
Running
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);
}
|