/* ============================================================================ VOLUMETRIC FX — raymarched proxy volumes for detonations, dust and plumes ---------------------------------------------------------------------------- Every "volumetric" effect in this renderer before today was a camera-facing quad with a soft texture on it. terrain.js:1280 says so in as many words: "not a raymarch". This file is the raymarch. WHAT IT IS A small ring of ANALYTIC VOLUMES — centre, anisotropic half-extents, age, density, emission — each drawn as a unit-cube proxy in ONE instanced draw. The fragment shader intersects the box in world space, marches the ray front-to-back through an ellipsoidal shell modulated by a 3D FBM texture, accumulates Beer-Lambert transmittance with a single-scatter term (an analytic sun chord weighted by two real density probes — no second march), and terminates the march on the scene depth buffer so smoke is cut by terrain, hulls and buildings instead of floating through them. Total per frame: one bufferSubData, one instanced draw, one fullscreen composite. No per-frame allocation, no per-particle GPU state. WHAT IT IS NOT — READ THIS BEFORE JUSTIFYING IT ON PERFORMANCE It is NOT a fill-rate win over the billboard system, and the measured baseline says so plainly. The billboard path is already TWO instanced draws for the entire battlefield (billboard.js:135), it already rasterises into the half-res scene target on MEDIUM, and the whole billboard system costs 0.6-1.2 ms of a 23-25 ms frame. Deleting it outright buys under one frame per second. A march step is several times the cost of FSBB's single texture fetch plus multiply, so the same explosion costs roughly an order of magnitude MORE ALU here than it did as quads. What this actually buys: - COST BECOMES BOUNDED. steps x pixels, both clamped, instead of scaling with the number of simultaneous events. Ten detonations cost the same as three, only coarser. - Blend BANDWIDTH drops (~39 additive layers on a covered pixel become one composited layer), which is the half that matters on a tiler. - The SHAPE becomes the effect: a mushroom column really is a column, lit from above, self-shadowed, and cut correctly by the ridge in front of it. That is a LOOK argument, and it is the honest one. So this ships behind a quality gate with the sprite path intact as the fallback, and it is OFF on LOW. Do not present it as a speed-up. INTEGRATION CONTRACT volFxBurst(x, y, worldY, kind, radius, opts) <- sim, gameplay thread volFxActive() <- sim, to gate sprite smoke volFxDraw(dtDraw, Sun) <- render3d, once per frame volFxGLReset() <- glrecover, after a restore volFxTelemetry() <- probes The sim call sites live in src/game/sim.js and are owned elsewhere; this file exposes the API and never reaches into the sim. Until those sites are wired the ring stays empty and the pass costs one early-out per frame. SCOPE COUPLING — everything below is read, never written: mesh.js mkProg, GL_PROG_ERRORS, glEpoch, aoReady, aoW/aoH, aoFB2, aoColB, aoDepth, matV, matVP, orthoSpan, gfxTune hud.js fogPointVisible terrain.js WATER_Y meta.js GFX (volSteps optional; a preset-derived default is used when the key is absent, so this file works before meta.js is updated) ============================================================================ */ /* Kinds. Keep these numeric and stable: they are packed into an instance attribute and compared as floats in the shader. */ const VOL_BLAST = 0; // detonation fireball: hot core, fast expansion const VOL_SMOKE = 1; // lingering smoke ball const VOL_DUST = 2; // ground-rooted, vertically billowing collapse/shear dust const VOL_PLUME = 3; // mushroom column / singularity plume const VOL_IMPACT= 4; // compact depth-clipped projectile/beam contact burst const VOL_TRAIL_ENERGY=5; // experimental swept plasma volume; probe-only until approved const VOL_TRAIL_SHELL =6; // experimental combustion/soot volume; probe-only until approved const VOL_TRAIL_CURVE_ENERGY=7; // one proxy sampling a fixed-step history texture const VOL_TRAIL_CURVE_SHELL =8; // one advected combustion/soot history volume /* Ring capacity. The arrays are always this size (48 * 16 floats = 3 KB of instance data worst case); the LIVE cap is a per-preset runtime limit so MEDIUM cannot queue a HIGH-preset workload. */ const VOL_CAP = 48; /* Struct-of-arrays, allocated once, exactly the discipline sim.js uses for its particle ring. Dense prefix: [0, volN) is live, no holes, no GC. AXIS NOTE: the sim's (x,y) is the GROUND PLANE and terrainH(x,y) is world Y. These arrays store world XYZ with Y genuinely up, so the shader's .y means what it says. Callers pass (x, y, worldY) and this file does the swap. */ const volPX = new Float32Array(VOL_CAP); // world X const volPY = new Float32Array(VOL_CAP); // world Y (UP) const volPZ = new Float32Array(VOL_CAP); // world Z (the sim's y) const volR0 = new Float32Array(VOL_CAP); // spawn radius, world units const volLife = new Float32Array(VOL_CAP); // seconds const volT = new Float32Array(VOL_CAP); // elapsed seconds const volDens = new Float32Array(VOL_CAP); const volEmis = new Float32Array(VOL_CAP); const volRise = new Float32Array(VOL_CAP); // world units/s along +Y const volDX = new Float32Array(VOL_CAP); const volDZ = new Float32Array(VOL_CAP); const volSeed = new Float32Array(VOL_CAP); /* Internal High/Cinematic material profiles. This travels beside the seed in an existing instance lane, so a profile does not become another draw or another macro-FX layer. */ const volStyle= new Uint8Array(VOL_CAP); const volAngle= new Float32Array(VOL_CAP); // world XZ direction for impacts /* Probe-only swept-trail axis and half length. Keeping the full 3D tangent is essential: an artillery arc cannot be represented by the yaw-only impact orientation without turning each steep segment into a separate upright capsule at the RTS camera angle. */ const volTX = new Float32Array(VOL_CAP); const volTY = new Float32Array(VOL_CAP); const volTZ = new Float32Array(VOL_CAP); const volTHalf= new Float32Array(VOL_CAP); const volAX = new Float32Array(VOL_CAP); // aspect const volAY = new Float32Array(VOL_CAP); const volAZ = new Float32Array(VOL_CAP); const volKind = new Uint8Array(VOL_CAP); const volTR = new Uint8Array(VOL_CAP); const volTG = new Uint8Array(VOL_CAP); const volTB = new Uint8Array(VOL_CAP); let volN = 0; /* Draw-side scratch. Preallocated: this is per-frame code. */ const VOL_INST_FLOATS = 20; // 80 B per instance const volInstData = new Float32Array(VOL_CAP*VOL_INST_FLOATS); const volOrder = new Int32Array(VOL_CAP); /* One bit per live proxy, rebuilt after a successful composite. Render-side fallback suppression queries this exact set instead of the old global `drawn>0`, which could hide a blast merely because an unrelated dust volume happened to render in the same frame. */ const volPresented = new Uint8Array(VOL_CAP); const VOL_ZERO4 = new Float32Array([0,0,0,0]); const volKey = new Float32Array(VOL_CAP); const volArea = new Float32Array(VOL_CAP); /* GL handles. Every one of these is invalidated by a context loss, which is what volEpoch is for. */ let volProg=null, volProgUp=null, volVAO=null, volEmptyVAO=null, volCubeVBO=null, volInstVBO=null; let volNoiseTex=null, volDriverTex=null, volTrailHistoryTex=null, volFB=null, volTex=null; let volW=0, volH=0, volFailN=0; let volU={}, volUup={}; /* WHICH GL CONTEXT THESE HANDLES BELONG TO, and whether the build already failed on it. Both halves are load-bearing, and gpufx.js:44-48 records why: a lost context leaves volProg TRUTHY (so a `if(volProg) return` guard refuses to rebuild and the pass drives dead VAOs forever), and a failed build with no latch recompiles on EVERY burst on a device that cannot compile it. */ let volEpoch=-1, volInitFailed=false; let volTime=0; /* The authored 4x4 density/emission sheet is deliberately a MATERIAL DRIVER, not a substitute billboard. It is sampled through object-space triplanar projections inside VOL_FS, where it modulates the existing 3D density and combustion field. A one-pixel neutral texture is installed synchronously; decode/upload failure simply leaves driverReady false and the procedural raymarch keeps rendering. */ const VOL_DRIVER_UNIT=14; const VOL_DRIVER_ASSET='assets/textures/vfx/mf-raymarch-density-emission-driver-v1.png'; let volDriverReady=false, volDriverState='uninitialised', volDriverError=''; let volDriverSerial=0; /* Fixed-step projectile histories. A row is one projectile and a texel is one simulation-owned sample: world XYZ plus authored radius. The renderer never appends, ages, or reorders this data. RGBA32F is sample-only (never a render target), so WebGL2 provides it without a float colour-buffer extension. Twelve points keep the fragment loop bounded while remaining smooth at the intended tactical/command camera spans. */ const VOL_TRAIL_HISTORY_ROWS=8,VOL_TRAIL_HISTORY_POINTS=12; const volTrailHistoryData=new Float32Array(VOL_TRAIL_HISTORY_ROWS*VOL_TRAIL_HISTORY_POINTS*4); const volTrailHistoryUpload=new Float32Array(VOL_TRAIL_HISTORY_ROWS*VOL_TRAIL_HISTORY_POINTS*4); const volTrailHistoryStamp=new Float32Array(VOL_TRAIL_HISTORY_ROWS*VOL_TRAIL_HISTORY_POINTS); const volTrailHistoryCount=new Uint8Array(VOL_TRAIL_HISTORY_ROWS); const volTrailHistoryKind=new Uint8Array(VOL_TRAIL_HISTORY_ROWS); const volTrailHistoryUsed=new Uint8Array(VOL_TRAIL_HISTORY_ROWS); const volTrailHistoryTint=new Uint8Array(VOL_TRAIL_HISTORY_ROWS*3); const volTrailHistorySeed=new Float32Array(VOL_TRAIL_HISTORY_ROWS); let volTrailHistoryRows=0,volTrailHistoryDirty=true,volTrailHistoryEpoch=-1; const VOL_TRAIL_HISTORY_TELEM={rows:0,points:0,pushes:0,emits:0,uploads:0, pausedStable:0,ready:false,lastError:''}; /* Read-back for probes. Sampled AFTER volFxDraw in the same frame — the counts are not zeroed by the draw. (render3d.js:3204-3207 records the opposite mistake with bbIcon.n.) */ const VOLFX_TELEMETRY={live:0, drawn:0, steps:0, marchedPx:0, compositePx:0, budget:0, progOK:false, glErr:0, w:0, h:0, culled:0, presentedBlast:0,presentedDust:0,presentedImpact:0, presentedTrailEnergy:0,presentedTrailShell:0,trailDriverReady:false,trailHistoryReady:false, fallbackQueries:0,fallbackHits:0,lastError:'', driverReady:false,driverState:'uninitialised',driverSamples:0, driverAsset:VOL_DRIVER_ASSET,driverError:''}; function volFxTelemetry(){ return VOLFX_TELEMETRY; } function volFxDriverTelemetry(){ return {ready:!!volDriverReady,state:volDriverState,error:volDriverError, asset:VOL_DRIVER_ASSET,unit:VOL_DRIVER_UNIT,dimensions:[1024,1024], grid:[4,4],gutter:4}; } function volFxPresentedAt(kind,x,y){ kind=kind|0;x=Number(x);y=Number(y); VOLFX_TELEMETRY.fallbackQueries++; if(!Number.isFinite(x)||!Number.isFinite(y)) return false; for(let i=0;i 0.48` is the most common effect gate in this codebase and it sits ABOVE the target device (perfScale 0.4125 on medium), which is exactly the bug sim.js:4849-4854 documents. So perfScale appears here ONLY as a multiplier inside the step budget, NEVER as an on/off. The on/off gates are deterministic: volSteps() > 0 preset / Advanced setting aoReady && aoW no offscreen path -> no sampleable depth -> no pass volProg latched compile failure --------------------------------------------------------------------------- */ function volStepCeiling(){ /* GFX.volSteps when meta.js carries it; otherwise derive from the preset so this module is useful before that edit lands. */ if(typeof GFX!=='undefined'&&GFX&&typeof GFX.volSteps==='number') return GFX.volSteps|0; let q='high'; if(typeof qualityKey==='function'){ try{ q=qualityKey(); }catch(e){} } return q==='low'?0 : q==='medium'?16 : q==='cinematic'?32 : 24; } /* Simulation intent is deliberately separate from render readiness. An event can occur while the AO/depth target is being resized or before the first offscreen frame has completed. Queue the density field for every tier that requests one; volFxDraw decides whether it can present this frame, while the already-armed flipbook remains the atomic failure fallback. */ function volFxEnabled(){ return volStepCeiling()>0; } function volLiveCap(){ return volStepCeiling()<=16?24:VOL_CAP; } function volIsTrailKind(k){ return k===VOL_TRAIL_ENERGY||k===VOL_TRAIL_SHELL|| k===VOL_TRAIL_CURVE_ENERGY||k===VOL_TRAIL_CURVE_SHELL; } function volFxActive(){ if(!volFxEnabled()) return false; if(typeof gl==='undefined'||!gl) return false; if(typeof aoReady==='undefined'||!aoReady) return false; if(typeof aoW==='undefined'||!(aoW>0)||!(aoH>0)) return false; volFxCtxCheck(); if(volInitFailed) return false; if(!volProg) volFxInit(); return !!volProg; } /* --------------------------------------------------------------------------- HOP 1 — sim -> ring. No allocation, no GL. --------------------------------------------------------------------------- */ function volFxBurst(x, y, worldY, kind, radius, opts){ if(!(radius>0)) return -1; const o=opts||{}; const k=kind|0; const style=Math.max(0,Math.min(volIsTrailKind(k)?15:3,o.style|0)); const cap=volLiveCap(); /* COALESCING. This is what makes the bounded-cost argument true: a cluster shell that fires 8 bomblets (sim.js:4959-4966) must produce ONE volume, not eight. Merge a same-kind neighbour that is still young. */ const mergeR=radius*0.6; for(let i=0;i0?volT[i]/volLife[i]:1; if(age>=0.25) continue; const dx=volPX[i]-x, dy=volPY[i]-worldY, dz=volPZ[i]-y; if(dx*dx+dy*dy+dz*dz > mergeR*mergeR) continue; volR0[i]=Math.max(volR0[i], radius); volDens[i]=Math.min(2.6, volDens[i]+0.18); if(o.emis>volEmis[i]) volEmis[i]=o.emis; const want=(o.life>0?o.life:volLife[i]); volLife[i]=Math.max(volLife[i], volT[i]+want); return i; } let s; if(volN0?volT[i]/volLife[i]:1; if(age>worst){ worst=age; s=i; } } } /* A NEW or EVICTED slot must never inherit the previous occupant's presentation bit. render3d suppresses an event's armed flipbook when the matching proxy reports "presented", so a stale bit makes a brand-new detonation hide its own fallback for a composite that belonged to a different explosion — the frame then shows neither the volume nor the card, which is the exact non-atomic failure this bit exists to prevent. */ volPresented[s]=0; const tint=o.tint; volPX[s]=x; volPY[s]=worldY; volPZ[s]=y; volR0[s]=radius; volLife[s]=o.life>0?o.life:(k===VOL_IMPACT?.46:k===VOL_BLAST?2.6:k===VOL_PLUME?7.0:3.4); volT[s]=0; volKind[s]=k; volDens[s]=o.dens>0?o.dens:(k===VOL_DUST?0.85:1.0); volEmis[s]=o.emis>0?o.emis:0; volRise[s]=(o.rise!=null)?o.rise:(k===VOL_PLUME?26:k===VOL_BLAST?9:k===VOL_IMPACT?2:3); volDX[s]=o.drift?o.drift[0]:0; volDZ[s]=o.drift?o.drift[1]:0; volSeed[s]=Number.isFinite(o.seed)?Math.max(0,Math.min(63.999,o.seed)):Math.random()*64; volStyle[s]=style; if(o.direction&&Number.isFinite(o.direction[0])&&Number.isFinite(o.direction[1])) volAngle[s]=Math.atan2(o.direction[1],o.direction[0]); else volAngle[s]=volSeed[s]*1.61803398875; if(o.trailAxis&&Number.isFinite(o.trailAxis[0])&&Number.isFinite(o.trailAxis[1])&& Number.isFinite(o.trailAxis[2])&&o.trailHalf>0){ volTX[s]=o.trailAxis[0];volTY[s]=o.trailAxis[1];volTZ[s]=o.trailAxis[2];volTHalf[s]=o.trailHalf; }else{ volTX[s]=0;volTY[s]=0;volTZ[s]=0;volTHalf[s]=0; } if(o.aspect){ volAX[s]=o.aspect[0]; volAY[s]=o.aspect[1]; volAZ[s]=o.aspect[2]; } else if(k===VOL_DUST){ volAX[s]=1.35; volAY[s]=0.85; volAZ[s]=1.35; } else if(k===VOL_PLUME){ volAX[s]=1.20; volAY[s]=3.60; volAZ[s]=1.20; } else if(k===VOL_IMPACT){ volAX[s]=1.45; volAY[s]=1.65; volAZ[s]=1.45; } else { volAX[s]=1.30; volAY[s]=3.20; volAZ[s]=1.30; } if(tint){ volTR[s]=tint[0]|0; volTG[s]=tint[1]|0; volTB[s]=tint[2]|0; } else if(k===VOL_DUST){ volTR[s]=196; volTG[s]=178; volTB[s]=150; } else if(k===VOL_BLAST){ volTR[s]=118; volTG[s]=112; volTB[s]=108; } else { volTR[s]=134; volTG[s]=130; volTB[s]=128; } return s; } /* Age the ring. Swap-down removal keeps the prefix dense with no holes. */ function volFxTick(dt){ if(!(dt>0)) dt=0; volTime+=dt; for(let i=0;i=volLife[i]){ const j=--volN; if(j!==i){ volPX[i]=volPX[j]; volPY[i]=volPY[j]; volPZ[i]=volPZ[j]; volR0[i]=volR0[j]; volLife[i]=volLife[j]; volT[i]=volT[j]; volDens[i]=volDens[j]; volEmis[i]=volEmis[j]; volRise[i]=volRise[j]; volDX[i]=volDX[j]; volDZ[i]=volDZ[j]; volSeed[i]=volSeed[j]; volStyle[i]=volStyle[j]; volAngle[i]=volAngle[j]; volTX[i]=volTX[j];volTY[i]=volTY[j];volTZ[i]=volTZ[j];volTHalf[i]=volTHalf[j]; volAX[i]=volAX[j]; volAY[i]=volAY[j]; volAZ[i]=volAZ[j]; volKind[i]=volKind[j]; volTR[i]=volTR[j]; volTG[i]=volTG[j]; volTB[i]=volTB[j]; /* The presentation bit is indexed by SLOT, so swap-down removal has to carry it with the event it describes; otherwise the surviving volume inherits a dead neighbour's bit and suppresses the wrong fallback. */ volPresented[i]=volPresented[j]; } volPresented[j]=0; volStyle[j]=0; continue; } volPY[i]+=volRise[i]*dt; volPX[i]+=volDX[i]*dt; volPZ[i]+=volDZ[i]*dt; volRise[i]*=Math.pow(0.62, dt); // buoyancy bleeds off i++; } } function volFxClear(){ volN=0;volPresented.fill(0);volStyle.fill(0); } /* --------------------------------------------------------------------------- FIXED-STEP TRAIL HISTORY — simulation writes, renderer reads. --------------------------------------------------------------------------- */ function volFxTrailHistoryReset(){ volTrailHistoryCount.fill(0);volTrailHistoryKind.fill(0);volTrailHistoryUsed.fill(0); volTrailHistoryData.fill(0);volTrailHistoryUpload.fill(0); volTrailHistoryRows=0;volTrailHistoryDirty=true; VOL_TRAIL_HISTORY_TELEM.rows=0;VOL_TRAIL_HISTORY_TELEM.points=0; VOL_TRAIL_HISTORY_TELEM.pushes=0;VOL_TRAIL_HISTORY_TELEM.emits=0; VOL_TRAIL_HISTORY_TELEM.pausedStable=0;VOL_TRAIL_HISTORY_TELEM.lastError=''; } function volFxTrailHistoryBegin(kind,opts){ let row=-1; for(let i=0;i=volTrailHistoryRows||!volTrailHistoryUsed[row])return false; if(!Number.isFinite(x)||!Number.isFinite(z)||!Number.isFinite(worldY)||!Number.isFinite(stamp))return false; let n=volTrailHistoryCount[row]|0; const rb=row*VOL_TRAIL_HISTORY_POINTS,db=rb*4; if(n>=VOL_TRAIL_HISTORY_POINTS){ volTrailHistoryData.copyWithin(db,db+4,db+VOL_TRAIL_HISTORY_POINTS*4); volTrailHistoryStamp.copyWithin(rb,rb+1,rb+VOL_TRAIL_HISTORY_POINTS); n=VOL_TRAIL_HISTORY_POINTS-1; } const o=db+n*4;volTrailHistoryData[o]=x;volTrailHistoryData[o+1]=worldY;volTrailHistoryData[o+2]=z;volTrailHistoryData[o+3]=0; volTrailHistoryStamp[rb+n]=stamp;volTrailHistoryCount[row]=n+1; volTrailHistoryDirty=true;VOL_TRAIL_HISTORY_TELEM.pushes++; let total=0;for(let r=0;r=volTrailHistoryRows||!volTrailHistoryUsed[row])return -1; const n=volTrailHistoryCount[row]|0;if(n<2)return -1; const o=opts||{},k=volTrailHistoryKind[row],rb=row*VOL_TRAIL_HISTORY_POINTS,db=rb*4; const widthScale=Math.max(.12,Math.min(2,Number(o.widthScale)||1)),noHead=!!o.noHead; const newest=volTrailHistoryStamp[rb+n-1],oldest=volTrailHistoryStamp[rb],duration=Math.max(1e-4,newest-oldest); let minX=1e9,minY=1e9,minZ=1e9,maxX=-1e9,maxY=-1e9,maxZ=-1e9,maxR=1; /* Keep a small screen-space floor without baking one camera distance into the authored history. `orthoSpan / VH` is the engine's established world-units-per-CSS-pixel conversion (billboard.js/input.js). The floor is evaluated only when the fixed-step history is emitted; rendering paused frames still cannot mutate the field or grow the pool. */ const worldPx=(typeof orthoSpan==='number'&&typeof VH==='number'&&VH>0) ?Math.max(.24,orthoSpan/VH):.58; for(let i=0;i0?o.life:.28,dens=o.dens||(k===VOL_TRAIL_CURVE_ENERGY?1.72:2.10), emis=o.emis||(k===VOL_TRAIL_CURVE_ENERGY?2.05:1.08),aspect=[Math.max(1,(maxX-minX)*.5),Math.max(1,(maxY-minY)*.5),Math.max(1,(maxZ-minZ)*.5)]; /* Refresh one proxy per row from fixed-step simulation. Searching the tiny bounded volume ring is safer than caching a slot index because volFxTick compacts the ring when unrelated smoke expires. No render call can grow this pool or advance its lifetime. */ let slot=-1; for(let i=0;i=0){volT[slot]=Math.max(0,Math.min(.9,Number(o.age)||0))*volLife[slot];VOL_TRAIL_HISTORY_TELEM.emits++;} return slot; } function volFxTrailHistoryRelease(row,fade){ row|=0;if(row<0||row>=VOL_TRAIL_HISTORY_ROWS||!volTrailHistoryUsed[row])return false; const k=volTrailHistoryKind[row]; for(let i=0;i=.32?1:(age/.32)*(age/.32)*(3-2*(age/.32)); const cq=Math.max(0,Math.min(1,(age-.42)/.50)); const collapse=cq*cq*(3-2*cq); return (.56+.34*ignite)*(1-.32*collapse); } if(kind===VOL_IMPACT)return 0.35+1.10*Math.min(1,age/.72); return 0.55+0.90*age; } /* Keep distant High/Cinematic volumes volumetric instead of letting a valid proxy collapse below a handful of half-resolution pixels. This is a small screen-space floor, capped so it cannot turn a tactical blast into a giant strategic cloud. The same value is used for culling and instance packing. */ function volRenderGrow(kind,age,r0,pxPerWorld,ax,ay,az,style){ let g=volGrow(kind,age,style)*r0; const projected=g*Math.max(ax,ay,az)*Math.max(.001,pxPerWorld); if(projected<5.5) g*=Math.min(1.65,5.5/Math.max(.25,projected)); return g; } /* --------------------------------------------------------------------------- NOISE — 32^3 RGBA8, 131 KB, generated once. R/G/B are tileable value noise at lattice 4 / 8 / 16 (three FBM octaves out of ONE texture fetch); A is an inverted Worley F1 at lattice 6, which is the cauliflower billow on the silhouette. Small enough to sit in texture cache for the whole pass, which is why this beats analytic hash noise on a mobile ALU budget. --------------------------------------------------------------------------- */ function volNoiseBuild(){ const N=32, out=new Uint8Array(N*N*N*4); const hash=(a)=>{ a=(a^61)^(a>>>16); a=(a+(a<<3))|0; a^=a>>>4; a=Math.imul(a,0x27d4eb2d); a^=a>>>15; return (a>>>0)/4294967296; }; const lat=(L,x,y,z)=>hash(((x%L+L)%L) + ((y%L+L)%L)*L + ((z%L+L)%L)*L*L + L*7919); const sm=(t)=>t*t*(3-2*t); const value=(L,fx,fy,fz)=>{ const gx=fx*L, gy=fy*L, gz=fz*L; const x0=Math.floor(gx), y0=Math.floor(gy), z0=Math.floor(gz); const tx=sm(gx-x0), ty=sm(gy-y0), tz=sm(gz-z0); const c=(dx,dy,dz)=>lat(L,x0+dx,y0+dy,z0+dz); const l=(a,b,t)=>a+(b-a)*t; const x00=l(c(0,0,0),c(1,0,0),tx), x10=l(c(0,1,0),c(1,1,0),tx); const x01=l(c(0,0,1),c(1,0,1),tx), x11=l(c(0,1,1),c(1,1,1),tx); return l(l(x00,x10,ty), l(x01,x11,ty), tz); }; /* Worley feature points, one per cell of a 6^3 lattice, wrapped. */ const WL=6, fp=new Float32Array(WL*WL*WL*3); for(let z=0;z{ let d=a-b; if(d>0.5) d-=1; else if(d<-0.5) d+=1; return d; }; for(let z=0;z0.5&&uRayDir.y<0.0) tExit=min(tExit,(uWaterY-vWorld.y)/uRayDir.y); tEnter=max(tEnter,-tHere); float span=tExit-tEnter; if(span<=0.0) discard; float steps=float(uSteps); float ds=span/steps; /* Object-space blue-noise jitter (see the note above the helpers). */ vec3 pEnter=vWorld+uRayDir*tEnter; vec3 jq=(pEnter-vCtr.xyz)/max(vec3(1e-3),vExt.xyz); float jit=texture(uNoise,jq*2.7+vec3(seed*0.37)).g; float t=tEnter+ds*clamp(jit,0.02,0.98); vec3 acc=vec3(0.0); float T=1.0; float progress=clamp(age,0.0,1.0); /* ---- CHRONOLOGY: ONE core, four explicit stages, ABSOLUTE seconds -------- vPar.w carries the event's life in seconds (it used to carry a world radius that this shader never read). Absolute time is required because a 100 ms flash is 6% of a 1.6 s bombardment but 22% of a 0.46 s contact hit — a single normalised threshold cannot express "sub-100 ms" for both. flash t < ~0.10 s compact, white-hot, high emission combustion -> ~40% life orange/yellow rolling fire soot -> ~78% life lobes separate, fire retreats into cavities smoke -> end translucent grey that thins rather than collapsing into an opaque black disc */ float life=max(0.08,vPar.w); float tSec=progress*life; /* ONE CLOCK PER BOUNDARY. sBurn used to RISE on absolute seconds and FALL on normalised progress. Below a life of about 0.40 s the fall opened before the rise had finished, so combustion never reached full weight — that is exactly why small contacts read as having no energy. Both knees are now in seconds, with the fall floored so a short event still gets a real burn plateau. */ float sFlash=1.0-smoothstep(0.040,0.105,tSec); float tFall=max(0.34*life,0.18); float sBurn =smoothstep(0.03,0.12,tSec)*(1.0-smoothstep(tFall,tFall*1.70,tSec)); /* sootHold is MONOTONE. The tint, extinction and cavity consumers must not let soot brighten back up over the tail — the bump form did, by 23%, directly contradicting the "then HOLD" intent written above them. The decaying bump survives only as the emission gate, where a fading ember residual is right. */ float sootHold=smoothstep(0.30,0.60,progress); float sSoot =sootHold*(1.0-smoothstep(0.72,0.95,progress)); float sSmoke=smoothstep(0.62,0.97,progress); for(int i=0;i<64;i++){ if(i>=uSteps) break; vec3 p=vWorld+uRayDir*t; t+=ds; vec3 localP=(p-vCtr.xyz)/vExt.xyz; float isImpact=1.0-step(0.45,abs(kind-4.0)); float isVoidImpact=isImpact*(1.0-step(0.45,abs(profile-2.0))); float isDust =1.0-step(0.45,abs(kind-2.0)); float isBlast =1.0-step(0.45,abs(kind)); float isSmoke =1.0-step(0.45,abs(kind-1.0)); float isCurveEnergy=1.0-step(0.45,abs(kind-7.0)); float isCurveShell =1.0-step(0.45,abs(kind-8.0)); float isCurve=max(isCurveEnergy,isCurveShell); float isTrailEnergy=max(1.0-step(0.45,abs(kind-5.0)),isCurveEnergy); float isTrailShell =max(1.0-step(0.45,abs(kind-6.0)),isCurveShell); float isTrail=max(isTrailEnergy,isTrailShell); /* Falling airframes are still VOL_BLAST and still one macro core. The profile merely folds their generic satellite lobes into a hot inner pocket below one tall, cooling soot crown. */ float isAirCrash=(1.0-step(0.45,abs(profile-1.0)))*isBlast; /* The proxy is intentionally a box; the density is the art. A blast is spherical during ignition and only stretches into a buoyant crown as it cools. An impact rotates into projectile direction and combines a compact contact bulb with one short excavating wake. Previously both paths marched the same tall mushroom, which made direct hits look like tiny copies of strategic explosions. */ float crown=mix(1.0,0.65+0.45*smoothstep(-0.4,0.6,localP.y), smoothstep(0.12,0.70,progress)*(1.0-isImpact)); /* Collapse dust begins as a grounded shear front, then rolls upward into cauliflower lobes. This keeps a real vertical silhouette from the RTS camera instead of reproducing the old flat horizontal card. */ crown=mix(crown,0.72+0.42*smoothstep(-0.72,0.72,localP.y),isDust); vec3 q=localP; q.xz/=crown; float blastY=mix(2.35,1.0,smoothstep(0.18,0.62,progress)); /* The generic flash starts vertically compressed for an outward blast. A falling hull is the opposite silhouette: give its coherent crown real vertical depth from the first visible frame, not a ground pancake. */ blastY=mix(blastY,mix(0.78,0.94,smoothstep(0.04,0.70,progress)),isAirCrash); q.y*=mix(1.0,blastY,isBlast); float ca=cos(vTint.w),sa=sin(vTint.w); vec2 iq=mat2(ca,sa,-sa,ca)*q.xz; /* EXPERIMENTAL SWEPT-VOLUME TRAILS. Curve kinds use ONE proxy and measure every ray sample against a bounded fixed-step history texture. Legacy kinds retain the ten-capsule laboratory path for direct A/B comparison. */ if(isTrail>0.5){ float headlessWake=isCurveShell*step(.5,vTrail.z); if(isCurve>0.5&&uTrailHistoryReady<0.5)discard; float trailU=0.5,localU=0.5,tubeR=1.0,segmentWeight=1.0; float headSeg=0.0,headR=1.0; vec3 radialVec=vec3(0.0),trailTangent=vec3(1.0,0.0,0.0); vec3 trailSide=vec3(0.0,0.0,1.0),trailUp=vec3(0.0,1.0,0.0); vec3 headPoint=vCtr.xyz,headAxis=vec3(1.0,0.0,0.0); if(isCurve>0.5&&uTrailHistoryReady>0.5){ int histRow=int(clamp(floor(vTrail.x+0.5),0.0,7.0)); int histCount=int(clamp(floor(vTrail.y+0.5),2.0,12.0)); float bestD2=1e30,bestU=0.0,bestR=1.0;vec3 bestPoint=vec3(0.0),bestProbe=p,bestTan=vec3(1.0,0.0,0.0); for(int hi=0;hi<11;hi++){ if(hi>=histCount-1)break; int im1=max(0,hi-1),ip2=min(histCount-1,hi+2); vec4 h0=texelFetch(uTrailHistory,ivec2(im1,histRow),0); vec4 h1=texelFetch(uTrailHistory,ivec2(hi,histRow),0); vec4 h2=texelFetch(uTrailHistory,ivec2(hi+1,histRow),0); vec4 h3=texelFetch(uTrailHistory,ivec2(ip2,histRow),0); vec3 chord=h2.xyz-h1.xyz; float h=clamp(dot(p-h1.xyz,chord)/max(1e-5,dot(chord,chord)),0.0,1.0); /* Two bounded projection refinements are enough for this smooth, sparsely sampled ballistic curve and much cheaper than spawning a row of overlapping capsule proxies. */ for(int refine=0;refine<2;refine++){ vec3 cp=trailCatmull(h0.xyz,h1.xyz,h2.xyz,h3.xyz,h); vec3 ct=trailCatmullTangent(h0.xyz,h1.xyz,h2.xyz,h3.xyz,h); h=clamp(h+dot(p-cp,ct)/max(1e-5,dot(ct,ct)),0.0,1.0); } vec3 nearP=trailCatmull(h0.xyz,h1.xyz,h2.xyz,h3.xyz,h); vec3 nearT=trailCatmullTangent(h0.xyz,h1.xyz,h2.xyz,h3.xyz,h); /* A 32-step volume march can step completely over a 3–6 world-unit trail inside a tall artillery proxy. Measure the complete current ray interval against the curve, rather than a single point on that interval. This is analytic coverage, not extra particle samples, and removes the bright/dark comb tied to march planes. */ vec3 rayProbe=p+uRayDir*clamp(dot(nearP-p,uRayDir),0.0,ds); h=clamp(h+dot(rayProbe-nearP,nearT)/max(1e-5,dot(nearT,nearT)),0.0,1.0); nearP=trailCatmull(h0.xyz,h1.xyz,h2.xyz,h3.xyz,h); nearT=trailCatmullTangent(h0.xyz,h1.xyz,h2.xyz,h3.xyz,h); rayProbe=p+uRayDir*clamp(dot(nearP-p,uRayDir),0.0,ds); float d2=dot(rayProbe-nearP,rayProbe-nearP),hs=h*h*(3.0-2.0*h); if(d21) and extended in Y (<1) to fight the pancake proxy; the wake alone carries the direction. */ float impactHead=length(vec3((iq.x-.06)*1.22,q.y*0.72,iq.y*1.08)); float impactWake=length(vec3((iq.x+.30)*0.86,q.y*1.05,iq.y*1.30)) +max(0.0,-iq.x-.10)*.34; /* ---- CAULIFLOWER LOBES WITH REAL ASYMMETRY ----------------------------- Seven lobes at mixed radii on an IRREGULAR ring — each one displaced by its own hash instead of an even angular step, and each with its own size. min() unions them so the creases between neighbours survive; the old five evenly-spaced same-radius lobes averaged straight back into a sphere, which is the smooth blob in the 02f-02h captures. */ float coreShape=length(q*vec3(1.08,0.92,1.08)); float radialLobes=100.0; /* A conventional blast benefits from an outward cauliflower union. A falling airframe does not: even inward-biased satellites still form two detached bodies at the oblique RTS camera angle. Its profile below uses this one tapered core plus the same 3D turbulence instead. */ if((isBlast>0.5||isDust>0.5)&&isAirCrash<0.5){ float expand=smoothstep(0.04,0.62,progress); for(int l=0;l<7;l++){ float fl=float(l); float hh=fract(sin(fl*12.9898+seed*7.233)*43758.5453); float a=fl*0.8975979+seed*2.39996+hh*0.90; vec3 lDir=vec3(cos(a)*0.88,(hh-0.32)*0.86,sin(a)*0.88); vec3 lCtr=lDir*mix(0.08,0.60,expand)*(0.62+0.74*hh); /* Keep the irregular lobe vocabulary, but join an air crash's outer satellites into one crown. Suppressing only their heat would leave a red/orange necklace under the soot at a top-down RTS angle. */ lCtr.xz*=mix(1.0,0.36,isAirCrash); /* A modest upward bias gives the crash a crown, but not a detached top balloon. The fire/soot material bridge below supplies the continuous vertical hand-off rather than a second lobe. */ lCtr.y=mix(lCtr.y,0.02+0.20*hh,isAirCrash); float lRad=mix(0.30,0.56,expand)*(0.70+0.60*hh)*mix(1.0,1.16,isAirCrash); radialLobes=min(radialLobes,length(q-lCtr)/lRad); } } float blastShape=min(coreShape*1.12,radialLobes); /* Contact bursts get the SAME cauliflower vocabulary at contact scale. The lobe loop above is gated to blast/dust, so an impact previously had no internal structure whatsoever — a single smooth ellipsoid, which is the other half of why it read as a flat sprite. Three small lobes are enough to break the silhouette and give the march something to shade. */ float impactShape=min(impactHead,impactWake); float voidField=0.0,voidCrescent=0.0,voidSpear=0.0,voidForks=0.0; if(isImpact>0.5){ float ex2=smoothstep(0.02,0.55,progress); for(int l=0;l<3;l++){ float fl=float(l); float hh=fract(sin(fl*31.7+seed*5.13)*43758.5453); float hg=fract(hh*7.31); vec3 lc=vec3((hh-0.55)*0.46,0.10+hg*0.34,(hg-0.5)*0.44)*mix(0.30,0.92,ex2); impactShape=min(impactShape, length(q-lc)/(mix(0.30,0.48,ex2)*(0.80+0.45*hh))); } /* VOID CONTACT — one genuine density field, not a card or extra layer. The short capsule is the incoming phase spear, the clipped shell is an asymmetric crescent, and both join a vertically thick contact bloom. Their separate clocks change the 3D silhouette while the event fades. */ /* The centre is deliberately an OFFSET ellipsoid rather than a full ball. It supplies thickness behind the crescent without filling the crescent's negative space from the RTS camera. */ float bloomR=length(vec3((iq.x+.15)*1.30,q.y*.78,iq.y*1.46)); float bloom=(1.0-smoothstep(.27,.66,bloomR)) *(1.0-smoothstep(.56,.97,progress)); /* One incoming spear plus two short forks live in this same density field. They are capsules, not emitted particles, so the silhouette is directional while the authoritative layer count remains one. */ float axis=clamp(iq.x,-.96,.18); float spearDist=length(vec3(iq.x-axis,q.y*1.58,iq.y*2.72)); float spearRadius=mix(.045,.145,smoothstep(-.96,.18,axis)); float spear=(1.0-smoothstep(spearRadius,spearRadius+.065,spearDist)) *(1.0-smoothstep(.34,.66,progress)); float forkGate=smoothstep(-.50,-.18,iq.x)*(1.0-smoothstep(.08,.30,iq.x)); float forkA=1.0-smoothstep(.045,.115, length(vec3(q.y*1.80,(iq.y-(iq.x+.30)*.48)*2.65,0.0))); float forkB=1.0-smoothstep(.045,.115, length(vec3(q.y*1.80,(iq.y+(iq.x+.30)*.48)*2.65,0.0))); float forks=max(forkA,forkB)*forkGate*(1.0-smoothstep(.28,.58,progress)); /* A ground-plane torus section gives the crescent a real hollow centre from the oblique RTS camera. The earlier spherical-shell subtraction integrated its front and back caps into two filled balloons. */ vec2 arcP=vec2((iq.x-.02)*1.02,iq.y*1.12); float arcRad=length(arcP); float arcTube=sqrt(pow((arcRad-.53)/.145,2.0)+pow(q.y/.30,2.0)); float crescent=(1.0-smoothstep(.68,1.12,arcTube)) *smoothstep(-.28,.10,iq.x)*(1.0-smoothstep(.52,.88,progress)); voidCrescent=crescent;voidSpear=spear;voidForks=forks; voidField=max(bloom*.44,max(max(spear,forks),crescent)); impactShape=mix(impactShape,1.0-voidField,isVoidImpact); } float r=mix(blastShape,impactShape,isImpact); /* ---- two turbulence octaves displace the SURFACE ---------------------- */ vec3 nq=mix(q,vec3(iq.x,q.y,iq.y),isImpact); /* AUTHORED MATERIAL DRIVER — blast and smoke only. The 4x4 source stages follow ignition -> combustion -> soot, while a standalone smoke event begins in the soot half of the same chronology. nq is reconstructed from this march sample, not a screen UV, then triplanar-projected through the proxy. Impacts deliberately stay out of this path: VOL_IMPACT keeps its compact authored analytic morphology and exact existing budget. */ float driverKind=max(isBlast,isSmoke); float driverProgress=mix(progress,0.54+progress*0.46,isSmoke); float driverFrame=floor(clamp(driverProgress,0.0,0.9999)*16.0); float driverWeight=driverKind*step(0.5,uDriverReady); vec4 driverField=vec4(0.5,0.0,0.5,1.0); if(driverWeight>0.5) driverField=driverTriplanar(nq,driverFrame); /* Leave a large procedural contribution even inside transparent portions of the authored source. This is a density/emission *modulator*, never a rectangular cutout or a replacement sprite. */ float driverDensity=mix(1.0,mix(0.72,1.22,driverField.r) *mix(0.84,1.0,driverField.a),driverWeight); float driverSoot=mix(1.0,mix(0.86,1.16,driverField.b),driverWeight); float driverHeat=mix(1.0,mix(0.62,1.30,driverField.g),driverWeight); vec3 drift=uWind*uTime*0.035 -vec3(progress*isImpact*.34,uTime*mix(0.08,0.025,isImpact)+progress*.45,0.0); vec3 nc=nq*mix(0.42,0.62,isImpact)+drift+vec3(seed); vec4 n=texture(uNoise,nc); vec4 nw=texture(uNoise,nc*2.15+vec3(seed*1.7)); float f=mix(fbm3(n),1.0-n.a,0.38)+(fbm3(nw)-0.5)*0.42; /* ---- INTERNAL CAVITIES ------------------------------------------------- Without carved voids the march integrates a solid shell and no amount of lighting stops it reading as a painted blob. These holes widen as the fireball cools, and they are also what lets combustion still burning in the interior show THROUGH the soot rather than being sealed behind it. */ vec4 nv=texture(uNoise,q*3.3+vec3(seed*1.31)+drift*0.6); float voids=smoothstep(0.44,0.88,nv.a)*mix(0.10,0.58,clamp(sootHold+sSmoke,0.0,1.0)); /* The source alpha records true authored empty space. Blend that lightly into the analytic cavities so it adds fire/smoke breakup without ever hollowing an impact or erasing the fallback procedural field. */ voids+=driverWeight*(1.0-driverField.a)*0.105; /* WORLD-SPACE MATERIAL SPLIT. localP is reconstructed from the live march sample p, not the screen coordinate or a painted colour ramp. Keep the fuel down in the inner/lower body while the lifted outer surface becomes soot. The old fire field lit every lobe equally, so a top-down camera integrated one opaque yellow pancake even though the density itself had cauliflower detail. These masks make the same density field carry a hot core, a cooler crown and real creases between them. */ float crownAge=smoothstep(0.08,0.66,progress)*(1.0-isImpact); float crownTop=smoothstep(-0.08,0.74,localP.y); float outerShell=smoothstep(0.20,0.88,r); float sootCrown=clamp(crownAge*(0.22+0.78*crownTop) +outerShell*(0.18+0.32*crownAge)+sootHold*0.16,0.0,1.0); /* Air-crash heat belongs in the lower centre. The rest of the same raymarched field becomes the visibly taller dark crown, so this does not add a card, plume, or second combustion layer. */ sootCrown=clamp(sootCrown+isAirCrash*(0.08+0.38*outerShell*(0.34+0.66*crownTop)),0.0,1.0); float fuelHeight=mix(1.0-smoothstep(-0.05,0.70,localP.y), 1.0-smoothstep(0.42,0.96,localP.y),isImpact); float fuelRadius=1.0-smoothstep(0.18,0.74,length(q.xz)); float fuelPocket=clamp(fuelHeight*fuelRadius*(1.0-0.46*sootCrown) +voids*0.16,0.0,1.0); float crashCore=1.0-smoothstep(0.10,0.50,length(q.xz)); float crashFuel=crashCore*(1.0-smoothstep(-0.16,0.28,localP.y)); /* This is a warm, deliberately thinner neck—not another flame lobe. It keeps the fuel column visible while it hands off into soot, so the tactical camera reads one combustion volume rather than an orange orb sitting below a separate grey oval. */ float crashBridge=crashCore*(1.0-smoothstep(-0.10,0.82,localP.y)); sootCrown=mix(sootCrown,max(0.0,sootCrown-crashBridge*0.38),isAirCrash); fuelPocket=mix(fuelPocket,clamp(fuelPocket*crashFuel+crashBridge*0.42 +voids*0.040*crashBridge,0.0,1.0),isAirCrash); /* A contact is its own compact material event, not a small rising crown. Preserve its pre-existing hot density response; only blast/dust samples receive the lower-fuel / lifted-soot separation above. */ sootCrown*=1.0-isImpact; fuelPocket=mix(fuelPocket,1.0,isImpact); /* ---- Beer-Lambert density ---------------------------------------------- Extinction now falls stage by stage so late soot stays TRANSLUCENT. The old flat 3.2-3.8 gain drove 1-exp(-sigma*d*ds) to 1.0 across the entire silhouette, which is why the aftermath was a uniform opaque black disc that completely hid the ground beneath it. */ float d=(0.80-r)+(f-0.5)*0.72-voids; /* Break the upper cap with its own 3D field and taper it slightly. This changes the true density boundary (not a colour overlay), preventing a perfectly oval soot silhouette at the top-down tactical angle. */ float crashTop=smoothstep(0.08,0.92,localP.y); d-=isAirCrash*crashTop*(0.10+0.26*(1.0-fbm3(nw))); float erode=1.0-smoothstep(mix(mix(0.80,0.66,isImpact),0.86,isDust),1.06,progress); float ext=mix(mix(3.05,3.55,isImpact),1.28,sSmoke)*mix(1.0,0.82,sootHold) *mix(1.0,0.62,sFlash); /* ignition is THIN and hot, not a dense ball */ float density=clamp(d*3.2,0.0,1.0)*erode*vPar.x*ext; /* Let the high soot cap fray into individual 3D filaments instead of preserving the old uniformly opaque upper hemisphere. The low fuel core stays dense enough to retain its incandescent read. */ float crownFilaments=mix(1.0,0.66+0.34*smoothstep(0.18,0.86,fbm3(nw)), crownAge*crownTop); density*=crownFilaments*driverDensity; if(density>0.01){ /* SELF-SHADOWING FROM THE ACTUAL FIELD. The old probe was two analytic sphere chords — length() of an offset point — so it produced one smooth radial gradient no matter what the density was doing. A smooth gradient over a round silhouette is precisely what reads as a flat disc. These two chords re-evaluate the SAME noise basis the density uses, so a lobe genuinely shadows the fold behind it and the eye gets the depth cue it was missing. */ float shadow=0.0; for(int sI=0;sI<2;sI++){ float sT=(float(sI)+1.0)*0.24; vec3 sq=q+uSunDir*sT; float sShape=length(sq*vec3(1.08,0.92,1.08))*1.12; vec4 sn=texture(uNoise,sq*mix(0.42,0.62,isImpact)+uWind*uTime*0.035+vec3(seed)); float sf=sn.r*0.52+sn.g*0.28+sn.b*0.20; sf=mix(sf,1.0-sn.a,0.38); shadow+=clamp(((0.78-sShape)+(sf-0.5)*0.65)*3.5,0.0,1.0)*(sI==0?0.62:0.38); } float transmittance=exp(-shadow*3.4); /* HEMISPHERIC AMBIENT + PHASE-WEIGHTED SUN. uAmbSky, uAmbGnd and uPhase were all uploaded every frame and never read by this shader. Without a phase function the sun term ignored scattering geometry entirely, and without a sky/ground gradient every sample got the same colour regardless of which way it faced — so the march returned a uniformly shaded blob. uPhase already carries the dual-lobe Henyey-Greenstein evaluated on the CPU (free under ortho); it is clamped here only so a grazing sun cannot blow out the core. */ float ph=clamp(uPhase,0.35,2.4); vec3 nrmUp=normalize(q+vec3(0.0,1e-4,0.0)); vec3 ambient=mix(uAmbGnd,uAmbSky,clamp(0.5+0.5*nrmUp.y,0.0,1.0)); /* SOOT IS DARK, NOT BLACK. 0.035 through a 0.45 mix landed every sample below the terrain's own value, so the integral converged to a flat black hole in the ground. Lift the floor and let ambient carry it — real soot backscatters plenty at this sun angle. */ vec3 darkSoot=vec3(0.085,0.076,0.070); vec3 sunLitSoot=uSunCol*vec3(0.30,0.25,0.20)*transmittance*ph; vec3 smoke=mix(darkSoot,sunLitSoot,0.52)+ambient*(0.34+0.26*transmittance); if(isDust>0.5){ smoke=mix(smoke,vTint.rgb*0.85,0.65); }else{ /* The owning combat recipe supplies faction energy as a material driver. Keep soot physically dark, but let its hot inner folds carry a restrained trace of that spectrum instead of spawning a separate coloured flourish around the volume. */ smoke=mix(smoke,vTint.rgb*0.20,0.14); } /* Cool toward soot across the middle of the life, then HOLD. Continuing to darken into the tail is what produced the opaque late mass. */ smoke*=mix(1.0,0.72,sootHold)*mix(1.0,0.86,sSmoke)*driverSoot; /* The crown has its own physically darker material response; this is what separates rising soot from the fire below without a second card or a screen-space overlay. */ smoke=mix(smoke,darkSoot+ambient*(0.18+0.22*transmittance),sootCrown*0.34); /* COMBUSTION, BIASED INTO THE CAVITIES. Where density was carved away the volume is thin, and that is exactly where interior burn should still be visible from outside — fire showing through pockets rather than painted onto a shell. */ /* ENERGY SCALES WITH THE EVENT, and this is the whole point. A flat gain is wrong at both ends: crank it and a rifle contact blows out like a bunker buster, damp it and a bombardment loses the incandescent core that makes heavy ordnance feel heavy. Power is taken from the authored emission AND the volume's own world size — vExt is in world units, so a bombardment proxy is several times a small impact's — and it drives the fire ramp, the white knee and the highlight shoulder together. Cavities REVEAL interior fire; they do not manufacture it, hence the small 0.30 term. */ float rWorld=max(vExt.x,max(vExt.y,vExt.z)); float power=clamp(vPar.y*(0.74+0.78*smoothstep(6.0,46.0,rWorld)),0.0,1.90); /* ONE temperature clock. The old -progress*0.55 was a fifth, unnamed cooling ramp that the four stage weights knew nothing about, decaying on a different schedule than the gate below and fighting it — which is why tuning the constants kept swinging between all-white and all-soot. The shrinking hot core is now a SHAPE change on the stage clock instead. */ float fireField=(0.86-r*mix(0.90,1.38,sootHold))+(fbm3(nw)-0.50)*0.85 +voids*mix(0.22,0.30,isImpact); float fireHeat=clamp(fireField*(1.35+1.25*power),0.0,1.0)*(1.0-isDust); /* Fire can only occupy the carved fuel pocket. Its lower/central world shape makes the volume read as a crown over a burning interior rather than a single yellow density blob; the cavity term leaves sparse hot glimpses through the soot rather than broadening the whole shell. */ fireHeat*=mix(0.28,1.0,fuelPocket)*mix(1.0,0.34,sootCrown)*driverHeat; /* Do not let the airframe's now-dark outer crown retain the generic blast's side fire. This keeps the remaining gold/orange ignition localized at the base instead of reading as separate flat lobes. */ float crashFlame=clamp(crashFuel+crashBridge*0.52,0.0,1.0); fireHeat*=mix(1.0,0.64+0.36*crashFlame,isAirCrash); /* The flash owns the white region outright; combustion is weighted to top out in the golden band so it reads as ROLLING FIRE rather than a white plateau with a thin orange rim. */ fireHeat*=clamp(sFlash*1.25+sBurn*0.70+sSoot*0.30,0.0,1.30); // Saturated Fire -> Golden Core -> Blinding Center vec3 deepEmber=vec3(0.75,0.14,0.01); vec3 brightFlame=vec3(1.00,0.48,0.04); vec3 goldenYellow=vec3(1.00,0.85,0.22); /* Heavy ordnance gets a genuinely incandescent core; light contacts get a hot but bounded one. */ vec3 coreWhite=mix(vec3(1.22,1.15,0.98),vec3(1.78,1.68,1.44), smoothstep(0.60,1.50,power)); /* Texture/profile-driven combustion: the centre stays incandescent and readable, while the cooler outer fire inherits Nova cyan, Legion heat, Syndicate phase-green, or Brood bio energy. This is still one density field and one logical core layer. */ vec3 factionFire=clamp(vTint.rgb*1.18,0.0,1.35); deepEmber=mix(deepEmber,factionFire*0.52,0.42); brightFlame=mix(brightFlame,max(factionFire,vec3(0.08))*1.08,0.48); vec3 fireColor=mix(deepEmber,brightFlame,smoothstep(0.02,0.38,fireHeat)); /* The white knee slides with power: a bombardment reaches incandescence across most of its core, a small contact only at its very centre. */ /* After the flash the knee lifts so combustion sits in the golden band — but the lift is SMALLER for heavy ordnance, so a bombardment still reaches an incandescent core while a rifle contact never can. */ float whiteKnee=mix(0.90,0.58,smoothstep(0.50,1.50,power)) +mix(0.30,0.16,smoothstep(0.60,1.50,power))*(1.0-sFlash); fireColor=mix(fireColor,goldenYellow,smoothstep(0.40,0.74,fireHeat)); float whiteMix=smoothstep(whiteKnee,1.00,fireHeat)*mix(1.0,sFlash,isAirCrash); fireColor=mix(fireColor,coreWhite,whiteMix); /* Sub-100 ms ignition is the ONLY stage allowed near the ceiling, and even it goes through the shoulder rather than clipping flat. */ fireColor=mix(fireColor,vec3(1.85,1.74,1.52),sFlash*0.80); float fireMix=smoothstep(mix(0.10,0.01,isImpact),mix(0.42,0.26,isImpact),fireHeat) *mix(1.0,0.44,sootCrown); fireMix*=mix(1.0,0.34+0.66*crashFlame,isAirCrash); vec3 stepColor=shoulder(mix(smoke,fireColor,fireMix), mix(0.40,0.10,smoothstep(0.60,1.50,power))*mix(1.55,1.0,sFlash)); /* Void energy never passes through the orange combustion/soot ramp. Its volume cools from a bounded white-violet centre into a blue-purple sheath, while density erosion—not black smoke—ends the contact. */ float voidHeat=clamp(voidField*(.82+sFlash*.78)+fireHeat*.22 +voidCrescent*.38+voidSpear*.48+voidForks*.22,0.0,1.35); vec3 voidColor=mix(vec3(.075,.035,.34),vec3(.44,.12,1.18), smoothstep(.08,.58,voidHeat)); voidColor=mix(voidColor,vec3(1.58,1.46,1.82),smoothstep(.68,1.12,voidHeat)); voidColor*=mix(.76,1.12,sFlash); stepColor=mix(stepColor,shoulder(voidColor,.12),isVoidImpact); float alpha=1.0-exp(-uSigma*density*ds*3.8); acc+=T*stepColor*alpha; T*=(1.0-alpha); if(T<0.015) break; } } float alpha=1.0-T; /* TRANSLUCENCY CEILING FOR THE SMOKE STAGE. acc is already premultiplied, so scaling colour and alpha together preserves the "over" identity exactly while guaranteeing the tail never becomes a solid occluder — terrain and structures stay readable through dissipating soot. */ float aCap=mix(1.0,0.70,sSmoke); if(alpha>aCap){ acc*=aCap/max(alpha,1e-4); alpha=aCap; } if(alpha<=0.002) discard; o=vec4(acc,alpha); }`; /* Fullscreen triangle, no vertex buffer — the same gl_VertexID idiom mesh.js already uses for VSQ (mesh.js:2296). */ const VOL_VS_UP=`#version 300 es out vec2 vUv; void main(){ vec2 p=vec2((gl_VertexID<<1)&2, gl_VertexID&2); vUv=p; gl_Position=vec4(p*2.0-1.0,0.0,1.0); }`; /* Bilateral upsample. volTex is NEAREST so the four taps are the actual marched texels; the depth key rejects a tap that marched a different surface, which is what keeps the half-res result from haloing over a hull. uBilateral=0 collapses this to a single tap for the degraded rung. */ const VOL_FS_UP=`#version 300 es precision highp float; precision highp sampler2D; in vec2 vUv; uniform sampler2D uVol; uniform sampler2D uDep; uniform vec2 uVolSize; uniform vec2 uInvVolSize; uniform float uDepthScale; uniform float uBilateral; out vec4 o; void main(){ if(uBilateral<0.5){ o=texture(uVol,vUv); return; } float dC=texture(uDep,vUv).r*uDepthScale; vec2 f=vUv*uVolSize-0.5; vec2 base=floor(f), fr=f-base; vec4 sum=vec4(0.0); float wsum=0.0; for(int j=0;j<2;j++){ for(int i=0;i<2;i++){ vec2 tc=(base+vec2(float(i),float(j))+0.5)*uInvVolSize; float wb=(i==0?1.0-fr.x:fr.x)*(j==0?1.0-fr.y:fr.y); float dS=texture(uDep,tc).r*uDepthScale; float w=wb/(1.0+abs(dC-dS)*0.35); sum+=texture(uVol,tc)*w; wsum+=w; } } o=sum/max(wsum,1e-4); }`; /* --------------------------------------------------------------------------- INIT / TEARDOWN --------------------------------------------------------------------------- */ function volFxCtxCheck(){ const ep=(typeof glEpoch!=='undefined')?glEpoch:0; if(volEpoch===ep) return; volEpoch=ep; volInitFailed=false; volFxGLReset(); } function volFxGLReset(){ volProg=volProgUp=volVAO=volEmptyVAO=volCubeVBO=volInstVBO=null; volNoiseTex=volDriverTex=volTrailHistoryTex=volFB=volTex=null; volTrailHistoryEpoch=-1;volTrailHistoryDirty=true;VOL_TRAIL_HISTORY_TELEM.ready=false; volW=volH=0; volFailN=0; volU={}; volUup={}; volDriverReady=false; volDriverState='uninitialised'; volDriverError=''; volDriverSerial++; VOLFX_TELEMETRY.progOK=false; VOLFX_TELEMETRY.driverReady=false; VOLFX_TELEMETRY.driverState=volDriverState; VOLFX_TELEMETRY.driverSamples=0; VOLFX_TELEMETRY.driverError=''; VOLFX_TELEMETRY.trailHistoryReady=false; } function volDriverTelemetrySync(){ VOLFX_TELEMETRY.driverReady=!!volDriverReady; VOLFX_TELEMETRY.driverState=volDriverState; VOLFX_TELEMETRY.driverError=volDriverError; } /* Keep a complete neutral sampler resident before asynchronous image decode. uDriverReady remains zero until the authored sheet uploads, so this value is never a visual fallback by itself; it only keeps the sampler valid on every ANGLE implementation while the existing procedural density is authoritative. */ function volDriverNeutral(tex){ if(!tex||typeof gl==='undefined'||!gl) return false; const wasActive=gl.getParameter(gl.ACTIVE_TEXTURE); gl.activeTexture(gl.TEXTURE0+VOL_DRIVER_UNIT); const wasTex=gl.getParameter(gl.TEXTURE_BINDING_2D); let ok=false; try{ gl.getError(); gl.bindTexture(gl.TEXTURE_2D,tex); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,false); gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL,gl.NONE); gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA8,1,1,0,gl.RGBA,gl.UNSIGNED_BYTE, new Uint8Array([128,0,128,255])); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); ok=gl.getError()===gl.NO_ERROR; }catch(e){ ok=false; } gl.bindTexture(gl.TEXTURE_2D,wasTex); gl.activeTexture(wasActive); return ok; } function volDriverCurrent(tex,epoch,serial){ return !!gl&&volDriverTex===tex&&volEpoch===epoch&&volDriverSerial===serial; } function volDriverFail(tex,epoch,serial,message){ if(!volDriverCurrent(tex,epoch,serial)) return; /* Restore the known complete 1x1 sampler if upload itself was the failure. The shader consequently takes uDriverReady==0 and retains the procedural raymarch; it never presents a blank volume or a second billboard. */ volDriverNeutral(tex); volDriverReady=false; volDriverState='procedural-fallback'; volDriverError=String(message||'driver load failed').slice(0,180); volDriverTelemetrySync(); } function volDriverLoad(){ const tex=volDriverTex,epoch=volEpoch,serial=++volDriverSerial; volDriverReady=false; volDriverState='loading'; volDriverError=''; volDriverTelemetrySync(); if(!tex||typeof Image==='undefined'){ volDriverFail(tex,epoch,serial,'Image decode API is unavailable'); return; } const img=new Image(); img.decoding='async'; img.onload=()=>{ if(!volDriverCurrent(tex,epoch,serial)) return; if(img.naturalWidth!==1024||img.naturalHeight!==1024){ volDriverFail(tex,epoch,serial,'invalid driver dimensions '+img.naturalWidth+'x'+img.naturalHeight); return; } const wasActive=gl.getParameter(gl.ACTIVE_TEXTURE); gl.activeTexture(gl.TEXTURE0+VOL_DRIVER_UNIT); const wasTex=gl.getParameter(gl.TEXTURE_BINDING_2D); const wasFlip=gl.getParameter(gl.UNPACK_FLIP_Y_WEBGL); const wasPremul=gl.getParameter(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL); const wasCS=gl.getParameter(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL); let ok=false; try{ gl.getError(); gl.bindTexture(gl.TEXTURE_2D,tex); /* Data texture: preserve packed density/emission values exactly. The atlas owns its guard band, so linear base-level filtering is deliberate and avoids mip levels blending separate animation cells together. */ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,false); gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL,gl.NONE); gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA8,gl.RGBA,gl.UNSIGNED_BYTE,img); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); ok=gl.getError()===gl.NO_ERROR; }catch(e){ ok=false; } gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,wasFlip); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,wasPremul); gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL,wasCS); gl.bindTexture(gl.TEXTURE_2D,wasTex); gl.activeTexture(wasActive); if(!volDriverCurrent(tex,epoch,serial)) return; if(!ok){ volDriverFail(tex,epoch,serial,'GPU upload failed'); return; } volDriverReady=true; volDriverState='ready'; volDriverError=''; volDriverTelemetrySync(); }; img.onerror=()=>volDriverFail(tex,epoch,serial,'image decode failed'); try{ img.src=(typeof mf2AssetURL==='function')?mf2AssetURL(VOL_DRIVER_ASSET):('./'+VOL_DRIVER_ASSET); }catch(e){ volDriverFail(tex,epoch,serial,e&&e.message||e); } } function volFxInit(){ if(volProg||volInitFailed) return; if(typeof gl==='undefined'||!gl) return; if(typeof mkProg!=='function'){ volInitFailed=true; return; } volProg=mkProg(VOL_VS,VOL_FS,'volfx'); volProgUp=mkProg(VOL_VS_UP,VOL_FS_UP,'volfx-upsample'); /* mkProg() defers link validation (mesh.js). This latch must know NOW, and both programs are validated explicitly so neither is left pending. */ const volOk=mfProgOk(volProg), volUpOk=mfProgOk(volProgUp); if(!volOk||!volUpOk){ /* One may have linked. Release it rather than orphaning it, and LATCH so the next detonation does not recompile the pair (gpufx.js:44-48). */ if(volProg) gl.deleteProgram(volProg); if(volProgUp) gl.deleteProgram(volProgUp); volProg=volProgUp=null; volInitFailed=true; VOLFX_TELEMETRY.progOK=false; return; } for(const k of ['uVP','uNoise','uDep','uDriver','uDriverReady','uTrailDriver','uTrailDriverReady','uTrailHistory','uTrailHistoryReady','uRayDir','uSunDir','uSunCol','uAmbSky','uAmbGnd', 'uWind','uInvVolSize','uWorldPerPx','uDepthScale','uSigma','uPhase','uTime', 'uWaterOn','uWaterY','uSteps']) volU[k]=gl.getUniformLocation(volProg,k); for(const k of ['uVol','uDep','uVolSize','uInvVolSize','uDepthScale','uBilateral']) volUup[k]=gl.getUniformLocation(volProgUp,k); /* PIN THE SAMPLER UNITS AT LINK TIME, not just per draw. Until a uniform is written, every sampler reads unit 0 — and this program has a sampler3D and a sampler2D, so before the first volFxDraw the program is in the state ANGLE reports as "Two textures of different types use the same sampler location": VALIDATE_STATUS false and undefined behaviour for any draw that slipped in first. Caught by the bring-up probe, which asserts VALIDATE_STATUS rather than trusting LINK_STATUS alone. Same fix, same save/restore of CURRENT_PROGRAM, as gpufx.js:196-200. */ { const wasP=gl.getParameter(gl.CURRENT_PROGRAM); gl.useProgram(volProg); if(volU.uNoise) gl.uniform1i(volU.uNoise,12); if(volU.uDep) gl.uniform1i(volU.uDep,4); if(volU.uDriver) gl.uniform1i(volU.uDriver,VOL_DRIVER_UNIT); if(volU.uDriverReady) gl.uniform1f(volU.uDriverReady,0); if(volU.uTrailDriver) gl.uniform1i(volU.uTrailDriver,11); if(volU.uTrailDriverReady) gl.uniform1f(volU.uTrailDriverReady,0); if(volU.uTrailHistory) gl.uniform1i(volU.uTrailHistory,10); if(volU.uTrailHistoryReady) gl.uniform1f(volU.uTrailHistoryReady,0); gl.useProgram(volProgUp); if(volUup.uVol) gl.uniform1i(volUup.uVol,13); if(volUup.uDep) gl.uniform1i(volUup.uDep,4); if(wasP) gl.useProgram(wasP); else gl.useProgram(null); } /* Unit cube, 36 non-indexed verts (432 B). Boxes not spheres: one mesh covers ball, dome and column, and the anisotropic extents give the plume its aspect for free. */ const C=new Float32Array(108); { const F=[[0,1,2,3],[5,4,7,6],[4,5,1,0],[3,2,6,7],[4,0,3,7],[1,5,6,2]]; const V=[[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1],[-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1]]; let w=0; for(const f of F){ const idx=[f[0],f[1],f[2], f[0],f[2],f[3]]; for(const q of idx){ C[w++]=V[q][0]; C[w++]=V[q][1]; C[w++]=V[q][2]; } } } volCubeVBO=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,volCubeVBO); gl.bufferData(gl.ARRAY_BUFFER,C,gl.STATIC_DRAW); volInstVBO=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,volInstVBO); gl.bufferData(gl.ARRAY_BUFFER,volInstData.byteLength,gl.DYNAMIC_DRAW); volVAO=gl.createVertexArray(); gl.bindVertexArray(volVAO); gl.bindBuffer(gl.ARRAY_BUFFER,volCubeVBO); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,3,gl.FLOAT,false,12,0); gl.vertexAttribDivisor(0,0); gl.bindBuffer(gl.ARRAY_BUFFER,volInstVBO); const ST=VOL_INST_FLOATS*4; for(let a=1;a<=5;a++){ gl.enableVertexAttribArray(a); gl.vertexAttribPointer(a,4,gl.FLOAT,false,ST,(a-1)*16); gl.vertexAttribDivisor(a,1); } gl.bindVertexArray(null); gl.bindBuffer(gl.ARRAY_BUFFER,null); /* The composite is a gl_VertexID fullscreen triangle with no attributes. It gets its own empty VAO rather than riding the default one, so a stray enabled attribute array left by another pass cannot fail the draw. */ volEmptyVAO=gl.createVertexArray(); volNoiseTex=gl.createTexture(); gl.activeTexture(gl.TEXTURE12); gl.bindTexture(gl.TEXTURE_3D,volNoiseTex); gl.texImage3D(gl.TEXTURE_3D,0,gl.RGBA8,32,32,32,0,gl.RGBA,gl.UNSIGNED_BYTE,volNoiseBuild()); gl.texParameteri(gl.TEXTURE_3D,gl.TEXTURE_MIN_FILTER,gl.LINEAR); gl.texParameteri(gl.TEXTURE_3D,gl.TEXTURE_MAG_FILTER,gl.LINEAR); gl.texParameteri(gl.TEXTURE_3D,gl.TEXTURE_WRAP_S,gl.REPEAT); gl.texParameteri(gl.TEXTURE_3D,gl.TEXTURE_WRAP_T,gl.REPEAT); gl.texParameteri(gl.TEXTURE_3D,gl.TEXTURE_WRAP_R,gl.REPEAT); gl.bindTexture(gl.TEXTURE_3D,null); gl.activeTexture(gl.TEXTURE0); /* The placeholder must exist before the asynchronous decode starts. If a device cannot allocate even this 1x1 sampler, mark the whole volume pass unavailable so the already-armed single flipbook remains the atomic fallback instead of attempting an incomplete sampler draw. */ volDriverTex=gl.createTexture(); if(!volDriverTex||!volDriverNeutral(volDriverTex)){ volDriverTex=null; volDriverReady=false; volDriverState='unavailable'; volDriverError='could not allocate neutral driver sampler'; volDriverTelemetrySync(); volInitFailed=true; VOLFX_TELEMETRY.progOK=false; return; } volDriverLoad(); VOLFX_TELEMETRY.progOK=true; } /* Half-res colour target. This mirrors aoAlloc's discipline verbatim (mesh.js:2380-2450) and for the same reason: detach BEFORE deleting, do not commit the cached size until the framebuffer reports COMPLETE, and bound the retry. A single failed alloc once latched AO off for 316 straight frames. */ function volAlloc(w,h){ w=Math.max(1,w|0); h=Math.max(1,h|0); if(volW===w&&volH===h&&volTex&&volFB) return true; if(volFailN>=4&&volW===w&&volH===h) return false; /* This runs MID-FRAME with aoFB2 bound. Binding the default target and returning would send water and every additive pass to the canvas, so the caller's framebuffer is saved and put back on BOTH exits. */ const wasFB=gl.getParameter(gl.FRAMEBUFFER_BINDING); if(volFB){ /* Detach BEFORE deleting: aoAlloc does the same and for the same reason. */ gl.bindFramebuffer(gl.FRAMEBUFFER,volFB); gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,null,0); } if(volTex) gl.deleteTexture(volTex); volTex=gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D,volTex); /* RGBA8 to match aoColB (mesh.js:2410). NEAREST because the bilateral upsample must see the texels that were actually marched. */ gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA8,w,h,0,gl.RGBA,gl.UNSIGNED_BYTE,null); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); gl.bindTexture(gl.TEXTURE_2D,null); if(!volFB) volFB=gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER,volFB); gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,volTex,0); const ok=gl.checkFramebufferStatus(gl.FRAMEBUFFER)===gl.FRAMEBUFFER_COMPLETE; gl.bindFramebuffer(gl.FRAMEBUFFER,wasFB); if(ok){ volW=w; volH=h; volFailN=0; return true; } volFailN++; if(volFailN>=4){ volW=w; volH=h; } // stop retrying every frame return false; } /* --------------------------------------------------------------------------- HOP 2 — ring -> GPU, once per frame. CALLED FROM render3d.js immediately BEFORE aoExtractBloom(), which is the last point in the frame where a pass can still reach the bright pass (render3d.js:2743-2747). At that instant the bound FBO is aoFB2 with aoColB + aoDepth, viewport aoW x aoH, scissor already disabled, and blend off / depth test on / depthMask true / cull on. This function must leave EXACTLY that. --------------------------------------------------------------------------- */ function volFxDraw(dtDraw,Sun,canPresent){ volPresented.fill(0); VOLFX_TELEMETRY.live=volN; VOLFX_TELEMETRY.drawn=0; VOLFX_TELEMETRY.culled=0; VOLFX_TELEMETRY.marchedPx=0; VOLFX_TELEMETRY.compositePx=0; VOLFX_TELEMETRY.steps=0; VOLFX_TELEMETRY.presentedBlast=0;VOLFX_TELEMETRY.presentedDust=0; VOLFX_TELEMETRY.presentedImpact=0; VOLFX_TELEMETRY.presentedTrailEnergy=0;VOLFX_TELEMETRY.presentedTrailShell=0; VOLFX_TELEMETRY.trailDriverReady=false;VOLFX_TELEMETRY.trailHistoryReady=false; VOLFX_TELEMETRY.fallbackQueries=0;VOLFX_TELEMETRY.fallbackHits=0; VOLFX_TELEMETRY.driverSamples=0; volDriverTelemetrySync(); volFxSyncRay(); /* MAINTENANCE PATH — this function is called EVERY frame, including frames where the offscreen/AO target was never bound. Clearing the presentation bits above is the whole reason: those bits are what suppress the armed flipbook, so a bit left over from the last composited frame would also suppress the fallback on a frame that cannot composite, and the detonation would render as nothing at all. Everything below needs the offscreen colour+depth, so on those frames the correct behaviour is to leave with the bits cleared and let the flipbook own the frame. */ if(canPresent===false) return; if(!volFxActive()) return; if(volN<=0) return; /* Thin ordnance wakes lose their white core and scalloped soot boundary in the shared half-resolution blast target. Trail kinds remain probe-only, so evaluate them at scene resolution while their cost/quality is being measured; production explosions retain the established half-res path. */ let trailFullRes=false,curveTrail=false; for(let vi=0;vi>1), Math.max(1,trailFullRes?aoH:aoH>>1))) return; /* ---- cull + project ------------------------------------------------- */ const cb=(typeof camBounds==='function')?camBounds():null; const pxPerWorld=volH/Math.max(1,(typeof orthoSpan==='number'?orthoSpan:1500)); let n=0, area=0; /* Phase B used to run its five-tap bilateral upsample over the entire AO target even when three tactical volumes covered <0.2% of it. Project the accepted proxy boxes once and scissor the composite to their exact union. The march remains half-resolution and depth-aware, and the preset still owns the same 24/32 maximum steps; this only removes guaranteed-zero fullscreen work. Coordinates stay in GL's bottom-left framebuffer space. */ let compX0=aoW,compY0=aoH,compX1=0,compY1=0; for(let i=0;i0)) continue; const age=Math.min(1,volT[i]/life); const g=volRenderGrow(volKind[i],age,volR0[i],pxPerWorld, volAX[i],volAY[i],volAZ[i],volStyle[i]); const ex=g*volAX[i], ey=g*volAY[i], ez=g*volAZ[i]; const rad=Math.max(ex,Math.max(ey,ez)); /* Fog disclosure is a GROUND-PLANE question: what the player would see is this volume's footprint on the terrain. Using the 3D radius let a tall plume's vertical half-extent (aspect y up to 3.6) inflate the test by several times the real footprint, so a volume whose ground position was well inside the fog could still be judged visible. */ const radXZ=Math.max(ex,ez); if(cb){ if(volPX[i]+radcb.x1) { VOLFX_TELEMETRY.culled++; continue; } if(volPZ[i]+radcb.y1) { VOLFX_TELEMETRY.culled++; continue; } } /* Fog of war owns disclosure. A volume must not draw an enemy detonation the player has no vision of — the same rule every other FX loop in render3d.js applies. */ const fogVisible=typeof fogFxFootprintVisible==='function' ?fogFxFootprintVisible(volPX[i],volPZ[i],radXZ) :(typeof fogPointVisible!=='function'||fogPointVisible(volPX[i],volPZ[i])); if(!fogVisible){ VOLFX_TELEMETRY.culled++; continue; } for(let cz=-1;cz<=1;cz+=2)for(let cy=-1;cy<=1;cy+=2)for(let cx=-1;cx<=1;cx+=2){ const wx=volPX[i]+ex*cx,wy=volPY[i]+ey*cy,wz=volPZ[i]+ez*cz; const cw=matVP[3]*wx+matVP[7]*wy+matVP[11]*wz+matVP[15]; if(Math.abs(cw)<1e-5)continue; const sx=((matVP[0]*wx+matVP[4]*wy+matVP[8]*wz+matVP[12])/cw*.5+.5)*aoW; const sy=((matVP[1]*wx+matVP[5]*wy+matVP[9]*wz+matVP[13])/cw*.5+.5)*aoH; compX0=Math.min(compX0,sx);compX1=Math.max(compX1,sx); compY0=Math.min(compY0,sy);compY1=Math.max(compY1,sy); } volOrder[n]=i; /* Ortho: the camera ray is constant, so a depth key of dot(centre,rayDir) is EXACT and a descending insertion sort over <=48 entries is free. Required because each volume's own march emits premultiplied "over", so cross-volume compositing has to be back-to-front. */ volKey[n]=volPX[i]*volRayX+volPY[i]*volRayY+volPZ[i]*volRayZ; volArea[n]=Math.PI*ex*ez*pxPerWorld*pxPerWorld; area+=volArea[n]; n++; } if(n<=0) return; /* A ready driver is sampled only by the blast/smoke density material. This is telemetry for the runtime probe, not an additional event/layer count. */ if(volDriverReady){ for(let s=0;s near (descending along the view ray) ---------------- */ for(let a=1;a=0&&volKey[b](1-g*g)/Math.pow(Math.max(1e-3,1+g*g-2*g*cosT),1.5); gl.uniform1f(volU.uPhase,0.5*(0.65*HG(0.5)+0.35*HG(-0.2))); } gl.uniform1f(volU.uTime,volTime); gl.uniform1f(volU.uWaterOn,(typeof waterIdxCount!=='undefined'&&waterIdxCount>0)?1:0); gl.uniform1f(volU.uWaterY,(typeof WATER_Y!=='undefined')?WATER_Y:0); gl.uniform1i(volU.uSteps,steps); gl.bindVertexArray(volVAO); gl.drawArraysInstanced(gl.TRIANGLES,0,36,n); gl.bindVertexArray(null); gl.bindBuffer(gl.ARRAY_BUFFER,null); /* ================= PHASE B — composite into aoFB2 ===================== aoDepth IS attached to aoFB2 and the bilateral upsample samples it: that is a framebuffer feedback loop. csmApply has the identical situation and records the consequence at mesh.js:3052-3055 — "ANGLE D3D11 returns INVALID_OPERATION (1282) and the multiply is undefined". Detach, draw, RE-ATTACH; the re-attach is mandatory because the additive effects block (render3d.js:2166) and the billboard pass both depth-test against it. */ gl.bindFramebuffer(gl.FRAMEBUFFER,aoFB2); gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,null,0); gl.viewport(0,0,aoW,aoH); const compPad=6; const compSX=Math.max(0,Math.floor(compX0)-compPad),compSY=Math.max(0,Math.floor(compY0)-compPad); const compEX=Math.min(aoW,Math.ceil(compX1)+compPad),compEY=Math.min(aoH,Math.ceil(compY1)+compPad); compSW=Math.max(0,compEX-compSX);compSH=Math.max(0,compEY-compSY); gl.enable(gl.SCISSOR_TEST); gl.scissor(compSX,compSY,compSW,compSH); /* CULLING OFF FOR THE COMPOSITE. Phase A left cullFace(FRONT) so the box BACK faces rasterise. The fullscreen triangle is wound CCW, i.e. FRONT facing, so leaving that state on culls the composite entirely: volTex fills correctly, the draw reports no GL error, and absolutely nothing reaches aoColB. Measured exactly that — 719 non-zero half-res texels in volTex against a 0-pixel delta in aoColB. aoResolve disables culling around its own fullscreen triangle for the same reason (mesh.js:2596). */ gl.disable(gl.CULL_FACE); gl.useProgram(volProgUp); gl.activeTexture(gl.TEXTURE13); gl.bindTexture(gl.TEXTURE_2D,volTex); gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,aoDepth); gl.uniform1i(volUup.uVol,13); gl.uniform1i(volUup.uDep,4); gl.uniform2f(volUup.uVolSize,volW,volH); gl.uniform2f(volUup.uInvVolSize,1/volW,1/volH); gl.uniform1f(volUup.uDepthScale,15000.0); gl.uniform1f(volUup.uBilateral,volBilateral?1:0); gl.blendFunc(gl.ONE,gl.ONE_MINUS_SRC_ALPHA); gl.bindVertexArray(volEmptyVAO); gl.drawArrays(gl.TRIANGLES,0,3); gl.bindVertexArray(null); gl.disable(gl.SCISSOR_TEST); gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,aoDepth,0); /* EVERY input this pass depends on must still be real, the composite must have covered a positive area, and the driver must report NO_ERROR. Any one of these failing means the frame did not actually put a volume on screen, and claiming presentation would suppress the armed flipbook as well — leaving the detonation invisible. Draw-time gl.getError() is read here rather than under a debug flag because it is the only signal that separates "composited" from "silently produced nothing". */ composited=(compSW>0&&compSH>0) &&!!volProg&&!!volProgUp&&!!volVAO&&!!volEmptyVAO&&!!volInstVBO &&!!volNoiseTex&&!!volDriverTex&&!!volTex&&!!volFB&&(!curveTrail||!!trailHistoryTex) &&(typeof aoDepth!=='undefined'&&!!aoDepth) &&gl.getError()===gl.NO_ERROR; }catch(e){ composited=false; VOLFX_TELEMETRY.lastError='draw: '+String(e&&e.message||e).slice(0,150); }finally{ /* Depth goes back FIRST and unconditionally: everything downstream of this pass depth-tests against aoDepth. */ try{ gl.bindFramebuffer(gl.FRAMEBUFFER,aoFB2); gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,aoDepth,0); }catch(_){} gl.bindFramebuffer(gl.FRAMEBUFFER,wasFB); gl.viewport(wasVP[0],wasVP[1],wasVP[2],wasVP[3]); gl.scissor(wasScBox[0],wasScBox[1],wasScBox[2],wasScBox[3]); if(wasScissor) gl.enable(gl.SCISSOR_TEST); else gl.disable(gl.SCISSOR_TEST); gl.activeTexture(gl.TEXTURE12); gl.bindTexture(gl.TEXTURE_3D,null); gl.activeTexture(gl.TEXTURE10); gl.bindTexture(gl.TEXTURE_2D,wasTex10); gl.activeTexture(gl.TEXTURE11); gl.bindTexture(gl.TEXTURE_2D,wasTex11); gl.activeTexture(gl.TEXTURE13); gl.bindTexture(gl.TEXTURE_2D,wasTex13); gl.activeTexture(gl.TEXTURE0+VOL_DRIVER_UNIT); gl.bindTexture(gl.TEXTURE_2D,wasTexDriver); gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,wasTex4); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,wasTex0); gl.activeTexture(wasActive); gl.bindVertexArray(wasVAO); gl.bindBuffer(gl.ARRAY_BUFFER,wasArr); gl.useProgram(wasProg); gl.cullFace(wasCullMode); gl.blendFuncSeparate(wasSrcRGB,wasDstRGB,wasSrcA,wasDstA); gl.blendEquationSeparate(wasEqRGB,wasEqA); 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); } /* ATOMIC PRESENTATION. A partial pass claims nothing, so render3d reveals exactly one armed flipbook instead of showing both or neither. */ if(!composited){ volPresented.fill(0); VOLFX_TELEMETRY.drawn=0; VOLFX_TELEMETRY.driverSamples=0; return; } for(let s=0;s 1-fetch upsample (the degraded rung) volFxSetErrCheck(true) -> read gl.getError() into telemetry each frame volFxDebugBurst(...) -> queue a volume without the sim */ function volFxSetBilateral(on){ volBilateral=!!on; } function volFxSetErrCheck(on){ volCheckErr=!!on; } function volFxDebugBurst(x,y,worldY,kind,radius,opts){ return volFxBurst(x,y,worldY,kind,radius,opts); } function volFxDebugTrail(x0,z0,y0,x1,z1,y1,kind,opts){ const o=opts||{},k=kind|0;if(k!==VOL_TRAIL_ENERGY&&k!==VOL_TRAIL_SHELL)return []; const count=10,arc=Number.isFinite(o.arc)?o.arc:150,life=o.life>0?o.life:8,age=Math.max(0,Math.min(.9,Number(o.age)||.18)); const headQ=Math.max(.08,Math.min(1,Number.isFinite(o.headQ)?o.headQ:.86)); const span=Math.max(.08,Math.min(headQ,Number.isFinite(o.span)?o.span:(k===VOL_TRAIL_ENERGY?.62:.54))); const qStart=headQ-span; const tint=o.tint||(k===VOL_TRAIL_ENERGY?[96,220,255]:[255,118,38]),ids=[]; const point=(q)=>[x0+(x1-x0)*q,y0+(y1-y0)*q+Math.sin(q*Math.PI)*arc,z0+(z1-z0)*q]; for(let j=0;j=0){volT[s]=age*life;ids.push(s);} } return ids; } /* Self-initialise at the end of our own file. There is no GL context at load time, so this only primes the ray basis and the epoch latch; the programs, buffers and 3D noise are built lazily on first use, exactly like gpfxInit. The bundle gate (tools/bundle.mjs:91) requires a file that declares init functions to call one of them here rather than relying on main.js. */ function volFxBoot(){ volEpoch=-1; volInitFailed=false; volN=0; volFxSyncRay(); } volFxBoot();