Spaces:
Running
Running
Download src/engine/volfx.js from CREATORJD/massfront-playtest: direct link, hf CLI and curl.
- Browser
- Download file 131 kB
-
https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/engine/volfx.js
- Command line
-
hf download hf://spaces/CREATORJD/massfront-playtest/src/engine/volfx.js
-
curl -L -o volfx.js https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/engine/volfx.js
131 kB
| /* ============================================================================ | |
| 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<volN;i++){ | |
| if(!volPresented[i]||volKind[i]!==kind) continue; | |
| const reach=Math.max(3,volR0[i]*.72),dx=volPX[i]-x,dy=volPZ[i]-y; | |
| if(dx*dx+dy*dy<=reach*reach){VOLFX_TELEMETRY.fallbackHits++;return true;} | |
| } | |
| return false; | |
| } | |
| /* --------------------------------------------------------------------------- | |
| QUALITY GATE | |
| The brief's warning is real: `perfScale > 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;i<volN;i++){ | |
| /* A profile changes the material inside an otherwise identical volume. | |
| Do not merge a falling airframe's soot crown into a conventional blast: | |
| keeping their one-core recipes separate is cheaper and more legible than | |
| allowing the oldest profile to accidentally repaint the newer event. */ | |
| if(volKind[i]!==k||volStyle[i]!==style) continue; | |
| const age=volLife[i]>0?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(volN<cap){ s=volN++; } | |
| else{ | |
| /* Evict the OLDEST by age, never the newest. A fresh detonation must always | |
| be visible; a three-second-old smoke ball losing its last half second is | |
| something no player will ever see. */ | |
| s=0; let worst=-1; | |
| for(let i=0;i<volN;i++){ | |
| const age=volLife[i]>0?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<volN;){ | |
| volT[i]+=dt; | |
| if(volLife[i]<=0||volT[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<VOL_TRAIL_HISTORY_ROWS;i++)if(!volTrailHistoryUsed[i]){row=i;break;} | |
| if(row<0)return -1; | |
| const o=opts||{};volTrailHistoryUsed[row]=1;volTrailHistoryRows=Math.max(volTrailHistoryRows,row+1); | |
| const k=(kind|0)===VOL_TRAIL_SHELL||(kind|0)===VOL_TRAIL_CURVE_SHELL | |
| ?VOL_TRAIL_CURVE_SHELL:VOL_TRAIL_CURVE_ENERGY; | |
| volTrailHistoryKind[row]=k;volTrailHistoryCount[row]=0; | |
| const rb=row*VOL_TRAIL_HISTORY_POINTS,db=rb*4; | |
| volTrailHistoryData.fill(0,db,db+VOL_TRAIL_HISTORY_POINTS*4); | |
| volTrailHistoryUpload.fill(0,db,db+VOL_TRAIL_HISTORY_POINTS*4); | |
| volTrailHistoryStamp.fill(0,rb,rb+VOL_TRAIL_HISTORY_POINTS); | |
| const tint=o.tint||(k===VOL_TRAIL_CURVE_ENERGY?[206,132,255]:[255,118,38]); | |
| const to=row*3;volTrailHistoryTint[to]=tint[0]|0;volTrailHistoryTint[to+1]=tint[1]|0;volTrailHistoryTint[to+2]=tint[2]|0; | |
| volTrailHistorySeed[row]=Number.isFinite(o.seed)?Math.max(0,Math.min(63.999,o.seed)):7.25; | |
| let used=0;for(let i=0;i<VOL_TRAIL_HISTORY_ROWS;i++)used+=volTrailHistoryUsed[i]?1:0; | |
| VOL_TRAIL_HISTORY_TELEM.rows=used;volTrailHistoryDirty=true;return row; | |
| } | |
| function volFxTrailHistoryPush(row,x,z,worldY,stamp){ | |
| row|=0;if(row<0||row>=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;r++)total+=volTrailHistoryCount[r]; | |
| VOL_TRAIL_HISTORY_TELEM.points=total;return true; | |
| } | |
| function volFxTrailHistoryEmit(row,opts){ | |
| row|=0;if(row<0||row>=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;i<n;i++){ | |
| const so=db+i*4,age=Math.max(0,Math.min(1,(newest-volTrailHistoryStamp[rb+i])/duration)); | |
| let x=volTrailHistoryData[so],y=volTrailHistoryData[so+1],z=volTrailHistoryData[so+2]; | |
| /* The uploaded centreline is the projectile's real deterministic history. | |
| Turbulence belongs in the density field, not in a CPU-side displacement | |
| that makes the old wake detach from the ballistic arc. */ | |
| const width=(k===VOL_TRAIL_CURVE_ENERGY | |
| ?Math.max(worldPx*6.40,3.2+2.4*(1-age)) | |
| :Math.max(worldPx*8.80,5.4+5.0*age))*widthScale; | |
| volTrailHistoryUpload[so]=x;volTrailHistoryUpload[so+1]=y;volTrailHistoryUpload[so+2]=z;volTrailHistoryUpload[so+3]=width; | |
| minX=Math.min(minX,x-width);maxX=Math.max(maxX,x+width);minY=Math.min(minY,y-width);maxY=Math.max(maxY,y+width);minZ=Math.min(minZ,z-width);maxZ=Math.max(maxZ,z+width);maxR=Math.max(maxR,width); | |
| } | |
| /* The projectile/head is an elongated part of the same volume. Give its | |
| terminal ellipsoid enough proxy room so it cannot be clipped at the last | |
| history sample. */ | |
| const ho=db+(n-1)*4,hx=volTrailHistoryUpload[ho],hy=volTrailHistoryUpload[ho+1],hz=volTrailHistoryUpload[ho+2]; | |
| const headPad=volTrailHistoryUpload[ho+3]*(noHead?.42:(k===VOL_TRAIL_CURVE_ENERGY?1.85:2.20)); | |
| minX=Math.min(minX,hx-headPad);maxX=Math.max(maxX,hx+headPad); | |
| minY=Math.min(minY,hy-headPad);maxY=Math.max(maxY,hy+headPad); | |
| minZ=Math.min(minZ,hz-headPad);maxZ=Math.max(maxZ,hz+headPad); | |
| const last=db+(n-1)*4; | |
| for(let i=n;i<VOL_TRAIL_HISTORY_POINTS;i++)volTrailHistoryUpload.set(volTrailHistoryUpload.subarray(last,last+4),db+i*4); | |
| volTrailHistoryDirty=true; | |
| const cx=(minX+maxX)*.5,cy=(minY+maxY)*.5,cz=(minZ+maxZ)*.5; | |
| const tintOff=row*3,tint=[volTrailHistoryTint[tintOff],volTrailHistoryTint[tintOff+1],volTrailHistoryTint[tintOff+2]]; | |
| const life=o.life>0?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<volN;i++)if(volKind[i]===k&&volStyle[i]===row&&volTX[i]===row){slot=i;break;} | |
| if(slot<0)slot=volFxBurst(cx,cz,cy,k,1,{life,rise:0,dens,emis,aspect, | |
| trailAxis:[row,n,noHead?1:0],trailHalf:1,tint,style:row,seed:volTrailHistorySeed[row]}); | |
| else{ | |
| volPresented[slot]=0;volPX[slot]=cx;volPY[slot]=cy;volPZ[slot]=cz;volR0[slot]=1; | |
| volLife[slot]=life;volT[slot]=0;volDens[slot]=dens;volEmis[slot]=emis;volRise[slot]=0; | |
| volDX[slot]=volDZ[slot]=0;volTX[slot]=row;volTY[slot]=n;volTZ[slot]=noHead?1:0;volTHalf[slot]=1; | |
| volAX[slot]=aspect[0];volAY[slot]=aspect[1];volAZ[slot]=aspect[2]; | |
| volTR[slot]=tint[0]|0;volTG[slot]=tint[1]|0;volTB[slot]=tint[2]|0; | |
| } | |
| if(slot>=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<volN;i++)if(volKind[i]===k&&volStyle[i]===row&&volTX[i]===row){ | |
| const left=Math.max(.04,Number(fade)||.12);volLife[i]=Math.min(volLife[i],volT[i]+left); | |
| } | |
| volTrailHistoryUsed[row]=0;volTrailHistoryCount[row]=0;volTrailHistoryDirty=true; | |
| let used=0,points=0;for(let i=0;i<VOL_TRAIL_HISTORY_ROWS;i++)if(volTrailHistoryUsed[i]){used++;points+=volTrailHistoryCount[i];} | |
| VOL_TRAIL_HISTORY_TELEM.rows=used;VOL_TRAIL_HISTORY_TELEM.points=points;return true; | |
| } | |
| function volFxTrailHistoryTelemetry(){ | |
| return {rows:VOL_TRAIL_HISTORY_TELEM.rows,points:VOL_TRAIL_HISTORY_TELEM.points,pushes:VOL_TRAIL_HISTORY_TELEM.pushes, | |
| emits:VOL_TRAIL_HISTORY_TELEM.emits,uploads:VOL_TRAIL_HISTORY_TELEM.uploads,pausedStable:VOL_TRAIL_HISTORY_TELEM.pausedStable, | |
| ready:VOL_TRAIL_HISTORY_TELEM.ready,lastError:VOL_TRAIL_HISTORY_TELEM.lastError}; | |
| } | |
| function volFxTrailHistoryTexture(){ | |
| if(typeof gl==='undefined'||!gl)return null; | |
| const ep=typeof glEpoch==='number'?glEpoch:0; | |
| if(volTrailHistoryEpoch!==ep){volTrailHistoryEpoch=ep;volTrailHistoryTex=null;volTrailHistoryDirty=true;} | |
| const wasActive=gl.getParameter(gl.ACTIVE_TEXTURE);gl.activeTexture(gl.TEXTURE10);const wasTex=gl.getParameter(gl.TEXTURE_BINDING_2D); | |
| try{ | |
| if(!volTrailHistoryTex){ | |
| volTrailHistoryTex=gl.createTexture();if(!volTrailHistoryTex)throw new Error('trail history texture allocation failed'); | |
| gl.bindTexture(gl.TEXTURE_2D,volTrailHistoryTex); | |
| 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.texImage2D(gl.TEXTURE_2D,0,gl.RGBA32F,VOL_TRAIL_HISTORY_POINTS,VOL_TRAIL_HISTORY_ROWS,0,gl.RGBA,gl.FLOAT,volTrailHistoryUpload); | |
| volTrailHistoryDirty=false;VOL_TRAIL_HISTORY_TELEM.uploads++; | |
| }else if(volTrailHistoryDirty){ | |
| gl.bindTexture(gl.TEXTURE_2D,volTrailHistoryTex); | |
| gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,VOL_TRAIL_HISTORY_POINTS,VOL_TRAIL_HISTORY_ROWS,gl.RGBA,gl.FLOAT,volTrailHistoryUpload); | |
| volTrailHistoryDirty=false;VOL_TRAIL_HISTORY_TELEM.uploads++; | |
| } | |
| VOL_TRAIL_HISTORY_TELEM.ready=true;VOL_TRAIL_HISTORY_TELEM.lastError='';return volTrailHistoryTex; | |
| }catch(e){VOL_TRAIL_HISTORY_TELEM.ready=false;VOL_TRAIL_HISTORY_TELEM.lastError=String(e&&e.message||e);return null;} | |
| finally{gl.bindTexture(gl.TEXTURE_2D,wasTex);gl.activeTexture(wasActive);} | |
| } | |
| /* Growth curve per kind. Age is the ONLY animation input the shader gets, so | |
| the CPU and the GPU must agree on the world extents or the march will start | |
| outside the density it is looking for. */ | |
| function volGrow(kind, age, style){ | |
| if(volIsTrailKind(kind))return 1.0; | |
| if(kind===VOL_DUST) return 0.45+1.25*age; | |
| if(kind===VOL_PLUME) return 0.50+0.95*age; | |
| if(kind===VOL_BLAST) return 0.50+0.90*age; | |
| /* A phase contact is a fast, bounded displacement of the field rather than | |
| a combustion balloon. The generic impact curve grows 4.1x before it | |
| dies, which made the late void frames become a large purple sphere. Hold | |
| the white-violet contact near its authored scale, then collapse it. */ | |
| if(kind===VOL_IMPACT&&(style|0)===2){ | |
| const ignite=age<=0?0:age>=.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<WL;z++) for(let y=0;y<WL;y++) for(let x=0;x<WL;x++){ | |
| const i=(z*WL*WL+y*WL+x)*3; | |
| fp[i ]=(x+hash(i+11))/WL; | |
| fp[i+1]=(y+hash(i+29))/WL; | |
| fp[i+2]=(z+hash(i+53))/WL; | |
| } | |
| const wrapD=(a,b)=>{ let d=a-b; if(d>0.5) d-=1; else if(d<-0.5) d+=1; return d; }; | |
| for(let z=0;z<N;z++) for(let y=0;y<N;y++) for(let x=0;x<N;x++){ | |
| const fx=x/N, fy=y/N, fz=z/N; | |
| const o=((z*N+y)*N+x)*4; | |
| out[o ]=Math.max(0,Math.min(255, (value( 4,fx,fy,fz)*255)|0)); | |
| out[o+1]=Math.max(0,Math.min(255, (value( 8,fx,fy,fz)*255)|0)); | |
| out[o+2]=Math.max(0,Math.min(255, (value(16,fx,fy,fz)*255)|0)); | |
| let best=4; | |
| const cx=Math.floor(fx*WL), cy=Math.floor(fy*WL), cz=Math.floor(fz*WL); | |
| for(let dz=-1;dz<=1;dz++) for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){ | |
| const ix=((cx+dx)%WL+WL)%WL, iy=((cy+dy)%WL+WL)%WL, iz=((cz+dz)%WL+WL)%WL; | |
| const i=(iz*WL*WL+iy*WL+ix)*3; | |
| const ax=wrapD(fx,fp[i]), ay=wrapD(fy,fp[i+1]), az=wrapD(fz,fp[i+2]); | |
| const d=ax*ax+ay*ay+az*az; | |
| if(d<best) best=d; | |
| } | |
| const f1=Math.min(1, Math.sqrt(best)*WL*1.15); | |
| out[o+3]=Math.max(0,Math.min(255,(f1*255)|0)); | |
| } | |
| return out; | |
| } | |
| /* --------------------------------------------------------------------------- | |
| SHADERS | |
| --------------------------------------------------------------------------- */ | |
| const VOL_VS=`#version 300 es | |
| layout(location=0) in vec3 aPos; // unit cube, -1..1, divisor 0 | |
| layout(location=1) in vec4 aCtr; // cx, cy(worldY), cz, ageNorm | |
| layout(location=2) in vec4 aExt; // half-extents xyz, kind | |
| layout(location=3) in vec4 aPar; // dens, emis, seed, LIFE SECONDS | |
| layout(location=4) in vec4 aTint; // albedo rgb, softness | |
| layout(location=5) in vec4 aTrail; // world tangent xyz, capsule half-length | |
| uniform mat4 uVP; | |
| flat out vec4 vCtr; | |
| flat out vec4 vExt; | |
| flat out vec4 vPar; | |
| flat out vec4 vTint; | |
| flat out vec4 vTrail; | |
| out vec3 vWorld; | |
| void main(){ | |
| vCtr=aCtr; vExt=aExt; vPar=aPar; vTint=aTint; vTrail=aTrail; | |
| vWorld=aCtr.xyz + aPos*aExt.xyz; | |
| gl_Position=uVP*vec4(vWorld,1.0); | |
| }`; | |
| /* The march. Everything about this shader is shaped by three facts: | |
| 1. The camera is ORTHOGRAPHIC (mesh.js:3277), so the ray direction is a | |
| per-frame CONSTANT: no matrix inverse, no perspective divide, and the | |
| Henyey-Greenstein phase can be evaluated once on the CPU. | |
| 2. Depth is LINEAR in window z under that projection, and the conversion | |
| constant is far-near = 15000 — the same number FSAO already hardcodes | |
| as uWorldPerZ (mesh.js:2609). COUPLING: if m4ortho's near/far in | |
| camUpdateMatrices ever change, BOTH call sites must change. | |
| 3. The target is RGBA8 (it matches aoColB), so the hot core clips to white | |
| before it reads as hot. That is why this pass is inserted where bloom | |
| can still see it. */ | |
| const VOL_FS=`#version 300 es | |
| precision highp float; | |
| precision highp sampler3D; | |
| precision highp sampler2D; | |
| flat in vec4 vCtr; | |
| flat in vec4 vExt; | |
| flat in vec4 vPar; | |
| flat in vec4 vTint; | |
| flat in vec4 vTrail; | |
| in vec3 vWorld; | |
| uniform sampler3D uNoise; | |
| uniform sampler2D uDep; | |
| /* Packed authored field: R density, G emission, B soot, A support. It is | |
| never drawn as a screen-facing image; driverUV receives object-space planes | |
| below and the result only modulates the procedural 3D material. */ | |
| uniform sampler2D uDriver; | |
| uniform float uDriverReady; | |
| uniform sampler2D uTrailDriver; | |
| uniform float uTrailDriverReady; | |
| uniform sampler2D uTrailHistory; | |
| uniform float uTrailHistoryReady; | |
| uniform vec3 uRayDir; | |
| uniform vec3 uSunDir; | |
| uniform vec3 uSunCol; | |
| uniform vec3 uAmbSky; | |
| uniform vec3 uAmbGnd; | |
| uniform vec3 uWind; | |
| uniform vec2 uInvVolSize; | |
| uniform float uWorldPerPx; | |
| uniform float uDepthScale; // far - near = 15000, see note above | |
| uniform float uSigma; | |
| uniform float uPhase; | |
| uniform float uTime; | |
| uniform float uWaterOn; | |
| uniform float uWaterY; | |
| uniform int uSteps; | |
| out vec4 o; | |
| /* THE JITTER IS OBJECT-SPACE, NOT A SCREEN GRID. | |
| This used to be a 4x4 ordered Bayer matrix indexed by gl_FragCoord. At 24-32 | |
| steps over a dense field that printed a visible crosshatch directly onto the | |
| fireball — the hex/diamond weave in the 02f-02h bombard captures is exactly | |
| it — and because it was pinned to the screen it slid across the volume as | |
| the camera moved. Offsetting the first sample by a tileable-noise lookup at | |
| the ray's OBJECT-SPACE entry point gives a blue-noise-like offset that is | |
| attached to the volume: it cannot align to the pixel grid and cannot crawl, | |
| and it is stable frame to frame with no temporal term (mesh.js:2262-2265 | |
| records what per-frame rotation without TAA did last time). */ | |
| /* Three FBM octaves out of ONE fetch: R/G/B are tileable value noise at | |
| lattice 4/8/16, A is an inverted Worley F1 (the cauliflower billow). */ | |
| float fbm3(vec4 n){ return n.r*0.52+n.g*0.30+n.b*0.18; } | |
| /* Uniform Catmull-Rom centreline reconstruction. The simulation uploads only | |
| fixed-step points; the renderer evaluates a C1 curve between them. This | |
| removes the exposed straight-segment joints without creating more trail | |
| instances or allowing render cadence to author new history. */ | |
| vec3 trailCatmull(vec3 p0,vec3 p1,vec3 p2,vec3 p3,float t){ | |
| float t2=t*t,t3=t2*t; | |
| return 0.5*((2.0*p1)+(-p0+p2)*t+(2.0*p0-5.0*p1+4.0*p2-p3)*t2+(-p0+3.0*p1-3.0*p2+p3)*t3); | |
| } | |
| vec3 trailCatmullTangent(vec3 p0,vec3 p1,vec3 p2,vec3 p3,float t){ | |
| float t2=t*t; | |
| return 0.5*((-p0+p2)+2.0*(2.0*p0-5.0*p1+4.0*p2-p3)*t+3.0*(-p0+3.0*p1-3.0*p2+p3)*t2); | |
| } | |
| /* A cell-local UV with a literal four-texel guard band. The baked atlas is | |
| 4x4 cells at 256px each, with 248px authored content inset by 4px. Keeping | |
| every filtered coordinate inside that region prevents frame bleeding without | |
| needing a second draw or an animated billboard. */ | |
| vec2 driverUV(vec2 plane,float frame){ | |
| vec2 inner=vec2(4.5/256.0); | |
| vec2 scale=vec2(247.0/256.0); | |
| vec2 local=inner+clamp(plane*0.5+0.5,0.0,1.0)*scale; | |
| vec2 cell=vec2(mod(frame,4.0),floor(frame*0.25)); | |
| return (cell+local)*0.25; | |
| } | |
| /* Project the same authored density/emission stage along all three local axes. | |
| The weights are derived from the world/object-space surface coordinate, so | |
| the art influences a real volume and cannot remain flat to the camera. */ | |
| vec4 driverTriplanar(vec3 p,float frame){ | |
| vec3 w=pow(abs(p)+vec3(0.035),vec3(1.35)); | |
| w/=max(1e-4,w.x+w.y+w.z); | |
| vec4 yz=texture(uDriver,driverUV(vec2(p.z,p.y),frame)); | |
| vec4 xz=texture(uDriver,driverUV(vec2(p.x,p.z),frame)); | |
| vec4 xy=texture(uDriver,driverUV(vec2(p.x,p.y),frame)); | |
| return yz*w.x+xz*w.y+xy*w.z; | |
| } | |
| /* Soft shoulder, with the amount of compression left to the CALLER. | |
| A single fixed shoulder was wrong in both directions: hard clipping | |
| flat-topped the fireball into white polygons, but compressing everything | |
| equally drained the punch out of heavy ordnance. Heavy events pass a small k | |
| so they can genuinely blow out; light contacts pass a large k and stay | |
| controlled. */ | |
| vec3 shoulder(vec3 c,float k){ return c/(1.0+c*k); } | |
| void main(){ | |
| float kind=vExt.w; | |
| float age=vCtr.w; | |
| /* Lane z holds a stable 0..64 noise seed plus a tiny internal material | |
| profile in 64-wide bands. Reusing the seed lane keeps the existing | |
| 16-float instance ABI intact: this is still one instanced volume draw. */ | |
| float profile=floor(vPar.z*(1.0/64.0)); | |
| float seed=vPar.z-profile*64.0; | |
| /* --- slab intersection, world units, sign-agnostic --------------------- | |
| vWorld is the BACK face of the proxy (we cull FRONT), so both roots are | |
| negative offsets along uRayDir. */ | |
| vec3 iD=1.0/uRayDir; | |
| vec3 t0=(vCtr.xyz-vExt.xyz-vWorld)*iD; | |
| vec3 t1=(vCtr.xyz+vExt.xyz-vWorld)*iD; | |
| vec3 tn=min(t0,t1), tf=max(t0,t1); | |
| float tEnter=max(max(tn.x,tn.y),tn.z); | |
| float tExit =min(min(tf.x,tf.y),tf.z); | |
| /* --- scene depth termination ------------------------------------------ */ | |
| vec2 uv=gl_FragCoord.xy*uInvVolSize; | |
| vec2 h=uInvVolSize*0.25; | |
| float dz=min(min(texture(uDep,uv+vec2( h.x, h.y)).r, texture(uDep,uv+vec2(-h.x, h.y)).r), | |
| min(texture(uDep,uv+vec2( h.x,-h.y)).r, texture(uDep,uv+vec2(-h.x,-h.y)).r)); | |
| float tHere =gl_FragCoord.z*uDepthScale; | |
| float tScene=dz*uDepthScale; | |
| tExit=min(tExit, tScene-tHere); | |
| if(uWaterOn>0.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(d2<bestD2){bestD2=d2;bestPoint=nearP;bestProbe=rayProbe;bestTan=nearT;bestR=mix(h1.w,h2.w,hs);bestU=(float(hi)+h)/float(histCount-1);} | |
| } | |
| trailU=clamp(bestU,0.006,0.994); | |
| tubeR=max(max(0.25,bestR),uWorldPerPx*mix(6.40,8.80,isTrailShell)*mix(1.0,.58,headlessWake)); | |
| trailTangent=normalize(bestTan+vec3(1e-5)); | |
| vec3 axisRef=abs(trailTangent.y)<0.88?vec3(0.0,1.0,0.0):vec3(1.0,0.0,0.0); | |
| trailSide=normalize(cross(trailTangent,axisRef));trailUp=normalize(cross(trailSide,trailTangent)); | |
| /* The centreline stays ballistic. Only old soot density drifts, with a | |
| low-frequency bounded curl that cannot expose history spacing. */ | |
| float oldWake=1.0-trailU; | |
| float curlPhase=trailU*13.0+seed*1.73-uTime*.31; | |
| vec3 curlOffset=(trailSide*sin(curlPhase)+trailUp*cos(curlPhase*.71)) | |
| *(tubeR*.16*oldWake*oldWake*isTrailShell); | |
| radialVec=bestProbe-(bestPoint+curlOffset); | |
| vec4 hpHead=texelFetch(uTrailHistory,ivec2(histCount-1,histRow),0); | |
| vec4 hpPrev=texelFetch(uTrailHistory,ivec2(histCount-2,histRow),0); | |
| /* vTrail.z marks headless persistent wakes such as aircraft smoke. | |
| Their newest history sample is not a projectile and must not grow a | |
| bright shell/fuse cap that obscures the aircraft. */ | |
| headSeg=1.0-step(.5,vTrail.z);headPoint=hpHead.xyz;headAxis=normalize(hpHead.xyz-hpPrev.xyz+vec3(1e-5)); | |
| headR=max(hpHead.w*mix(.94,1.12,isTrailShell),uWorldPerPx*mix(5.8,8.2,isTrailShell)); | |
| }else{ | |
| float segCount=10.0; | |
| vec3 trailAxis=normalize(vTrail.xyz);float halfLen=max(0.001,vTrail.w); | |
| vec3 trailWorld=p-vCtr.xyz;float along=dot(trailWorld,trailAxis); | |
| localU=clamp(along/(halfLen*2.0)+0.5,0.0,1.0); | |
| trailU=clamp((profile+localU)/segCount,0.006,0.994); | |
| vec3 nearest=trailAxis*clamp(along,-halfLen,halfLen); | |
| vec3 spare=max(vec3(0.001),vExt.xyz-abs(trailAxis)*halfLen); | |
| tubeR=min(spare.x,min(spare.y,spare.z)); | |
| vec3 axisRef=abs(trailAxis.y)<0.88?vec3(0.0,1.0,0.0):vec3(1.0,0.0,0.0); | |
| trailSide=normalize(cross(trailAxis,axisRef));trailUp=normalize(cross(trailSide,trailAxis)); | |
| float wakeAgeLegacy=1.0-trailU,curlPhase=trailU*31.0+seed*1.73-uTime*0.58*wakeAgeLegacy; | |
| vec3 curlOffset=(trailSide*sin(curlPhase)+trailUp*cos(curlPhase*0.83)) | |
| *(tubeR*0.25*wakeAgeLegacy*wakeAgeLegacy*isTrailShell); | |
| radialVec=trailWorld-nearest-curlOffset; | |
| headSeg=step(8.5,profile);vec3 headCentre=trailAxis*(halfLen*0.806); | |
| headPoint=vCtr.xyz+headCentre;headAxis=trailAxis; | |
| headR=tubeR*mix(.82,.96,isTrailShell); | |
| float fadeIn=mix(smoothstep(0.0,0.22,localU),1.0,1.0-step(0.5,profile)); | |
| float fadeOut=mix(1.0-smoothstep(0.78,1.0,localU),1.0,step(8.5,profile)); | |
| segmentWeight=fadeIn*fadeOut; | |
| } | |
| float radial=length(radialVec); | |
| float row=mix(0.25,0.75,isTrailShell); | |
| float crossY=clamp(dot(radialVec,trailUp)/max(0.001,tubeR),-1.0,1.0); | |
| float crossZ=clamp(dot(radialVec,trailSide)/max(0.001,tubeR),-1.0,1.0); | |
| vec4 drvY=texture(uTrailDriver,vec2(trailU,row+crossY*0.235)); | |
| vec4 drvZ=texture(uTrailDriver,vec2(trailU,row+crossZ*0.235)); | |
| vec4 drv=max(drvY,drvZ); | |
| float drvReady=step(0.5,uTrailDriverReady); | |
| float driverHeat=max(drv.r,max(drv.g,drv.b))*drvReady; | |
| vec4 tn=texture(uNoise,vec3(trailU*4.1,crossZ,crossY)+vec3(seed*0.19,uTime*0.11,-uTime*0.07)); | |
| /* Energy narrows into a real tail; combustion already receives a wider | |
| world-space radius from the CPU. Noise perturbs the density boundary, | |
| not just its colour, so the silhouette has volume breakup instead of | |
| reading as a uniformly glowing hose. */ | |
| float wakeAge=1.0-trailU; | |
| float radiusScale=mix(mix(0.36,0.72,smoothstep(0.02,0.84,trailU)), | |
| mix(0.56,0.20,smoothstep(0.04,0.94,trailU)),isTrailShell); | |
| /* Combustion wakes need a connected low-frequency silhouette. The old | |
| 71-cycle modulation made the density read as a spray of orange dots at | |
| tactical zoom. Two broad, incommensurate waves now shape large rolling | |
| smoke cells while the 3D field supplies the small turbulent breakup. */ | |
| float lobeWave=0.92+0.10*sin(trailU*11.0+sin(trailU*5.0)*.72) | |
| +0.07*sin(trailU*17.0+seed*1.7); | |
| float shellBillow=mix(1.0,lobeWave*(0.97+0.11*smoothstep(0.20,0.84,tn.a)),isTrailShell); | |
| float energyBreath=.91+.10*sin(trailU*8.0+seed*.37)+.09*(tn.a-.5); | |
| radiusScale*=mix(energyBreath,shellBillow,isTrailShell); | |
| float rr=radial/max(0.001,tubeR*radiusScale); | |
| float edgeNoise=(fbm3(tn)-0.5)*mix(0.54,0.62,isTrailShell)+(drv.a-0.5)*0.08*drvReady; | |
| float support=1.0-smoothstep(mix(.52,.48,isTrailShell)+edgeNoise,1.02+edgeNoise,rr); | |
| /* The energy sheath is allowed to curl around the stable filament. It is | |
| still evaluated inside this one density field; the offset creates ion | |
| haze and asymmetry without a second ribbon or sprite layer. */ | |
| float ionPhase=trailU*9.0+seed*.63-uTime*.24; | |
| vec3 ionOffset=(trailSide*sin(ionPhase)+trailUp*cos(ionPhase*.77)) | |
| *(tubeR*(.16+.18*wakeAge)); | |
| float ionRR=length(radialVec-ionOffset)/max(.001,tubeR*radiusScale*1.22); | |
| float ionEdge=(fbm3(tn)-.5)*.48; | |
| float ionSupport=1.0-smoothstep(.48+ionEdge,1.08+ionEdge,ionRR); | |
| support=max(support,ionSupport*.82*(1.0-isTrailShell)); | |
| /* Longitudinal breakup comes from the continuous noise domain, never | |
| history indices. It can pulse and tear the plasma without restoring | |
| the twelve-sample knot pattern. */ | |
| vec4 longTex=texture(uNoise,vec3(trailU*9.7+seed*.13,.29+seed*.017,.71-uTime*.05)); | |
| float longNoise=fbm3(longTex); | |
| float wave=.5+.5*sin(trailU*33.0-uTime*8.0+longNoise*4.2+seed*.31); | |
| float energyPulse=.18+.82*smoothstep(.36,.72,longNoise*.46+wave*.54); | |
| /* Five explicit packets travel inside the uninterrupted filament. Their | |
| axial phase is analytic (rather than a lucky noise maximum), so launch, | |
| mid-flight and command zoom all expose the same readable cadence. */ | |
| float packetPhase=abs(fract(trailU*5.0-uTime*.58+seed*.071)-.5); | |
| float energyPacket=(1.0-smoothstep(.055,.145,packetPhase)) | |
| *smoothstep(.10,.24,trailU)*(1.0-smoothstep(.91,.982,trailU)); | |
| float packetEnvelope=(1.0-smoothstep(.03,.46,rr))*energyPacket; | |
| /* Tear the ion sheath longitudinally while retaining the much narrower | |
| analytic filament below. The breakup is sampled from continuous | |
| object-space noise, so its rhythm cannot reveal the 12 history knots. */ | |
| support=mix(max(support*(.38+.62*energyPulse), | |
| ionSupport*(.22+.78*energyPulse)),support,isTrailShell); | |
| /* Kinetic smoke is two advected wisps plus a faint connector—not a | |
| filled cone. Both wisps remain within this one raymarched instance. */ | |
| vec3 wispOffA=(trailSide*sin(trailU*15.0+seed)+trailUp*cos(trailU*9.0+seed*.7)) | |
| *(tubeR*(.15+.23*wakeAge)); | |
| vec3 wispOffB=(trailSide*cos(trailU*11.0+seed*.43)-trailUp*sin(trailU*13.0+seed)) | |
| *(tubeR*(.21+.19*wakeAge)); | |
| float wispRA=length(radialVec-wispOffA)/max(.001,tubeR*radiusScale*1.16); | |
| float wispRB=length(radialVec-wispOffB)/max(.001,tubeR*radiusScale*1.02); | |
| float wispA=1.0-smoothstep(.20+edgeNoise*.42,.84+edgeNoise*.34,wispRA); | |
| float wispB=1.0-smoothstep(.17-edgeNoise*.22,.78+edgeNoise*.28,wispRB); | |
| float wispGateA=smoothstep(.20,.62,longNoise*.74+tn.g*.26); | |
| float wispGateB=smoothstep(.24,.66,(1.0-longNoise)*.58+tn.b*.42); | |
| float wispGate=max(wispGateA,wispGateB*.78); | |
| float shellConnector=(1.0-smoothstep(.04,.34,rr))*.08; | |
| /* Three axial windows shape distinct soot lobes. They overlap just enough | |
| to read as advected smoke, while the independent pressure backbone | |
| below maintains physical continuity all the way to the shell. */ | |
| float smokeLobeA=smoothstep(.035,.085,trailU)*(1.0-smoothstep(.16,.22,trailU)); | |
| float smokeLobeB=smoothstep(.285,.34,trailU)*(1.0-smoothstep(.43,.49,trailU)); | |
| float smokeLobeC=smoothstep(.535,.59,trailU)*(1.0-smoothstep(.68,.75,trailU)); | |
| float smokeLobes=max(smokeLobeA,max(smokeLobeB,smokeLobeC)); | |
| float lobeDensity=max(wispA,wispB*.82)*smokeLobes*(.44+.56*wispGate); | |
| float shellWisps=max(shellConnector*.62,lobeDensity); | |
| /* A pressure-diffused soot envelope joins the displaced wisps. It is | |
| deliberately soft and low-density, but broad enough to survive grey | |
| terrain and command zoom; the previous wisp-only field measured as | |
| present while visually collapsing to a one-pixel brown scratch. */ | |
| float shellHaze=(1.0-smoothstep(.18,.94,rr))*(.22+.34*smoothstep(.24,.82,tn.a)) | |
| *smoothstep(.025,.18,trailU)*(1.0-smoothstep(.88,.985,trailU))*mix(1.0,.22,headlessWake); | |
| support=mix(support,max(shellWisps,shellHaze),isTrailShell); | |
| /* Preserve a continuous plasma filament through every history segment. | |
| Noise is allowed to fray the sheath, never to punch gaps through the | |
| spine—the dotted tail in the first hardware capture was a density | |
| failure, not an intended pulse. */ | |
| float filamentSupport=(1.0-smoothstep(.035,.32,rr)) | |
| *mix(.88,1.0,smoothstep(.02,.24,trailU)); | |
| support=max(support,filamentSupport*(1.0-isTrailShell)); | |
| /* One field, two genuinely different terminal forms. Energy ends in a | |
| contained plasma ellipsoid and ion envelope. Kinetic ends in a compact | |
| metal projectile with a rear fuse—not a spherical orange fireball. */ | |
| vec3 headProbe=p+uRayDir*clamp(dot(headPoint-p,uRayDir),0.0,ds); | |
| vec3 headDelta=headProbe-headPoint; | |
| float headAlong=dot(headDelta,headAxis); | |
| float headPerp=length(headDelta-headAxis*headAlong); | |
| float energyHeadMetric=length(vec2(headAlong/max(.001,headR*1.58),headPerp/max(.001,headR*.46))); | |
| float shellHeadMetric=length(vec2(headAlong/max(.001,headR*2.16),headPerp/max(.001,headR*.46))); | |
| float energyHead=headSeg*(1.0-smoothstep(.62,1.0,energyHeadMetric)); | |
| float energyCore=headSeg*(1.0-smoothstep(.08,.62,energyHeadMetric)); | |
| float shellBody=headSeg*(1.0-smoothstep(.72,1.0,shellHeadMetric)); | |
| vec3 fusePoint=headPoint-headAxis*(headR*.74); | |
| vec3 fuseProbe=p+uRayDir*clamp(dot(fusePoint-p,uRayDir),0.0,ds); | |
| float fuseMetric=length(fuseProbe-fusePoint)/max(.001,headR*.27); | |
| float fuseSupport=headSeg*(1.0-smoothstep(.32,1.0,fuseMetric)); | |
| float fuseCore=headSeg*(1.0-smoothstep(.04,.38,fuseMetric)); | |
| float compression=smoothstep(.76,.91,trailU)*(1.0-smoothstep(.965,.995,trailU)) | |
| *(1.0-smoothstep(.08,.34,rr))*(1.0-shellBody); | |
| float pressureWake=smoothstep(.045,.12,trailU)*(1.0-smoothstep(.968,.996,trailU)) | |
| *(1.0-smoothstep(.018,.17,rr)); | |
| float headSupport=mix(energyHead,max(shellBody,fuseSupport),isTrailShell); | |
| float headCore=mix(energyCore,fuseCore,isTrailShell); | |
| support=max(support,headSupport); | |
| /* Partition the 24% proxy overlap. Without this, premultiplied over | |
| integrates both capsules at every join and the continuous plasma | |
| spine becomes a row of bright beads. First/last retain their exposed | |
| cap; internal endpoints cross-fade into their neighbour. */ | |
| segmentWeight=max(headSupport,segmentWeight); | |
| /* Energy texture supplies only subtle density breakup. Letting its | |
| bright knots own opacity produced the soft lavender beads/hose seen | |
| in the rejected capture. Shell smoke may use more of the authored | |
| alpha because density variation is physically desirable there. */ | |
| float driverDensity=mix(0.88+drv.a*0.06,0.74+drv.a*0.18,isTrailShell); | |
| float authored=mix(0.80,driverDensity,drvReady); | |
| authored=max(authored,headSupport*(0.82+0.18*driverHeat)); | |
| float turbulent=mix(0.78+0.30*fbm3(tn), | |
| 0.48+0.38*fbm3(tn)+0.06*(1.0-tn.a),isTrailShell); | |
| float energyFade=smoothstep(.006,.18,trailU)*(.28+.72*smoothstep(.10,.72,trailU)); | |
| /* Smoke starts as separated old wisps, is strongest through the middle | |
| of the path, then clears around the projectile so its gunmetal body and | |
| tiny hot base stay readable instead of living inside a dark cone. */ | |
| float shellFade=smoothstep(.025,.25,trailU) | |
| *(1.0-.78*smoothstep(.82,.965,trailU)); | |
| float tailFade=mix(energyFade,shellFade,isTrailShell); | |
| float longitudinal=mix(.30+.70*energyPulse,.58+.42*wispGate,isTrailShell); | |
| float trailDensity=support*authored*turbulent*vPar.x*segmentWeight*tailFade | |
| *longitudinal*mix(1.16,1.82,isTrailShell); | |
| /* A very narrow optical backbone makes sub-pixel kinetic smoke survive | |
| the half-resolution march. It carries no glow and remains weaker than | |
| either displaced wisp; its job is continuity, not a filled cone. */ | |
| trailDensity+=shellConnector*vPar.x*shellFade*(.16+.20*wispGate)*isTrailShell; | |
| trailDensity+=shellBody*vPar.x*2.36*isTrailShell+fuseSupport*vPar.x*.46*isTrailShell; | |
| trailDensity+=lobeDensity*vPar.x*3.10*shellFade*isTrailShell; | |
| trailDensity+=pressureWake*vPar.x*.58*isTrailShell; | |
| /* The continuous energy filament has its own small extinction floor. | |
| Packets modulate brightness inside it instead of punching opacity holes | |
| that turn the trail back into isolated particle beads. */ | |
| trailDensity+=filamentSupport*energyFade*segmentWeight*vPar.x | |
| *(1.18+.18*energyPulse+.34*energyPacket)*(1.0-isTrailShell); | |
| trailDensity+=packetEnvelope*energyFade*segmentWeight*vPar.x*.42*(1.0-isTrailShell); | |
| trailDensity+=energyHead*vPar.x*1.24*(1.0-isTrailShell); | |
| /* ds is in world units. Normalising it by diameter prevents a long ray | |
| chord from saturating the entire tube to alpha=1, which erased every | |
| authored and procedural detail in the first probe. */ | |
| float opticalStep=ds/max(1.0,tubeR*2.0); | |
| float trailAlpha=1.0-exp(-uSigma*trailDensity*opticalStep*mix(5.55,6.20,isTrailShell)*mix(1.0,.72,headlessWake)); | |
| /* The energy profile is a narrow white plasma filament inside a softer | |
| ionised sheath. Axial turbulence modulates emission rather than radius, | |
| retaining one continuous beam-like volume without reintroducing beads. */ | |
| float core=max(1.0-smoothstep(0.025,0.22,rr),headCore); | |
| float spine=max(1.0-smoothstep(0.006,0.105,rr),headCore); | |
| float ionPulse=0.91+0.09*tn.b; | |
| float ionFray=smoothstep(0.28,0.92,tn.a)*(1.0-smoothstep(0.72,1.08,ionRR)); | |
| float containmentRim=energyHead*smoothstep(.50,.78,energyHeadMetric)*(1.0-smoothstep(.78,1.02,energyHeadMetric)); | |
| float frontCap=energyHead*smoothstep(.38,.78,headAlong/max(.001,headR*1.58)) | |
| *(1.0-smoothstep(.12,.62,headPerp/max(.001,headR*.46))); | |
| vec3 energySheath=vTint.rgb*(0.26+0.23*tn.g+0.27*ionFray)*ionPulse*(.30+.70*energyPulse); | |
| vec3 energyRgb=mix(energySheath,vec3(1.72,1.86,2.18),core*(.18+.82*energyPulse)); | |
| energyRgb=mix(energyRgb,vec3(2.54,2.72,3.08),spine*(.30+.70*energyPulse)); | |
| energyRgb*=0.62+vPar.y*0.24+energyCore*.52; | |
| float packetGlow=(core*.42+spine*.80) | |
| *max(smoothstep(.48,.90,energyPulse)*.34,energyPacket); | |
| energyRgb+=spine*vec3(1.02,1.14,1.34)+packetGlow*vec3(1.72,1.88,2.12); | |
| energyRgb+=packetEnvelope*vec3(1.44,1.62,1.94); | |
| energyRgb+=containmentRim*vTint.rgb*.14+energyCore*vec3(2.12,2.22,2.48)+frontCap*vec3(2.9,3.02,3.18); | |
| vec3 radialN=normalize(radialVec+vec3(1e-4)); | |
| float smokeLight=0.58+0.42*max(0.0,dot(radialN,normalize(uSunDir))); | |
| float smokeBody=smoothstep(0.10,0.78,wakeAge); | |
| float smokeEdge=smoothstep(0.34,0.94,rr); | |
| /* Use translucent, light-scattering grey-brown rather than nearly black | |
| extinction. Sparse lobes must remain separable over concrete without | |
| reading as an opaque painted cone or a ground shadow. */ | |
| vec3 shellSmoke=mix(vec3(.075,.072,.068),vec3(.31,.30,.285), | |
| clamp(smokeEdge*.56+tn.g*.32,0.0,1.0))*mix(.82,1.06,smokeLight); | |
| shellSmoke=mix(shellSmoke,vec3(.062,.060,.057),smokeBody*.12); | |
| /* Headless aircraft damage wakes are soot, not pressure-lit ordnance | |
| plumes. This removes the pale circular fog disk under an airframe while | |
| retaining a broken, light-responsive volume behind it. */ | |
| vec3 airSoot=mix(vec3(.022,.024,.027),vec3(.13,.14,.15), | |
| clamp(smokeEdge*.38+tn.g*.30,0.0,1.0))*mix(.72,.92,smokeLight); | |
| shellSmoke=mix(shellSmoke,airSoot,headlessWake); | |
| vec3 shellFire=mix(vec3(0.76,0.022,0.001),vec3(2.25,1.16,0.28),core); | |
| /* Heat belongs close to the travelling shell. The rejected material | |
| multiplied the driver over most of the history and turned smoke into | |
| one orange cloud. This transfers monotonically from compact flame to | |
| neutral soot over the newest ~22% of the wake. */ | |
| /* The shell reference carries a visible combustion-to-soot hand-off, | |
| not one orange pixel followed by a black hose. Keep the newest third | |
| hot, then let authored heat and sparse pockets survive into the middle | |
| third before neutral smoke owns the old wake. */ | |
| float heatZone=smoothstep(0.93,0.992,trailU) | |
| *(0.96+0.04*driverHeat)*(1.0-smoothstep(.20,.46,rr)); | |
| float combustion=clamp(heatZone,0.0,1.0)*(1.0-headlessWake); | |
| vec3 shellWake=mix(shellSmoke,shellFire,combustion); | |
| float pressureHeat=pressureWake*smoothstep(.62,.91,trailU); | |
| shellWake=mix(shellWake,vec3(.32,.235,.17),pressureWake*.34); | |
| shellWake=mix(shellWake,vec3(.285,.275,.26),clamp(lobeDensity*.12,0.0,.16)); | |
| shellWake=mix(shellWake,vec3(1.34,.42,.09),pressureHeat*.72); | |
| shellWake+=compression*vec3(.94,.75,.48); | |
| float shellRim=shellBody*smoothstep(.42,.84,shellHeadMetric); | |
| vec3 shellMetal=mix(vec3(.075,.09,.12),vec3(.82,.86,.90), | |
| .28+.72*max(0.0,dot(normalize(headDelta+vec3(1e-4)),normalize(uSunDir)))); | |
| shellMetal+=shellRim*vec3(.28,.27,.24); | |
| vec3 shellRgb=mix(shellWake,shellMetal,shellBody); | |
| shellRgb+=fuseSupport*vec3(1.32,.24,.016)+fuseCore*vec3(2.8,1.72,.48); | |
| vec3 trailRgb=mix(energyRgb,shellRgb,isTrailShell); | |
| float ember=isTrailShell*support*smoothstep(0.976,0.998,tn.b) | |
| *smoothstep(0.89,0.97,trailU)*(1.0-headCore); | |
| trailRgb+=vec3(1.50,0.22,0.008)*ember*0.08; | |
| acc+=T*trailRgb*trailAlpha;T*=1.0-trailAlpha; | |
| if(T<0.015)break; | |
| continue; | |
| } | |
| /* IMPACT: a COMPACT contact bulb plus a short excavating wake. | |
| The head used to multiply iq.x by 0.84, and a factor BELOW 1 EXTENDS the | |
| isosurface along that axis. It sat inside a proxy box the weapon profile | |
| already makes 1.46 long and only 0.62 tall, so two elongations compounded | |
| on the same axis and the field integrated to a flat directional smear — | |
| from the RTS camera that is indistinguishable from a ground decal, which | |
| is exactly what it got mistaken for. The head is now COMPRESSED along the | |
| axis (>1) 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<volN;vi++)if(volIsTrailKind(volKind[vi])){ | |
| trailFullRes=true;if(volKind[vi]===VOL_TRAIL_CURVE_ENERGY||volKind[vi]===VOL_TRAIL_CURVE_SHELL)curveTrail=true; | |
| } | |
| if(!volAlloc(Math.max(1,trailFullRes?aoW:aoW>>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;i<volN;i++){ | |
| const life=volLife[i]; if(!(life>0)) 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]+rad<cb.x0||volPX[i]-rad>cb.x1) { VOLFX_TELEMETRY.culled++; continue; } | |
| if(volPZ[i]+rad<cb.y0||volPZ[i]-rad>cb.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<n;s++){ | |
| const k=volKind[volOrder[s]]; | |
| if(k===VOL_BLAST||k===VOL_SMOKE) VOLFX_TELEMETRY.driverSamples++; | |
| } | |
| } | |
| /* ---- sort far -> near (descending along the view ray) ---------------- */ | |
| for(let a=1;a<n;a++){ | |
| const ki=volKey[a], oi=volOrder[a], ai=volArea[a]; | |
| let b=a-1; | |
| while(b>=0&&volKey[b]<ki){ volKey[b+1]=volKey[b]; volOrder[b+1]=volOrder[b]; volArea[b+1]=volArea[b]; b--; } | |
| volKey[b+1]=ki; volOrder[b+1]=oi; volArea[b+1]=ai; | |
| } | |
| /* ---- step budget: ONE uniform, never a boolean gate ------------------ | |
| perfScale appears here and nowhere else, as a multiplier. The effect never | |
| disappears under load; it gets coarser. */ | |
| const ps=(typeof perfScale==='number'?perfScale:1); | |
| const budget=volW*volH*(4.0+8.0*Math.max(0.25,Math.min(1,ps))); | |
| const ceil=volStepCeiling(); | |
| const steps=Math.max(6,Math.min(ceil,Math.floor(budget/Math.max(1,area)))); | |
| /* ---- pack instances -------------------------------------------------- */ | |
| for(let s=0;s<n;s++){ | |
| const i=volOrder[s], o=s*VOL_INST_FLOATS; | |
| const life=volLife[i]; | |
| 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]); | |
| /* NO SECOND TAIL FADE HERE. The march already dissolves the field with an | |
| age-driven erosion threshold that is shape-aware (it eats the thin | |
| edges first and leaves the dense core last). Multiplying a second | |
| smoothstep on top of it made the two fades compound: by 62% of life the | |
| core was being attenuated twice and the blast visually disappeared while | |
| its own clock still had a third of the burn left to run, which is the | |
| "late-life double fade" the audit saw. One fade, owned by the shader. */ | |
| volInstData[o ]=volPX[i]; | |
| volInstData[o+1]=volPY[i]; | |
| volInstData[o+2]=volPZ[i]; | |
| volInstData[o+3]=age; | |
| volInstData[o+4]=g*volAX[i]; | |
| volInstData[o+5]=g*volAY[i]; | |
| volInstData[o+6]=g*volAZ[i]; | |
| volInstData[o+7]=volKind[i]; | |
| volInstData[o+8]=volDens[i]; | |
| volInstData[o+9]=volEmis[i]; | |
| /* Lane 10 was a 0..64 seed. Pack the tiny internal material profile above | |
| that range so the shader can recover both without widening the instance | |
| struct or introducing another volume draw. */ | |
| volInstData[o+10]=volSeed[i]+volStyle[i]*64; | |
| /* LANE 11 CARRIES LIFE IN SECONDS, not the world radius it used to hold. | |
| The shader never read that radius (its `rMax` was dead), and the march | |
| needs ABSOLUTE time: "sub-100 ms flash" is 6% of a 1.6 s bombardment but | |
| 22% of a 0.46 s contact hit, so no normalised threshold can express it | |
| for both. Extents already carry the size, so nothing is lost. */ | |
| volInstData[o+11]=life; | |
| volInstData[o+12]=volTR[i]/255; | |
| volInstData[o+13]=volTG[i]/255; | |
| volInstData[o+14]=volTB[i]/255; | |
| volInstData[o+15]=volAngle[i]; | |
| volInstData[o+16]=volTX[i]; | |
| volInstData[o+17]=volTY[i]; | |
| volInstData[o+18]=volTZ[i]; | |
| volInstData[o+19]=volTHalf[i]*g; | |
| } | |
| /* ---- state save ------------------------------------------------------ | |
| EVERY piece of state this pass mutates is captured, and the restore runs | |
| from a finally. The depth attachment is the dangerous one: Phase B detaches | |
| aoDepth from aoFB2 to break a feedback loop, and a throw anywhere between | |
| the detach and the re-attach used to leave the scene's own framebuffer with | |
| NO depth buffer — the additive block and the billboard pass both depth-test | |
| against it, so a single bad frame silently removed depth from everything | |
| drawn after this point. The old restore also assumed it knew what the | |
| caller's state was ("EXACTLY what render3d left") and hardcoded cullFace | |
| BACK plus a specific blendFunc; it now puts back what was actually there. */ | |
| const wasFB=gl.getParameter(gl.FRAMEBUFFER_BINDING); | |
| const wasVP=gl.getParameter(gl.VIEWPORT); | |
| const wasScBox=gl.getParameter(gl.SCISSOR_BOX); | |
| const wasScissor=gl.isEnabled(gl.SCISSOR_TEST); | |
| const wasProg=gl.getParameter(gl.CURRENT_PROGRAM); | |
| const wasVAO=gl.getParameter(gl.VERTEX_ARRAY_BINDING); | |
| const wasArr=gl.getParameter(gl.ARRAY_BUFFER_BINDING); | |
| const wasActive=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); | |
| const wasCullMode=gl.getParameter(gl.CULL_FACE_MODE); | |
| const wasSrcRGB=gl.getParameter(gl.BLEND_SRC_RGB),wasDstRGB=gl.getParameter(gl.BLEND_DST_RGB); | |
| const wasSrcA=gl.getParameter(gl.BLEND_SRC_ALPHA),wasDstA=gl.getParameter(gl.BLEND_DST_ALPHA); | |
| const wasEqRGB=gl.getParameter(gl.BLEND_EQUATION_RGB),wasEqA=gl.getParameter(gl.BLEND_EQUATION_ALPHA); | |
| gl.activeTexture(gl.TEXTURE0); const wasTex0=gl.getParameter(gl.TEXTURE_BINDING_2D); | |
| gl.activeTexture(gl.TEXTURE4); const wasTex4=gl.getParameter(gl.TEXTURE_BINDING_2D); | |
| gl.activeTexture(gl.TEXTURE10); const wasTex10=gl.getParameter(gl.TEXTURE_BINDING_2D); | |
| gl.activeTexture(gl.TEXTURE11); const wasTex11=gl.getParameter(gl.TEXTURE_BINDING_2D); | |
| gl.activeTexture(gl.TEXTURE13); const wasTex13=gl.getParameter(gl.TEXTURE_BINDING_2D); | |
| gl.activeTexture(gl.TEXTURE0+VOL_DRIVER_UNIT); const wasTexDriver=gl.getParameter(gl.TEXTURE_BINDING_2D); | |
| gl.activeTexture(wasActive); | |
| /* Drain any pre-existing error so the NO_ERROR gate below judges THIS pass | |
| rather than inheriting a flag another system left set. */ | |
| gl.getError(); | |
| let composited=false; | |
| /* Declared out here because the telemetry write below the finally reads | |
| them, and a throw must still leave them defined. */ | |
| let compSW=0,compSH=0; | |
| try{ | |
| /* ================= PHASE A — march into volFB ========================= | |
| Sampling aoDepth HERE is safe: the feedback rule applies only to the | |
| currently bound DRAW framebuffer, and aoDepth is attached to aoFB2, which | |
| is not bound. */ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,volFB); | |
| gl.viewport(0,0,volW,volH); | |
| gl.disable(gl.SCISSOR_TEST); | |
| /* clearBufferfv, NOT clearColor+clear: this runs mid-frame and must not | |
| leave a different clear colour behind for whoever clears next. */ | |
| gl.clearBufferfv(gl.COLOR,0,VOL_ZERO4); | |
| gl.disable(gl.DEPTH_TEST); | |
| gl.depthMask(false); | |
| gl.enable(gl.CULL_FACE); | |
| gl.cullFace(gl.FRONT); // rasterise BACK faces: works inside the box too | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.ONE,gl.ONE_MINUS_SRC_ALPHA); // premultiplied over: what the march emits | |
| gl.useProgram(volProg); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,volInstVBO); | |
| gl.bufferSubData(gl.ARRAY_BUFFER,0,volInstData,0,n*VOL_INST_FLOATS); | |
| gl.activeTexture(gl.TEXTURE12); gl.bindTexture(gl.TEXTURE_3D,volNoiseTex); | |
| gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,aoDepth); | |
| gl.activeTexture(gl.TEXTURE0+VOL_DRIVER_UNIT); gl.bindTexture(gl.TEXTURE_2D,volDriverTex); | |
| const trailDriverTex=typeof mfOrdnanceTrailDriverTexture==='function'?mfOrdnanceTrailDriverTexture():null; | |
| gl.activeTexture(gl.TEXTURE11);gl.bindTexture(gl.TEXTURE_2D,trailDriverTex); | |
| const trailHistoryTex=curveTrail?volFxTrailHistoryTexture():null; | |
| gl.activeTexture(gl.TEXTURE10);gl.bindTexture(gl.TEXTURE_2D,trailHistoryTex); | |
| gl.uniform1i(volU.uNoise,12); | |
| gl.uniform1i(volU.uDep,4); | |
| gl.uniform1i(volU.uDriver,VOL_DRIVER_UNIT); | |
| gl.uniform1f(volU.uDriverReady,volDriverReady?1:0); | |
| gl.uniform1i(volU.uTrailDriver,11); | |
| gl.uniform1f(volU.uTrailDriverReady,trailDriverTex?1:0); | |
| gl.uniform1i(volU.uTrailHistory,10); | |
| gl.uniform1f(volU.uTrailHistoryReady,trailHistoryTex?1:0); | |
| VOLFX_TELEMETRY.trailHistoryReady=!!trailHistoryTex; | |
| VOLFX_TELEMETRY.trailDriverReady=!!trailDriverTex; | |
| gl.uniformMatrix4fv(volU.uVP,false,matVP); | |
| gl.uniform3f(volU.uRayDir,volRayX,volRayY,volRayZ); | |
| const sd=(Sun&&Sun.dir)?Sun.dir:[0.4,0.8,0.45]; | |
| const sc=(Sun&&Sun.col)?Sun.col:[1,0.95,0.86]; | |
| const sk=(Sun&&Sun.sky)?Sun.sky:[0.4,0.45,0.55]; | |
| const gn=(Sun&&Sun.gnd)?Sun.gnd:[0.22,0.22,0.25]; | |
| gl.uniform3f(volU.uSunDir,sd[0],sd[1],sd[2]); | |
| gl.uniform3f(volU.uSunCol,sc[0],sc[1],sc[2]); | |
| gl.uniform3f(volU.uAmbSky,sk[0],sk[1],sk[2]); | |
| gl.uniform3f(volU.uAmbGnd,gn[0],gn[1],gn[2]); | |
| gl.uniform3f(volU.uWind,0.35,0,0.18); | |
| gl.uniform2f(volU.uInvVolSize,1/volW,1/volH); | |
| gl.uniform1f(volU.uWorldPerPx,Math.max(.24,(typeof orthoSpan==='number'?orthoSpan:500)/Math.max(1,typeof VH==='number'?VH:852))); | |
| /* 15000 = far - near from m4ortho(-6000, 9000) in camUpdateMatrices | |
| (mesh.js:3277). FSAO hardcodes the same constant as uWorldPerZ at | |
| mesh.js:2611. COUPLING: change the ortho near/far and BOTH must change. */ | |
| gl.uniform1f(volU.uDepthScale,15000.0); | |
| /* Extinction. 0.055 marched to a translucent film that read as a flat | |
| layer, not a mass — the frame-02 critique. 0.12 puts real optical depth | |
| in the interior: the silhouette occludes, the blackbody core still burns | |
| through the thin pockets, and the sun-side/far-side probe shading above | |
| has something to bite on. */ | |
| gl.uniform1f(volU.uSigma,0.12); | |
| /* Henyey-Greenstein is a per-frame CONSTANT under ortho (both uRayDir and | |
| uSunDir are constant), so it is evaluated once here instead of per step — | |
| a genuine orthographic win. DUAL LOBE: this camera looks DOWN at smoke | |
| lit from ABOVE, i.e. permanent backscatter geometry, and a single | |
| forward lobe (g=0.35) evaluated there starved the sun term to ~0.2 and | |
| the whole volume went black. Real soot backscatters (multiple | |
| scattering folded into a negative-g lobe), so mix a forward and a | |
| backward lobe the way every dual-lobe HG media model does. */ | |
| { | |
| const cosT=volRayX*sd[0]+volRayY*sd[1]+volRayZ*sd[2]; | |
| const HG=(g)=>(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<n;s++){ | |
| const i=volOrder[s];volPresented[i]=1; | |
| if(volKind[i]===VOL_BLAST)VOLFX_TELEMETRY.presentedBlast++; | |
| else if(volKind[i]===VOL_DUST)VOLFX_TELEMETRY.presentedDust++; | |
| else if(volKind[i]===VOL_IMPACT)VOLFX_TELEMETRY.presentedImpact++; | |
| else if(volKind[i]===VOL_TRAIL_ENERGY||volKind[i]===VOL_TRAIL_CURVE_ENERGY)VOLFX_TELEMETRY.presentedTrailEnergy++; | |
| else if(volKind[i]===VOL_TRAIL_SHELL||volKind[i]===VOL_TRAIL_CURVE_SHELL)VOLFX_TELEMETRY.presentedTrailShell++; | |
| } | |
| VOLFX_TELEMETRY.drawn=n; | |
| VOLFX_TELEMETRY.steps=steps; | |
| VOLFX_TELEMETRY.budget=budget; | |
| VOLFX_TELEMETRY.marchedPx=area; | |
| VOLFX_TELEMETRY.compositePx=compSW*compSH; | |
| VOLFX_TELEMETRY.w=volW; VOLFX_TELEMETRY.h=volH; | |
| if(volCheckErr) VOLFX_TELEMETRY.glErr=gl.getError(); | |
| } | |
| /* The camera ray under ortho: view-forward is the NEGATION of the third row of | |
| m4look's basis (mesh.js:54-56), which is matV[2], matV[6], matV[10]. | |
| Recomputed once per frame in volFxDraw's caller order — cheap enough to just | |
| do it at the top of the draw. */ | |
| let volRayX=0, volRayY=-1, volRayZ=0; | |
| let volBilateral=true; | |
| let volCheckErr=false; | |
| function volFxSyncRay(){ | |
| if(typeof matV==='undefined'||!matV) return; | |
| let x=-matV[2], y=-matV[6], z=-matV[10]; | |
| const l=Math.hypot(x,y,z)||1; | |
| volRayX=x/l; volRayY=y/l; volRayZ=z/l; | |
| } | |
| function volSmooth(a,b,x){ | |
| const t=Math.max(0,Math.min(1,(x-a)/((b-a)||1e-6))); | |
| return t*t*(3-2*t); | |
| } | |
| /* Probe knobs. These exist so the design's bring-up controls are runnable | |
| without editing this file: each one is a single uniform or preset value, and | |
| none of them forks a code path. | |
| volFxSetBilateral(false) -> 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<count;j++){ | |
| const q0=qStart+span*(j/count),q1=qStart+span*((j+1)/count); | |
| const u0=j/count,u1=(j+1)/count,a=point(q0),b=point(q1); | |
| /* Deposited shell exhaust leaves the ballistic centreline. Its oldest | |
| samples rise and move with a restrained cross-wind while the hot end | |
| remains attached to the shell. This is deterministic history shaping, | |
| not render-loop emission. */ | |
| if(k===VOL_TRAIL_SHELL){ | |
| const wa=(1-u0)*(1-u0),wb=(1-u1)*(1-u1); | |
| a[0]+=wa*8.0;a[1]+=wa*13.0;a[2]-=wa*5.0; | |
| b[0]+=wb*8.0;b[1]+=wb*13.0;b[2]-=wb*5.0; | |
| } | |
| const dx=b[0]-a[0],dy=b[1]-a[1],dz=b[2]-a[2]; | |
| const q=(j+.5)/count,thick=k===VOL_TRAIL_ENERGY?7.0:16.0-q*8.0; | |
| const segLen=Math.hypot(dx,dy,dz),ux=dx/segLen,uy=dy/segLen,uz=dz/segLen; | |
| /* Twelve percent overlap at both ends closes filtering/jitter cracks while | |
| keeping ten independently culled proxies for the curved history. */ | |
| const halfLen=segLen*.62; | |
| const ex=Math.abs(ux)*halfLen+thick,ey=Math.abs(uy)*halfLen+thick,ez=Math.abs(uz)*halfLen+thick; | |
| const s=volFxBurst((a[0]+b[0])*.5,(a[2]+b[2])*.5,(a[1]+b[1])*.5,k,1,{ | |
| life,rise:0,dens:(o.dens||(k===VOL_TRAIL_ENERGY?1.42:1.55))*(0.58+0.42*q), | |
| emis:(o.emis||(k===VOL_TRAIL_ENERGY?1.65:.92))*(0.72+0.28*q), | |
| direction:[dx,dz],aspect:[ex,ey,ez],trailAxis:[ux,uy,uz],trailHalf:halfLen, | |
| tint,style:j,seed:(o.seed||7.25)%64 | |
| }); | |
| if(s>=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(); | |