massfront-playtest / src /engine /materials-world-v2.js
CREATORJD's picture
Publish MASSFRONT web 1.33.33 (part 3)
3597f5a verified
Raw
History Blame Contribute Delete
24.2 kB
/* ============================================================================
MATERIAL SYSTEM V2 — MAP STRUCTURES
----------------------------------------------------------------------------
The five map-generated civilian/military structures use the same authored
BaseAO / normal-roughness-emissive / material-mask contract proven by the V2
tank and factory. They are packed into three shared 1024 maps because a
battlefield may contain hundreds of these objects; loading their individual
2048 showcase maps would spend roughly 320 MB before mipmaps.
HIGH and CINEMATIC use this battle material LOD. LOW, failed texture loads,
and `?worldv2=0` retain the old InstMesh path, which makes the conversion
reversible and prevents a missing optional asset from erasing a city.
============================================================================ */
const MFWORLD2_QUERY=typeof location!=='undefined'?new URLSearchParams(location.search):new URLSearchParams();
const MFWORLD2_DISABLED=MFWORLD2_QUERY.get('worldv2')==='0';
const MFWORLD2_DEFS=[
{kind:0,key:'mdlCityTower',family:0,role:0,rect:[.0234375,.0234375,.296875,.296875]},
{kind:1,key:'mdlCityDome', family:0,role:1,rect:[.3515625,.0234375,.296875,.296875]},
{kind:2,key:'mdlCityHall', family:1,role:2,rect:[.6796875,.0234375,.296875,.296875]},
{kind:3,key:'mdlCityTank', family:1,role:3,rect:[.0234375,.3515625,.296875,.296875]},
{kind:4,key:'mdlCivicBlock',family:0,role:4,rect:[.3515625,.3515625,.296875,.296875]},
];
let mfWorld2Prog=null,mfWorld2U={},mfWorld2BaseAO=null,mfWorld2NRE=null,mfWorld2Masks=null,mfWorld2DamageTex=null,mfWorld2Detail=null;
let mfWorld2Meshes={},mfWorld2Ready=false,mfWorld2Loaded=0,mfWorld2Error=false,mfWorld2Epoch=-1;
const MFWORLD2_VS=`#version 300 es
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aNrm;
layout(location=2) in vec3 aCol;
layout(location=3) in vec2 aUV;
layout(location=4) in float aMat;
layout(location=5) in vec4 aInst;
layout(location=6) in float aYaw;
layout(location=7) in vec4 aTint;
layout(location=8) in float aWide;
layout(location=9) in float aAnim;
uniform mat4 uVP;
uniform vec3 uEye;
uniform float uHazeQ;
uniform vec4 uRect;
out vec3 vNrm;out vec3 vObjNrm;out vec3 vWorld;out vec3 vObj;out vec2 vUV;out vec3 vTint;
out float vFog;out float vDamage;out vec2 vFowUV;
void main(){
float c=cos(aYaw),s=sin(aYaw);
vec3 sp=vec3(aPos.x*aInst.w,aPos.y*aInst.w,aPos.z*aWide);
vec3 p=vec3(sp.x*c-sp.z*s,sp.y,sp.x*s+sp.z*c)+aInst.xyz;
vNrm=normalize(vec3(aNrm.x*c-aNrm.z*s,aNrm.y,aNrm.x*s+aNrm.z*c));
vObjNrm=aNrm;vWorld=p;vObj=aPos;
/* Inset authored UVs so mip/atlas bleed cannot sample a neighbour tile's
emissive into a roof. 0.02 stays inside the 24px gutter of each 304px cell. */
vUV=uRect.xy+clamp(aUV,0.02,0.98)*uRect.zw;vTint=aTint.rgb;
vDamage=clamp(aAnim,0.0,1.0);
vFowUV=p.xz/MAPSIZE_CONST;
float d=length(p-uEye);
float planar=length(vec2(p.x-uEye.x, p.z-uEye.z));
float hq=uHazeQ>0.01?uHazeQ:1.0;
vFog=clamp((planar-1380.0)/1900.0,0.0,0.40)*hq;
float bd=min(min(p.x, MAPSIZE_CONST-p.x), min(p.z, MAPSIZE_CONST-p.z));
vFog=max(vFog, clamp((BFOG_CONST-bd)/BFOG_CONST,0.0,1.0));
gl_Position=uVP*vec4(p,1.0);
}`;
const MFWORLD2_FS=`#version 300 es
precision highp float;
in vec3 vNrm;in vec3 vObjNrm;in vec3 vWorld;in vec3 vObj;in vec2 vUV;in vec3 vTint;
in float vFog;in float vDamage;in vec2 vFowUV;
uniform sampler2D uBaseAO;uniform sampler2D uNRE;uniform sampler2D uMasks;uniform sampler2D uDamageTex;uniform sampler2D uDetail;
uniform sampler2D uFowMap;uniform float uFowOn;
uniform vec3 uEye,uSun,uSunC,uAmbSky,uAmbGnd,uFogC;uniform float uNight,uTime,uFamily,uRole;
uniform int uLightCount;uniform vec4 uLightPosR[8];uniform vec4 uLightColI[8];
out vec4 o;
float hash21(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 mfUvGradClamp(vec2 g){
float l=length(g);
return (l>0.25)?g*(0.25/l):g;
}
mat3 cotangent(vec3 N,vec3 p,vec2 uv){
vec3 dp1=dFdx(p),dp2=dFdy(p);vec2 du1=mfUvGradClamp(dFdx(uv)),du2=mfUvGradClamp(dFdy(uv));
vec3 p2=cross(dp2,N),p1=cross(N,dp1);
vec3 T=p2*du1.x+p1*du2.x,B=p2*du1.y+p1*du2.y;
float t2=dot(T,T),b2=dot(B,B);
if(max(t2,b2)<1e-10){
vec3 Tf=normalize(abs(N.y)<0.999?cross(N,vec3(0.0,1.0,0.0)):cross(N,vec3(1.0,0.0,0.0)));
return mat3(Tf,cross(N,Tf),N);
}
vec3 To=T-N*dot(N,T);
if(dot(To,To)<1e-10){
vec3 Tf=normalize(abs(N.y)<0.999?cross(N,vec3(0.0,1.0,0.0)):cross(N,vec3(1.0,0.0,0.0)));
return mat3(Tf,cross(N,Tf),N);
}
T=normalize(To);
vec3 Bn=cross(N,T);
B=normalize(dot(Bn,B)<0.0?-Bn:Bn);
return mat3(T,B,N);
}
void main(){
vec2 dxa=mfUvGradClamp(dFdx(vUV)),dya=mfUvGradClamp(dFdy(vUV));
vec4 ba=textureGrad(uBaseAO,vUV,dxa,dya),nr=textureGrad(uNRE,vUV,dxa,dya),mk=textureGrad(uMasks,vUV,dxa,dya);
vec3 dan=pow(abs(normalize(vObjNrm))+vec3(.0001),vec3(5.0));dan/=dan.x+dan.y+dan.z;
float damageData=texture(uDamageTex,vObj.zy*.078).r*dan.x+
texture(uDamageTex,vObj.xz*.078).r*dan.y+texture(uDamageTex,vObj.xy*.078).r*dan.z;
/* Shared neutral micro-surface detail gives civilian concrete, ceramic and
military alloy the same restrained age/roughness breakup as live units.
It is intentionally far weaker than the authored maps: it should support
roof seams and facade normals, never turn a city into visual static. */
float detailData=texture(uDetail,vObj.zy*.115).r*dan.x+
texture(uDetail,vObj.xz*.115).r*dan.y+texture(uDetail,vObj.xy*.115).r*dan.z;
float damageCrust=smoothstep(.055,.38,damageData),damageCrack=smoothstep(.23,.49,damageData);
vec2 xy=(nr.rg*2.0-1.0)*1.18;float nz=sqrt(max(.03,1.0-dot(xy,xy)));
vec3 N=normalize(cotangent(normalize(vNrm),vWorld,vUV)*vec3(xy,nz));
/* SRGB8_ALPHA8 yields LINEAR albedo (AO in alpha stays linear). FS3D and
terrain light DISPLAY albedo — RGBA8 atlas/canvas * linearized sun, then
1-exp(-lit*1.55) with no gamma encode. Feeding linear alb into that curve
parked hulls in the filmic toe (near-black silhouettes) while procedural
window RGB, already display values, still punched. Encode into the
production lighting space; keep the shared filmic write so SSAO and
multiply-shadows share one curve with units and ground. */
vec3 alb=pow(max(ba.rgb,vec3(0.0)),vec3(1.0/2.2));
float ao=pow(clamp(ba.a,.06,1.0),1.42),rough=clamp(nr.b,.10,1.0),metal=mk.r,emis=nr.a;
float micro=detailData-.5;ao=clamp(ao-micro*.035,0.0,1.0);rough=clamp(rough-micro*.075,.08,1.0);
/* Civilian blocks are weathered warm concrete and ceramic; military plant
is cold steel with denser soot. This is family grading, not a paint wash,
so authored roof/window/material separation survives. */
float grime=mk.a*(uFamily<.5?.42:.72);
alb*=uFamily<.5?vec3(.92,.88,.82):vec3(.82,.86,.90);
alb=mix(alb,alb*vec3(.32,.29,.25),grime);
/* Battle-scale V2 still needs authored age when the structure is healthy.
Reuse the shared object-space surface tile at low strength: broad faces
stay quiet, while raised normal detail and sparse rubbed edges keep the
building from reading as a clean grey cut-out against weathered terrain. */
float relief=clamp(length(xy),0.0,1.0),weather=smoothstep(.46,.80,damageData);
float aluma=dot(alb,vec3(.2126,.7152,.0722));
float authoredEdge=smoothstep(.018,.085,length(vec2(dFdx(aluma),dFdy(aluma))));
float edgeWear=max(relief*weather,authoredEdge*.72)*(1.0-grime);
alb*=.96+ao*.08;
alb=mix(alb,uFamily<.5?vec3(.43,.44,.42):vec3(.38,.42,.46),edgeWear*.31);
rough=clamp(rough+grime*.18,0.0,1.0);
rough=clamp(rough+(weather-.5)*.10-edgeWear*.17-(uFamily<.5?0.0:1.0)*metal*.08,0.0,1.0);
/* Authored emissive maps are mostly black on the legacy rebuilds. Walls
get occupied windows; caps keep roof beacons — a command camera never
sees the facade, so facade-only lights made every HQ a silhouette. */
vec3 on=abs(vObjNrm.y)<.22&&max(abs(vObjNrm.x),abs(vObjNrm.z))>.85?vec3(1.0):vec3(0.0);
float roofOn=step(.55,vObjNrm.y);
vec2 face=abs(vObjNrm.x)>abs(vObjNrm.z)?vec2(vObj.z,vObj.y):vec2(vObj.x,vObj.y);
vec2 cell=face*vec2(.072,.098),f=fract(cell),id=floor(cell);
float pane=step(.24,f.x)*step(f.x,.76)*step(.24,f.y)*step(f.y,.69);
float occupied=step(.78,hash21(id+vec2(uRole*19.0,7.0)));
float civilian=(uFamily<.5?1.0:0.0);
/* CivicBlock already has authored window/emissive strips. Procedural panes
are reserved for the otherwise-unlit tower, and only above its service
plinth. Applying them to every civil mesh produced the blue circuit-board
wash that the V2 material system was specifically meant to replace. */
float tower=float(uRole<.5),window=on.x*pane*occupied*civilian*tower*step(8.0,vObj.y);
float signBand=on.x*step(.46,f.y)*step(f.y,.55)*step(.18,f.x)*step(f.x,.82);
float signGate=step(.90,hash21(floor(face*vec2(.025,.041))+vec2(uRole*3.1,2.7)));
float sign=signBand*signGate*float(uRole<.5||uRole>3.5);
float warning=on.x*(1.0-civilian)*step(.80,f.x)*step(.78,f.y)*step(.955,hash21(id+9.4));
/* Industrial halls and tank farms need powered service detail as much as
towers need windows, but cool cyan strips made every faction read Nova.
Sparse amber loading-bay lamps retain the brutalist military character
and give the facade a useful scale cue without becoming neon trim. */
float industrial=float(uRole>1.5&&uRole<3.5);
float service=on.x*industrial*step(.35,f.x)*step(f.x,.65)*step(.70,f.y)*step(.91,hash21(id+vec2(4.2,11.7)));
/* Night-only occupancy flicker. A noon sin() on every pane read as shader
strobe; daylight windows stay static. */
float winPulse=mix(1.0,0.93+0.07*sin(uTime*1.15+id.x*1.7+id.y),uNight);
vec3 emissive=vec3(.30,.74,.92)*((window*winPulse)*.30+sign*.42)+vec3(1.0,.20,.025)*warning*.86;
emissive+=vec3(1.0,.42,.085)*service*.56;
/* Roof lamps are DISCS, not hash cells. floor(xz*0.07) lit a ~14-unit tile
and bloomed into a white halo on every command-camera cap. */
vec2 roofCell=fract(vObj.xz*0.34);
float roofId=hash21(floor(vObj.xz*0.34)+vec2(uRole*5.0,4.2));
float roofLite=roofOn*step(.96,roofId)*step(length(roofCell-vec2(.5)),.10);
float lampPulse=mix(1.0,0.90+0.10*sin(uTime*1.35+roofId*8.0),uNight);
emissive+=vec3(.32,.78,.95)*roofLite*.40*lampPulse;
/* Authored emis on facades and the tiny roof discs — never a whole cap.
Sampling NRE.a across roofOn turned neighbouring atlas texels into
glowing tiles. */
emissive+=mix(vec3(.22,.63,.84),vec3(1.0,.18,.018),uFamily)*emis*.42*(on.x+roofLite);
/* Damage carbonises the authored material, destroys polish and turns off
facade systems. Wreck particles are a separate layer; the surface itself
must stop looking like intact glossy paint before flames appear. */
float chip=smoothstep(.36,.84,hash21(floor(face*.31)+floor(vObj.y*.17)));
float charMask=clamp(vDamage*(.62+.38*damageCrust+.18*chip)+grime*vDamage*.50,0.0,1.0);
alb=mix(alb,vec3(.018,.015,.013),charMask*.94);
rough=mix(rough,.985,charMask);metal=mix(metal,0.0,charMask*.94);
emissive*=1.0-smoothstep(.10,.58,vDamage);
/* Towers must scorch as soon as they are under fire. A crack-only mask
stayed black when the carbon tile was quiet, so civic/defense blocks
looked intact until they popped. */
float crack=mix(damageCrack,max(damageCrack,.55*tower),.80);
/* Dead towers used to lose the hot term at vDamage=1, so a collapsed
civic block read as intact grey. Keep embers on the wreck. */
float wreck=smoothstep(.88,1.0,vDamage);
float hot=smoothstep(.10,.46,vDamage)*crack*(1.0-wreck*0.22)+wreck*crack*0.62;
alb=mix(alb,vec3(.28,.020,.002),hot*.78);
emissive+=vec3(1.0,.075,.003)*hot*2.25+vec3(1.0,.42,.035)*smoothstep(.42,.80,damageData)*hot*1.05;
vec3 V=normalize(uEye-vWorld),L=normalize(uSun),H=normalize(V+L);
float ndl=max(dot(N,L),0.0),ndv=max(dot(N,V),.001),ndh=max(dot(N,H),.001),vdh=max(dot(V,H),0.0);
float a=rough*rough,a2=a*a,den=ndh*ndh*(a2-1.0)+1.0;
float D=a2/max(3.14159265*den*den,.001),k=(rough+1.0)*(rough+1.0)*.125;
float gv=ndv/(ndv*(1.0-k)+k),gl=ndl/(ndl*(1.0-k)+k);
vec3 f0=mix(vec3(.04),alb,metal),F=f0+(1.0-f0)*pow(1.0-vdh,5.0);
vec3 spec=D*gv*gl*F/max(4.0*ndl*ndv,.001);
/* Same command-camera rule as FS3D: (1-metal) kd made steel civic blocks
ambient-only grey. Do not divide Lambert by PI — production exposure
was authored without it (the lab shader does, for one showcase object). */
vec3 kd=(1.0-F)*mix(1.0,0.55,metal),amb=mix(uAmbGnd,uAmbSky,N.y*.5+.5);
float wrap=dot(N,uSun)*0.5+0.5;
float sunLen=max(length(vec2(uSun.x,uSun.z)),1e-5);
float sunSide=dot(vec2(N.x,N.z),vec2(uSun.x,uSun.z)/sunLen);
ndl=min(1.0,ndl+max(sunSide,0.0)*0.32*(N.y*0.5+0.5));
/* Lambert sun, not GGX sheet-spec: overhead ndv≈1 made civic roofs one
pale value. Fade spec on camera-facing caps; keep ndl for day/night. */
float specAtten=mix(1.0,0.18,smoothstep(0.60,0.94,ndv));
/* Same sun/ambient weights as FS3D. The 0.68 ambient scale parked civic
blocks in the filmic toe (black hulls) next to correctly exposed units.
Overhead ndv lift matches FS3D so HIGH civic HQs are not 0.62-crushed. */
float metalLift=mix(1.0, mix(0.70, 0.90, smoothstep(0.50, 0.94, ndv)), metal);
vec3 lit=alb*metalLift*(amb*ao*0.82+uSunC*(ndl*0.92+max(wrap,0.0)*0.10*ao))
+spec*specAtten*uSunC*ndl;
/* Eight camera-relevant sources are already selected for the production
forward renderer. Reusing that list gives powered windows/signage actual
environmental influence without turning every window into a GPU light. */
for(int i=0;i<8;i++){
if(i>=uLightCount)break;
vec3 d=uLightPosR[i].xyz-vWorld;float dist=length(d),att=pow(clamp(1.0-dist/uLightPosR[i].w,0.0,1.0),2.0);
vec3 ll=d/max(dist,.001);float nll=max(dot(N,ll),0.0);
lit+=(alb*(1.0-metal)*.34+f0*.20)*uLightColI[i].rgb*uLightColI[i].a*nll*att;
}
vec3 env=mix(uAmbGnd,uAmbSky,clamp(N.y*.5+.5,0.0,1.0));
lit+=env*(f0+(1.0-f0)*pow(1.0-ndv,5.0))*(1.0-rough)*.28*ao*(1.0-charMask);
/* Sharp window/engine lights at noon. 0.34 assumed bloom would carry them
and they vanished at thresh 0.86. 0.70 keeps charcoal facades and lets
the panes punch without a bloom soup. */
float fowA=texture(uFowMap,vFowUV).a*uFowOn;
/* Civic blocks used to skip the sensor map, so a city on fogged ground
kept daylight hulls — the same glowing-pieces bug the model shader
already fixed. Dim lamps with the shroud, then veil only un-fogged faces. */
lit+=emissive*(.95+uNight*.50)*(1.0-fowA);
lit=mix(lit,mix(uAmbGnd*0.10,uFogC*0.20,0.5),fowA);
lit=mix(lit,uFogC,vFog*(1.0-fowA));
/* Same display contract as FS3D / terrain: filmic roll-off, no extra
pow(1/2.2) on the output. Albedo was lifted into that space above. */
lit=vec3(1.0)-exp(-max(lit,vec3(0.0))*1.55);
o=vec4(clamp(lit,vec3(0.0),vec3(1.0)),1.0);
}`;
class MFWorldV2InstMesh extends InstMesh{
constructor(gl,geo,cap,def){super(gl,geo,cap);this.def=def;}
flushWorld(){
if(!this.n)return;
const g=this.gl,D=this.def;
g.uniform4fv(mfWorld2U.uRect,D.rect);g.uniform1f(mfWorld2U.uFamily,D.family);g.uniform1f(mfWorld2U.uRole,D.role);
g.bindVertexArray(this.vao);g.bindBuffer(g.ARRAY_BUFFER,this.ivb);
g.bufferSubData(g.ARRAY_BUFFER,0,this.data,0,this.n*INST_FLOATS);
g.drawElementsInstanced(g.TRIANGLES,this.count,g.UNSIGNED_SHORT,0,this.n);
drawCalls++;triCount+=this.count/3*this.n;this.n=0;
}
}
function mfWorld2TexScratch(fn){
/* Civic maps decode after boot, often mid-match. Binding on the active unit
(TEXTURE0 / atlas) then leaving it there made every hull sample a 1x1
placeholder for a frame. Unit 7 is the shared scratch (ads, detail);
restore the previous 7-binding, never bindTexture(null), never unit 0. */
const was=gl.getParameter(gl.ACTIVE_TEXTURE);
gl.activeTexture(gl.TEXTURE7);
const prev=gl.getParameter(gl.TEXTURE_BINDING_2D);
fn();
gl.bindTexture(gl.TEXTURE_2D,prev);
gl.activeTexture(was);
}
function mfWorld2Placeholder(r,g,b,a){
const t=gl.createTexture();
mfWorld2TexScratch(()=>{
gl.bindTexture(gl.TEXTURE_2D,t);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA8,1,1,0,gl.RGBA,gl.UNSIGNED_BYTE,new Uint8Array([r,g,b,a]));
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
});
return t;
}
function mfWorld2Load(tex,file,srgb,epoch,repeat){
const img=new Image();img.onload=()=>{
if(epoch!==glEpoch)return;
const align=gl.getParameter(gl.UNPACK_ALIGNMENT),flip=gl.getParameter(gl.UNPACK_FLIP_Y_WEBGL);
mfWorld2TexScratch(()=>{
gl.bindTexture(gl.TEXTURE_2D,tex);gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false);gl.pixelStorei(gl.UNPACK_ALIGNMENT,1);
gl.texImage2D(gl.TEXTURE_2D,0,srgb?gl.SRGB8_ALPHA8:gl.RGBA8,gl.RGBA,gl.UNSIGNED_BYTE,img);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,repeat?gl.REPEAT:gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,repeat?gl.REPEAT:gl.CLAMP_TO_EDGE);
gl.generateMipmap(gl.TEXTURE_2D);
if(typeof mfAniso==='function') mfAniso();
else {
const an=gl.getExtension('EXT_texture_filter_anisotropic');
if(an)gl.texParameterf(gl.TEXTURE_2D,an.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(4,gl.getParameter(an.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
}
gl.pixelStorei(gl.UNPACK_ALIGNMENT,align);gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,flip);
});
mfWorld2Loaded++;mfWorld2Ready=mfWorld2Loaded===5&&!mfWorld2Error;
};
img.onerror=()=>{mfWorld2Error=true;mfWorld2Ready=false;console.warn('[WorldV2] texture unavailable:',file);};
img.src='./assets/textures/materials/'+file;
}
function mfWorld2Init(){
mfWorld2Ready=false;mfWorld2Loaded=0;mfWorld2Error=false;mfWorld2Epoch=glEpoch;
mfWorld2Prog=mkProg(
MFWORLD2_VS.replace(/MAPSIZE_CONST/g,MAP.toFixed(1)).replace(/BFOG_CONST/g,'430.0'),
MFWORLD2_FS,'world-structures-v2');if(!mfWorld2Prog){mfWorld2Error=true;return;}
for(const k of ['uVP','uEye','uHazeQ','uRect','uBaseAO','uNRE','uMasks','uDamageTex','uDetail','uFowMap','uFowOn','uSun','uSunC','uAmbSky','uAmbGnd','uFogC','uNight','uTime','uFamily','uRole','uLightCount'])
mfWorld2U[k]=gl.getUniformLocation(mfWorld2Prog,k);
mfWorld2U.uLightPosR=gl.getUniformLocation(mfWorld2Prog,'uLightPosR[0]');mfWorld2U.uLightColI=gl.getUniformLocation(mfWorld2Prog,'uLightColI[0]');
mfWorld2BaseAO=mfWorld2Placeholder(128,128,128,255);mfWorld2NRE=mfWorld2Placeholder(128,128,220,0);mfWorld2Masks=mfWorld2Placeholder(0,0,0,0);
mfWorld2DamageTex=mfWorld2Placeholder(22,22,22,255);
mfWorld2Detail=mfWorld2Placeholder(128,128,128,255);
/* The atlas PNG stores display/sRGB albedo in RGB and linear AO in alpha.
Loading it as plain RGBA treated encoded mid-grey as linear light, then
linearToSrgb() lifted it a second time: roofs clipped pale and baked AO
disappeared. SRGB8_ALPHA8 decodes RGB only; alpha remains linear, which is
exactly this packed map's contract. */
mfWorld2Load(mfWorld2BaseAO,'mf-world-structures-v2-baseao.png',true,mfWorld2Epoch);
mfWorld2Load(mfWorld2NRE,'mf-world-structures-v2-nre.png',false,mfWorld2Epoch);
mfWorld2Load(mfWorld2Masks,'mf-world-structures-v2-masks.png',false,mfWorld2Epoch);
mfWorld2Load(mfWorld2DamageTex,'mf2-carbon-cracks-v1.png',false,mfWorld2Epoch,true);
mfWorld2Load(mfWorld2Detail,'mf_mechanical_microdetail_v2.webp',false,mfWorld2Epoch,true);
}
function mfWorldV2Enabled(){
/* HIGH/CINEMATIC only. Mid used to keep this path so civic hulls would
not "look like a missing shader", but the PBR civic pass is HIGH-class
work (extra maps, 8-light loop, detail on unit 7). MEDIUM falls back
to the production atlas — still a building, much cheaper. */
const G=typeof GFX!=='undefined'?GFX:{};
if(G.worldV2===false) return false;
const q=typeof qualityKey==='function'?qualityKey():'high';
return !MFWORLD2_DISABLED&&q!=='low'&&mfWorld2Ready&&mfWorld2Epoch===glEpoch;
}
function mfWorldV2Queue(R,scale,height,lod){
/* V2 is the tactical/showcase material. At strategic range its normal,
mask, damage and eight-light reads collapse into a few pixels, so the
caller deliberately falls back to the legacy one-atlas material. That is
material LOD, not an asset failure, and keeps city density scalable. */
if(!mfWorldV2Enabled()||(lod||2)<2)return false;
const M=mfWorld2Meshes[R.kind];if(!M)return false;
const health=R.hpm>0?clamp(R.hp/Math.max(1,R.hpm),0,1):0;
M.add(R.x,R.y,height,scale*(R.kind===0?.9:1),R.a+(R.lean||0)+(R.alive?0:0.42),255,255,255,255,undefined,R.alive?1-health:1);
return true;
}
function mfWorldV2Flush(S,nA,time){
if(!mfWorldV2Enabled())return false;
const wasBlend=gl.isEnabled(gl.BLEND),wasCull=gl.isEnabled(gl.CULL_FACE),wasDepth=gl.isEnabled(gl.DEPTH_TEST),wasMask=gl.getParameter(gl.DEPTH_WRITEMASK);
gl.useProgram(mfWorld2Prog);gl.enable(gl.DEPTH_TEST);gl.enable(gl.CULL_FACE);gl.disable(gl.BLEND);gl.depthMask(true);
gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D,mfWorld2BaseAO);
gl.activeTexture(gl.TEXTURE2);gl.bindTexture(gl.TEXTURE_2D,mfWorld2NRE);
gl.activeTexture(gl.TEXTURE3);gl.bindTexture(gl.TEXTURE_2D,mfWorld2Masks);
gl.activeTexture(gl.TEXTURE1);gl.bindTexture(gl.TEXTURE_2D,mfWorld2DamageTex);
/* Unit 7 is outside the 4/5/6 post chain and is rebound by begin3D after the
world pass, so detailed civic materials cannot corrupt AO or bloom. */
gl.activeTexture(gl.TEXTURE7);gl.bindTexture(gl.TEXTURE_2D,mfWorld2Detail);
gl.uniform1i(mfWorld2U.uBaseAO,0);gl.uniform1i(mfWorld2U.uNRE,2);gl.uniform1i(mfWorld2U.uMasks,3);gl.uniform1i(mfWorld2U.uDamageTex,1);gl.uniform1i(mfWorld2U.uDetail,7);
/* Unit 8 is the live sensor map in the model pass. Terrain rebinds it to
splat art, so this flush must put fogTex back before sampling. */
const fowReady=typeof fogGameplayActive==='function'&&fogGameplayActive()&&!demoMode&&typeof fogTex!=='undefined'&&fogTex;
gl.activeTexture(gl.TEXTURE8);gl.bindTexture(gl.TEXTURE_2D,fowReady?fogTex:mfWorld2BaseAO);
if(mfWorld2U.uFowMap!=null){gl.uniform1i(mfWorld2U.uFowMap,8);gl.uniform1f(mfWorld2U.uFowOn,fowReady?1:0);}
gl.uniformMatrix4fv(mfWorld2U.uVP,false,matVP);gl.uniform3f(mfWorld2U.uEye,eyeX,eyeY,eyeZ);
if(mfWorld2U.uHazeQ) gl.uniform1f(mfWorld2U.uHazeQ, typeof mfHazeQ==='function'?mfHazeQ():1);
gl.uniform3f(mfWorld2U.uSun,S.dir[0],S.dir[1],S.dir[2]);
let c=_lin(S.col);gl.uniform3f(mfWorld2U.uSunC,c[0],c[1],c[2]);c=_lin(S.sky);gl.uniform3f(mfWorld2U.uAmbSky,c[0],c[1],c[2]);
c=_lin(S.gnd);gl.uniform3f(mfWorld2U.uAmbGnd,c[0],c[1],c[2]);c=_lin(S.fog);gl.uniform3f(mfWorld2U.uFogC,c[0],c[1],c[2]);
gl.uniform1f(mfWorld2U.uNight,nA);gl.uniform1f(mfWorld2U.uTime,time);gl.uniform1i(mfWorld2U.uLightCount,_sceneLightN);
if(_sceneLightN){gl.uniform4fv(mfWorld2U.uLightPosR,_sceneLightPR);gl.uniform4fv(mfWorld2U.uLightColI,_sceneLightCI);}
for(const D of MFWORLD2_DEFS){const M=mfWorld2Meshes[D.kind];if(M)M.flushWorld();}
if(wasBlend)gl.enable(gl.BLEND);else gl.disable(gl.BLEND);if(wasCull)gl.enable(gl.CULL_FACE);else gl.disable(gl.CULL_FACE);
if(wasDepth)gl.enable(gl.DEPTH_TEST);else gl.disable(gl.DEPTH_TEST);gl.depthMask(wasMask);gl.activeTexture(gl.TEXTURE0);
begin3D(nA);return true;
}
/* Take over after models-civic so the old world meshes remain present as a
runtime fallback. V2 owns separate authored-UV VAOs and shared textures. */
const mfWorld2BaseInitModels=initModels;
initModels=function(){
mfWorld2BaseInitModels();mfWorld2Init();mfWorld2Meshes={};
for(const D of MFWORLD2_DEFS){const geo=loadWorldModel(D.key,true);if(geo)mfWorld2Meshes[D.kind]=new MFWorldV2InstMesh(gl,geo,420,D);}
};
if(typeof window!=='undefined')window.MFWorldStructuresV2={
status:()=>({enabled:mfWorldV2Enabled(),ready:mfWorld2Ready,error:mfWorld2Error,loaded:mfWorld2Loaded,epoch:mfWorld2Epoch}),
definitions:MFWORLD2_DEFS
};