File size: 24,162 Bytes
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
b51e03a
 
3597f5a
b51e03a
 
 
 
 
3597f5a
 
 
 
b51e03a
3597f5a
 
 
 
 
 
 
b51e03a
 
 
 
 
3597f5a
b51e03a
3597f5a
b51e03a
 
 
 
3597f5a
 
 
 
b51e03a
3597f5a
b51e03a
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
3597f5a
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
b51e03a
 
 
 
 
3597f5a
b51e03a
 
 
 
 
 
 
 
 
3597f5a
b51e03a
 
 
3597f5a
 
 
 
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
b51e03a
3597f5a
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
b51e03a
3597f5a
 
 
 
 
 
b51e03a
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
 
 
 
3597f5a
 
 
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
b51e03a
 
 
 
 
 
 
 
 
 
3597f5a
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
b51e03a
3597f5a
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
/* ============================================================================
   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
};