/* ============================================================================ 3D RENDER PASS ---------------------------------------------------------------------------- Draw order matters here in a way it never did for sprites: 1. opaque geometry, depth-write ON — terrain, models, ruins, scenery 2. water, depth-test ON, blended — so shorelines and hulls occlude it 3. additive effects, depth-write OFF — fire, energy, beams, light shafts Depth sorting is the GPU's job now, so nothing needs painter's ordering and a tank genuinely disappears behind a hill instead of being drawn over it. ============================================================================ */ let sunDir=[0.42,0.78,0.30]; const _tmpV=[0,0,0]; /* Read-only renderer telemetry for device QA. This makes an "effects are gone" report diagnosable without a debug console overlay or mutating a live match. */ const _burnVec=new Float32Array(64), _burnKind=new Float32Array(16); const MF_COMBAT_VFX_TELEMETRY={projectiles:0,beams:0,particles:0,additive:0,maxProjectiles:0,maxBeams:0,maxParticles:0,framesWithCombat:0}; if(typeof window!=='undefined')window.MFCombatVfxTelemetry=MF_COMBAT_VFX_TELEMETRY; const MF_COMBAT_VFX_DIAGNOSTIC=typeof location!=='undefined'&&location.search.indexOf('beamshow=1')>=0; function sunFor(nA){ /* The sun swings across the sky with the day cycle and reddens at the horizon, which is where all the "cinematic" feel comes from now — it's real directional light, not a colour wash over a flat image. */ const ang=Math.PI*0.12 + (1-nA)*Math.PI*0.62; const az=0.6+nA*0.9; const y=Math.max(0.06,Math.sin(ang)); const h=Math.cos(ang); const l=Math.hypot(Math.cos(az)*h,y,Math.sin(az)*h)||1; sunDir[0]=Math.cos(az)*h/l; sunDir[1]=y/l; sunDir[2]=Math.sin(az)*h/l; const low=1-Math.min(1,y*2.2); // 1 at the horizon const day=1-nA; return { dir:sunDir, // key light: warm and strong at noon, deep amber at the horizon /* Exposure budget: a fully sunlit face gets sky + key, and that sum times a typical albedo has to land just under 1.0 or the whole world clips to white. sky ~0.40 + key ~1.06 against a 0.55 albedo is the sweet spot. */ col:[ (0.44+day*0.62)*(1+low*0.30), (0.42+day*0.58)*(1-low*0.10), (0.42+day*0.54)*(1-low*0.34) ], /* Midnight still needs to be command-readable on an outdoor phone. The previous sky floor was physically moody but crushed terrain and unit silhouettes into the same near-black value. This is cool moonlight, not a second sun: direct light remains low while the ambient floor retains form and lets tactical lights add useful contrast. */ sky:[ 0.34+day*0.11, 0.39+day*0.12, 0.51+day*0.16 ], gnd:[ 0.20+day*0.08, 0.21+day*0.08, 0.24+day*0.07 ], /* Horizon weather, not a second key light. Noon used to sit brighter than sky ambient, so even a thin veil bleached grass toward milk. Stay under the sky so distance haze recedes without crushing daytime midtones. */ fog:[ 0.24+day*0.20+low*0.12, 0.29+day*0.21, 0.39+day*0.22 ], }; } /* ============================================================================ GROUND SHADOWS ---------------------------------------------------------------------------- Everything in the reference art casts one, and nothing here did. That single omission is what made structures look pasted on: with no shadow there is no contact point, so the eye reads a building as a decal lying on the grass rather than a mass standing on it. These are decals, not a shadow map. MEDIUM/LOW stay on this cheap path. HIGH/CINEMATIC add a sun-depth atlas beside it (mesh.js csmBegin) and skip the stretched CAST so the two do not double-darken. SSAO stays contact creasing; this pass is the weld-to-grass near blob. The decal is stretched along the light and offset by the object's height, so shadows lengthen and swing round as the sun moves across the day cycle. Blend is MULTIPLY: the mesh is white at its rim and dark at its core, so the rim leaves the ground exactly as it was and only the core darkens. That is what lets overlapping shadows merge into one soft pool instead of stacking into hard black rectangles. HIGH/CINEMATIC with shadowQ>=2 replace the stretched CAST blob with a real sun-depth atlas (csmBegin / csmApply). Contact near-blobs stay — they weld a footprint to grass; CSM owns the directional mass. MEDIUM (shadowQ 1) and LOW (0) never enter that pass. ============================================================================ */ function drawShadows(S){ if(!FX.shadow) return; const sq=(typeof GFX!=='undefined'&&GFX.shadowQ!=null)?GFX.shadowQ:2; /* shadowQ 0 is a real off — not a hidden stride. The Advanced Shadows row would otherwise look like it worked while every building still painted a blob. */ if(sq<=0) return; const csmOn=typeof csmActive==='function'&&csmActive(); const contact=!(typeof GFX!=='undefined'&&GFX.contact===false); const cb=camBounds(); const vis=(x,y,pad)=>x>=cb.x0-(pad||0)&&x<=cb.x1+(pad||0)&&y>=cb.y0-(pad||0)&&y<=cb.y1+(pad||0); const gh=(x,y)=>terrainH(x,y); const sd=S.dir; const el=Math.max(0.22,sd[1]); const kx=-sd[0]/el, kz=-sd[2]/el; // ground offset per unit of height const q=typeof mfGfxKey==='function'?mfGfxKey():'high'; const cine=q==='cinematic'&&sq>=2; const stretch=Math.min(2.4,Math.hypot(kx,kz))*(sq===1?0.78:cine?1.12:1); const yaw=Math.atan2(kz,kx); /* Instance alpha stays at full: the MULTIPLY blend takes the fragment colour directly, so any alpha below 1 would darken the white rim as well and put a visible disc edge around every object. Shadow strength lives in the mesh vertex colours instead. */ const A=255; const putCast=(x,y,rad,hgt,wide)=>{ const cx=x+kx*hgt*0.55, cy=y+kz*hgt*0.55; if(!vis(cx,cy,rad+hgt)) return; FX.shadow.add(cx,cy,gh(cx,cy)+2.4, rad*0.55+hgt*stretch*0.12, yaw, 255,255,255,A, (wide||rad)*0.58); }; /* Near cascade: tight footprint under the object. HIGH uses this on buildings; CINEMATIC on nearby everything. When the sun-depth atlas is live the stretched CAST is skipped so the two do not double-darken. */ const putNear=(x,y,rad,hgt,wide)=>{ const cx=x+kx*hgt*0.18, cy=y+kz*hgt*0.18; if(!vis(cx,cy,rad)) return; FX.shadow.add(cx,cy,gh(cx,cy)+1.8, rad*0.42, yaw, 255,255,255,A, (wide||rad)*0.46); }; const put=(x,y,rad,hgt,wide,near)=>{ if(near) putNear(x,y,rad,hgt,wide); if(!csmOn) putCast(x,y,rad,hgt,wide); }; for(const B of blds){ if(!B.alive||!vis(B.x,B.y,B.r*2)) continue; if(!fogEntityVisible(B.team,B.x,B.y)) continue; const f=(typeof bldFoot==='function')?bldFoot(B):[B.r*1.6,B.r*1.6]; const sw=(Math.round((B.rot||0)/(Math.PI/2))&1)===1; const fw=(sw?f[1]:f[0])*0.56, fh=(sw?f[0]:f[1])*0.56; put(B.x,B.y,Math.max(fw,fh),B.r*1.8,Math.min(fw,fh)*1.05,sq>=2); } /* Generated civilian/military structures are relics, not `blds`, so the original shadow pass skipped the entire city. V2 materials could be perfectly lit and still look pasted on because no mass reached the ground. Use the planned footprint and an authored height estimate; the terrain apron carries the small contact AO while this supplies the directional cast shadow. */ for(const R of relics){ if(!R.alive||!vis(R.x,R.y,Math.max(R.w,R.h)+110)||!fogPointVisible(R.x,R.y))continue; const hgt=R.kind===0?118:R.kind===2?58:R.kind===3?42:R.kind===4?66:R.kind===5?290:52; put(R.x,R.y,Math.max(R.w,R.h)*.57,hgt,Math.min(R.w,R.h)*.55,sq>=2); } /* At strategic range a regiment's individual contact shadows occupy fewer than two pixels and merge into one tone. Sample ordinary units there but retain selected units and commanders; off-camera units were already fully culled above, so this is the second (distance/material) LOD band. */ /* MEDIUM (shadowQ=1) used stride 2–4, near HIGH at tactical zoom. 3/6 still paints commanders and selected units every frame. contact=false keeps building/relic blobs (the mass-on-grass read) and drops unit/scenery — that is the Advanced Contact Shadows row. */ const shadowStride=cine?1:(sq===1?Math.max(3,orthoSpan>1800?6:3):(orthoSpan>2550?4:orthoSpan>2050?2:1)); if(contact){ for(let i=0;i1&&!important&&(i%shadowStride))continue; const near=cine&&dist2(ux[i],uy[i],cam.x,cam.y)<520*520; put(ux[i],uy[i],T.size*0.70,T.air?T.size*2.6:T.size*0.8,undefined,near); } /* Scenery casts too. A boulder with no shadow beside a tank with one reads as a decal painted on the grass. MEDIUM skips crystals (tiny, many). */ for(const o of rocks) if(vis(o.x,o.y,60)&&fogPointVisible(o.x,o.y)) put(o.x,o.y,o.s*0.62,o.s*0.75); for(const o of trees) if(vis(o.x,o.y,60)&&fogPointVisible(o.x,o.y)) put(o.x,o.y,o.s*0.52,o.s*1.15); if(typeof cover!=='undefined') for(const o of cover) if(vis(o.x,o.y,40)&&fogPointVisible(o.x,o.y)) put(o.x,o.y,o.s*0.46,o.s*0.55); if(sq>=2) for(const o of crystals){ const D=deposits[o.dep],tier=depositTier(D);if(!D||o.band>tier||!vis(o.x,o.y,60))continue; if(!fogPointVisible(o.x,o.y))continue; put(o.x,o.y,o.s*(o.core?.15:.11),o.s*(o.core?.34:.24)); } if(typeof carrier!=='undefined'&&carrier.active&&carrierEffectiveAlt()<80) put(carrier.x,carrier.y,22,Math.max(2,carrierEffectiveAlt()*0.22)); } if(!FX.shadow.n) return; gl.useProgram(progG); gl.uniformMatrix4fv(UG.uVP,false,matVP); gl.enable(gl.BLEND); gl.blendFunc(gl.ZERO,gl.SRC_COLOR); // multiply: white is a no-op gl.depthMask(false); gl.disable(gl.CULL_FACE); /* Keep DEPTH_TEST. Turning it off laid the whole stepped shadow disc on the pavement as concentric dark rings (live recapture). Offset + a 2.2 raise keeps contact without z-fighting the kerb into grain. */ gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(-12,-48); FX.shadow.flush(gl); gl.disable(gl.POLYGON_OFFSET_FILL); gl.enable(gl.CULL_FACE); gl.depthMask(true); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); gl.disable(gl.BLEND); } function csmDrawBuildingCasters(){ if(typeof BLD_MESH==='undefined') return; const sets=[BLD_MESH,...Object.values(BLD_FACTION_MESH||{})]; for(const set of sets){ if(!set) continue; for(const k in set){ const M=set[k]; if(M.variants) for(const V of M.variants){ csmDrawMesh(V.base); if(V.tur) csmDrawMesh(V.tur); } else { csmDrawMesh(M.base); if(M.tur) csmDrawMesh(M.tur); } } } } function csmDrawSceneryCasters(){ if(typeof FX==='undefined') return; for(const k of ['rock','tree','crystal','cityT','cityD','cityH','cityK','cityC','sky1','sky2','skyA','skyS','wreck']) if(FX[k]) csmDrawMesh(FX[k]); if(typeof worldSites!=='undefined'){ for(const S of worldSites) if(S.fill) csmDrawMesh(S.fill); if(typeof WORLD_KIT!=='undefined') for(const k in WORLD_KIT) if(WORLD_KIT[k].mesh) csmDrawMesh(WORLD_KIT[k].mesh); } } function csmDrawUnitCasters(){ if(typeof UNIT_MESH!=='undefined') for(const M of UNIT_MESH){ if(!M) continue; csmDrawMesh(M.hull); if(M.tur) csmDrawMesh(M.tur); } if(typeof FAC_MESH!=='undefined') for(const k in FAC_MESH) for(const ty in FAC_MESH[k]){ const M=FAC_MESH[k][ty]; csmDrawMesh(M.hull); if(M.tur) csmDrawMesh(M.tur); } if(typeof COMMANDER_KIT_MESH!=='undefined') for(const k in COMMANDER_KIT_MESH){ const M=COMMANDER_KIT_MESH[k]; if(!M) continue; csmDrawMesh(M.hull); if(M.tur) csmDrawMesh(M.tur); } if(typeof FAC_DOCTRINE_MESH!=='undefined') for(const k in FAC_DOCTRINE_MESH){ csmDrawMesh(FAC_DOCTRINE_MESH[k].ground); csmDrawMesh(FAC_DOCTRINE_MESH[k].air); } } function csmDrawModuleCasters(){ if(typeof MOD_ATTACH_MESH==='undefined') return; for(const id in MOD_ATTACH_MESH) for(const M of MOD_ATTACH_MESH[id]) csmDrawMesh(M); } /* HIGH skins commanders, size>=21, and anything inside 720 of the look-at. CINEMATIC skins every boned mesh in csmBindSkin — the flag is redundant. */ function csmMarkUnitSkin(M,T,heroUnit,X,Y){ if(!M||!M.hull||!M.hull.bones) return; const q=typeof mfGfxKey==='function'?mfGfxKey():'high'; if(q!=='high'&&q!=='cinematic') return; if(q==='cinematic'||heroUnit||T.cat==='hero'||T.size>=21||dist2(X,Y,cam.x,cam.y)<720*720){ M.hull.csmSkin=1; if(M.tur&&M.tur.bones) M.tur.csmSkin=1; } } /* Light values in sunFor() are display-space colours picked by eye. The shading maths is linear, so they are converted once here rather than in the shader. */ const _lin=c=>[Math.pow(c[0],2.2),Math.pow(c[1],2.2),Math.pow(c[2],2.2)]; /* -------------------------------------------------------------------------- CINEMATIC LOCAL LIGHTS The world renderer deliberately remains forward-rendered. A phone battle can have hundreds of lamps, engines and sparks, but only the eight strongest camera-relevant sources are promoted into the material shader. Everything else keeps its emissive billboard, which preserves the feeling of a living base without turning a large fight into a G-buffer bandwidth problem. -------------------------------------------------------------------------- */ const _sceneLightPR=new Float32Array(8*4),_sceneLightCI=new Float32Array(8*4); let _sceneLightN=0; function sceneLightCap(){ const n=(typeof GFX!=='undefined'&&GFX.lights!=null)?(GFX.lights|0):8; return Math.max(0,Math.min(8,n)); } function sceneLightPush(x,y,z,range,cr,cg,cb,intensity,score){ const cap=sceneLightCap(); if(cap<=0) return; let at=_sceneLightN; if(at0)) continue; const dx=L.x-cam.x,dy=L.y-cam.y,d2=dx*dx+dy*dy; if(d2>orthoSpan*orthoSpan*.62) continue; const c=_lin(sceneLightColor(bldFactionKey(L))); const isHQ=L.type==='hq',damage=critical||L.hitT>0; const range=isHQ?112:(damage?82:70); const boost=damage?1.65:(isHQ?1.35:1); /* Favour key structures over a nearer minor lamp, but still require them to be within the current strategic view. */ const score=boost*range*range/(d2+range*range); sceneLightPush(L.x,L.y,terrainH(L.x,L.y)+(isHQ?34:22),range,c[0],c[1],c[2], Math.min(0.88,(0.46+night*.38)*boost),score); } /* Powered civic windows and industrial warning lamps use the same eight camera-relevant-light budget as bases and vehicles. The emissive facade is still visible when a source is not promoted; only the strongest nearby districts spend fragment lighting work. */ for(const R of relics){ if(!R.alive||!fogPointVisible(R.x,R.y)||(R.kind!==4&&R.kind!==2&&R.kind!==3&&R.kind!==5))continue; const dx=R.x-cam.x,dy=R.y-cam.y,d2=dx*dx+dy*dy; if(d2>orthoSpan*orthoSpan*.54)continue; const civic=R.kind===4||R.kind===5,c=_lin(civic?[.15,.68,1.0]:[1.0,.24,.035]); const range=R.kind===5?128:civic?78:58,intensity=(R.kind===5?.30:civic?.23:.16)+night*(R.kind===5?.55:civic?.42:.31); sceneLightPush(R.x,R.y,terrainH(R.x,R.y)+(R.kind===5?96:civic?24:18),range,c[0],c[1],c[2],intensity, (civic?1.05:.72)*range*range/(d2+range*range)); } /* The commander and a few nearby vehicles participate in actual material lighting. Billboard headlamps illuminate the ground visually; these local sources make adjacent hulls and building faces react to them too. */ let promoted=0; for(let i=0;iorthoSpan*orthoSpan*.48)continue; const c=_lin(sceneLightColor(typeof playerFactionKey==='function'?playerFactionKey():'nova')); const range=isCommander?108:58,boost=isCommander?1.42:(chosen?1.05:.76); /* THROWN AHEAD, not centred on the hull. A light sitting inside the unit brightens the unit and leaves the direction it faces dark — the exact opposite of a flashlight. Placing the source low and forward along the facing makes terrain, rocks and building walls IN FRONT catch the beam, so where the unit looks is where the player can see. */ const fa=uang[i]-Math.PI/2, ahead=isCommander?T.size*3.4:T.size*1.9; const lx=ux[i]+Math.cos(fa)*ahead, ly=uy[i]+Math.sin(fa)*ahead; sceneLightPush(lx,ly,terrainH(lx,ly)+7,range,c[0],c[1],c[2],(.26+night*.42)*boost,boost*range*range/(d2+range*range)); /* The commander also keeps a soft source on the hull itself, so the machine reads lit rather than emitting from empty ground. */ if(isCommander) sceneLightPush(ux[i],uy[i],terrainH(ux[i],uy[i])+T.size*.52,66,c[0],c[1],c[2],(.16+night*.22)*boost,boost*.6); promoted++; } if(typeof singularities!=='undefined') for(const Sg of singularities){ const dxs=Sg.x-cam.x,dys=Sg.y-cam.y; sceneLightPush(Sg.x,Sg.y,terrainH(Sg.x,Sg.y)+22,150,_lin([172,120,255])[0],_lin([172,120,255])[1],_lin([172,120,255])[2], 0.9+0.5*Math.sin((typeof stats!=='undefined'?stats.t:0)*9),150*150/(dxs*dxs+dys*dys+150*150)+3); } if(carrier.active&&carrier.phase<2){ const c=_lin(sceneLightColor(dropFactionKey(carrier.fac))); const alt=Math.max(18,carrierEffectiveAlt()*.65); const dx=carrier.x-cam.x,dy=carrier.y-cam.y,d2=dx*dx+dy*dy; sceneLightPush(carrier.x,carrier.y,terrainH(carrier.x,carrier.y)+alt,28,c[0],c[1],c[2],0.08,28*28/(d2+28*28)+1); } /* The shader expects intensity in the fourth colour lane. Selection used it as a ranking key, so restore the small sidecar just before upload. */ for(let i=0;i<_sceneLightN;i++) _sceneLightCI[i*4+3]=_sceneLightI[i]; } function visualDebugMode(){ const v=typeof window!=='undefined' ? Number(window.MFVisualDebug||0) : 0; return Math.max(0,Math.min(7,v|0)); } let MF_BONES_ON=false; function begin3D(nA){ const S=sunFor(nA); gl.useProgram(prog3D); /* FX and custom passes share InstMesh but do not own prog3D's uniforms. Re-entering the model pass is the one reliable boundary where a previous rig or per-asset skin must be cleared. This keeps an unlit flush from touching a foreign uniform location and prevents stale V2 maps/bones from bleeding into the next ordinary unit or structure. */ if(typeof U3!=='undefined'){ if(MF_ASSET_ON&&U3.uAssetOn) gl.uniform1f(U3.uAssetOn,0.0); if(MF_BONES_ON&&U3.uBoneN!==undefined&&U3.uBoneN!==null) gl.uniform1i(U3.uBoneN,0); } MF_ASSET_ON=false; MF_BONES_ON=false; /* If the procedural material atlases were not generated (context recovery race, mobile memory pressure, or a startup ordering change), rebuild now instead of drawing every building with missing/muted PBR detail. */ if(!matTex || !matNrmTex || !matOrmTex || !matDamageTex || !matDetailTex){ if(typeof buildMatAtlas==='function') try{ buildMatAtlas(); }catch(e){} } gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,matDamageTex); gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D,matNrmTex); gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D,matOrmTex); /* Texture units 4/5/6 remain reserved for the post chain. Unit 7 is reused after terrain/fog and makes the live V2 micro-detail available to every instanced unit and building without adding draw calls. */ gl.activeTexture(gl.TEXTURE7); gl.bindTexture(gl.TEXTURE_2D,matDetailTex); /* Unit 8 carries the live fog-of-war map into the MODEL pass so units, buildings and remembered scenery darken with the ground they stand on. */ if(typeof fogTex!=='undefined'&&fogTex){ gl.activeTexture(gl.TEXTURE8); gl.bindTexture(gl.TEXTURE_2D,fogTex); } /* Units 4-6 carry the per-asset baked triplet when a draw declares one. They must reference a COMPLETE texture even when it is unused: WebGL2 validates every sampler the program references at draw time, not only the ones the taken branch reads, and an unbound unit drops the whole draw call -- which is every mesh in the game vanishing while the program still reports as linked. The atlas stands in; uAssetOn keeps it from ever being sampled. */ gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,matTex); gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D,matTex); gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D,matTex); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,matTex); gl.uniformMatrix4fv(U3.uVP,false,matVP); gl.uniform3f(U3.uEye,eyeX,eyeY,eyeZ); gl.uniform3f(U3.uSun,S.dir[0],S.dir[1],S.dir[2]); /* View direction under an orthographic projection is the same for every pixel in the frame, so the Blinn half-vector is a per-frame constant. */ const vx=matV[2], vy=matV[6], vz=matV[10]; let hx=S.dir[0]+vx, hy=S.dir[1]+vy, hz=S.dir[2]+vz; const hl=Math.hypot(hx,hy,hz)||1; gl.uniform3f(U3.uHalf,hx/hl,hy/hl,hz/hl); { const c=_lin(S.col); gl.uniform3f(U3.uSunC,c[0],c[1],c[2]); } { const c=_lin(S.sky); gl.uniform3f(U3.uAmbSky,c[0],c[1],c[2]); } { const c=_lin(S.gnd); gl.uniform3f(U3.uAmbGnd,c[0],c[1],c[2]); } { const c=_lin(S.fog); gl.uniform3f(U3.uFogC,c[0],c[1],c[2]); } if(U3.uHazeQ) gl.uniform1f(U3.uHazeQ, typeof mfHazeQ==='function'?mfHazeQ():1); gl.uniform1f(U3.uEmis,0); if(U3.uNight) gl.uniform1f(U3.uNight,nA); if(U3.uTime) gl.uniform1f(U3.uTime,(typeof performance!=='undefined'?performance.now():0)*0.001); gl.uniform1i(U3.uDebugMode,visualDebugMode()); if(U3.uFowMap!=null){ gl.uniform1i(U3.uFowMap,8); gl.uniform1f(U3.uFowOn,(typeof fogGameplayActive==='function'&&fogGameplayActive()&&!demoMode&&typeof fogTex!=='undefined'&&fogTex)?1:0); } gl.uniform1i(U3.uLightCount,_sceneLightN); if(_sceneLightN){ gl.uniform4fv(U3.uLightPosR,_sceneLightPR); gl.uniform4fv(U3.uLightColI,_sceneLightCI); } return S; } function setEmis(v){ gl.uniform1f(U3.uEmis,v); } /* A battlefield limit should read as command infrastructure, not the renderer running out of world. The red core and EVERY grid rung sit on the safe side; procedural exclusion art starts beyond them in the terrain shader. It is flushed by the unlit additive pass, so sensor fog cannot turn it black and the physical-edge haze cannot erase it. */ function terrainExclusionStyle(mapKey,themeKey){ const TH=THEMES[themeKey]||THEMES.verdant; const style=mapKey==='isles'?1:(themeKey==='ashland'||mapKey==='crater'?2: (themeKey==='arctic'||mapKey==='highland'?3:0)); const tint=style===1?TH.wDeep:(style===2?(TH.cliff||[64,48,42]): (style===3?(themeKey==='arctic'?[108,128,150]:[76,88,104]):(TH.g0||[62,82,54]))); return {style,tint}; } let mapBoundaryDrawCount=0,mapBoundaryOutsideCount=0; function queueBattlefieldEdgeGrid(t,vis){ mapBoundaryDrawCount=0;mapBoundaryOutsideCount=0; const B=typeof battlefieldPlayBounds==='function'?battlefieldPlayBounds(0):{lo:0,hi:MAP,span:MAP}; const d=typeof battlefieldSignedDistance==='function'?Math.abs(battlefieldSignedDistance(cam.x,cam.y,0)): Math.min(Math.abs(cam.x-B.lo),Math.abs(cam.x-B.hi),Math.abs(cam.y-B.lo),Math.abs(cam.y-B.hi)); const reach=Math.max(480,orthoSpan*.42); if(d>=reach) return 0; const reveal=clamp((reach-d)/(reach*.64),0,1),span=B.hi-B.lo; const seg=Math.max(64,Math.round(span/28)),inner=Math.min(112,span*.07),safe=9; const pulse=.78+.22*Math.sin(t*3.2), coreA=(180+55*pulse)*reveal, gridA=(58+38*pulse)*reveal; const inside=(x,y,pad)=>typeof battlefieldContains==='function'?battlefieldContains(x,y,pad||0): x>=B.lo+(pad||0)&&x<=B.hi-(pad||0)&&y>=B.lo+(pad||0)&&y<=B.hi-(pad||0); const put=(x,y,len,ang,r,g,b,a,w,endpoints)=>{ if(a<5||!vis(x,y,len*.55+55)) return; FX.line.add(x,y,terrainH(x,y)+7.5,len,ang,r,g,b,a,w); if(!inside(x,y,0)||(endpoints&&endpoints.some(P=>!inside(P[0],P[1],0))))mapBoundaryOutsideCount++; mapBoundaryDrawCount++; }; const point=(a,pad)=>typeof battlefieldBoundaryPoint==='function'?battlefieldBoundaryPoint(a,pad):[ (B.lo+B.hi)*.5+Math.cos(a)*span*.48,(B.lo+B.hi)*.5+Math.sin(a)*span*.48]; /* Sample the authored silhouette itself. Coastal inlets become a soft, rounded perimeter; scorched maps keep sharper chamfers; storm fronts bow asymmetrically. No map is forced through the old four-sided stamp. */ for(let k=0;k.2){ /* Atlas cells keep ~16% transparent padding for mip safety. Adjacent spans therefore need deliberate overlap; 1.28 closes that padding at every zoom without letting a ribbon overshoot its true endpoints by more than one soft glow fringe. */ const worldLen=Math.max(width*1.2,screenLen*orthoSpan/Math.max(1,VH)*1.28); /* The atlas beam runs along local +Y. Screen Y points down, hence this less-obvious angle instead of atan2(sy,sx). */ const rot=Math.atan2(-sx,-sy); bbAdd.addOrientedRect(uv,(ax+ex)*.5,(ay+ey)*.5,(ah+eh)*.5, width,worldLen,rot,r,g,b,a); } ax=ex; ay=ey; ah=eh; } } function addBeamPathFx(x0,h0,y0,x1,h1,y1,width,r,g,b,a){ /* Pearl-necklace of circular energy + smoke. sprites.beam is a thin atlas line — the shaft has to be GLOW KNOTS or it reads as a cheap streak. Position-seeded, no velocity. First and last knots sit on the bore and the impact; jitter in the middle only. */ const q=typeof mfVfxQ==='function'?mfVfxQ():1; if(q<0.35||a<14) return; if(typeof orthoSpan==='number'&&orthoSpan>1800) return; const dx=x1-x0, dy=y1-y0, dh=h1-h0; const len=Math.hypot(dx,dy)||1; if(len<10) return; const n=Math.max(3,Math.min(q>=0.95?16:13, Math.round(len/(q>=0.95?14:17)))); const seed=x0*0.073+y0*0.051+x1*0.019; const smoke=sprites.smoke||sprites.glow; for(let i=0;i<=n;i++){ const u=i/n; const end=i===0||i===n; const jx=end?0:Math.sin(seed+i*12.9898)*width*0.16; const jy=end?0:Math.cos(seed+i*78.233)*width*0.16; const x=x0+dx*u+jx, y=y0+dy*u+jy, h=h0+dh*u; const sz=width*(end?1.15:1.55+(Math.sin(seed*3.1+i*2.4)*0.5+0.5)*0.85); bbAdd.add(sprites.glow,x,y,h,sz*3.4,0,r,g,b,a*(end?0.72:0.58)); bbAdd.add(sprites.glow,x,y,h,sz*1.55,0,r,g,b,a*(end?0.90:0.78)); bbAdd.add(sprites.glow,x,y,h,sz*0.58,0,255,253,248,a*0.92); if(q>=0.55&&(i&1)&&!end) bbAlpha.add(smoke,x,y,h+1.6,sz*3.8,seed+i, 52+r*0.14,48+g*0.11,44+b*0.09, Math.min(95,a*0.28)); } } function addBeam3D(mesh,x0,h0,y0,x1,h1,y1,rad,r,g,b,a,opt){ const q=typeof mfVfxQ==='function'?mfVfxQ():1; const width=Math.max(4.80,rad*(q>=1.25?6.40:q>=0.95?5.85:q>=0.65?5.40:4.20)); /* Glow-tube + knots. The beam atlas cell is a 1px streak — do not use it as the body. Do not stretch GPU points into ellipses. */ addBeamRibbon(sprites.glow,x0,h0,y0,x1,h1,y1, width*(q>=0.95?5.10:4.20),r,g,b,a*(q>=0.95?.70:.58),210); addBeamRibbon(sprites.glow,x0,h0,y0,x1,h1,y1, width*1.85,r,g,b,Math.min(255,a*1.22),150); addBeamRibbon(sprites.glow,x0,h0,y0,x1,h1,y1, Math.max(2.40,width*0.88),255,253,248,Math.min(255,a*1.40),150); addBeamPathFx(x0,h0,y0,x1,h1,y1,width,r,g,b,a); /* Mid-flight tracers pass noMuzzle — a burst behind the round is the floating flash that made shots look disconnected from the barrel. */ if(opt&&opt.noMuzzle) return; bbAdd.add(sprites.glow,x0,y0,h0,width*2.8,0,r,g,b,a*0.85); bbAdd.add(sprites.glow,x0,y0,h0,width*1.15,0,255,253,246,a); } /* Double-helix filaments give the largest weapons an authored silhouette. The radius closes at both ends so the strands grow out of the emitter and merge into the impact, rather than looking like two unrelated cables. */ function addBeamHelix(bm,h0,h1,radius,turns,r,g,b,a,t,oneStrand){ const dx=bm.x1-bm.x0, dy=bm.y1-bm.y0, dl=Math.hypot(dx,dy)||1; const ox=-dy/dl, oy=dx/dl, steps=10; const strands=oneStrand?1:2; for(let s=0;s=0.40&&size>1.4){ const n=q>=1.2?8:q>=0.65?6:4; const seed=x*0.073+y*0.051; for(let k=0;k=0.4&&size>2.2 &&(typeof gpfxLive==='undefined'||gpfxLive6?22:12,[r,g,b],{speed:72,up:0.32,life:0.64,size:6.6,min:4}); } function addMuzzleFlash(x,y,h,dx,dy,size,r,g,b,a){ /* Brief barrel bloom plus a cone along the shot. Both stay on the weapon; nothing here is given velocity that would orbit the chassis. */ if(a<8||size<0.4) return; const l=Math.hypot(dx,dy)||1, nx=dx/l, ny=dy/l; const q=typeof mfVfxQ==='function'?mfVfxQ():1; const cone=Math.max(8.2,size*(q>=0.95?2.65:2.05)); bbAdd.add(sprites.glow,x,y,h,size*1.48,0,r,g,b,a); bbAdd.add(sprites.glow,x,y,h,size*.58,0,255,252,242,a); addBeamRibbon(sprites.glow,x,h,y,x+nx*cone,h,y+ny*cone, Math.max(2.05,size*.92),255,248,228,a,80); /* MEDIUM+ GPU spray at the already-lifted muzzle. n stays under the water-ripple stamp (n>=20). LOW keeps the cone so the shot is not mute. */ if(typeof gpfxBurst==='function'&&q>=0.45&&perfScale>.28 &&(typeof camDist==='undefined'||camDist<2400)&&gpfxLive0.22&&sprites.smoke) bbAlpha.add(sprites.smoke,x,y,h+span*0.55,span*0.85,seed, 38,36,34, Math.min(90,48*heat)); } function stampCrystalVeins(D,H,col,pulse,fieldR,taken){ /* Branching ground veins. sprites.crater under every node was the dark disk on crystal spawns — including occupied mex pads. Veins read as ore in the dirt on a FREE node. Occupied pads must not stamp additive glow: five ribbons + forks under the mex still sat past the 0.936 bloom thresh and kept the white extractor disc. */ if(!sprites.glow || taken) return; const th=(typeof terrainH==='function')?terrainH:()=>0; const seed=(D.pulse||0)+D.x*0.017+D.y*0.013; const n=fieldR>52?6:5; const a0=taken?15:28; for(let k=0;k=0){ const T=TYPES[utype[u]]; const M=typeof UNIT_MESH!=='undefined'?UNIT_MESH[utype[u]]:null; const ss=(T.size/15)*(M&&M.s||1)*1.5*(T.vscale||1); const th=(M&&M.turH>0?M.turH:(T.air?6:4.6))*ss; const hy=unitGroundY(T,ux[u],uy[u],u); return muzzle?hy+th:hy+th*0.55; } } if(typeof blds!=='undefined'&&typeof BCS!=='undefined'&&typeof BGW!=='undefined'&&bGrid){ const cr=1, r2=rad*rad; const cx=clamp(x/BCS|0,0,BGW-1), cy=clamp(y/BCS|0,0,BGW-1); let best=-1, bd=r2; for(let gy=Math.max(0,cy-cr);gy<=Math.min(BGW-1,cy+cr);gy++) for(let gx=Math.max(0,cx-cr);gx<=Math.min(BGW-1,cx+cr);gx++){ const cell=bGrid[gy*BGW+gx]; if(!cell) continue; for(const bi of cell){ const B=blds[bi]; if(!B||!B.alive||B.prog<1) continue; const th0=typeof BLD_TUR_H!=='undefined'?BLD_TUR_H[B.type]:0; if(!th0) continue; const d=dist2(x,y,B.x,B.y); if(d>=bd) continue; bd=d; best=bi; } } if(best>=0){ const B=blds[best]; const M=typeof bldMeshFor==='function'?bldMeshFor(B):null; const grow=B.prog||1; const th=(M&&M.turH)||(typeof BLD_TUR_H!=='undefined'&&BLD_TUR_H[B.type])||14; const hy=(BT[B.type]&&BT[B.type].placement==='water')?0:base; return hy+th*grow*(muzzle?1:0.62); } } return base+(muzzle?13:2.4); } /* Rail batteries and strategic silos are base landmarks. Their geometry is faction-authored, but at an RTS camera distance a small, quiet charge motif is what keeps the role readable in motion. These are billboard/ring effects only: no lights, emitters or simulation particles, so a dense fortress does not turn into additive fog on a phone. The caller has already passed both viewport and fog-of-war visibility gates. */ function addFactionStrategicBuildingVfx(B,fac,H,bob,grow,t,M){ if(B.prog<1||perfScale<.32||(B.type!=='rail'&&B.type!=='nova')) return; const pal=fac==='legion'?[[255,76,48],[255,171,72]]: fac==='syndicate'?[[186,78,255],[118,246,182]]: fac==='horde'?[[136,232,72],[198,84,255]]: [[72,205,255],[205,244,255]]; const p=.72+Math.sin(t*(B.type==='nova'?2.0:3.2)+B.x*.017+B.y*.011)*.28; const th=(M.turH||BLD_TUR_H[B.type]||(B.type==='nova'?20:18))*grow; const top=H+bob+th+(B.type==='nova'?10:6)*grow; const a=(B.cool||0)<=0?1:.46; if(B.type==='rail'){ const ang=(B.tang||0)-Math.PI/2,dx=Math.cos(ang)*(10+2*(B.lvl||1))*grow; const dy=Math.sin(ang)*(10+2*(B.lvl||1))*grow; bbAdd.add(sprites.glow,B.x+dx,B.y+dy,top,(7+3*p)*grow,0,pal[0][0],pal[0][1],pal[0][2],145*a); bbAdd.add(sprites.ring||sprites.glow,B.x+dx,B.y+dy,top,(5.2+1.7*p)*grow, t*.45,pal[1][0],pal[1][1],pal[1][2],118*a); if(perfScale>.56) bbAdd.add(sprites.glow,B.x-dx*.42,B.y-dy*.42,top-3*grow, 10*grow,0,pal[0][0],pal[0][1],pal[0][2],55*a); return; } /* NOVA's slow orbital lock is intentionally wider than its weapon muzzle: it telegraphs a strategic asset without resembling ordinary gunfire. */ FX.ring.add(B.x,B.y,H+1.5,34+5*p,t*.18,pal[0][0],pal[0][1],pal[0][2],52*a); bbAdd.add(sprites.glow,B.x,B.y,top,(14+5*p)*grow,0,pal[0][0],pal[0][1],pal[0][2],98*a); bbAdd.add(sprites.ring||sprites.glow,B.x,B.y,top,(11+3*p)*grow,-t*.25, pal[1][0],pal[1][1],pal[1][2],125*a); if(perfScale>.56){ const n=Math.min(4,1+(B.lvl||1)); for(let k=0;k[x+lx*ca-lz*sa,y+lx*sa+lz*ca]; /* Nova's four lift ducts are real articulated geometry, not exhaust sprites. The model's hinge maps 0=upright hover and 1=aft-tilted cruise. Rotors are separate instances so their blades can continuously spin while the duct itself performs the slower altitude-driven transition. */ if(D.vtol&&P.vtol){ const pose=clamp(vtolPose||0,0,1),phase=Math.asin(pose),fanA=clamp(1-pose*1.35,0,1); for(let k=0;k.02) D.rotor.add(q[0],q[1],alt+12.2*(P.scale||1),P.scale||1, ang+t*(k&1?-11.5:11.5),tc[0],tc[1],tc[2],alpha*fanA); } } /* Stamp before flush — dropship streams empty on flush, so a later unit pass cannot see them. Same InstMesh path, no new format. */ if(typeof csmActive==='function'&&csmActive()&&typeof csmBegin==='function'&&csmBegin(false)){ csmDrawMesh(D.body); if(D.gear) csmDrawMesh(D.gear); if(D.vtol) csmDrawMesh(D.vtol); if(D.rotor) csmDrawMesh(D.rotor); csmEnd(_csmFrameNA); } D.body.flush(gl); if(gear&&D.gear) D.gear.flush(gl); if(D.vtol&&P.vtol){ D.vtol.flush(gl); if(D.rotor) D.rotor.flush(gl); } /* Engine bells are ENERGY on the mesh. Additive glow sprites at P.eng bloomed into the orange/yellow sparks on the two rear ports. Dust is type-10 alpha, not these. */ } /* Per-frame render scratch, allocated once. Everything here used to be created fresh inside render() and thrown away 60 times a second. */ let _hbI=new Int32Array(4096), _hbF=new Float32Array(4096); const _hbCells=new Map(), _wallStreams=new Set(); let _csmFrameNA=0; function unitGroundY(T,x,y,i){ if(T.air){ const alt=(i!=null&&typeof unitAirAlt==='function')?unitAirAlt(i):58; return terrainH(x,y)+alt; } if(T.naval){ /* Visual bob only. Sim pathing stays on the flat naval mask — a bouncing flowfield is a bug, and sim.js is contended. */ return (typeof waterSurfaceY==='function'?waterSurfaceY(x,y):0)+0.95; } return terrainH(x,y); } function queueWaterFx(){ if(typeof waterFxBegin!=='function'||!waterIdxCount) return; waterFxBegin(); const wet=typeof authoredWaterAt==='function'?authoredWaterAt:()=>false; const cb=camBounds(); const vis=(x,y,p)=>x>=cb.x0-(p||0)&&x<=cb.x1+(p||0)&&y>=cb.y0-(p||0)&&y<=cb.y1+(p||0); const push=(i)=>{ if(!ualive[i]||!umov[i]) return; const T=TYPES[utype[i]]; if(!T||!T.naval) return; if(!vis(ux[i],uy[i],90)) return; if(!fogEntityVisible(uteam[i],ux[i],uy[i])) return; /* Hulls face +X at mesh yaw 0, which is uang-π/2. mdlWake is authored aft along -X; the water-sheet V uses the same angle so foam trails the hull instead of sitting 90° off the bow. */ const len=T.size*(T.vscale||1)*3.6; waterFxWake(ux[i],uy[i],uang[i]-Math.PI/2,len,T.size*1.2); }; for(let i=0;i0) bzShow=Math.max(0,bzShow-dtDraw*0.5); /* Screen shake is now a camera-space nudge rather than a world offset — with a perspective camera you shake the EYE, not the contents of the world. */ if(shake>0){ cam.x+=rr(-shake,shake)*shakeMult*0.9; cam.y+=rr(-shake,shake)*shakeMult*0.9; shake*=0.86; if(shake<0.3) shake=0; clampCam(); camUpdateMatrices(); } drawCalls=0; triCount=0; gl.viewport(0,0,cv.width,cv.height); const Sun=sunFor(S_nA); selectCinematicLights(S_nA); /* Opaque geometry renders offscreen so screen-space AO can read its depth. If the target can't be created the call returns false and everything below draws straight to the canvas exactly as before. */ const aoActive=aoBeginScene(); if(aoActive&&typeof aoW==='number'&&aoW>0) gl.viewport(0,0,aoW,aoH); /* Clear to EXACTLY the fog colour. The border haze fades the last stretch of ground into uFogC, so anything the camera sees past the edge has to be the same value or the illusion ends in a visible seam. */ gl.clearColor(Sun.fog[0],Sun.fog[1],Sun.fog[2],1); gl.clearDepth(1); gl.enable(gl.DEPTH_TEST); gl.depthMask(true); gl.disable(gl.BLEND); gl.enable(gl.CULL_FACE); gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT); /* Portrait takeover in main.js disables this — AABB scissor left fog strips. */ if(typeof mfGfxScissor==='function') mfGfxScissor(true); begin3D(S_nA); const B=camBounds(); const x0=B.x0, x1=B.x1, y0=B.y0, y1=B.y1; const vis=(x,y,pad)=>x>=x0-(pad||0)&&x<=x1+(pad||0)&&y>=y0-(pad||0)&&y<=y1+(pad||0); /* Rendering relevance has three bands and never changes simulation state: 0 outside the expanded camera — submit nothing; 1 visible strategic/far — legacy/far material and no cosmetic hardware; 2 tactical/important — full V2 material and secondary detail. Owned and allied armies keep fighting off-screen; they simply stop consuming draw bandwidth until the camera can see them again. */ const renderBand=(x,y,pad,important)=>{ if(!vis(x,y,pad))return 0; if(important)return 2; const far=orthoSpan>(typeof mfLodSpan==='function'?mfLodSpan(2250):2250)||dist2(x,y,cam.x,cam.y)>Math.pow(orthoSpan*.58+pad,2); return far?1:2; }; const gh=(x,y)=>terrainH(x,y); /* Stage 1 ring LOD. vis() drops off-screen; usel drops unselected mass. Army-select at 1000 still paints a carpet at tactical zoom — cap at 48 and cell-collapse. Command altitude (orthoSpan>1400) keeps the commander only: 48 rings at that height is still a smear. Draw budget, not a fake 4000 pop cap. Count once so icon plates and ground rings share it. */ let selOnCam=0; for(let i=0;i(typeof mfLodSpan==='function'?mfLodSpan(1400):1400); const ringKeepCmd=i=>i===heroIdx||(TYPES[utype[i]]&&TYPES[utype[i]].cat==='hero') ||(typeof isEnemyCommander==='function'&&isEnemyCommander(i)); if(typeof mfIconStackRebuild==='function') mfIconStackRebuild(vis, ringKeepCmd); /* ---------------- terrain ---------------- Drawn with its own program so it can sample the painted map canvas plus a tiling detail layer, rather than flat vertex colour. UNLESS THAT PROGRAM DID NOT BUILD. On a GPU where the terrain shader fails to compile or link, every other program still works — so units, buildings, rocks and crystals render normally and the GROUND is simply absent. That is the "map isn't rendering" report, and it is invisible from here because the failure only ever reached a phone's console. The terrain VAO carries the model program's exact vertex layout, so we can still draw real lit ground: vertex colour and material instead of the painted map, which is a downgrade but not a void. */ if(typeof terrainProgOK!=='undefined'&&!terrainProgOK&&prog3D){ begin3D(S_nA); setEmis(0); drawTerrainFallback(); begin3D(S_nA); } else { gl.useProgram(progT); gl.uniformMatrix4fv(UT.uVP,false,matVP); gl.uniform3f(UT.uEye,eyeX,eyeY,eyeZ); gl.uniform3f(UT.uSun,Sun.dir[0],Sun.dir[1],Sun.dir[2]); { const c=_lin(Sun.col); gl.uniform3f(UT.uSunC,c[0],c[1],c[2]); } { const c=_lin(Sun.sky); gl.uniform3f(UT.uAmbSky,c[0],c[1],c[2]); } { const c=_lin(Sun.gnd); gl.uniform3f(UT.uAmbGnd,c[0],c[1],c[2]); } { const c=_lin(Sun.fog); gl.uniform3f(UT.uFogC,c[0],c[1],c[2]); } if(UT.uHazeQ) gl.uniform1f(UT.uHazeQ, typeof mfHazeQ==='function'?mfHazeQ():1); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,terrainTex); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,detailTex); /* Units 4/5/6 belong to the post chain. Fog uses 7 and immediately restores the active unit, so it cannot alias a material or AO sampler. */ gl.activeTexture(gl.TEXTURE7); gl.bindTexture(gl.TEXTURE_2D,fogTex||terrainTex); gl.uniform1f(UT.uFogActive,fogGameplayActive()&&!demoMode&&fogTex?1:0); /* Splat inputs live on 8/9 — above the post chain's 4/5/6, so neither side can alias the other. Tile cells are integers into the 11x11 atlas. */ const _rt=(typeof terrGroundTex!=='undefined')&&terrGroundTex&&terrSoilTex&&terrPaveTex&&terrGrassTex; gl.activeTexture(gl.TEXTURE8); gl.bindTexture(gl.TEXTURE_2D,_rt?terrGroundTex:matTex); gl.activeTexture(gl.TEXTURE9); gl.bindTexture(gl.TEXTURE_2D,(typeof groundMaskTex!=='undefined'&&groundMaskTex)||terrainTex); gl.activeTexture(gl.TEXTURE10); gl.bindTexture(gl.TEXTURE_2D,(typeof heightTex!=='undefined'&&heightTex)||terrainTex); gl.activeTexture(gl.TEXTURE11); gl.bindTexture(gl.TEXTURE_2D,_rt?terrSoilTex:matTex); gl.activeTexture(gl.TEXTURE12); gl.bindTexture(gl.TEXTURE_2D,_rt?terrPaveTex:matTex); gl.activeTexture(gl.TEXTURE13); gl.bindTexture(gl.TEXTURE_2D,_rt?terrGrassTex:matTex); const _rn=(typeof terrGroundNrm!=='undefined')&&terrGroundNrm&&terrSoilNrm&&terrPaveNrm&&terrGrassNrm; gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D,_rn?terrGroundNrm:matNrmTex); gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D,_rn?terrSoilNrm:matNrmTex); gl.activeTexture(gl.TEXTURE14); gl.bindTexture(gl.TEXTURE_2D,_rn?terrPaveNrm:matNrmTex); gl.activeTexture(gl.TEXTURE15); gl.bindTexture(gl.TEXTURE_2D,_rn?terrGrassNrm:matNrmTex); gl.activeTexture(gl.TEXTURE0); gl.uniform1f(UT.uRealTex,_rt?1:0); { const E=typeof battlefieldPlayBounds==='function'?battlefieldPlayBounds(0):{lo:0,hi:MAP}; const ES=terrainExclusionStyle(curMap,curTheme),style=ES.style,ec=ES.tint; const lc=_lin([ec[0]/255,ec[1]/255,ec[2]/255]); gl.uniform2f(UT.uPlayBounds,E.lo,E.hi); gl.uniform1f(UT.uEdgeStyle,style);gl.uniform1f(UT.uEdgeTime,t); /* Impact burns: newest first, culled to view, cooled by age. Explosive glow dies in ~11 s, its char fades by ~70 s; civic ember fields hold heat longer (~90 s) so a burning city stays readable. Kinetic churn settles in ~22 s. Cheap: at most 16 vec4s a frame. */ if(typeof groundBurns!=='undefined'){ const now=stats.t; const burnLife=G=>!G.kind?22:(G.civic?90:70); for(let i=groundBurns.length-1;i>=0;i--){ const G=groundBurns[i]; if(now-G.t0>burnLife(G)) groundBurns.splice(i,1); } const bv=_burnVec, bk=_burnKind; let bn=0; for(let i=groundBurns.length-1;i>=0&&bn<16;i--){ const G=groundBurns[i]; if(!vis(G.x,G.y,G.r+60)) continue; const life=burnLife(G); bv[bn*4]=G.x; bv[bn*4+1]=G.y; bv[bn*4+2]=G.r; bv[bn*4+3]=clamp((now-G.t0)/life,0,1); bk[bn]=G.kind; bn++; } gl.uniform1i(UT.uBurnN,bn); if(bn){ gl.uniform4fv(UT.uBurns,bv); gl.uniform1fv(UT.uBurnKind,bk); } } else gl.uniform1i(UT.uBurnN,0); gl.uniform3f(UT.uEdgeTint,lc[0],lc[1],lc[2]); } gl.activeTexture(gl.TEXTURE0); drawTerrainEdge(); drawTerrain(); } if(typeof csmPrepare==='function') csmPrepare(Sun); if(typeof materialV2QueueShadows==='function')materialV2QueueShadows(Sun); drawShadows(Sun); // ground shadows go on before anything stands on them begin3D(S_nA); // back to the lit model program /* Material V2 is an opt-in laboratory until its mobile/army gates pass. It draws into the ordinary opaque/SSAO target, then restores begin3D so every production stream remains on the legacy shader. */ if(typeof renderMaterialV2Lab==='function')renderMaterialV2Lab(S_nA,t); // ---------------- scenery ---------------- const qDraw=typeof qualityKey==='function'?qualityKey():'high'; /* MEDIUM/LOW: every other rock/tree at command zoom. HIGH still submits the full stand — one instanced draw either way; this is instance fill at a height where a trunk is ~2 px. */ const sceneryStep=(qDraw==='medium'||qDraw==='low')&&orthoSpan>(typeof mfLodSpan==='function'?mfLodSpan(2000):2000)?2:1; const BK=typeof biomeKit==='function'?biomeKit():null; const rockTint=(BK&&BK.rockTint)||[190,186,178]; const floraMesh=k=>k==='pine'?(FX.treePine||FX.tree):k==='palm'?(FX.treePalm||FX.tree): k==='dead'?(FX.treeDead||FX.tree):k==='spore'?(FX.treeSpore||FX.tree):FX.tree; const rockMesh=k=>k==='ice'?(FX.rockIce||FX.rock):k==='slag'?(FX.rockSlag||FX.rock):FX.rock; for(let ri=0;ri1&&(ri%sceneryStep)) continue; const r=rocks[ri]; if(!vis(r.x,r.y,40)||!fogPointVisible(r.x,r.y)) continue; rockMesh(r.k).add(r.x,r.y,gh(r.x,r.y),r.s*0.035,r.a,rockTint[0],rockTint[1],rockTint[2],255); } /* Settlements: one instanced draw per kit piece + one per site fill mesh. Fog rule matches buildings — an unscouted town stays dark. */ if(typeof worldSites!=='undefined') for(const S of worldSites){ if(!vis(S.x,S.y,S.r+140)||!fogPointVisible(S.x,S.y)) continue; if(S.fill&&S.fill.n!==undefined) S.fill.add(S.x,S.y,gh(S.x,S.y),1,0,255,255,255,255); for(const p of S.props){ if(!vis(p.x,p.y,p.s+30)) continue; const K=WORLD_KIT[p.k]; if(K) K.mesh.add(p.x,p.y,gh(p.x,p.y),p.s,p.a,255,255,255,255); } } const tt=(BK&&BK.treeTint)||THEMES[curTheme].treeTint; const ct=(BK&&BK.coverTint)||[86,118,58]; for(let ti=0;ti1&&(ti%sceneryStep)) continue; const tr=trees[ti]; if(!vis(tr.x,tr.y,50)||!fogPointVisible(tr.x,tr.y)) continue; floraMesh(tr.k).add(tr.x,tr.y,gh(tr.x,tr.y),tr.s*0.030,tr.a,tt[0],tt[1],tt[2],255); } if(typeof cover!=='undefined'&&FX.bush) for(let ci=0;ci1&&(ci%sceneryStep)) continue; const b=cover[ci]; if(!vis(b.x,b.y,36)||!fogPointVisible(b.x,b.y)) continue; FX.bush.add(b.x,b.y,gh(b.x,b.y),b.s*0.042,b.a,ct[0],ct[1],ct[2],255); } for(const cs of crystals){ const D=deposits[cs.dep],tier=depositTier(D);if(!D||cs.band>tier||!vis(cs.x,cs.y,140))continue; /* Occupied pad: the extractor owns the node. Drawing the shard cluster plus additive glows under a mex stacked into the white bloom discs. */ if(D.taken)continue; if(!fogPointVisible(cs.x,cs.y))continue; const fill=clamp((D.remaining-(tier-1)*DEPOSIT_BAND)/DEPOSIT_BAND,0,1),edge=cs.band===tier?(.46+.54*fill):1; const col=cs.band===3?[255,120,255]:cs.band===2?[105,255,176]:[105,226,255]; const sc=cs.s*(cs.core?.090:.074)*edge; const H=gh(cs.x,cs.y); FX.crystal.add(cs.x,cs.y,H,sc,cs.a,col[0],col[1],col[2],255); /* Facet glints. A crystal that never flashes is a rock. Each cluster twinkles on its own phase (position-hashed), one short bright spark at a time - plus a standing cool glow pooled at the base, the light the translucent shader path implies it is leaking into the ground. */ /* FADE, do not gate: a hard cutoff pops glows on and off when a machine legitimately crosses the threshold; scaled alpha arrives as dimming. Floor stays up during deploy — matchLive is false there, and the 0.45 perfScale cutoff left crystals looking like unlit rocks. */ const deployGlow=(!matchLive&&typeof carrier!=='undefined'&&carrier.active)?0.9:0; const glowQ=Math.max(0.65, deployGlow, clamp((perfScale-0.28)/0.45,0,1)); if(glowQ>0.02){ const tw=Math.sin(t*2.1+cs.x*0.37+cs.y*0.113); if(tw>0.97){ const f=(tw-0.97)*33, ga=cs.a+cs.x; bbAdd.add(sprites.spark||sprites.glow, cs.x+Math.cos(ga)*sc*46, cs.y+Math.sin(ga)*sc*46, H+sc*175*(0.55+0.4*Math.sin(cs.y)), 1.8+f*2.2, t*2+cs.x, 235,250,255, 140*f*glowQ); } if(cs.core){ bbAdd.add(sprites.glow,cs.x,cs.y,H+1.5,sc*14,0,col[0],col[1],col[2],32*glowQ); } else { bbAdd.add(sprites.glow,cs.x,cs.y,H+1.3,sc*8,0,col[0],col[1],col[2],20*glowQ); } } } /* Mass nodes are the glowing shard cluster, tinted by depletion band. Brown habit tints turned the bed into dirt plates and fought the CRYST spikes. Kit energy habit still owns geysers. */ const enHabit=(BK&&BK.energy)||'vent'; const enCol=enHabit==='frost'?[176,216,226]:enHabit==='heat'?[226,128,58]: enHabit==='spore'?[186,78,140]:[92,196,206]; for(const D of deposits){ if(!vis(D.x,D.y,180)||!fogPointVisible(D.x,D.y)) continue; const tier=depositTier(D); const col=tier===3?[255,122,255]:tier===2?[110,255,180]:tier===1?[112,228,255]:[88,92,100]; const pulse=.82+.18*Math.sin(t*2.1+(D.pulse||0)),H=gh(D.x,D.y); const fieldR=46+(D.initialTier||1)*8; stampCrystalVeins(D,H,col,pulse,fieldR,!!D.taken); /* Occupied: extractor owns the pad. Veins, halo, CRYST and the old lamp all stacked into the white bloom disc. */ if(D.taken) continue; if(FX.dep) FX.dep.add(D.x,D.y,H,1.18+tier*.17,0,col[0],col[1],col[2],tier?255:145); /* Unused nodes keep a faint seep + ring. Veins already mark the bed. */ const depGlow=(!matchLive&&typeof carrier!=='undefined'&&carrier.active)?0.9:0; const depQ=Math.max(0.55, depGlow, clamp((perfScale-0.28)/0.45,0,1)); const halo=fieldR*pulse; /* One ground aura + one faint ring — the old 2D hud.js language. Five stacked additive sprites (halo*2.55 + pool + H+10/H+12 punches at alpha 140) blew the node into a white disc and bypassed bloom. */ bbAdd.add(sprites.glow,D.x,D.y,H+0.48,halo*0.82,0,col[0],col[1],col[2],(tier?10:4)*depQ); if(tier&&sprites.ring) bbAdd.add(sprites.ring,D.x,D.y,H+0.62,halo*0.70,t*.10+(D.pulse||0),col[0],col[1],col[2],8*depQ); } for(const G of geysers){ if(!vis(G.x,G.y,180)||!fogPointVisible(G.x,G.y)) continue; const tier=typeof geyserTier==='function'?geyserTier(G):(G.taken?2:3); const pc=tier?enCol:[83,82,78]; /* Mesh stays rock. Cyan habit on the whole instance made the cairn a painted metal hatch. Steam billboard carries the energy colour. */ const rock=enHabit==='frost'?[168,176,184]:enHabit==='heat'?[118,88,72]: enHabit==='spore'?[96,78,82]:[112,104,92]; const pulse=.72+.28*Math.sin(t*2.6+(G.pulse||G.x*.01)),H=gh(G.x,G.y); if(FX.geyser) FX.geyser.add(G.x,G.y,H,1.48+tier*.10,0,rock[0],rock[1],rock[2],tier?(G.taken?205:255):145); if(tier&&qDraw!=='low'){ bbAdd.add(sprites.glow,G.x,G.y,H+28,(16+tier*3)*pulse,0,pc[0],pc[1],pc[2],G.taken?22:36); } } // crater berms — real mounds standing on the deformed ground for(const M of relief){ if(!vis(M.x,M.y,60)||!fogPointVisible(M.x,M.y)) continue; FX.berm.add(M.x,M.y,gh(M.x,M.y),M.w*0.075,M.a,208,196,178,255); } // salvage fields /* Salvage is coloured by what it came from, so a player can tell at a glance whether a field is worth walking to: cold alloy reads as scrap and pays energy, pale carcass reads as biomass and does not. Tinting here rather than in the model keeps it one instanced draw for every kind. */ for(const W of wrecks){ if(!vis(W.x,W.y,50)||!fogPointVisible(W.x,W.y)) continue; const f=0.7+0.5*(W.mass/Math.max(1,W.m0)); const c = W.kind===5 ? [206,190,150] // biomass — bone-pale carcass : W.kind===2 ? [172,166,158] // city ruin — dusty concrete : [200,190,178]; // scrap and fallen structures FX.wreck.add(W.x,W.y,gh(W.x,W.y),W.s*0.045*f*(W.kind===5?0.8:1),W.a, c[0],c[1],c[2],255); } for(const Cc of crates){ if(!vis(Cc.x,Cc.y,60)||(!fogPointVisible(Cc.x,Cc.y)&&!Cc.seen)) continue; const cc=Cc.kind&&Cc.kind.col||[255,240,190],rs=1+(Cc.kind&&Cc.kind.rarity||0)*.08; FX.crate.add(Cc.x,Cc.y,gh(Cc.x,Cc.y)+Cc.alt*0.55+(Cc.alt>0?0:Math.sin(t*2.4+Cc.x)*1.6),rs,Cc.alt>0?t*2.2:t*0.35,cc[0],cc[1],cc[2],255); } // ---------------- derelict districts ---------------- const worldV2=typeof mfWorldV2Enabled==='function'&&mfWorldV2Enabled(); for(const R of relics){ const deadAge=R.alive?0:Math.max(0,(typeof stats!=='undefined'?stats.t:t)-(R.fallT||0)); if(!R.alive && deadAge>20) continue; const rLod=renderBand(R.x,R.y,120,false); if(!rLod||!fogPointVisible(R.x,R.y)) continue; const dmg=R.alive?(R.hp/R.hpm):0, tint=R.alive?(180+70*dmg):96; /* Each ruin mesh is authored at a reference footprint; the instance scale maps the planned plot size onto it. Tower blocks are the tall ones, so they get the tightest divisor or they overwhelm the skyline. */ /* Divisors map a plot footprint onto each mesh's own authored reference size. The derelict meshes are authored at tens of units; the WORLD_KIT meshes are authored NORMALISED (height 0.63-1.49), so their divisor is 1 and the plot footprint IS the scale. Dividing them by 34 like a ruin rendered 1-unit-tall buildings -- present in every count, invisible on screen. */ const sc=Math.max(R.w,R.h)/ (R.kind===2?104 : R.kind===0?46 : R.kind===3?52 : R.kind===4?46 : R.kind===5?44 : (R.kind===6||R.kind===7)?1 : 44)*(R.alive?1:clamp(0.16+0.12*(1-deadAge/20),0.16,0.28)); const wreckYaw=(R.a||0)+(R.lean||0)+(R.alive?0:0.42); /* Kind 4 is the intact civic block. Falling back to the low block if its mesh is missing is not paranoia: models-civic.js has to be registered in BOTH boot.js and assets/data/manifest.json, and a file that is listed in only one silently does not load — which would otherwise turn every civic plot into a null dereference in the hot render loop. Kind 5 is the skyline anchor: the alien crystalline monolith on the foreign worlds, otherwise the two skyscrapers alternated by position hash so twin districts don't clone. */ const alien5=curTheme==='vespera'||curTheme==='ashland'; /* Kinds 6/7 are authored template plots and draw from WORLD_KIT, keyed by the role the template named. Same null-guard discipline as kind 4 above: worldkit.js is registered in both manifests, but a kit whose initialiser never ran leaves WORLD_KIT empty, and an unguarded lookup here is a null dereference in the hot render loop. Falling back to the derelict block shows a building rather than nothing. */ const kitM=(R.kind===6||R.kind===7)&&typeof WORLD_KIT!=='undefined'&&R.role ? (WORLD_KIT[R.role]&&WORLD_KIT[R.role].mesh) : null; const mesh=kitM || (R.kind===2?FX.cityH : R.kind===3?FX.cityK : R.kind===0?FX.cityT : R.kind===4?(FX.cityC||FX.cityD) : R.kind===5?((alien5?FX.skyA:(((R.x*7+R.y*13)|0)%2?FX.sky2:FX.sky1))||FX.cityT) : FX.cityD); /* V2 and legacy are deliberately separate instance streams. Until all three maps have decoded (or on LOW quality), the old mesh draws instead of leaving an empty lot. Skyline anchors have no V2 stream — they are authored geometry and always draw through their own mesh. */ if(R.kind===5){ const vt=curTheme==='vespera'; /* Past the shear the anchor draws as its own stump. The alien monolith has no stump form — a crystal growth shatters rather than shearing, so it keeps its silhouette and loses it all at zero. */ const m5=(R.part&&mesh!==FX.skyA&&FX.skyS)?FX.skyS:mesh; m5.add(R.x,R.y,gh(R.x,R.y),sc,wreckYaw, vt?226:(curTheme==='ashland'?255:tint), vt?205:(curTheme==='ashland'?214:tint-6), vt?244:(curTheme==='ashland'?196:tint-16),255); } else if(!worldV2||!mfWorldV2Queue(R,sc,gh(R.x,R.y),rLod)) mesh.add(R.x,R.y,gh(R.x,R.y),sc*(R.kind===0?0.9:1),wreckYaw,tint,tint-6,tint-16,255); if(FX.decal) FX.decal.add(R.x,R.y,gh(R.x,R.y)+0.2,sc*1.05,R.a,12,18,28,130); /* The berm that ties the block to the ground. Footprint-shaped (the cross-axis lane carries depth), tinted with the BIOME so the transition belongs to this world rather than to the model's own grey. */ if(FX.skirt&&rLod<2) FX.skirt.add(R.x,R.y,gh(R.x,R.y)+0.14,R.w*1.34,R.a,_skC[0],_skC[1],_skC[2],255,R.h*1.34); } const worldV2CsmDefer=worldV2&&typeof csmActive==='function'&&csmActive(); if(worldV2&&!worldV2CsmDefer)mfWorldV2Flush(Sun,S_nA,t); /* Structure hardstands are no longer drawn as geometry at all. They are levelled into the heightfield and painted into the terrain texture the moment a structure is placed, so they ARE the ground — which removes an entire pass of ground-hugging quads and, with it, the z-fighting that made them flicker as white sheets on shallower depth buffers. */ /* A building keeps its simulation type, while its resolved owner selects a completely different model registry. Iterating buildings once preserves instancing without multiplying the hot loop by every faction and type. */ for(const Bd of blds){ const wreckAge=(!Bd.alive&&Bd.fallT)?Math.max(0,(typeof stats!=='undefined'?stats.t:t)-Bd.fallT):-1; const wreck=wreckAge>=0&&wreckAge<14; if(!Bd.alive&&!wreck) continue; const bImportant=Bd.alive&&Bd.team===0&&(Bd.type==='hq'||Bd===blds[openBld]); const bLod=renderBand(Bd.x,Bd.y,140,bImportant); if(!bLod) continue; if(!fogEntityVisible(Bd.team,Bd.x,Bd.y)) continue; const fac=bldFactionKey(Bd); /* STRATEGIC TIER — STRUCTURES. The mirror of the unit branch at :1175, and until now the missing half of the feature: mfIconCellForBld, mfBldSpan and the eight building glyphs existed, were rasterised into the atlas every session, and had no caller. A field of unit symbols floating over unreadable building meshes is not a strategic view. SITED AFTER THE FOG GATE ABOVE, never in place of it. Disclosure is fog's decision at every tier; an icon is only a different way of drawing something the player is already allowed to see. (The literal fogEntityVisible(Bd.team,Bd.x,Bd.y) line above is pinned by tools/test-faction-strategic-defense.mjs.) Sited before bldMeshFor() so a fully iconised structure also skips the mesh registry lookup, the berm, the turret and the strategic VFX — the same CPU saving the unit branch takes. WHAT ACTUALLY CONVERTS, measured at VH=915: mfBldSpan is footprint-based (size*2.2), so a wall crosses to a pure icon at span 2952 and a Sentinel reaches q=0.82 at SPAN_MAX=3400, while a Factory sits at q=0 and a Carrier HQ would need span 10199. The crossings scale with VH, so this is the 915 px phone case and a shorter viewport converts MORE, not less: at VH=800 the Sentinel is a pure icon and the Extractor reaches q=0.83. Small emplacements become symbols; landmarks keep their silhouettes for the whole zoom range. That is the Supreme-Commander read this file's header argues for, and it is why the icon LAYERS over the mesh rather than fading it — fading a building by screen footprint is exactly the regression in docs/POSTMORTEM-1.33.31-REGRESSION.md. */ const BTd=BT[Bd.type]; const bIcon=wreck?0:((typeof mfIconQ==='function'&&BTd)?mfIconQ(mfBldSpan(BTd)):0); if(bIcon>0&&mfIconEnsure()){ const bH=(BTd.placement==='water'?0:gh(Bd.x,Bd.y))+2, bBody=mfIconBody(Bd.team), bInk=mfIconInk(Bd.team), bDpx=mfIconDpxBld(BTd), bIa=255*bIcon; /* Domain 'str' — the flat anchored base variant of this faction's plate. A structure is not a vehicle and should not wear the ground plate. */ bbIcon.add(mfIconPlateFor(fac,null,'str'),Bd.x,Bd.y,bH,bDpx,0,bBody[0],bBody[1],bBody[2],bIa); bbIcon.add(mfIconCellForBld(BTd,fac),Bd.x,Bd.y,bH,bDpx*0.60,0,bInk[0],bInk[1],bInk[2],bIa); if(bImportant){ const bBr=(typeof TEAMB!=='undefined'&&TEAMB[Bd.team])||bBody; bbIcon.add(MF_ICO.pl_ring,Bd.x,Bd.y,bH,bDpx*1.26,0,bBr[0],bBr[1],bBr[2],bIa); } /* Only a FINISHED structure may drop its mesh. A construction site still has to show `grow` — replacing a half-built factory with a completed symbol would report a building the player does not yet own. */ if(bIcon>=1&&Bd.prog>=1) continue; } const M=bldMeshFor(Bd); if(!M) continue; /* Buildings need a lighter faction-grade tint than tiny units: at phone zoom the regular team-blue multiplication crushed PBR panels into one navy silhouette. The livery remains coloured, but steel and glass read. */ const tc=fac==='nova'?[188,226,255]:fac==='legion'?[255,156,126]:fac==='syndicate'?[174,224,255]:fac==='horde'?[214,166,255]:(TEAMC[Bd.team]||TEAMC[2]), H=BT[Bd.type]&&BT[Bd.type].placement==='water'?0:gh(Bd.x,Bd.y); const deployAge=Bd.type==='hq'&&Bd.deployT?Math.max(0,t-Bd.deployT):99; const deployQ=clamp(deployAge/2.1,0,1); /* The functional HQ is created on touchdown, but its art unfolds over two seconds. This avoids the old visual lie where a dropship disappeared and a completed base teleported into the exact same footprint. */ const grow=(Bd.prog<1 ? 0.30+0.70*Bd.prog : 1)*(deployAge<2.1?.62+.38*deployQ:1)*(wreck?clamp(0.22+0.16*(1-wreckAge/14),0.22,0.38):1); const an=t*1.6+(Bd.anim||0); let bob=0, sq=1, em=0; switch(Bd.type){ case 'mex': bob=Math.sin(an*2.6)*1.2; break; case 'pgen': case 'geo': em=0.05+Math.sin(an*1.7)*0.04; break; case 'fac': case 'tgate': case 'airfield': case 'harbor': if(Bd.queue.length){ bob=Math.sin(an*3.6)*0.8; em=0.03+Math.abs(Math.sin(an*4))*0.04; } break; case 'fab': em=0.08+Math.abs(Math.sin(an*5.2))*0.10; break; case 'techlab': em=0.03+Math.sin(an*2.2)*0.03; break; case 'arc': em=0.06+Math.abs(Math.sin(an*3.4))*0.12; break; case 'nest': sq=1+Math.sin(an*1.9)*0.05; break; case 'nova': em=Bd.cool<=0?0.10+Math.sin(an*3)*0.06:0; break; /* Charged Stormcaller hums visibly; a firing one strobes. */ case 'stormcaller': em=Bd.sq?0.22+Math.abs(Math.sin(an*9))*0.2:Bd.cool<=0?0.12+Math.sin(an*2.6)*0.07:0.02; break; } if(fac==='syndicate'){ bob+=1.15+Math.sin(an*2.25+Bd.x*.01)*0.55; em+=0.05+Math.abs(Math.sin(an*2.1))*0.05; }else if(fac==='horde'){ bob*=0.18; sq*=1+Math.sin(an*1.72+Bd.y*.012)*0.026; em+=0.025+Math.abs(Math.sin(an*2.8))*0.035; } if(Bd.hitT>0) em+=0.35; if(deployAge<2.4){ const pulse=1-deployAge/2.4,pc=fac==='horde'?[168,235,78]:fac==='legion'?[255,132,78]:fac==='syndicate'?[76,215,255]:[132,218,255]; FX.ring.add(Bd.x,Bd.y,H+2,42+deployQ*48,t*1.4,pc[0],pc[1],pc[2],190*pulse); FX.ring.add(Bd.x,Bd.y,H+3,24+deployQ*34,-t*1.9,pc[0],pc[1],pc[2],125*pulse); if(fac==='horde'){ for(let q=0;q<5;q++){ const a=q/5*TAU+t*.35,r=28+deployQ*43; bbAdd.add(sprites.glow,Bd.x+Math.cos(a)*r,Bd.y+Math.sin(a)*r,H+5,7,0,pc[0],pc[1],pc[2],105*pulse); } } } const vi=Math.min((M.variants&&M.variants.length||1)-1,Math.max(0,(Bd.lvl||1)-1)); const V=M.variants?M.variants[vi]:M; const damageState=wreck?0.999:(Bd.prog<1?0:Math.min(.999,1-clamp(Bd.hp/Math.max(1,Bd.hpm),0,1))); /* HQs are landmarks, so they receive the dedicated V2 landmark profile instead of merely being another building using a faction tint. This is the live production route for future authored HQ map packs. */ /* HQ=landmark (profile 2). Other structures use profile 3 so FS3D scorches them under fire; units stay on band 0 / commander 1. */ const surfaceState=(Bd.type==='hq'?4:6)+damageState; /* Encode pulse above opaque alpha; the solid shader separates it into a per-instance emission value. A shared mesh can now batch every building of this faction/type without one animated reactor flushing (and tinting) unrelated instances that were already queued. */ if(wreck){ em=0; bob=0; } V.base.add(Bd.x,Bd.y,H+bob,grow*sq,(Bd.rot||0)+(wreck?0.20:0),tc[0],tc[1],tc[2],255*(1+em),undefined,undefined,surfaceState); /* THE SAME BERM THE DERELICTS GET. A faction structure dropped onto a graded pad meets it at a perfect edge and reads as a game piece sitting on a board; this is the turned ground, spoil and broken slab that makes it read as EMPLACED. It follows `grow`, so it rises out of the ground with the building during construction rather than popping in complete, and it is biome-tinted, so the same factory belongs on ice and on ash. */ if(FX.skirt&&Bd.prog>=1&&bLod<2){ /* Sized to the PAVED APRON, not the hull. The join that reads as pasted on is concrete-to-terrain at the pad's outer edge, not hull-to-pad — skirting the hull only decorates ground that already matched. The foundation is bldFoot x 1.30 snapped to grid, so the berm straddles that boundary and dies into the biome just outside it. */ const fr=(typeof foundationRect==='function')?foundationRect(Bd):null; const fw=(fr?fr[0]:(BT[Bd.type]&&BT[Bd.type].size||18)*2.2)*grow; const fh=(fr?fr[1]:(BT[Bd.type]&&BT[Bd.type].size||18)*2.2)*grow; FX.skirt.add(Bd.x,Bd.y,H+0.14,fw*1.05,Bd.rot||0,_skC[0],_skC[1],_skC[2],255,fh*1.05); } if(V.tur){ V.tur.add(Bd.x,Bd.y,H+(M.turH||BLD_TUR_H[Bd.type]||14)*grow+bob, grow*(M.turS||BLD_TUR_S[Bd.type]||1),(Bd.tang||0)-Math.PI/2,tc[0],tc[1],tc[2],255,undefined,undefined,surfaceState); } if(!wreck&&(bLod===2||Bd.type==='hq'))addFactionStrategicBuildingVfx(Bd,fac,H,bob,grow,t,M); /* The command structure is the player's visual anchor. A restrained world-space beacon remains readable at strategic zoom, while the BASE button centers the same live object in one tap. */ if(!wreck&&Bd.team===0&&Bd.type==='hq'&&Bd.prog>=1){ const pulse=.76+.24*Math.sin(t*2.2); const bc=fac==='legion'?[255,112,76]:fac==='syndicate'?[72,214,255]:[92,210,255]; FX.ring.add(Bd.x,Bd.y,H+2,58+pulse*7,t*.28,bc[0],bc[1],bc[2],74); /* Close-up: the old 18-unit glow sprite bloomed into a white halo on the roof. Keep a small beacon only at command zoom. */ if(orthoSpan>560) bbAdd.add(sprites.glow,Bd.x,Bd.y,H+42,7+pulse*2,0,bc[0],bc[1],bc[2],62); if(orthoSpan>720) addBeamRibbon(sprites.glow,Bd.x,H+34,Bd.y,Bd.x,H+210,Bd.y,9,bc[0],bc[1],bc[2],42,120); } } if(typeof csmBegin==='function'&&typeof csmActive==='function'&&csmActive()&&csmBegin(true)){ if(typeof csmDrawTerrain==='function') csmDrawTerrain(); if(typeof mfWorld2Meshes!=='undefined') for(const k in mfWorld2Meshes) csmDrawMesh(mfWorld2Meshes[k]); csmDrawBuildingCasters(); csmDrawSceneryCasters(); csmEnd(S_nA); } if(worldV2CsmDefer)mfWorldV2Flush(Sun,S_nA,t); /* MEDIUM has no CSM restore. Icon-ensure / doodad uploads can steal unit 0 mid-loop; structures sample uMat there. Always re-bind the model atlas before the world flush. */ begin3D(S_nA); const bldMeshSets=[BLD_MESH,...Object.values(BLD_FACTION_MESH)]; for(const set of bldMeshSets) for(const k in set){ const M=set[k]; if(M.variants) for(const V of M.variants){V.base.flush(gl);if(V.tur)V.tur.flush(gl);} else {M.base.flush(gl);if(M.tur)M.tur.flush(gl);} } /* Flush every world-object stream. Each of these is one draw call for an entire class of object — all the rocks on screen, all the tower blocks, every wreck — which is the whole point of instancing. */ FX.rock.flush(gl); if(FX.rockIce) FX.rockIce.flush(gl); if(FX.rockSlag) FX.rockSlag.flush(gl); FX.tree.flush(gl); if(FX.treePine) FX.treePine.flush(gl); if(FX.treePalm) FX.treePalm.flush(gl); if(FX.treeDead) FX.treeDead.flush(gl); if(FX.treeSpore) FX.treeSpore.flush(gl); if(FX.bush) FX.bush.flush(gl); FX.crystal.flush(gl); if(typeof worldSites!=='undefined'){ for(const S of worldSites) if(S.fill&&S.fill.n) S.fill.flush(gl); if(typeof WORLD_KIT!=='undefined') for(const k in WORLD_KIT){ const M=WORLD_KIT[k]; if(M.mesh.n) M.mesh.flush(gl); } } FX.dep.flush(gl); FX.geyser.flush(gl); FX.berm.flush(gl); FX.wreck.flush(gl); FX.crate.flush(gl); FX.cityT.flush(gl); FX.cityD.flush(gl); FX.cityH.flush(gl); FX.cityK.flush(gl); if(FX.cityC) FX.cityC.flush(gl); if(FX.skirt){ /* +0.14 raise is not enough at HIGH DPR: 24-bit depth still stitches the berm into the pad as a shimmering seam. Offset, then restore. */ gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(-6,-20); FX.skirt.flush(gl); gl.disable(gl.POLYGON_OFFSET_FILL); } FX.plate.flush(gl); FX.line.flush(gl); /* ---- curtain wall spans ------------------------------------------ Each linked pair of barricades gets a rampart section built between them, so a wall run becomes one continuous fortification instead of a dotted line of separate blocks. Drawn as stretched wall geometry along the link axis, which is the visible payoff for laying walls in a line. */ _wallStreams.clear(); const wallStreams=_wallStreams; for(const W of blds){ if(!W.alive||(W.type!=='wall'&&W.type!=='gate')||!W.linkA||!W.linkA.length) continue; if(!vis(W.x,W.y,90)) continue; if(!fogEntityVisible(W.team,W.x,W.y)) continue; const tc=TEAMC[W.team]||TEAMC[2]; const family=BLD_FACTION_MESH[bldFactionKey(W)]; const WM=(family&&family.wall)||BLD_MESH.wall; const WV=WM.variants?WM.variants[Math.min(WM.variants.length-1,Math.max(0,(W.lvl||1)-1))]:WM; for(const la of W.linkA){ if(Math.cos(la)<0 || (Math.abs(Math.cos(la))<1e-6 && Math.sin(la)<0)) continue; const mx2=W.x+Math.cos(la)*WALL_LINK*0.34, my2=W.y+Math.sin(la)*WALL_LINK*0.34; const wallState=1-clamp(W.hp/Math.max(1,W.hpm),0,1); WV.base.add(mx2,my2,gh(mx2,my2),0.62,la,tc[0],tc[1],tc[2],255,undefined,undefined,wallState); wallStreams.add(WV.base); } } if(typeof csmBegin==='function'&&typeof csmActive==='function'&&csmActive()&&csmBegin(false)){ for(const stream of wallStreams) csmDrawMesh(stream); csmEnd(S_nA); } for(const stream of wallStreams) stream.flush(gl); /* ---- power conduits ------------------------------------------------- The paved service run is baked into the ground; this is the live cable lying on top of it, with a charge pulse travelling each span. The difference between "six buildings near each other" and "a base" is almost entirely these lines. */ for(const Bc of blds){ if(!Bc.alive||!Bc.conduit||!Bc.conduit.length) continue; if(!vis(Bc.x,Bc.y,200)) continue; if(!fogEntityVisible(Bc.team,Bc.x,Bc.y)) continue; const tb=TEAMB[Bc.team]||TEAMB[2]; for(const O of Bc.conduit){ if(!O.alive) continue; if(!fogEntityVisible(O.team,O.x,O.y)) continue; if(O.x=1&&O.prog>=1)?1:0.34; const mx2=(Bc.x+O.x)/2, my2=(Bc.y+O.y)/2; const len=Math.hypot(O.x-Bc.x,O.y-Bc.y); const ang=Math.atan2(O.y-Bc.y,O.x-Bc.x); FX.line.add(mx2,my2,gh(mx2,my2)+2.4,len,ang, tb[0],tb[1],tb[2], 140*live, 2.4); const ph=(t*0.55+(Bc.x+Bc.y)*0.004)%1; const pxp=Bc.x+(O.x-Bc.x)*ph, pyp=Bc.y+(O.y-Bc.y)*ph; bbAdd.add(sprites.glow,pxp,pyp,gh(pxp,pyp)+5,14,0, tb[0],tb[1],tb[2], 210*live); } } /* ---------------- orbital dropship (pre-deployment) ---------------- Flown, not placed. It holds a hover altitude, banks into its turns and runs its engines — none of which a building does, which was exactly why borrowing the deployed HQ's mesh made it read as a sliding bunker. */ if(typeof singularities!=='undefined') for(const Sg of singularities){ const dxs=Sg.x-cam.x,dys=Sg.y-cam.y; sceneLightPush(Sg.x,Sg.y,terrainH(Sg.x,Sg.y)+22,150,_lin([172,120,255])[0],_lin([172,120,255])[1],_lin([172,120,255])[2], 0.9+0.5*Math.sin((typeof stats!=='undefined'?stats.t:0)*9),150*150/(dxs*dxs+dys*dys+150*150)+3); } if(carrier.active&&carrier.phase<2){ const H=gh(carrier.x,carrier.y),key=dropFactionKey(carrier.fac); const hover=carrier.phase===1? 26+Math.sin(t*1.5)*2.4 : 0; const flightAlt=carrierEffectiveAlt(); const alt=H+flightAlt*0.85+hover; const dx=carrier.tx-carrier.x, dy=carrier.ty-carrier.y; const moving=Math.hypot(dx,dy)>10; const thrust=flightAlt>0?1:(moving?0.8:0.36); const vtolPose=clamp(flightAlt/Math.max(1,CARRIER_CRUISE_ALT),0,1); drawDropCraft(key,carrier.x,carrier.y,alt,carrier.ang,t,255, carrier.phase===1&&flightAlt<8,thrust,vtolPose); } /* Enemy headquarters retain their own landed craft during the player's planning phase, then visibly lift away at match start. This is a bounded arrival cue, not a second interactive carrier simulation. */ for(const A of aiDeployArrivals){ if(!vis(A.x,A.y,150)||!fogPointVisible(A.x,A.y)) continue; const elapsed=A.depart?Math.max(0,t-A.depart):0; if(elapsed>8) continue; const fade=A.depart?clamp(1-elapsed/8,0,1):1; const rise=A.depart?elapsed*elapsed*11:0; const drift=A.depart?elapsed*13:0; const x=A.x+Math.cos(A.ang)*drift,y=A.y+Math.sin(A.ang)*drift; const alt=gh(A.x,A.y)+28+Math.sin(t*1.35+A.x*.01)*1.6+rise; drawDropCraft(A.fac,x,y,alt,A.ang,t,255*fade,!A.depart||elapsed<.8,.48+fade*.35, clamp(rise/Math.max(1,CARRIER_CRUISE_ALT),0,1)); if(!A.depart||elapsed<1.3){ const P=DROP_PROFILE[dropFactionKey(A.fac)]; bbAdd.add(sprites.glow,A.x,A.y,gh(A.x,A.y)+1.1,12,0,P.glow[0],P.glow[1],P.glow[2],24*fade); } } // ---------------- units ---------------- const step=teamCount[2]>9000?2:1; /* Resolved once per frame, not per unit: the equipped module set only changes between matches, and modAttachSync() short-circuits on an unchanged signature so this is a string compare in the steady state. */ const modKit=(typeof modAttachSync==='function')?modAttachSync():[]; for(let i=0;i1 && (i&1)) continue; /* Enemy factions field DIFFERENT HARDWARE, not a recolour: the Syndicate hovers on plenum skirts with coil emitters, the Horde is grown carapace and claws. You should know what you're fighting from the silhouette before the colour registers. */ /* Team 0 was hard-wired to the Nova kit, which is what made the player's own faction choice cosmetic. Both sides now resolve their kit the same way, from whichever faction is actually fielding the unit. */ const ownFac=(typeof playerFaction!=='undefined'&&playerFaction)||'nova'; const ownKit=(typeof playerKitKey==='function')?playerKitKey(): ((typeof FACTIONS!=='undefined'&&FACTIONS[ownFac]&&FACTIONS[ownFac].kit)||'nova'); const unitKit=uteam[i]===0?ownKit:uteam[i]===2?'horde': (uteam[i]===1&&AI.fac&&FACTIONS[AI.fac]?FACTIONS[AI.fac].kit:null); /* STRATEGIC TIER. Past the point where this unit's own footprint stops reading (~24 px fading to ~15 px — per type, not per camera constant) a flat symbol carries role and allegiance better than a smear of mesh. The plate silhouette is the FACTION and the glyph is the role, so an icon claims the same allegiance its mesh would; resolved from the same unitKit the 3D path uses rather than a parallel guess. Sited after the kit resolves but before factionUnitMeshFor(), so a fully iconised unit still skips the mesh lookup, doctrine shells, equipped modules and organic motion — the tier saves CPU as well as pixels. The mesh is never faded, only dropped: the icon reaches full opacity while the mesh is still a ~15 px smear, and fading a mesh by screen footprint is precisely what flattened every building in docs/POSTMORTEM-1.33.31-REGRESSION.md. */ const uIcon=(typeof mfIconQ==='function')?mfIconQ(mfUnitSpan(T)):0; /* A commander is marked on a ramp of its own, and ONLY marked: uMark drives the symbol's alpha while uIcon alone still decides whether the mesh is dropped below. Overloading one q for both would delete the commander's silhouette the moment it earned a badge — at SPAN_MIN that is a 229 px hero replaced by a 46 px plate. The icon layers OVER the mesh here; it never replaces or fades one (docs/POSTMORTEM-1.33.31-REGRESSION.md). */ const uCmdQ=(typeof mfCmdIconQ==='function')?mfCmdIconQ(T):0; const uMark=uIcon>uCmdQ?uIcon:uCmdQ; const stackSkip=typeof mfIconStackSkip==='function'&&mfIconStackSkip(i); if(uMark>0&&!stackSkip&&mfIconEnsure()){ const ih=unitGroundY(T,X,Y,i)+2, body=mfIconBody(uteam[i]), ink=mfIconInk(uteam[i]), dpx=(typeof mfIconDpx==='function')?mfIconDpx(T) :clamp(18+mfUnitSpan(T)*0.12,22,40)*mfWorldPx(), ia=255*uMark, iKit=unitKit||(uteam[i]===2?'horde':null); bbIcon.add(mfIconPlateFor(iKit,T),X,Y,ih,dpx,0,body[0],body[1],body[2],ia); /* iKit again, not a second guess: the glyph is the owner's delivered faction art and must claim the same allegiance the plate does. A kit with no art for this role falls back to the procedural glyph inside mfIconCellForUnit, so this stays one lookup and one instance. */ bbIcon.add(mfIconCellForUnit(T,iKit),X,Y,ih,dpx*0.60,0,ink[0],ink[1],ink[2],ia); /* Selected mass at cap: the plate already shows allegiance. A ring on every selected icon is the same fillrate trap as FX.ring. Keep commanders. Skip unselected (uImportant), skip command-zoom mass, keep the ring for small on-camera squads. */ if(uImportant&&(ringKeepCmd(i)||(!RING_STRATEGIC&&selOnCam<=SEL_RING_LOD&&usel[i]))){ const br=(typeof TEAMB!=='undefined'&&TEAMB[uteam[i]])||body; bbIcon.add(MF_ICO.pl_ring,X,Y,ih,dpx*1.26,0,br[0],br[1],br[2],ia); } if(uIcon>=1) continue; // fully iconised: no mesh work at all } if(stackSkip) continue; /* Never begin from UNIT_MESH and then hope an override exists. That made the mixed global registry an accidental cross-faction fallback: Blue slot 12 became a Ravager and a missing Brood slot became a tank. */ let M=unitKit&&typeof factionUnitMeshFor==='function'?factionUnitMeshFor(utype[i],unitKit):null; /* Per-instance kit, keyed by that hero's commanderId. Player uses playerCommanderId; enemy/ally seats use AI.bases/allies.commanderId. Replacing FAC_MESH[type] would retint every chassis of that type. */ if((T.cat==='hero'||T.hero||utype[i]===4||utype[i]===28||utype[i]===29)&&typeof commanderKitMeshFor==='function'){ const cid=typeof commanderIdForUnit==='function'?commanderIdForUnit(i): (i===heroIdx&&typeof playerCommanderId!=='undefined'?playerCommanderId:null); if(cid){ const KM=commanderKitMeshFor(cid); if(KM) M=KM; } } if(!M) continue; const tc=TEAMC[uteam[i]]; const H=unitGroundY(T,X,Y,i); /* Drawn deliberately LARGER than their collision size. At command-view zoom a literally-scaled tank is about twenty pixels across, which is not enough to read a silhouette; every RTS oversizes units for legibility and keeps the sim honest underneath. */ const sc=T.size/15*M.s*1.5*(T.vscale||1); const a=umode[i]===4?110:255; // wildlife pulses and lurches; machines don't let ss=sc, wide=sc, doctrine=null; if(uteam[i]===2) ss*=1+Math.sin(t*6.2+i*2.399)*0.07; /* ai.js already authored scale/squash as faction doctrine, but only the old sprite fallback consumed it. Applying it to the live WebGL path makes Ascendancy armour columns broad/heavy, Coalition hulls compact/narrow, and Brood bodies visibly small and numerous. A light geometry shell is added only where no bespoke faction chassis exists, so artillery stays artillery rather than every role becoming the same faction tank. */ const bespoke=M!==UNIT_MESH[utype[i]],heroUnit=T.cat==='hero'||!!T.hero||utype[i]===4||i===heroIdx||isEnemyCommander(i); /* Doctrine shells exist to keep a SHARED role chassis faction-readable. Commanders already have authored silhouettes. Layering the generic ground shell over a walking hero created a second rigid vehicle whose rails stayed planted while the Commander's legs moved beneath it. */ if(uteam[i]===0&&!heroUnit&&utype[i]<28&&utype[i]!==12&&utype[i]!==13&&!bespoke&&FAC_DOCTRINE_MESH[ownFac]){ doctrine=FAC_DOCTRINE_MESH[ownFac][T.air?'air':'ground']; const PF=FACTIONS[ownFac]; if(PF&&ownFac!=='nova'){ ss*=PF.scale||1; wide=ss/(PF.squash||1); } }else if(uteam[i]===1&&typeof FACTIONS!=='undefined'&&FACTIONS[AI.fac]){ const F=FACTIONS[AI.fac]; ss*=F.scale||1; wide=ss/(F.squash||1); if(!heroUnit&&!bespoke&&utype[i]<28&&FAC_DOCTRINE_MESH[F.kit]) doctrine=FAC_DOCTRINE_MESH[F.kit][T.air?'air':'ground']; }else wide=ss; const crashing=T.air&&typeof uCrash!=='undefined'&&uCrash[i]; const bank=T.air?(crashing?(typeof uCroll!=='undefined'?uCroll[i]:0):Math.sin(t*1.7+i)*0.10):0; const crashYaw=crashing&&typeof uCpitch!=='undefined'?uCpitch[i]*0.38:0; /* Walk phase. Driven by DISTANCE covered rather than by the clock, so a damaged or slowed machine takes shorter strides instead of moon-walking, and a stationary one plants its feet. Legged units only — anything on tracks, wheels, wings or a plenum skirt passes zero and the vertex stage leaves it alone. */ const organic=uteam[i]===2||(uteam[i]===1&&AI.fac==='horde')||utype[i]===12||utype[i]===13||utype[i]===30; /* Procedural spring phase replaces per-bone CPU simulation. Tactical view gets breathing, mandible lag and flexible limbs; strategic/low quality supplies zero so the shader skips all secondary-motion math. */ const organicSpan=(typeof GFX!=='undefined'&&GFX.organicSpan!=null)?GFX.organicSpan:2700; const organicPhase=organic&&perfScale>.36&&orthoSpan{ const T=TYPES[utype[i]]; FX.ring.add(ux[i],uy[i],gh(ux[i],uy[i])+1.4,T.size*(T.vscale||1)*1.05,0,90,255,150,210); }; if(RING_STRATEGIC){ for(let i=0;iSEL_RING_LOD){ if(_hbI.length{ if(!pts||pts.length<2)return; const segs=closed?pts.length:pts.length-1,base=draft?[92,224,255]:[100,245,170]; for(let j=0;j=0?patrolRoutes[ri]:null; if(R&&R.pts&&!seen[ri]){ seen[ri]=1;p3Route(R.pts,true,false,R.step); const row=R.targets&&R.targets[R.step]; if(row){ const stride=Math.max(1,Math.ceil(row.length/36)); for(let k=0;k=10)p3Route([{x:upx1[i],y:upy1[i]},{x:upx2[i],y:upy2[i]}],true,false,null); } } if(typeof patrolDraft!=='undefined'&&patrolDraft)p3Route(patrolDraft.pts,patrolDraft.pts.length>2,true,null); } const now3=performance.now(),confirm3=typeof orderConfirm!=='undefined'&&orderConfirm&&now3ualive[i]&&usel[i]),fd=FORMS[form3.form]||FORMS[0]; /* Memoised in input.js: the assignment only changes when the target or the group does, not once per frame. */ const slots=formationPreviewSlots(form3,members,fd.id); let cx3=0,cy3=0;for(const i of members){cx3+=ux[i];cy3+=uy[i];} if(members.length){ cx3/=members.length;cy3/=members.length; const dx=form3.x-cx3,dy=form3.y-cy3,len=Math.hypot(dx,dy),fade=orderPreview?1:clamp((form3.until-now3)/950,0,1); /* noLine: a tap-move confirm whose real route is drawn by orderfx - the straight beam would contradict the traced path around water. */ if(len>3&&!form3.noLine)FX.line.add((cx3+form3.x)*.5,(cy3+form3.y)*.5,gh((cx3+form3.x)*.5,(cy3+form3.y)*.5)+3.5, len,Math.atan2(dy,dx),88,224,255,145*fade,3.2); FX.ring.add(form3.x,form3.y,gh(form3.x,form3.y)+4.2,9.2,t*.8,95,235,255,230*fade); for(let k=0;km.i):artBarrageSelected(); for(const i of src){ if(!ualive[i]||uteam[i]!==0||TYPES[utype[i]].cat!=='art')continue; const len=Math.hypot(A.x-ux[i],A.y-uy[i]); if(vis(ux[i],uy[i],ART_BARRAGE.range+30)&&aiming===5) FX.ring.add(ux[i],uy[i],gh(ux[i],uy[i])+2.8,ART_BARRAGE.range/3,0,255,184,70,28); if(vis((ux[i]+A.x)*.5,(uy[i]+A.y)*.5,len*.5+20)) FX.line.add((ux[i]+A.x)*.5,(uy[i]+A.y)*.5,gh((ux[i]+A.x)*.5,(uy[i]+A.y)*.5)+5, len,Math.atan2(A.y-uy[i],A.x-ux[i]),255,184,68,48+prog*105,2.5+prog*2.2); if(vis(ux[i],uy[i],40)){ const S=TYPES[utype[i]].size*(1.05+prog*.32); FX.ring.add(ux[i],uy[i],gh(ux[i],uy[i])+4,S,t*(.5+prog),255,197,82,160+prog*85); bbAdd.add(sprites.glow,ux[i],uy[i],gh(ux[i],uy[i])+TYPES[utype[i]].size*.7, 6+prog*12,0,255,145,42,75+prog*130); } } } } /* ---- DEFENCE COVERAGE ------------------------------------------------- Opening a defensive structure is the player's explicit request for its tactical detail, so show its live range and nearby overlapping fields at that moment. Placement does the same before money is committed. Rings use the renderer's authored 3x decal scale (hence /3), matching the true sim radius instead of the oversized circles used by the old placement HUD. */ const openDef=openBld>=0&&blds[openBld]&&blds[openBld].alive?blds[openBld]:null; const placeDef=placing&&DEF_WEAPON_DATA[placing.type]?placing:null; const coverAnchor=placeDef||((openDef&&DEF_WEAPON_DATA[openDef.type])?openDef:null); if(coverAnchor){ const ax=coverAnchor.x,ay=coverAnchor.y; for(const B of bldLive){ if(!B.alive||B.team!==0||B.prog<1||!DEF_WEAPON_DATA[B.type]||!vis(B.x,B.y,620)) continue; if(B!==openDef&&dist2(ax,ay,B.x,B.y)>720*720) continue; const W=bldWeaponSnapshot(B,B.lvl||1),sel=B===openDef; FX.ring.add(B.x,B.y,gh(B.x,B.y)+2.1,W.range/3,t*.08,74,204,255,sel?185:48); if(W.minRange) FX.ring.add(B.x,B.y,gh(B.x,B.y)+2.4,W.minRange/3,-t*.12,255,118,76,sel?145:32); } if(placeDef){ const ghost={type:placeDef.type,lvl:1,team:0,boost:0,boostM:UPLINK_BOOST}; const W=bldWeaponSnapshot(ghost,1); FX.ring.add(placeDef.x,placeDef.y,gh(placeDef.x,placeDef.y)+2.5,W.range/3,t*.12,90,235,150,205); if(W.minRange) FX.ring.add(placeDef.x,placeDef.y,gh(placeDef.x,placeDef.y)+2.8,W.minRange/3,-t*.18,255,118,76,160); } } else if(openDef&&(openDef.type==='sgen'||openDef.type==='uplink')){ const S=bldSupportSnapshot(openDef,openDef.lvl||1),col=openDef.type==='sgen'?[88,235,178]:[92,205,255]; FX.ring.add(openDef.x,openDef.y,gh(openDef.x,openDef.y)+2.2,S.field/3,t*.1,col[0],col[1],col[2],185); } for(const B of bldLive){ if(B.alive&&B.type==='techlab'&&B.guardT>0&&vis(B.x,B.y,120)){ const p=.65+.35*Math.sin(t*7+B.x*.01); FX.ring.add(B.x,B.y,gh(B.x,B.y)+2.7,(BT.techlab.size*1.65)/3,-t*.7,95,225,255,130+90*p); FX.ring.add(B.x,B.y,gh(B.x,B.y)+3.0,(BT.techlab.size*1.15)/3,t*.9,255,220,105,95+75*p); } } /* The warning corridor ends at the threatened player position and only extends a few hundred metres outward. It communicates approach direction without drawing a breadcrumb trail back to an unseen enemy base. */ if(typeof waveThreat!=='undefined'&&waveThreat&&stats.t<=waveThreat.expires){ const W=waveThreat,ang=Math.atan2(W.dy,W.dx),px=-W.dy,py=W.dx,p=.65+.35*Math.sin(t*5.5); FX.ring.add(W.x,W.y,gh(W.x,W.y)+2.7,27+3*p,-t*.35,255,174,70,155+70*p); for(const side of [-1,1]){ const mx=W.x+W.dx*235+px*side*42,my=W.y+W.dy*235+py*side*42; FX.line.add(mx,my,gh(mx,my)+2.5,350,ang,255,154,58,62+40*p,2.0); } for(let k=0;k<4;k++){ const d=95+k*82,x=W.x+W.dx*d,y=W.y+W.dy*d; FX.line.add(x,y,gh(x,y)+3,48,ang,255,202,92,125+60*p,3.0); } } /* ---- BUILD TERRITORY ------------------------------------------------- Drawn straight from the rasterised zone grid, so what you see is exactly what placementValid() enforces. A cell whose neighbour is outside the zone contributes a border segment; the result is a hard rectilinear frontier that grows in squares as you plant Uplinks, instead of a smear of circles that never matched the rule. */ /* The placement UI already draws the exact local grid, invalid footprint cells, alignment guides and builder ranges in hud.js. Scanning the full raster territory here as well emitted thousands of plates/lines per frame and made the battle appear frozen until the player hit X. The expensive frontier is now only a short change pulse; placement keeps its precise local feedback below. */ if(bzShow>0&&!placing){ const glow=bzShow; /* Mobile builder zones are drawn directly rather than rasterised into the static grid: they move every frame, and re-stamping a 37k-cell grid at frame rate to chase two units would be absurd. */ forBuilders(0,(bx,by,r)=>{ for(const [ex,ey,rot2] of [[0,-r,0],[0,r,0],[-r,0,Math.PI/2],[r,0,Math.PI/2]]) FX.line.add(bx+ex,by+ey,gh(bx+ex,by+ey)+2.4,r*2,rot2, 130,235,190, 190*glow, 3.0); FX.ring.add(bx,by,gh(bx,by)+1.8, 22, t*0.9, 130,235,190, 150*glow); }); const cx0=Math.max(0,bzG(x0)-1), cx1=Math.min(BZN-1,bzG(x1)+1); const cy0=Math.max(0,bzG(y0)-1), cy1=Math.min(BZN-1,bzG(y1)+1); const pulse=0.55+Math.sin(t*2.4)*0.2; let drawn=0; for(let gy=cy0;gy<=cy1&&drawn<9000;gy++) for(let gx=cx0;gx<=cx1;gx++){ const st=bzAt(gx,gy); if(st===BZ_OUT) continue; const wx=bzW(gx), wy=bzW(gy), H=gh(wx,wy)+1.5; /* Blocked cells are called out individually in red — water, cliffs, and ground already occupied by a structure, ruin or resource node. Seeing WHY a spot is unavailable before you commit is the whole point; previously the only feedback was a rejection message after the fact. */ const navalCell=false; if(st===BZ_BAD&&!navalCell){ FX.plate.add(wx,wy,H+0.3,BZ*0.80,0, 255,70,60, 76*glow); // a diagonal slash reads as "occupied" at a glance, even in a solid block FX.line.add(wx,wy,H+0.6,BZ*1.15,Math.PI*0.25, 255,120,100, 150*glow, 2.2); } else if(((gx+gy)&1)===0){ FX.plate.add(wx,wy,navalCell?1.0:H,BZ*0.62,0, navalCell?70:95,navalCell?230:205,255, (navalCell?48:30)*glow); } // border: a segment for every edge that leaves the territory entirely if(!bzIn(gx-1,gy)){ bzEdge(wx-BZ*0.5,wy,H+0.5,Math.PI/2,glow*pulse); drawn++; } if(!bzIn(gx+1,gy)){ bzEdge(wx+BZ*0.5,wy,H+0.5,Math.PI/2,glow*pulse); drawn++; } if(!bzIn(gx,gy-1)){ bzEdge(wx,wy-BZ*0.5,H+0.5,0,glow*pulse); drawn++; } if(!bzIn(gx,gy+1)){ bzEdge(wx,wy+BZ*0.5,H+0.5,0,glow*pulse); drawn++; } } } if(placing){ const T=BT[placing.type]; const ok=placementValid(), f=bldFoot(placing.type), rt=placing.rot||0; const H=T.placement==='water'?0:gh(placing.x,placing.y); const col=ok?[90,235,150]:[255,80,70]; FX.plate.add(placing.x,placing.y,H+1.6,1,rt,col[0],col[1],col[2],255); const c2=Math.cos(rt), s2=Math.sin(rt); // footprint outline: four hairlines plus a facing tick down the front const edge=(ex,ey,len,r2)=>FX.line.add(placing.x+ex*c2-ey*s2, placing.y+ex*s2+ey*c2, H+2.2, len, rt+r2, col[0],col[1],col[2],255, 2.2); edge(0,-f[1]/2,f[0],0); edge(0,f[1]/2,f[0],0); edge(-f[0]/2,0,f[1],Math.PI/2); edge(f[0]/2,0,f[1],Math.PI/2); edge(f[0]*0.34,0,f[0]*0.3,0); /* The ghost is a promise about what is going to be there. Showing a Nova silo to a Brood player breaks that promise for the whole placement. */ const M=(typeof bldMeshFor==='function'?bldMeshFor({type:placing.type,team:0}):null)||BLD_MESH[placing.type]; if(M){ M.base.add(placing.x,placing.y,H,1,rt,col[0],col[1],col[2],200); M.base.flush(gl); } } /* Pull flat decals toward the camera so 24-bit depth does not stitch them into the terrain as cyan scanlines / broken C-rings. Depth TEST off as well: a ring of radius 50 sits at one world Y and still loses to kerbs and scar lips a few units taller than the node centre. */ gl.disable(gl.DEPTH_TEST); gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(-8,-32); FX.ring.flush(gl); FX.plate.flush(gl); FX.line.flush(gl); gl.disable(gl.POLYGON_OFFSET_FILL); gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.depthMask(true); gl.disable(gl.BLEND); // ---------------- water ---------------- /* Bloom is extracted first so the ocean is not a bright-pass source. Depth stays the opaque scene, so hulls still occlude the sheet. */ if(typeof mfGfxScissor==='function') mfGfxScissor(false); if(aoActive&&typeof aoExtractBloom==='function') aoExtractBloom(); if(waterIdxCount){ if((tick&3)===0) animateWater(t); queueWaterFx(); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false); gl.disable(gl.CULL_FACE); drawWater(); if(FX.wake||FX.ripple){ gl.useProgram(progG); gl.uniformMatrix4fv(UG.uVP,false,matVP); gl.blendFunc(gl.SRC_ALPHA,gl.ONE); gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(-8,-32); if(FX.wake) FX.wake.flush(gl); if(FX.ripple) FX.ripple.flush(gl); gl.disable(gl.POLYGON_OFFSET_FILL); } gl.enable(gl.CULL_FACE); gl.depthMask(true); gl.disable(gl.BLEND); } // ================= ADDITIVE EFFECTS ================= gl.useProgram(progG); gl.uniformMatrix4fv(UG.uVP,false,matVP); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE); gl.depthMask(false); gl.disable(gl.CULL_FACE); queueBattlefieldEdgeGrid(t,vis); /* Mosswatch arrivals are tears between planes, not ordinary spawn flashes. Keep a bounded billboard/beam scar alive long enough for a player who taps the warning to actually see it. Fog still owns disclosure: a wound beyond allied vision cannot leak the enemy's position. */ if(typeof storyCampaignRuntime!=='undefined'&&storyCampaignRuntime&&storyCampaignRuntime.rifts){ for(const Rf of storyCampaignRuntime.rifts){ const age=Math.max(0,(stats.t||0)-(Rf.t||0)); if(age>42||!vis(Rf.x,Rf.y,170)||!fogPointVisible(Rf.x,Rf.y))continue; const life=clamp(1-age/42,0,1),pulse=.72+.28*Math.sin(t*7+Rf.x*.013),H=gh(Rf.x,Rf.y); FX.ring.add(Rf.x,Rf.y,H+2,46+Math.sin(t*3)*8,t*.32,188,72,255,135*life); FX.ring.add(Rf.x,Rf.y,H+3,72+age*1.4,-t*.21,255,54,105,76*life); bbAdd.add(sprites.glow,Rf.x,Rf.y,H+52,94*pulse,0,168,54,255,92*life); bbAdd.add(sprites.glow,Rf.x,Rf.y,H+68,42*pulse,0,255,78,134,155*life); for(let q=0;q<3;q++){ const sway=Math.sin(t*(4.8+q*.7)+q*2.1)*13,off=(q-1)*16; addBeam3D(FX.beam,Rf.x+off,H+4,Rf.y,Rf.x+sway+off*.35,H+125+q*22,Rf.y+Math.cos(t*3+q)*9, 5.5-q*.8,q===1?255:176,q===1?82:60,255,life*(q===1?215:118)); } if(age<8)bbAdd.add(sprites.warn,Rf.x,Rf.y,H+150,25+pulse*6,-t*.45,255,104,182,225*life); } } /* At the strategic overview, thousands of individually correct glows placed on regimented formation rows merge into solid stripes. Sample the effects there; the full billboard stack returns automatically as the player zooms to tactical range. */ const overviewVfx=orthoSpan>(typeof mfLodSpan==='function'?mfLodSpan(2400):2400); /* Pickup identity is carried above the physical pod, so a Mass Cache, scan beacon and NOVA code cylinder do not become the same gold box at command zoom. One marker plus a bounded number of rarity pips keeps this readable without adding meshes or draw calls per pickup. */ for(const Cc of crates){ if(Cc.alt>0||!vis(Cc.x,Cc.y,80)||(!fogPointVisible(Cc.x,Cc.y)&&!Cc.seen)) continue; const k=Cc.kind||CRATE_KINDS[0],cc=k.col||[255,225,140],H=gh(Cc.x,Cc.y),bob=Math.sin(t*2.4+Cc.x)*1.8; const mark=sprites[k.spr]||sprites.crate; bbAdd.add(sprites.glow,Cc.x,Cc.y,H+18+bob,58+(k.rarity||0)*7,0,cc[0],cc[1],cc[2],78); bbAdd.add(mark,Cc.x,Cc.y,H+35+bob,17+(k.rarity||0)*1.5,-t*.65,cc[0],cc[1],cc[2],235); FX.ring.add(Cc.x,Cc.y,H+2,27+(k.rarity||0)*3,t*.55,cc[0],cc[1],cc[2],135); if(Cc.site) FX.ring.add(Cc.x,Cc.y,H+2,42+Math.sin(t*2.2)*4,-t*.28,105,235,170,105); for(let q=0;q