Spaces:
Running
Running
Download src/engine/mesh.js from CREATORJD/massfront-playtest: direct link, hf CLI and curl.
- Browser
- Download file 230 kB
-
https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/engine/mesh.js
- Command line
-
hf download hf://spaces/CREATORJD/massfront-playtest/src/engine/mesh.js
-
curl -L -o mesh.js https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/engine/mesh.js
230 kB
| /* ============================================================================ | |
| MASSFRONT β TRUE 3D RENDERER | |
| ---------------------------------------------------------------------------- | |
| This replaces the old billboard/sprite pipeline entirely. Nothing here is a | |
| camera-facing quad: every unit, structure, ruin, rock and effect is real | |
| indexed triangle geometry with real normals, lit by a real directional sun, | |
| drawn into a real depth buffer under a real perspective projection. | |
| Why it's built this way: | |
| * ONE shader, ONE vertex format, INSTANCED draws. A thousand Strikers is a | |
| single draw call carrying a thousand transforms, which is what lets an | |
| army of tens of thousands stay affordable on a phone. | |
| * Per-instance transform is compressed to (position, uniform scale, yaw, | |
| tint). Units and buildings sit on the ground and rotate about the | |
| vertical β a full mat4 per instance would triple the bandwidth to | |
| express rotations nothing in this game actually uses. | |
| * Vertex colour rather than textures. Models are procedurally welded from | |
| coloured primitives, so there are no texture atlases to sample, no | |
| filtering blur at close zoom, and colour survives arbitrary scaling. | |
| ============================================================================ */ | |
| /* ---------- 4x4 matrix math (column-major, GL order) ---------- */ | |
| function m4(){ return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); } | |
| function m4mul(o,a,b){ | |
| for(let c=0;c<4;c++){ | |
| const b0=b[c*4],b1=b[c*4+1],b2=b[c*4+2],b3=b[c*4+3]; | |
| o[c*4 ]=a[0]*b0+a[4]*b1+a[8 ]*b2+a[12]*b3; | |
| o[c*4+1]=a[1]*b0+a[5]*b1+a[9 ]*b2+a[13]*b3; | |
| o[c*4+2]=a[2]*b0+a[6]*b1+a[10]*b2+a[14]*b3; | |
| o[c*4+3]=a[3]*b0+a[7]*b1+a[11]*b2+a[15]*b3; | |
| } | |
| return o; | |
| } | |
| function m4persp(o,fovy,asp,near,far){ | |
| const f=1/Math.tan(fovy/2), nf=1/(near-far); | |
| o[0]=f/asp;o[1]=0;o[2]=0;o[3]=0; | |
| o[4]=0;o[5]=f;o[6]=0;o[7]=0; | |
| o[8]=0;o[9]=0;o[10]=(far+near)*nf;o[11]=-1; | |
| o[12]=0;o[13]=0;o[14]=2*far*near*nf;o[15]=0; | |
| return o; | |
| } | |
| function m4ortho(o,l,r,b,t,n,f){ | |
| o.fill(0); | |
| o[0]=2/(r-l); o[5]=2/(t-b); o[10]=-2/(f-n); o[15]=1; | |
| o[12]=-(r+l)/(r-l); o[13]=-(t+b)/(t-b); o[14]=-(f+n)/(f-n); | |
| return o; | |
| } | |
| function m4look(o,ex,ey,ez,cx,cy,cz,upx,upy,upz){ | |
| let zx=ex-cx, zy=ey-cy, zz=ez-cz; | |
| let l=Math.hypot(zx,zy,zz)||1; zx/=l; zy/=l; zz/=l; | |
| let xx=upy*zz-upz*zy, xy=upz*zx-upx*zz, xz=upx*zy-upy*zx; | |
| l=Math.hypot(xx,xy,xz)||1; xx/=l; xy/=l; xz/=l; | |
| const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx; | |
| o[0]=xx;o[1]=yx;o[2]=zx;o[3]=0; | |
| o[4]=xy;o[5]=yy;o[6]=zy;o[7]=0; | |
| o[8]=xz;o[9]=yz;o[10]=zz;o[11]=0; | |
| o[12]=-(xx*ex+xy*ey+xz*ez); | |
| o[13]=-(yx*ex+yy*ey+yz*ez); | |
| o[14]=-(zx*ex+zy*ey+zz*ez); | |
| o[15]=1; | |
| return o; | |
| } | |
| function m4invert(o,m){ | |
| /* Inverse for the CSM apply pass (world from window depth). Column-major. */ | |
| const a00=m[0],a01=m[1],a02=m[2],a03=m[3]; | |
| const a10=m[4],a11=m[5],a12=m[6],a13=m[7]; | |
| const a20=m[8],a21=m[9],a22=m[10],a23=m[11]; | |
| const a30=m[12],a31=m[13],a32=m[14],a33=m[15]; | |
| const b00=a00*a11-a01*a10, b01=a00*a12-a02*a10, b02=a00*a13-a03*a10; | |
| const b03=a01*a12-a02*a11, b04=a01*a13-a03*a11, b05=a02*a13-a03*a12; | |
| const b06=a20*a31-a21*a30, b07=a20*a32-a22*a30, b08=a20*a33-a23*a30; | |
| const b09=a21*a32-a22*a31, b10=a21*a33-a23*a31, b11=a22*a33-a23*a32; | |
| let det=b00*b11-b01*b10+b02*b09+b03*b08-b04*b07+b05*b06; | |
| if(Math.abs(det)<1e-12){ o.set(m); return o; } | |
| det=1/det; | |
| o[0]=(a11*b11-a12*b10+a13*b09)*det; | |
| o[1]=(a02*b10-a01*b11-a03*b09)*det; | |
| o[2]=(a31*b05-a32*b04+a33*b03)*det; | |
| o[3]=(a22*b04-a21*b05-a23*b03)*det; | |
| o[4]=(a12*b08-a10*b11-a13*b07)*det; | |
| o[5]=(a00*b11-a02*b08+a03*b07)*det; | |
| o[6]=(a32*b02-a30*b05-a33*b01)*det; | |
| o[7]=(a20*b05-a22*b02+a23*b01)*det; | |
| o[8]=(a10*b10-a11*b08+a13*b06)*det; | |
| o[9]=(a01*b08-a00*b10-a03*b06)*det; | |
| o[10]=(a30*b04-a31*b02+a33*b00)*det; | |
| o[11]=(a21*b02-a20*b04-a23*b00)*det; | |
| o[12]=(a11*b07-a10*b09-a12*b06)*det; | |
| o[13]=(a00*b09-a01*b07+a02*b06)*det; | |
| o[14]=(a31*b01-a30*b03-a32*b00)*det; | |
| o[15]=(a20*b03-a21*b01+a22*b00)*det; | |
| return o; | |
| } | |
| /* ============================================================================ | |
| MESH BUILDER | |
| Models are welded from primitives into one vertex/index buffer. Every vertex | |
| carries position, normal and colour, so a single model can be many materials | |
| without any state changes at draw time. | |
| Convention: X/Z are the ground plane (matching the sim's x/y), +Y is up. | |
| Models are authored around the origin with their FEET at y=0, so placing one | |
| is just "put it at this ground point". | |
| ============================================================================ */ | |
| /* Vertex: pos(3) normal(3) colour(3) uv(2) material(1) bone(1) = 13 floats. | |
| UVs are in WORLD-ish units and the shader takes fract() before mapping into | |
| the material's atlas tile, so surface detail keeps a consistent physical | |
| scale whether it's on a 3-unit gun barrel or a 90-unit factory wall. */ | |
| const UVS=0.055; // texture repeats per world unit | |
| /* FLOATS PER VERTEX, named once. | |
| It was written as a literal 12 in FIVE separate places β MeshBuilder.scale, | |
| the mirror welder, the infestation limb welder, the airlift welder and the | |
| LIVE 3D preview's own VAO. Briefly growing the vertex to 13 for a bone index | |
| proved how dangerous that is: the sites that were missed kept striding by 12, | |
| read each vertex from the middle of its predecessor, and sheared whole models | |
| into coloured shards with a completely clean console. The vertex is back to | |
| 12 (see _mid below for where the bone index actually lives) but the constant | |
| stays, because nothing should ever walk this array by a literal again. */ | |
| const VFLOATS=12; | |
| /* Mirrors uAssetOn on the GPU so a draw with no baked maps only pays a | |
| uniform write when the previous draw actually turned them on. */ | |
| let MF_ASSET_ON=false; | |
| class MeshBuilder{ | |
| constructor(){ | |
| this.v=[]; this.i=[]; this.n=0; this.m=0; this.tm=0; | |
| /* Face spans, flat [firstVertex,count,...]. Only unwrapAssetUV() reads them; | |
| they cost two array pushes per face and nothing at draw time. */ | |
| this.faces=[]; | |
| /* ---- SKELETON ------------------------------------------------------- | |
| Bones are RIGID BODIES, not skinning weights. Chitin is armour: a limb | |
| segment does not deform, it rotates about the joint that carries it, and | |
| its parent does the same one link up. So every vertex belongs to exactly | |
| ONE bone and the transform is a forward-kinematic chain, which is both | |
| anatomically right and far cheaper than weighted skinning β no bone | |
| matrix palette, no CPU skinning, and still one draw call for the whole | |
| swarm because the chain is evaluated in the vertex stage from the same | |
| single per-instance phase float the gait already uses. | |
| joint[] vec4(pivot.xyz, parentIndex) parent -1 = attached to the body | |
| axis[] vec4(axis.xyz, phase) phase seeds this joint's swing | |
| swing[] vec2(amplitude, bias) radians */ | |
| this.joints=[]; this.b=-1; | |
| this.tfStack=[]; this.curTf=null; | |
| } | |
| pushTransform(mat){ | |
| const parent=this.curTf; | |
| let next; | |
| if(mat){ | |
| if(!parent) next=new Float32Array(mat); | |
| else { next=new Float32Array(16); m4mul(next,parent,mat); } | |
| } else { | |
| next=parent?new Float32Array(parent):null; | |
| } | |
| this.tfStack.push(next); this.curTf=next; | |
| return this; | |
| } | |
| popTransform(){ | |
| this.tfStack.pop(); | |
| this.curTf=this.tfStack.length>0?this.tfStack[this.tfStack.length-1]:null; | |
| return this; | |
| } | |
| translate(x,y,z){ const m=m4(); m[12]=x; m[13]=y; m[14]=z; return this.pushTransform(m); } | |
| rotateX(rad){ const m=m4(),c=Math.cos(rad),s=Math.sin(rad); m[5]=c; m[6]=s; m[9]=-s; m[10]=c; return this.pushTransform(m); } | |
| rotateY(rad){ const m=m4(),c=Math.cos(rad),s=Math.sin(rad); m[0]=c; m[2]=-s; m[8]=s; m[10]=c; return this.pushTransform(m); } | |
| rotateZ(rad){ const m=m4(),c=Math.cos(rad),s=Math.sin(rad); m[0]=c; m[1]=s; m[4]=-s; m[5]=c; return this.pushTransform(m); } | |
| scale3d(sx,sy,sz){ const m=m4(); m[0]=sx; m[5]=sy===undefined?sx:sy; m[10]=sz===undefined?sx:sz; return this.pushTransform(m); } | |
| _tfP(p){ | |
| if(!this.curTf) return p; | |
| const m=this.curTf, x=p[0], y=p[1], z=p[2]; | |
| return [m[0]*x+m[4]*y+m[8]*z+m[12], m[1]*x+m[5]*y+m[9]*z+m[13], m[2]*x+m[6]*y+m[10]*z+m[14]]; | |
| } | |
| _tfN(nx,ny,nz){ | |
| if(!this.curTf) return [nx,ny,nz]; | |
| const m=this.curTf; | |
| let tx=m[0]*nx+m[4]*ny+m[8]*nz, ty=m[1]*nx+m[5]*ny+m[9]*nz, tz=m[2]*nx+m[6]*ny+m[10]*nz; | |
| const l=Math.hypot(tx,ty,tz)||1; return [tx/l, ty/l, tz/l]; | |
| } | |
| /* Declare a joint and return its bone index. Geometry emitted while that | |
| index is current rotates about `pivot`, after its parent has moved. */ | |
| joint(pivot,parent,axis,phase,amp,bias){ | |
| const l=Math.hypot(axis[0],axis[1],axis[2])||1; | |
| this.joints.push([pivot[0],pivot[1],pivot[2],parent==null?-1:parent, | |
| axis[0]/l,axis[1]/l,axis[2]/l,phase||0, | |
| amp||0,bias||0]); | |
| return this.joints.length-1; | |
| } | |
| bone(id){ this.b=(id==null?-1:id); return this; } | |
| mat(id){ this.m=id; return this; } // current material for following primitives | |
| /* Team livery flag. Multiplying an ENTIRE model by the team colour drowns | |
| every panel line and material difference in one flat wash β a blue tank | |
| becomes a blue blob. Instead, only the faces marked here take the full | |
| team colour; everything else keeps its own metal, rubber and glass and | |
| picks up just a hint of it. That is what lets you read a hull from a | |
| turret from a tread at 20 pixels while still telling the sides apart. */ | |
| team(on){ this.tm=on?1:0; return this; } | |
| /* MATERIAL AND BONE IN ONE FLOAT. | |
| A 13th vertex attribute would have made every wall, tank, tree and turret | |
| in the game carry a bone index it can never use β 8% more vertex memory and | |
| bandwidth, on the ~95% of the geometry that has no skeleton, to say -1. | |
| Only organics have bones, so only organics should pay for them. | |
| The material id is a small integer (< 64) and the bone index is < 128, so | |
| the bone fits in the fractional part with room to spare: 7 bits of integer | |
| plus 7 bits of fraction is 14 of float32's 24 mantissa bits, and n/128 is an | |
| exact binary fraction, so the round-trip is lossless rather than merely | |
| close. A vertex with no bone contributes exactly 0 to the fraction, which | |
| makes every non-organic vertex bit-identical to what it was before bones | |
| existed. */ | |
| get _mid(){ return ((this.m+1)+(this.b+1)/128)*(this.tm?-1:1); } | |
| /* Project a face in its own tangent plane. This is deliberately based on | |
| world distance, not a 0..1 rectangle, so a narrow bevel and a wide armour | |
| plate receive the same texel density instead of stretching one atlas tile | |
| to fit both. */ | |
| _planarUV(P,nx,ny,nz){ | |
| const a=P[0], b=P[1]; | |
| let tx=b[0]-a[0],ty=b[1]-a[1],tz=b[2]-a[2]; | |
| let tl=Math.hypot(tx,ty,tz)||1; tx/=tl;ty/=tl;tz/=tl; | |
| let bx=ny*tz-nz*ty,by=nz*tx-nx*tz,bz=nx*ty-ny*tx; | |
| let bl=Math.hypot(bx,by,bz)||1; bx/=bl;by/=bl;bz/=bl; | |
| if(P.length>2 && (P[P.length-1][0]-a[0])*bx+(P[P.length-1][1]-a[1])*by+(P[P.length-1][2]-a[2])*bz<0){ | |
| bx=-bx;by=-by;bz=-bz; | |
| } | |
| return P.map(p=>[((p[0]-a[0])*tx+(p[1]-a[1])*ty+(p[2]-a[2])*tz)*UVS, | |
| ((p[0]-a[0])*bx+(p[1]-a[1])*by+(p[2]-a[2])*bz)*UVS]); | |
| } | |
| tri(a,b,c,nx,ny,nz,col,uvs){ | |
| if(this.curTf){ | |
| a=this._tfP(a); b=this._tfP(b); c=this._tfP(c); | |
| const N=this._tfN(nx,ny,nz); nx=N[0]; ny=N[1]; nz=N[2]; | |
| } | |
| const o=this.n, M=this._mid; | |
| const P=[a,b,c]; | |
| const U=uvs||this._planarUV(P,nx,ny,nz); | |
| for(let k=0;k<3;k++) this.v.push(P[k][0],P[k][1],P[k][2],nx,ny,nz,col[0],col[1],col[2],U[k][0],U[k][1],M); | |
| this.i.push(o,o+1,o+2); this.n+=3; this.faces.push(o,3); | |
| } | |
| /* Face normal and winding both derive from the corner order a->b->c->d. | |
| The outward normal is (d-a) x (b-a) and the front face is the REVERSED | |
| traversal. Getting either backwards lights and culls every solid in the | |
| game inside-out, which reads as "flat and muddy" rather than as an | |
| obvious error β it cost a full render pass to spot. */ | |
| quad(a,b,c,d,col,uvs){ | |
| if(this.curTf){ | |
| a=this._tfP(a); b=this._tfP(b); c=this._tfP(c); d=this._tfP(d); | |
| } | |
| const ux=b[0]-a[0], uy=b[1]-a[1], uz=b[2]-a[2]; | |
| const vx=d[0]-a[0], vy=d[1]-a[1], vz=d[2]-a[2]; | |
| let nx=vy*uz-vz*uy, ny=vz*ux-vx*uz, nz=vx*uy-vy*ux; | |
| const l=Math.hypot(nx,ny,nz)||1; nx/=l; ny/=l; nz/=l; | |
| /* Face-local planar UVs preserve texel density on trapezoids and chamfers. | |
| Mapping every quad to a rectangle stretched the atlas along bevelsβthe | |
| exact distortion that was most obvious on tower receivers and pads. */ | |
| const P=[a,b,c,d], U=uvs||this._planarUV(P,nx,ny,nz); | |
| const o=this.n, M=this._mid; | |
| for(let k=0;k<4;k++) this.v.push(P[k][0],P[k][1],P[k][2],nx,ny,nz,col[0],col[1],col[2],U[k][0],U[k][1],M); | |
| this.i.push(o,o+2,o+1, o,o+3,o+2); this.n+=4; this.faces.push(o,4); | |
| } | |
| /* Axis-aligned box, centred on (x,z), sitting from y to y+h. | |
| The workhorse: hulls, slabs, crates, wall segments, city blocks. */ | |
| box(x,y,z,w,h,d,col,yaw){ | |
| const hw=w/2, hd=d/2, c=Math.cos(yaw||0), s=Math.sin(yaw||0); | |
| const P=(px,py,pz)=>[x+px*c-pz*s, y+py, z+px*s+pz*c]; | |
| const a=P(-hw,0,-hd), b=P(hw,0,-hd), cc=P(hw,0,hd), dd=P(-hw,0,hd); | |
| const e=P(-hw,h,-hd), f=P(hw,h,-hd), g=P(hw,h,hd), hh=P(-hw,h,hd); | |
| this.quad(e,f,g,hh,col); // top | |
| this.quad(dd,cc,b,a,col); // bottom | |
| this.quad(a,b,f,e,col); this.quad(cc,dd,hh,g,col); | |
| this.quad(b,cc,g,f,col); this.quad(dd,a,e,hh,col); | |
| return this; | |
| } | |
| /* Tapered cylinder / cone / drum. r2<r1 gives a taper, r2=0 a cone. | |
| Used for turrets, barrels, tanks, chimneys, legs, domes-by-stacking. */ | |
| cyl(x,y,z,r1,r2,h,seg,col,cap){ | |
| seg=seg||10; | |
| const ring1=[], ring2=[]; | |
| for(let k=0;k<seg;k++){ | |
| const a=k/seg*TAU; | |
| ring1.push([x+Math.cos(a)*r1, y, z+Math.sin(a)*r1]); | |
| ring2.push([x+Math.cos(a)*r2, y+h, z+Math.sin(a)*r2]); | |
| } | |
| const circ=TAU*Math.max(r1,r2)*UVS; | |
| const arc1=TAU*Math.abs(r1)/seg*UVS, arc2=TAU*Math.abs(r2)/seg*UVS; | |
| const sideNormal=(a,b,c)=>{ | |
| const ux=b[0]-a[0],uy=b[1]-a[1],uz=b[2]-a[2]; | |
| const vx=c[0]-a[0],vy=c[1]-a[1],vz=c[2]-a[2]; | |
| let nx=uy*vz-uz*vy,ny=uz*vx-ux*vz,nz=ux*vy-uy*vx; | |
| const l=Math.hypot(nx,ny,nz)||1;return [nx/l,ny/l,nz/l]; | |
| }; | |
| for(let k=0;k<seg;k++){ | |
| const j=(k+1)%seg; | |
| /* A frustum's two rings have different physical circumferences. Giving | |
| both edges the larger ring's U width stretches every cone and taper at | |
| its narrow end. Keep each edge's own arc length, centred on the same | |
| angular patch; equal-radius cylinders remain identical to the old map. */ | |
| const uc=(k+0.5)/seg*circ; | |
| const vh=Math.hypot(h,r2-r1)*UVS; | |
| if(Math.abs(r2)<1e-7){ | |
| const P=[ring1[k],ring2[k],ring1[j]],n=sideNormal(ring1[k],ring2[k],ring1[j]); | |
| this.tri(P[0],P[1],P[2],n[0],n[1],n[2],col,[[uc-arc1/2,0],[uc,vh],[uc+arc1/2,0]]); | |
| }else if(Math.abs(r1)<1e-7){ | |
| const P=[ring1[k],ring2[k],ring2[j]],n=sideNormal(...P); | |
| this.tri(P[0],P[1],P[2],n[0],n[1],n[2],col,[[uc,0],[uc-arc2/2,vh],[uc+arc2/2,vh]]); | |
| }else this.quad(ring1[k],ring1[j],ring2[j],ring2[k],col, | |
| [[uc-arc1/2,0],[uc+arc1/2,0],[uc+arc2/2,vh],[uc-arc2/2,vh]]); | |
| } | |
| if(cap!==false){ | |
| const uvc=p=>[ (p[0]-x)*UVS, (p[2]-z)*UVS ]; | |
| for(let k=1;k<seg-1;k++){ | |
| /* CAP WINDING. A face pointing UP is front-facing when its corners run | |
| CLOCKWISE in the x/z plane β the same order bevelBox's top uses. The | |
| fan was emitted counter-clockwise, so every cylinder in the game lost | |
| its top cap to backface culling and rendered its underside instead: | |
| barrels, silos, tanks and thruster bells all read as dark hollow | |
| tubes. Both fans are reversed here. */ | |
| if(Math.abs(r2)>=1e-7)this.tri(ring2[0],ring2[k+1],ring2[k],0,1,0,col,[uvc(ring2[0]),uvc(ring2[k+1]),uvc(ring2[k])]); | |
| if(Math.abs(r1)>=1e-7)this.tri(ring1[0],ring1[k],ring1[k+1],0,-1,0,col,[uvc(ring1[0]),uvc(ring1[k]),uvc(ring1[k+1])]); | |
| } | |
| } | |
| return this; | |
| } | |
| /* Latitude/longitude sphere or hemisphere (v1<1 truncates the bottom). | |
| Domes, cockpits, blast shells, boulders, alien carapaces. */ | |
| sphere(x,y,z,r,seg,col,squashY,half){ | |
| seg=seg||8; squashY=squashY===undefined?1:squashY; | |
| const rows=half?Math.ceil(seg/2):seg; | |
| const grid=[]; | |
| for(let iy=0;iy<=rows;iy++){ | |
| const row=[], ph=(half? (iy/rows)*Math.PI/2 : (iy/rows)*Math.PI - Math.PI/2); | |
| const cy2=Math.sin(ph), cr=Math.cos(ph); | |
| for(let ix=0;ix<=seg;ix++){ | |
| const th=ix/seg*TAU; | |
| row.push([x+Math.cos(th)*cr*r, y+cy2*r*squashY, z+Math.sin(th)*cr*r]); | |
| } | |
| grid.push(row); | |
| } | |
| for(let iy=0;iy<rows;iy++) for(let ix=0;ix<seg;ix++){ | |
| const a=grid[iy][ix], b=grid[iy][ix+1], c=grid[iy+1][ix+1], d=grid[iy+1][ix]; | |
| // Gradient of an ellipsoid: the compressed axis needs inverse-square | |
| // correction. Dividing only once makes squashed domes shade as spheres. | |
| const nrm=p=>{const dx=p[0]-x,dy=(p[1]-y)/((squashY||1)*(squashY||1)),dz=p[2]-z,l=Math.hypot(dx,dy,dz)||1;return [dx/l,dy/l,dz/l];}; | |
| const na=nrm(a),nb=nrm(b),nc=nrm(c),nd=nrm(d); | |
| /* Low-poly domes are hard-surface parts. Equirectangular UVs collapse at | |
| the poles, while a conventional quad grid also emits one zero-area | |
| triangle for every pole segment. Emit one real triangle at a pole and | |
| face-project each remaining patch, preserving smooth lighting normals. */ | |
| const emit=(P,N,quad)=>{ | |
| const u=[P[1][0]-P[0][0],P[1][1]-P[0][1],P[1][2]-P[0][2]]; | |
| const v=[P[P.length-1][0]-P[0][0],P[P.length-1][1]-P[0][1],P[P.length-1][2]-P[0][2]]; | |
| let fnx=v[1]*u[2]-v[2]*u[1],fny=v[2]*u[0]-v[0]*u[2],fnz=v[0]*u[1]-v[1]*u[0]; | |
| const fl=Math.hypot(fnx,fny,fnz)||1;fnx/=fl;fny/=fl;fnz/=fl; | |
| const U=this._planarUV(P,fnx,fny,fnz),o=this.n,M=this._mid; | |
| for(let k=0;k<P.length;k++)this.v.push(P[k][0],P[k][1],P[k][2],N[k][0],N[k][1],N[k][2],col[0],col[1],col[2],U[k][0],U[k][1],M); | |
| if(quad){this.i.push(o,o+2,o+1,o,o+3,o+2);this.n+=4;} | |
| else{this.i.push(o,o+1,o+2);this.n+=3;} | |
| }; | |
| const lowerPole=Math.hypot(a[0]-b[0],a[1]-b[1],a[2]-b[2])<1e-7; | |
| const upperPole=Math.hypot(c[0]-d[0],c[1]-d[1],c[2]-d[2])<1e-7; | |
| if(lowerPole)emit([a,d,c],[na,nd,nc],false); | |
| else if(upperPole)emit([a,c,b],[na,nc,nb],false); | |
| else emit([a,b,c,d],[na,nb,nc,nd],true); | |
| } | |
| return this; | |
| } | |
| /* ========================================================================== | |
| SCULPT β a parametric organic surface. | |
| Everything else in this class is hard-surface assembly: boxes, frusta, | |
| spheres. That vocabulary builds a tank beautifully and a creature not at | |
| all, because a creature is ONE continuous skin with relief sculpted into | |
| it, not a pile of primitives that happen to overlap. Stacking spheres for | |
| an abdomen gives you visible intersection seams, no control over section | |
| shape, and a silhouette made of circles β which is exactly why the swarm | |
| read as a green blob with twigs. | |
| Here the caller supplies fn(u,v) -> [x,y,z] in local space: u wraps around | |
| the body, v runs nose to tail. Ridges, tubercles, keels and flares are | |
| written INTO that function as displacement, so they are part of the skin | |
| rather than parts sitting on it. Normals come from central differences of | |
| the surface itself, so sculpted detail lights correctly instead of shading | |
| like the smooth form underneath it β that is the whole difference between | |
| a bumpy sphere and a carapace. | |
| u wraps (seam at u=0/1); v does not, so pass capLo/capHi to close the ends. | |
| ========================================================================== */ | |
| sculpt(x,y,z,segU,segV,fn,col,capLo,capHi){ | |
| const E=1/2048, P=[], N=[]; | |
| const at=(u,v)=>fn(u-Math.floor(u), v<0?0:v>1?1:v); | |
| for(let iv=0;iv<=segV;iv++){ | |
| const rp=[], rn=[]; | |
| for(let iu=0;iu<=segU;iu++){ | |
| const u=iu/segU, v=iv/segV, p=at(u,v); | |
| const a=at(u+E,v), b=at(u-E,v), c=at(u,Math.min(1,v+E)), d=at(u,Math.max(0,v-E)); | |
| const tu=[a[0]-b[0],a[1]-b[1],a[2]-b[2]], tv=[c[0]-d[0],c[1]-d[1],c[2]-d[2]]; | |
| let nx=tv[1]*tu[2]-tv[2]*tu[1], ny=tv[2]*tu[0]-tv[0]*tu[2], nz=tv[0]*tu[1]-tv[1]*tu[0]; | |
| const l=Math.hypot(nx,ny,nz)||1; | |
| rp.push([x+p[0],y+p[1],z+p[2]]); rn.push([nx/l,ny/l,nz/l]); | |
| } | |
| P.push(rp); N.push(rn); | |
| } | |
| /* `col` may be a flat colour OR a function (u,v)->[r,g,b]. The second form | |
| is what makes a sculpted carapace read like the reference: pale plate | |
| fields against near-black seams is a COLOUR break as much as a form | |
| break, and painting it per-vertex from the same plate function that | |
| displaced the surface guarantees the two can never drift apart. It costs | |
| nothing at runtime β the vertex format already carries a colour. */ | |
| const perV=typeof col==='function'; | |
| const flat=perV?null:col; | |
| const CC=[], CN=[]; | |
| if(perV) for(let iv=0;iv<=segV;iv++){ | |
| const r=[]; for(let iu=0;iu<=segU;iu++) r.push(col(iu/segU, iv/segV)); | |
| CC.push(r); | |
| } | |
| const emit=(pts,nrm,cols)=>{ | |
| const u0=[pts[1][0]-pts[0][0],pts[1][1]-pts[0][1],pts[1][2]-pts[0][2]]; | |
| const v0=[pts[pts.length-1][0]-pts[0][0],pts[pts.length-1][1]-pts[0][1],pts[pts.length-1][2]-pts[0][2]]; | |
| let fx=v0[1]*u0[2]-v0[2]*u0[1], fy=v0[2]*u0[0]-v0[0]*u0[2], fz=v0[0]*u0[1]-v0[1]*u0[0]; | |
| const fl=Math.hypot(fx,fy,fz)||1; fx/=fl; fy/=fl; fz/=fl; | |
| const U=this._planarUV(pts,fx,fy,fz), o=this.n, M=this._mid; | |
| for(let k=0;k<pts.length;k++){ | |
| const c=cols?cols[k]:flat; | |
| this.v.push(pts[k][0],pts[k][1],pts[k][2],nrm[k][0],nrm[k][1],nrm[k][2], | |
| c[0],c[1],c[2],U[k][0],U[k][1],M); | |
| } | |
| if(pts.length===4){ this.i.push(o,o+2,o+1,o,o+3,o+2); this.n+=4; } | |
| else { this.i.push(o,o+1,o+2); this.n+=3; } | |
| }; | |
| for(let iv=0;iv<segV;iv++) for(let iu=0;iu<segU;iu++){ | |
| const a=P[iv][iu], b=P[iv][iu+1], c=P[iv+1][iu+1], d=P[iv+1][iu]; | |
| /* A row that has collapsed to a point (a pole) would emit degenerate | |
| quads β the same defect that once put black shards through every roof | |
| in the game. Drop to a triangle instead. */ | |
| const lo=Math.hypot(a[0]-b[0],a[1]-b[1],a[2]-b[2])<1e-6; | |
| const hi=Math.hypot(c[0]-d[0],c[1]-d[1],c[2]-d[2])<1e-6; | |
| if(lo&&hi) continue; | |
| if(lo) emit([a,d,c],[N[iv][iu],N[iv+1][iu],N[iv+1][iu+1]], | |
| perV?[CC[iv][iu],CC[iv+1][iu],CC[iv+1][iu+1]]:null); | |
| else if(hi) emit([a,b,c],[N[iv][iu],N[iv][iu+1],N[iv+1][iu+1]], | |
| perV?[CC[iv][iu],CC[iv][iu+1],CC[iv+1][iu+1]]:null); | |
| else emit([a,b,c,d],[N[iv][iu],N[iv][iu+1],N[iv+1][iu+1],N[iv+1][iu]], | |
| perV?[CC[iv][iu],CC[iv][iu+1],CC[iv+1][iu+1],CC[iv+1][iu]]:null); | |
| } | |
| const cap=(row,flip)=>{ | |
| let cx=0,cy=0,cz=0; | |
| for(let k=0;k<segU;k++){ cx+=row[k][0]; cy+=row[k][1]; cz+=row[k][2]; } | |
| cx/=segU; cy/=segU; cz/=segU; | |
| const C=[cx,cy,cz]; | |
| for(let k=0;k<segU;k++){ | |
| const a=row[k], b=row[k+1]; | |
| const t=flip?[C,b,a]:[C,a,b]; | |
| const u0=[t[1][0]-t[0][0],t[1][1]-t[0][1],t[1][2]-t[0][2]]; | |
| const v0=[t[2][0]-t[0][0],t[2][1]-t[0][1],t[2][2]-t[0][2]]; | |
| let nx=u0[1]*v0[2]-u0[2]*v0[1], ny=u0[2]*v0[0]-u0[0]*v0[2], nz=u0[0]*v0[1]-u0[1]*v0[0]; | |
| const l=Math.hypot(nx,ny,nz)||1; | |
| this.tri(t[0],t[1],t[2],nx/l,ny/l,nz/l,perV?col(0,flip?0:1):col); | |
| } | |
| }; | |
| if(capLo) cap(P[0],true); | |
| if(capHi) cap(P[segV],false); | |
| return this; | |
| } | |
| /* Wedge / ramp β a box with one sloped face. Prows, glacis plates, roofs. */ | |
| /* Ramp: a box whose top face slopes from y=0 on one edge up to y=h on the | |
| opposite edge. Written out corner by corner because the first version | |
| emitted a degenerate quad, which showed up in-world as long black shards | |
| radiating out of every roof that used it. */ | |
| wedge(x,y,z,w,h,d,col,yaw,flip){ | |
| const hw=w/2, hd=d/2, c=Math.cos(yaw||0), s=Math.sin(yaw||0); | |
| const P=(px,py,pz)=>[x+px*c-pz*s, y+py, z+px*s+pz*c]; | |
| const f=flip?-1:1; | |
| const lo=-hd*f, hi=hd*f; | |
| const a=P(-hw,0,lo), b=P(hw,0,lo), cc=P(hw,0,hi), dd=P(-hw,0,hi); // base | |
| const e=P(-hw,h,hi), g=P(hw,h,hi); // ridge | |
| this.quad(a,b,cc,dd,col); // floor | |
| this.quad(dd,cc,g,e,col); // tall back wall | |
| this.quad(b,a,e,g,col); // sloped roof | |
| this.tri(a,dd,e, -c,0,-s, col); // left gable | |
| this.tri(b,g,cc, c,0, s, col); // right gable | |
| return this; | |
| } | |
| /* ---- BEVELLED BOX ------------------------------------------------- | |
| A hard 90-degree edge is the single biggest giveaway of cheap geometry: | |
| real machined and cast parts have a chamfer, and that chamfer is what | |
| catches a highlight and reads the form. This builds the box as a top | |
| face inset by `b`, a ring of angled chamfer faces, and straight sides. | |
| Costs 6 extra quads and transforms a slab into a machined component. */ | |
| bevelBox(x,y,z,w,h,d,b,col,yaw){ | |
| const hw=w/2, hd=d/2, c=Math.cos(yaw||0), s=Math.sin(yaw||0); | |
| b=Math.min(b, Math.min(w,d)*0.4, h*0.5); | |
| const P=(px,py,pz)=>[x+px*c-pz*s, y+py, z+px*s+pz*c]; | |
| // outer ring at the top of the straight sides | |
| const o1=P(-hw,h-b,-hd), o2=P(hw,h-b,-hd), o3=P(hw,h-b,hd), o4=P(-hw,h-b,hd); | |
| // inner ring at the true top, pulled in by the chamfer | |
| const i1=P(-hw+b,h,-hd+b), i2=P(hw-b,h,-hd+b), i3=P(hw-b,h,hd-b), i4=P(-hw+b,h,hd-b); | |
| const b1=P(-hw,0,-hd), b2=P(hw,0,-hd), b3=P(hw,0,hd), b4=P(-hw,0,hd); | |
| this.quad(i1,i2,i3,i4,col); // top | |
| this.quad(b4,b3,b2,b1,col); // bottom | |
| this.quad(b1,b2,o2,o1,col); this.quad(b2,b3,o3,o2,col); // straight sides | |
| this.quad(b3,b4,o4,o3,col); this.quad(b4,b1,o1,o4,col); | |
| this.quad(o1,o2,i2,i1,col); this.quad(o2,o3,i3,i2,col); // chamfers | |
| this.quad(o3,o4,i4,i3,col); this.quad(o4,o1,i1,i4,col); | |
| return this; | |
| } | |
| /* ---- INNER EXTRUSION (recessed panel) ----------------------------- | |
| Cuts a sunken rectangle into a face: four sloped walls dropping to a | |
| smaller floor. This is how you get hatches, vents, intake grilles, door | |
| recesses and sensor wells β detail that reads as MACHINED rather than as | |
| a decal painted on a flat slab. `depth` is negative for a raised boss. */ | |
| inset(x,y,z,w,d,depth,shrink,col,yaw){ | |
| const hw=w/2, hd=d/2, c=Math.cos(yaw||0), s=Math.sin(yaw||0); | |
| const iw=hw*(1-shrink), id=hd*(1-shrink); | |
| const P=(px,py,pz)=>[x+px*c-pz*s, y+py, z+px*s+pz*c]; | |
| const o1=P(-hw,0,-hd), o2=P(hw,0,-hd), o3=P(hw,0,hd), o4=P(-hw,0,hd); | |
| const i1=P(-iw,-depth,-id), i2=P(iw,-depth,-id), i3=P(iw,-depth,id), i4=P(-iw,-depth,id); | |
| if(depth>=0){ | |
| this.quad(o1,o2,i2,i1,col); this.quad(o2,o3,i3,i2,col); | |
| this.quad(o3,o4,i4,i3,col); this.quad(o4,o1,i1,i4,col); | |
| this.quad(i1,i2,i3,i4,col); | |
| } else { // raised boss: flip the walls | |
| this.quad(i1,i2,o2,o1,col); this.quad(i2,i3,o3,o2,col); | |
| this.quad(i3,i4,o4,o3,col); this.quad(i4,i1,o1,o4,col); | |
| this.quad(i1,i2,i3,i4,col); | |
| } | |
| return this; | |
| } | |
| /* ---- TUBE (hollow cylinder) --------------------------------------- | |
| Exhaust stacks, barrel muzzles, silo mouths, pipe ends. A capped cylinder | |
| reads as solid; a tube reads as something gas comes out of. */ | |
| tube(x,y,z,rOut,rIn,h,seg,col){ | |
| seg=seg||10; | |
| for(let k=0;k<seg;k++){ | |
| const a=k/seg*TAU, b2=(k+1)/seg*TAU; | |
| const O=(r,ang,yy)=>[x+Math.cos(ang)*r, y+yy, z+Math.sin(ang)*r]; | |
| this.quad(O(rOut,a,0),O(rOut,b2,0),O(rOut,b2,h),O(rOut,a,h),col); // outside | |
| this.quad(O(rIn,b2,0),O(rIn,a,0),O(rIn,a,h),O(rIn,b2,h),col); // inside | |
| this.quad(O(rIn,a,h),O(rIn,b2,h),O(rOut,b2,h),O(rOut,a,h),col); // rim | |
| } | |
| return this; | |
| } | |
| /* ---- GREEBLE STRIP ------------------------------------------------ | |
| Scatters small raised blocks along a line. Mechanical clutter is what | |
| makes a large blank surface read as equipment instead of a wall, and it | |
| is far cheaper than modelling each fitting deliberately. */ | |
| greeble(x,y,z,len,wid,hMax,n,col,yaw,seed){ | |
| let sd=seed||1; | |
| const rr2=()=>{ sd=(sd*1664525+1013904223)&0x7fffffff; return (sd>>>10)/2097152; }; | |
| const c=Math.cos(yaw||0), s=Math.sin(yaw||0); | |
| for(let k=0;k<n;k++){ | |
| const t=(k+0.5)/n-0.5; | |
| const px=t*len, pz=(rr2()-0.5)*wid; | |
| const bw=len/n*(0.4+rr2()*0.5), bd=wid*(0.2+rr2()*0.45), bh=hMax*(0.3+rr2()*0.7); | |
| this.box(x+px*c-pz*s, y, z+px*s+pz*c, bw,bh,bd, col, yaw||0); | |
| } | |
| return this; | |
| } | |
| /* Extrude an arbitrary 2D outline upward. Any silhouette you can describe as | |
| a polygon becomes a solid β irregular ruins, hull plates, odd footprints. */ | |
| extrude(x,y,z,pts,h,col,yaw){ | |
| const c=Math.cos(yaw||0), s=Math.sin(yaw||0), n=pts.length; | |
| const P=(p,py)=>[x+p[0]*c-p[1]*s, y+py, z+p[0]*s+p[1]*c]; | |
| for(let k=0;k<n;k++){ | |
| const j=(k+1)%n; | |
| this.quad(P(pts[k],0),P(pts[j],0),P(pts[j],h),P(pts[k],h),col); | |
| } | |
| const uvp=q=>[q[0]*UVS,q[1]*UVS]; | |
| for(let k=1;k<n-1;k++){ | |
| /* Same reversal as cyl(): the roof of every extruded hull was culled, so | |
| the HQ read as an open shell with the ground showing through it. */ | |
| this.tri(P(pts[0],h),P(pts[k+1],h),P(pts[k],h),0,1,0,col,[uvp(pts[0]),uvp(pts[k+1]),uvp(pts[k])]); | |
| this.tri(P(pts[0],0),P(pts[k],0),P(pts[k+1],0),0,-1,0,col,[uvp(pts[0]),uvp(pts[k]),uvp(pts[k+1])]); | |
| } | |
| return this; | |
| } | |
| /* Flat ring lying on the ground β selection markers, blast rings, zones. | |
| Real geometry, so it tilts and rotates with the world like everything. */ | |
| ring(x,y,z,r0,r1,seg,col){ | |
| seg=seg||28; | |
| for(let k=0;k<seg;k++){ | |
| const a=k/seg*TAU, b=(k+1)/seg*TAU; | |
| const A=[x+Math.cos(a)*r0,y,z+Math.sin(a)*r0], B=[x+Math.cos(b)*r0,y,z+Math.sin(b)*r0]; | |
| const C=[x+Math.cos(b)*r1,y,z+Math.sin(b)*r1], D=[x+Math.cos(a)*r1,y,z+Math.sin(a)*r1]; | |
| const ringUV=p=>[(p[0]-x)*UVS,(p[2]-z)*UVS]; | |
| if(Math.abs(r0)<1e-7){ | |
| this.tri(A,D,C,0,1,0,col,[ringUV(A),ringUV(D),ringUV(C)]); | |
| continue; | |
| } | |
| if(Math.abs(r1)<1e-7){ | |
| this.tri(A,C,B,0,1,0,col,[ringUV(A),ringUV(C),ringUV(B)]); | |
| continue; | |
| } | |
| const o=this.n, M=this._mid, P=[A,B,C,D]; | |
| for(let q=0;q<4;q++) this.v.push(P[q][0],P[q][1],P[q][2],0,1,0,col[0],col[1],col[2], | |
| (P[q][0]-x)*UVS,(P[q][2]-z)*UVS,M); | |
| this.i.push(o,o+2,o+1, o,o+3,o+2); this.n+=4; | |
| } | |
| return this; | |
| } | |
| /* Uniformly scale everything built so far β lets models be authored at a | |
| comfortable size then normalised to the sim's unit scale. */ | |
| scale(k){ | |
| for(let i=0;i<this.v.length;i+=VFLOATS){ this.v[i]*=k; this.v[i+1]*=k; this.v[i+2]*=k; } | |
| return this; | |
| } | |
| /* Append an already-built geometry, offsetting its indices. World-site | |
| assembly bakes many small procedural buildings into ONE site mesh so a | |
| settlement is a single draw call rather than one per building. */ | |
| raw(g){ | |
| const base=this.v.length/VFLOATS; | |
| for(let i=0;i<g.v.length;i++) this.v.push(g.v[i]); | |
| for(let i=0;i<g.count;i++) this.i.push(base+g.i[i]); | |
| return this; | |
| } | |
| /* ---- ASSET UNWRAP ------------------------------------------------------ | |
| Rewrites lanes 9-10 from the shared-atlas planar UV into an asset-local | |
| 0..1 chart, for meshes that carry their own baked maps. OPT-IN: a mesh that | |
| does not call this keeps the atlas UV byte for byte, so nothing that exists | |
| today changes. | |
| INJECTIVE BY CONSTRUCTION, which is the whole requirement. The atlas UV is | |
| not β it is a tiling coordinate, so it repeats deliberately, and measured | |
| across the roster it lands 8.59 faces on the average texel and 500-1500 on | |
| the worst. You cannot bake into that: every face would overwrite its | |
| neighbours. Here each face owns one cell of a sqrt(n) grid and no two faces | |
| can ever address the same texel. | |
| Each face is normalised by ITS OWN bounding box rather than the mesh's, | |
| because _planarUV already origins every face at its own first vertex and | |
| orients it along its own first edge β the coordinates are face-local | |
| already, so per-face normalisation is the only one that is meaningful. | |
| Equal cells, not area-proportional. A real packer would spend texels where | |
| the surface is large; this spends them evenly. That is a texel-density | |
| compromise, not a correctness one, and it is the difference between a | |
| hundred lines and a thousand. Trading it for a proper packer later needs no | |
| change anywhere else, because only this function knows the layout. */ | |
| unwrapAssetUV(){ | |
| const F=this.faces, nF=F.length>>1; | |
| if(!nF) return this; | |
| const g=Math.ceil(Math.sqrt(nF)); | |
| /* Half a texel of a 1024 map, doubled: bilinear reads one neighbour, and | |
| mip level 1 reaches two. Without it faces bleed into each other at | |
| distance, which looks exactly like a broken unwrap. */ | |
| const gut=2.0/1024; | |
| const cell=1/g; | |
| for(let f=0;f<nF;f++){ | |
| const first=F[f*2], cnt=F[f*2+1]; | |
| let u0=Infinity,v0=Infinity,u1=-Infinity,v1=-Infinity; | |
| for(let k=0;k<cnt;k++){ | |
| const b=(first+k)*VFLOATS; | |
| const u=this.v[b+9], vv=this.v[b+10]; | |
| if(u<u0)u0=u; if(u>u1)u1=u; if(vv<v0)v0=vv; if(vv>v1)v1=vv; | |
| } | |
| const du=(u1-u0)||1, dv=(v1-v0)||1; | |
| const cx=(f%g)*cell+gut, cy=((f/g)|0)*cell+gut, sz=cell-gut*2; | |
| for(let k=0;k<cnt;k++){ | |
| const b=(first+k)*VFLOATS; | |
| this.v[b+9] =cx+((this.v[b+9] -u0)/du)*sz; | |
| this.v[b+10]=cy+((this.v[b+10]-v0)/dv)*sz; | |
| } | |
| } | |
| this.assetUV={faces:nF,grid:g}; | |
| return this; | |
| } | |
| build(){ | |
| const J=this.joints, sk=new Float32Array(Math.max(1,J.length)*10); | |
| for(let k=0;k<J.length;k++) sk.set(J[k],k*10); | |
| return {v:new Float32Array(this.v), i:new Uint16Array(this.i), count:this.i.length, | |
| skel:sk, bones:J.length, assetUV:this.assetUV||null}; | |
| } | |
| } | |
| const MB=()=>new MeshBuilder(); | |
| /* ---- ASSET SKIN -------------------------------------------------------- | |
| The last link: give one InstMesh its own baked triplet. Loads the three maps | |
| artv2 publishes, and only once ALL THREE decode does it unwrap a copy of the | |
| geometry, re-upload it and attach the maps. Order matters -- unwrapping first | |
| would leave the mesh reading a 0..1 corner of the shared atlas for however | |
| long the network takes, and a partial set would sample an incomplete texture | |
| unit, which drops the whole draw call silently (see docs/UNIT_PER_ASSET_UV_UNLOCK.md). | |
| Nothing calls this by default. mfAssetSkinEnabled() gates the wiring. */ | |
| const MF_ASSET_TEX={}; | |
| function mfAssetSkinEnabled(){ | |
| try{ return /[?&]assetskin=1/.test(location.search); }catch(e){ return false; } | |
| } | |
| function mfAssetTex(gl,url){ | |
| if(MF_ASSET_TEX[url]) return MF_ASSET_TEX[url]; | |
| const epoch=typeof glEpoch==='number'?glEpoch:0; | |
| const t=gl.createTexture(); | |
| const rec={tex:t,ready:false}; | |
| const img=new Image(); | |
| img.crossOrigin='anonymous'; | |
| img.onload=()=>{ | |
| /* Image decode can outlive WEBGL_lose_context. A replacement request now | |
| owns this URL; the detached callback must not upload through its dead | |
| texture wrapper or notify the new generation's completion closure. */ | |
| if(epoch!==(typeof glEpoch==='number'?glEpoch:0)||MF_ASSET_TEX[url]!==rec) return; | |
| /* Same trap as tacticons.js: a valid PNG of the wrong size decodes and | |
| never hits onerror. The unwrap chart is authored at 1024 (2px gutters | |
| in mfUnwrapGeoUV), so a 512 sheet would stretch one island across four | |
| and a 2048 sheet would sample a quarter of the chart. Stay on the atlas. */ | |
| if(img.naturalWidth!==1024||img.naturalHeight!==1024){ | |
| rec.failed=true; rec.reject='size'; if(rec.onready) rec.onready(); return; | |
| } | |
| gl.bindTexture(gl.TEXTURE_2D,t); | |
| gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false); | |
| gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA8,gl.RGBA,gl.UNSIGNED_BYTE,img); | |
| gl.generateMipmap(gl.TEXTURE_2D); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); | |
| rec.ready=true; if(rec.onready) rec.onready(); | |
| }; | |
| img.onerror=()=>{ | |
| if(epoch!==(typeof glEpoch==='number'?glEpoch:0)||MF_ASSET_TEX[url]!==rec) return; | |
| rec.failed=true; if(rec.onready) rec.onready(); | |
| }; | |
| img.src=url; | |
| MF_ASSET_TEX[url]=rec; | |
| return rec; | |
| } | |
| function mfAssetSkin(gl,mesh,name){ | |
| if(!gl||!mesh||!name) return false; | |
| const base='assets/textures/materials/'+name; | |
| const urls=[base+'-baseao.png',base+'-nre.png',base+'-masks.png'].map(u=> | |
| (typeof mf2AssetURL==='function')?mf2AssetURL(u):('./'+u)); | |
| const recs=urls.map(u=>mfAssetTex(gl,u)); | |
| const tryFinish=()=>{ | |
| if(recs.some(r=>!r.ready&&!r.failed)) return; // still loading | |
| if(recs.some(r=>r.failed)) return; // incomplete: stay on the atlas | |
| const g=mesh.geo; | |
| const copy={v:new Float32Array(g.v),i:g.i,count:g.count,skel:g.skel,bones:g.bones}; | |
| mfUnwrapGeoUV(copy); | |
| mesh.reuploadGeo(copy); | |
| mesh.assetMaps={base:recs[0].tex,nre:recs[1].tex,mask:recs[2].tex}; | |
| }; | |
| for(const r of recs){ if(r.ready||r.failed) continue; r.onready=tryFinish; } | |
| tryFinish(); | |
| return true; | |
| } | |
| /* Four architectural materials share one compact 2x2 PBR set. Unlike the | |
| per-model skin above, this deliberately keeps the world-kit's face-stable | |
| planar UVs: every WORLDKIT_* semantic id selects one 512px quadrant. Units | |
| 4/5/6 are already reserved for BaseAO/NRE/masks, so this adds neither a | |
| sampler nor a draw call while restoring 4x the linear texel density of the | |
| mobile 1408 atlas (512 versus 128 texels per material). */ | |
| const MF_WORLDKIT_SKIN={meshes:[],recs:null,maps:null}; | |
| function mfWorldKitSkin(gl,mesh){ | |
| if(!gl||!mesh) return false; | |
| if(MF_WORLDKIT_SKIN.maps){ | |
| mesh.assetMaps=MF_WORLDKIT_SKIN.maps; mesh.assetMode=2; return true; | |
| } | |
| if(MF_WORLDKIT_SKIN.meshes.indexOf(mesh)<0) MF_WORLDKIT_SKIN.meshes.push(mesh); | |
| if(!MF_WORLDKIT_SKIN.recs){ | |
| const base='assets/textures/materials/mf-worldkit-v4'; | |
| const urls=[base+'-baseao.png',base+'-nre.png',base+'-masks.png'].map(u=> | |
| (typeof mf2AssetURL==='function')?mf2AssetURL(u):('./'+u)); | |
| MF_WORLDKIT_SKIN.recs=urls.map(u=>mfAssetTex(gl,u)); | |
| const finish=()=>{ | |
| const recs=MF_WORLDKIT_SKIN.recs; | |
| if(!recs||recs.some(r=>!r.ready&&!r.failed)) return; | |
| if(recs.some(r=>r.failed)) return; // atomic fallback to shared atlas | |
| const maps={base:recs[0].tex,nre:recs[1].tex,mask:recs[2].tex}; | |
| MF_WORLDKIT_SKIN.maps=maps; | |
| for(const M of MF_WORLDKIT_SKIN.meshes){ M.assetMaps=maps; M.assetMode=2; } | |
| }; | |
| for(const r of MF_WORLDKIT_SKIN.recs){ if(!r.ready&&!r.failed) r.onready=finish; } | |
| finish(); | |
| } | |
| return true; | |
| } | |
| function mfWorldKitSkinReset(){ | |
| /* Core/faction meshes are rebuilt too, so every optional per-asset record | |
| belongs to the lost contextβnot only the world-kit triplet. Clearing the | |
| shared registry also detaches pending old-generation image callbacks. */ | |
| for(const k in MF_ASSET_TEX) delete MF_ASSET_TEX[k]; | |
| MF_WORLDKIT_SKIN.meshes.length=0; | |
| MF_WORLDKIT_SKIN.recs=null; MF_WORLDKIT_SKIN.maps=null; | |
| } | |
| /* The same grid unwrap applied to an ALREADY BUILT geometry, treating each | |
| index triple as a face. The builder method works on face spans, so it keeps | |
| quads whole and wastes fewer cells; this one exists so the injectivity | |
| property can be measured on any shipped mesh without rebuilding it through | |
| its model file (tools/verify-asset-unwrap.mjs). Same cell arithmetic, so a | |
| pass here is evidence about the real thing. */ | |
| function mfUnwrapGeoUV(geo){ | |
| if(!geo||!geo.v||!geo.i) return geo; | |
| /* Group triangles into FACES first. A quad emits o,o+2,o+1 then o,o+3,o+2 -- | |
| the two triangles SHARE two vertices. Unwrapping per triangle therefore | |
| writes a shared vertex into two different cells, the second write wins, and | |
| both triangles end up straddling cells: measured worst 2-3 faces per texel | |
| instead of 1. Vertices are the unit of UV storage, so faces that share a | |
| vertex must share a cell. */ | |
| const tris=Math.floor(geo.count/3), v=geo.v, ix=geo.i; | |
| const faces=[]; let cur=null; | |
| for(let t=0;t<tris;t++){ | |
| const a=ix[t*3],b=ix[t*3+1],c=ix[t*3+2]; | |
| if(cur && (cur.has(a)?1:0)+(cur.has(b)?1:0)+(cur.has(c)?1:0)>=2){ | |
| cur.add(a); cur.add(b); cur.add(c); | |
| } else { cur=new Set([a,b,c]); faces.push(cur); } | |
| } | |
| const nF=faces.length, g=Math.ceil(Math.sqrt(nF)); | |
| const gut=2.0/1024, cell=1/g; | |
| for(let f=0;f<nF;f++){ | |
| const verts=[...faces[f]]; | |
| let u0=Infinity,v0=Infinity,u1=-Infinity,v1=-Infinity; | |
| for(const q of verts){ const bq=q*VFLOATS, u=v[bq+9], w=v[bq+10]; | |
| if(u<u0)u0=u; if(u>u1)u1=u; if(w<v0)v0=w; if(w>v1)v1=w; } | |
| const du=(u1-u0)||1, dv=(v1-v0)||1; | |
| const cx=(f%g)*cell+gut, cy=((f/g)|0)*cell+gut, sz=cell-gut*2; | |
| for(const q of verts){ const bq=q*VFLOATS; | |
| v[bq+9] =cx+((v[bq+9] -u0)/du)*sz; | |
| v[bq+10]=cy+((v[bq+10]-v0)/dv)*sz; } | |
| } | |
| geo.assetUV={faces:nF,grid:g}; | |
| return geo; | |
| } | |
| /* ============================================================================ | |
| INSTANCED MESH β one geometry, many placements, one draw call. | |
| Per-instance stream: position/scale, yaw, tint, width, animation, surface | |
| state, optional pitch, optional roll. Roll is local about model +X (forward) | |
| and is applied before the existing pitch/yaw so aircraft bank on the | |
| longitudinal axis instead of yaw-wobbling. Omit it and it stays 0. | |
| ============================================================================ */ | |
| const VSTRIDE=VFLOATS*4; | |
| const INST_FLOATS=14, INST_STRIDE=INST_FLOATS*4; | |
| const MAX_INST=26000; | |
| /* Eight limbs of seven segments is 56, and the Sovereign's tendrils take it to | |
| 68 β 64 was not enough and the overflow was silent, which is the worst kind. | |
| GLES3 guarantees 256 vec4 of vertex uniform space; 80 bones costs | |
| 80 + 80 + 40 = 200 vec4, leaving room for the matrices and lighting. */ | |
| const MAX_BONES=80; | |
| class InstMesh{ | |
| constructor(gl,geo,cap){ | |
| this.gl=gl; this.count=geo.count; this.cap=cap||2400; this.n=0; | |
| this.data=new Float32Array(this.cap*INST_FLOATS); | |
| /* bindShadow() and flush() consume the SAME instance stream. CSM runs | |
| first, so uploading that unchanged stream again for colour doubled the | |
| per-frame instance traffic of every shadow caster. The cache is keyed by | |
| content revision, count, GL context and buffer identity: it can only | |
| suppress the exact upload bindShadow already performed. */ | |
| this._instRevision=1; | |
| this._instUploadedRevision=-1; | |
| this._instUploadedN=-1; | |
| this._instUploadedGL=null; | |
| this._instUploadedBuffer=null; | |
| this.vao=gl.createVertexArray(); | |
| gl.bindVertexArray(this.vao); | |
| const vb=gl.createBuffer(); | |
| /* Kept, with the source geometry, so a mesh can be re-skinned after its | |
| baked maps arrive. The unwrap must NOT be applied before then: 0..1 chart | |
| coordinates read as a tiny corner of the shared atlas, so an unwrapped | |
| mesh drawn on the atlas path is visibly wrong. Load first, then swap. */ | |
| this.vb=vb; this.geo=geo; | |
| gl.bindBuffer(gl.ARRAY_BUFFER,vb); | |
| gl.bufferData(gl.ARRAY_BUFFER,geo.v,gl.STATIC_DRAW); | |
| gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,3,gl.FLOAT,false,VSTRIDE,0); // pos | |
| gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,3,gl.FLOAT,false,VSTRIDE,12); // normal | |
| gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,3,gl.FLOAT,false,VSTRIDE,24); // colour | |
| gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3,2,gl.FLOAT,false,VSTRIDE,36); // uv | |
| gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4,1,gl.FLOAT,false,VSTRIDE,44); // material id | |
| /* Split once into the layout the shader wants. Doing it per frame would | |
| allocate three typed arrays per draw call. */ | |
| /* Truncating here would drop the last limbs off an animal with no error at | |
| all β exactly the failure the bone test now gates against. Say so. */ | |
| if((geo.bones||0)>MAX_BONES) | |
| console.warn('mesh: model needs '+geo.bones+' bones, only '+MAX_BONES+' fit β limbs will be rigid'); | |
| /* Null until an asset declares a baked triplet; see flush(). Held per mesh | |
| because one InstMesh is one geometry -- which is exactly what makes | |
| per-asset surfacing possible without widening the vertex. */ | |
| this.assetMaps=null; | |
| this.bones=Math.min(geo.bones||0,MAX_BONES); | |
| if(this.bones){ | |
| this.jointBuf=new Float32Array(this.bones*4); | |
| this.axisBuf =new Float32Array(this.bones*4); | |
| this.swingBuf=new Float32Array(this.bones*2); | |
| for(let k=0;k<this.bones;k++){ | |
| const o=k*10; | |
| this.jointBuf[k*4 ]=geo.skel[o ]; this.jointBuf[k*4+1]=geo.skel[o+1]; | |
| this.jointBuf[k*4+2]=geo.skel[o+2]; this.jointBuf[k*4+3]=geo.skel[o+3]; | |
| this.axisBuf [k*4 ]=geo.skel[o+4]; this.axisBuf [k*4+1]=geo.skel[o+5]; | |
| this.axisBuf [k*4+2]=geo.skel[o+6]; this.axisBuf [k*4+3]=geo.skel[o+7]; | |
| this.swingBuf[k*2 ]=geo.skel[o+8]; this.swingBuf[k*2+1]=geo.skel[o+9]; | |
| } | |
| } | |
| const ib=gl.createBuffer(); | |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ib); | |
| gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,geo.i,gl.STATIC_DRAW); | |
| this.ivb=gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,this.ivb); | |
| gl.bufferData(gl.ARRAY_BUFFER,this.data.byteLength,gl.DYNAMIC_DRAW); | |
| gl.enableVertexAttribArray(5); gl.vertexAttribPointer(5,4,gl.FLOAT,false,INST_STRIDE,0); // xyz + scale | |
| gl.vertexAttribDivisor(5,1); | |
| gl.enableVertexAttribArray(6); gl.vertexAttribPointer(6,1,gl.FLOAT,false,INST_STRIDE,16); // yaw | |
| gl.vertexAttribDivisor(6,1); | |
| gl.enableVertexAttribArray(7); gl.vertexAttribPointer(7,4,gl.FLOAT,false,INST_STRIDE,20); // rgba | |
| gl.vertexAttribDivisor(7,1); | |
| gl.enableVertexAttribArray(8); gl.vertexAttribPointer(8,1,gl.FLOAT,false,INST_STRIDE,36); // cross-axis width | |
| gl.vertexAttribDivisor(8,1); | |
| /* Animation phase. One float per instance is all a walk cycle needs: the | |
| vertex stage swings anything marked as an actuator around its hip, so ten | |
| thousand legs animate with no extra draw calls and no CPU skinning. */ | |
| gl.enableVertexAttribArray(9); gl.vertexAttribPointer(9,1,gl.FLOAT,false,INST_STRIDE,40); | |
| gl.vertexAttribDivisor(9,1); | |
| /* Material V2 surface state. One normalized float carries health-derived | |
| wear/char independently from movement animation: reusing aAnim made a | |
| damaged walker change pose and made every stationary building immune to | |
| the live damage material. Existing callers omit it and remain pristine. */ | |
| gl.enableVertexAttribArray(10); gl.vertexAttribPointer(10,1,gl.FLOAT,false,INST_STRIDE,44); | |
| gl.vertexAttribDivisor(10,1); | |
| /* Optional local pitch. Zero for every legacy caller; articulated turret | |
| assemblies opt in without another mesh stream or draw call. */ | |
| gl.enableVertexAttribArray(11); gl.vertexAttribPointer(11,1,gl.FLOAT,false,INST_STRIDE,48); | |
| gl.vertexAttribDivisor(11,1); | |
| /* Optional local roll about model +X. Aircraft consume uAirBank / uCroll | |
| here; ground, FX and turret callers omit the argument and stay level. */ | |
| gl.enableVertexAttribArray(12); gl.vertexAttribPointer(12,1,gl.FLOAT,false,INST_STRIDE,52); | |
| gl.vertexAttribDivisor(12,1); | |
| gl.bindVertexArray(null); | |
| } | |
| _instMutated(){ this._instRevision++; } | |
| _instInvalidateUpload(){ | |
| this._instUploadedRevision=-1; | |
| this._instUploadedN=-1; | |
| this._instUploadedGL=null; | |
| this._instUploadedBuffer=null; | |
| } | |
| _instUpload(gl){ | |
| /* Preserve the historical ARRAY_BUFFER bind even on a cache hit. Callers | |
| use this as a state boundary; only bufferSubData is redundant. */ | |
| gl.bindBuffer(gl.ARRAY_BUFFER,this.ivb); | |
| if(this._instUploadedRevision===this._instRevision&& | |
| this._instUploadedN===this.n&& | |
| this._instUploadedGL===gl&& | |
| this._instUploadedBuffer===this.ivb) return false; | |
| gl.bufferSubData(gl.ARRAY_BUFFER,0,this.data,0,this.n*INST_FLOATS); | |
| this._instUploadedRevision=this._instRevision; | |
| this._instUploadedN=this.n; | |
| this._instUploadedGL=gl; | |
| this._instUploadedBuffer=this.ivb; | |
| return true; | |
| } | |
| clear(){ if(this.n){ this.n=0; this._instMutated(); } } | |
| /* Instance capacities are only a starting allocation. Large fortress maps | |
| can legitimately put hundreds of wall or turret instances into one mesh | |
| stream; silently dropping everything after `cap` made faction kits look | |
| incomplete exactly when a base got interesting. Grow geometrically on | |
| the rare overflow frame, while retaining a hard ceiling against corrupt | |
| or runaway callers. */ | |
| grow(){ | |
| if(this.cap>=MAX_INST) return false; | |
| const next=Math.min(MAX_INST,Math.max(this.cap+1,this.cap<1024?this.cap*2:Math.ceil(this.cap*1.5))); | |
| const data=new Float32Array(next*INST_FLOATS); data.set(this.data); | |
| this.data=data; this.cap=next; | |
| const gl=this.gl; | |
| gl.bindBuffer(gl.ARRAY_BUFFER,this.ivb); | |
| gl.bufferData(gl.ARRAY_BUFFER,this.data.byteLength,gl.DYNAMIC_DRAW); | |
| /* bufferData replaces the store even though the WebGLBuffer identity is | |
| unchanged. No pre-grow upload is valid against the new allocation. */ | |
| this._instMutated(); | |
| this._instInvalidateUpload(); | |
| return true; | |
| } | |
| /* Note x,y are SIM coordinates (ground plane) and h is height above ground β | |
| the swap to GL's X/Y/Z happens here so callers never think about it. */ | |
| /* `wide` scales the model's Z axis independently. Everything except ground | |
| lines passes it undefined and gets uniform scaling; a line needs its | |
| length and its thickness to be separate numbers, or a long one comes out | |
| as a fat bar. */ | |
| add(x,y,h,scale,yaw,r,g,b,a,wide,anim,state,pitch,roll){ | |
| if(this.n>=this.cap&&!this.grow()) return; | |
| const o=this.n*INST_FLOATS, d=this.data; | |
| d[o]=x; d[o+1]=h; d[o+2]=y; d[o+3]=scale; | |
| d[o+4]=yaw; | |
| d[o+5]=r/255; d[o+6]=g/255; d[o+7]=b/255; d[o+8]=(a===undefined?255:a)/255; | |
| d[o+9]=(wide===undefined?scale:wide); | |
| d[o+10]=anim||0; | |
| /* 0..1 is ordinary damage. Values above it reserve an asset-specific V2 | |
| profile band: profile*2 + damage. This keeps the profile per instance, | |
| so commanders and landmark structures can graduate to their own authored | |
| map packs without splitting the main battle streams. */ | |
| d[o+11]=clamp(state||0,0,7.999); | |
| d[o+12]=Number.isFinite(pitch)?pitch:0; | |
| d[o+13]=Number.isFinite(roll)?roll:0; | |
| this.n++; | |
| this._instMutated(); | |
| } | |
| /* Replace the vertex block in place. Layout, index buffer and instance | |
| attributes are untouched -- only lanes 9-10 differ. */ | |
| reuploadGeo(geo){ | |
| const gl=this.gl; | |
| gl.bindVertexArray(this.vao); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,this.vb); | |
| gl.bufferData(gl.ARRAY_BUFFER,geo.v,gl.STATIC_DRAW); | |
| gl.bindVertexArray(null); | |
| this.geo=geo; | |
| } | |
| flush(gl){ | |
| if(!this.n) return; | |
| gl.bindVertexArray(this.vao); | |
| /* The skeleton is per-MODEL, not per-instance: every Ravager on the field | |
| shares one rest pose and differs only in its gait phase, which already | |
| rides the instance stream. So it costs two small uniform arrays per draw | |
| call and nothing per unit β a thousand of them still animate on one call. | |
| Uploaded here rather than at bind time because the program is shared and | |
| another model's skeleton may be resident. */ | |
| /* Per-ASSET baked maps, uploaded per draw call for the same reason the | |
| skeleton above is: one InstMesh is one geometry, so this costs three | |
| binds per draw and nothing per instance. Switched off explicitly when | |
| absent -- a stale uAssetOn would paint the last asset's skin onto this | |
| one. */ | |
| /* Units 4/5/6 are the post chain (SSAO/bloom). Asset skins borrow them | |
| for THIS draw only. Combat additive flushes share InstMesh with progG | |
| bound β writing prog3D locations then is INVALID_OPERATION, and leaving | |
| a baked map on unit 5 makes bloom sample-while-write and strobes every | |
| hull. Bones used to uniform1i(uBoneN,0) on every boneless FX mesh, which | |
| is the same error on every tracer. */ | |
| /* CURRENT_PROGRAM is a GPU sync. 40β80 flushes/frame paid it. Track the | |
| last useProgram instead β look is identical, HIGH still binds skins. */ | |
| const onModel=!!(prog3D&&MF_PROG_MODEL); | |
| let boundAssets=false; | |
| if(onModel&&typeof U3!=='undefined'&&U3.uAssetOn){ | |
| if(this.assetMaps){ | |
| gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,this.assetMaps.base); | |
| gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D,this.assetMaps.nre); | |
| gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D,this.assetMaps.mask); | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.uniform1f(U3.uAssetOn,this.assetMode||1.0); MF_ASSET_ON=true; | |
| boundAssets=true; | |
| } else if(MF_ASSET_ON){ gl.uniform1f(U3.uAssetOn,0.0); MF_ASSET_ON=false; } | |
| } | |
| if(onModel&&typeof U3!=='undefined'&&U3.uBoneN!=null){ | |
| if(this.bones){ | |
| gl.uniform1i(U3.uBoneN,this.bones); | |
| gl.uniform4fv(U3.uJoint,this.jointBuf); | |
| gl.uniform4fv(U3.uAxis,this.axisBuf); | |
| gl.uniform2fv(U3.uSwing,this.swingBuf); | |
| MF_BONES_ON=true; | |
| } else if(MF_BONES_ON){ gl.uniform1i(U3.uBoneN,0); MF_BONES_ON=false; } | |
| } | |
| this._instUpload(gl); | |
| gl.drawElementsInstanced(gl.TRIANGLES,this.count,gl.UNSIGNED_SHORT,0,this.n); | |
| drawCalls++; triCount+=this.count/3*this.n; | |
| if(boundAssets&&typeof matTex!=='undefined'&&matTex){ | |
| 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); | |
| } | |
| this.n=0; | |
| this._instMutated(); | |
| } | |
| /* Sun-depth only. Leaves n intact so the colour flush still owns the list. | |
| No samplers β cannot alias post 4/5/6 or the atlas on 0. */ | |
| bindShadow(gl){ | |
| if(!this.n) return false; | |
| gl.bindVertexArray(this.vao); | |
| this._instUpload(gl); | |
| return true; | |
| } | |
| drawShadow(gl){ | |
| if(!this.n) return; | |
| gl.drawElementsInstanced(gl.TRIANGLES,this.count,gl.UNSIGNED_SHORT,0,this.n); | |
| drawCalls++; | |
| } | |
| } | |
| let drawCalls=0, triCount=0; | |
| /* Last useProgram was prog3D? InstMesh.flush reads this instead of querying GL. */ | |
| let MF_PROG_MODEL=false; | |
| /* ============================================================================ | |
| SHADERS | |
| One lit program for solid geometry, one unlit additive program for glow. | |
| ============================================================================ */ | |
| const VS3D=`#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; // xyz = world position, w = uniform scale | |
| layout(location=6) in float aYaw; | |
| layout(location=7) in vec4 aTint; | |
| layout(location=8) in float aWide; | |
| layout(location=9) in float aAnim; | |
| layout(location=10) in float aState; | |
| layout(location=11) in float aPitch; | |
| layout(location=12) in float aRoll; | |
| /* ---- SKELETON ------------------------------------------------------------- | |
| Rigid-body forward kinematics. Each limb segment is a solid that rotates | |
| about the joint carrying it, after its parent has already moved β the same | |
| thing a physics engine does with a hinge constraint, evaluated here instead | |
| of on the CPU so the whole swarm still costs one draw call. | |
| No skinning weights, deliberately: chitin is armour. A shell segment does not | |
| stretch between two bones, it pivots, and weighting it would smear the joint | |
| into rubber. One bone per vertex is both anatomically correct here and about | |
| four times cheaper than a matrix palette. | |
| uJoint[i] = vec4(pivot.xyz, parent) parent < 0 means rigid to the body | |
| uAxis [i] = vec4(axis.xyz, phase) phase seeds this joint's own swing | |
| uSwing[i] = vec2(amplitude, bias) radians | |
| --------------------------------------------------------------------------- */ | |
| uniform int uBoneN; | |
| uniform vec4 uJoint[80]; | |
| uniform vec4 uAxis[80]; | |
| uniform vec2 uSwing[80]; | |
| vec3 rotAxis(vec3 v, vec3 k, float a){ | |
| float c=cos(a), s=sin(a); | |
| return v*c + cross(k,v)*s + k*dot(k,v)*(1.0-c); | |
| } | |
| uniform mat4 uVP; | |
| uniform vec3 uEye; | |
| uniform float uHazeQ; | |
| out vec3 vNrm; out vec3 vCol; out float vFog; out vec3 vWorld; out vec3 vObj; | |
| out float vAlpha; out float vInstEmis; out float vState; | |
| out vec2 vFowUV; | |
| out vec2 vUV; | |
| /* FLAT, not smooth. A material id is an index, not a quantity β interpolating | |
| between "leaf" (13) and "earth" (9) across a triangle walks through every id | |
| in between, which painted hazard stripes and brass across the landscape. */ | |
| flat out float vMat; | |
| void main(){ | |
| float c=cos(aYaw), s=sin(aYaw); | |
| /* WALK CYCLE. | |
| Vertices painted with the actuator material are legs. They swing fore and | |
| aft around a hip line, with the two sides in opposite phase and the throw | |
| proportional to how far below the hip the vertex sits β so a foot travels | |
| and a thigh barely moves. The body rides a vertical bob at twice the leg | |
| frequency, which is what the eye actually reads as walking. | |
| No skinning, no bones, no extra draw calls: one float per instance. */ | |
| vec3 ap=aPos; | |
| /* Material id and bone index share one float: integer part is the material, | |
| fractional part is the bone. Nothing without a skeleton writes a fraction, | |
| so a wall, a tank or a tree unpacks to bone -1 and skips the chain | |
| entirely β structures pay nothing for a system they do not use. */ | |
| float aMatAbs=abs(aMat); | |
| float aBone=floor(fract(aMatAbs)*128.0+0.5)-1.0; | |
| /* Walk the chain from this vertex's own bone up to the body. Bounded at 8 β | |
| a limb is coxa..tarsus and nothing in this game is deeper β so the loop | |
| unrolls and there is no unbounded iteration on a mobile compiler. */ | |
| if(uBoneN>0 && aBone>=0.0){ | |
| int cur=int(aBone+0.5); | |
| for(int k=0;k<8;k++){ | |
| if(cur<0 || cur>=uBoneN) break; | |
| vec4 J=uJoint[cur]; vec4 A=uAxis[cur]; vec2 S=uSwing[cur]; | |
| /* The gait. Amplitude and bias are per joint, so a hip sweeps and a | |
| tarsus only flicks; the phase offset is what makes a leg unfurl in | |
| sequence down its length instead of folding as one rigid hook. */ | |
| float ang = S.y + S.x*sin(aAnim + A.w); | |
| ap = J.xyz + rotAxis(ap - J.xyz, A.xyz, ang); | |
| cur = int(J.w); | |
| } | |
| } | |
| float matId=floor(aMatAbs)-1.0; | |
| float leg = (matId == SERVO_CONST) ? 1.0 : 0.0; | |
| float bioLimb = (matId == BIOLEG_CONST) ? 1.0 : 0.0; | |
| float bioBody = (matId == CHITIN_CONST) ? 1.0 : 0.0; | |
| if(aAnim!=0.0){ | |
| float side = aPos.z<0.0 ? 1.0 : -1.0; | |
| /* +PI and -PI are the SAME sine phase. The old expression therefore | |
| drove both legs together and produced the reported hopping gait. Give | |
| one side zero offset and the other PI, then lift only the advancing | |
| leg; abs(sw) would make both feet rise together again. */ | |
| float sidePhase = aPos.z<0.0 ? 0.0 : 3.14159; | |
| float sw = sin(aAnim + sidePhase); | |
| float below= max(0.0, HIP_CONST - aPos.y); | |
| ap.x += leg * sw * below * 0.36; | |
| ap.y += leg * max(0.0,sw) * below * 0.10; // one planted foot, one advancing foot | |
| ap.y += (1.0-leg) * abs(sin(aAnim)) * 0.32; // body bob, twice per stride | |
| /* Two delayed oscillators approximate spring follow-through on antennae, | |
| mandibles, sacs and legs. It costs no bones, CPU state, draw calls or | |
| instance bytes; distant/low-quality Brood pass aAnim=0 and skip it. */ | |
| /* Bones win where a vertex has one. The two-oscillator spring below was a | |
| stand-in for articulation; running it ON TOP of a real hinge chain adds a | |
| second, uncorrelated wobble and the limb reads as rubber. It still earns | |
| its place on everything unboned β jaws, spines, sacs, membrane. */ | |
| float loose = aBone<0.0 ? 1.0 : 0.0; | |
| float flex=clamp((aPos.y+.35)/7.5,0.12,1.0)*loose; | |
| float lead=sin(aAnim+aPos.x*.22+side*.55); | |
| float lag=sin(aAnim*.73-1.05+aPos.x*.13-side*.32); | |
| float bend=lead*.68+lag*.32; | |
| ap.x += bioLimb*bend*flex*.46; | |
| ap.z += bioLimb*(lead-lag)*flex*.20; | |
| ap.y += bioLimb*abs(lag)*flex*.12; | |
| float breath=sin(aAnim*.43+aPos.x*.11)*.035; | |
| ap.y += bioBody*breath*max(0.0,aPos.y)*.32; | |
| ap.z *= 1.0+bioBody*breath; | |
| } | |
| /* Attitude is roll about local +X (forward), then the existing pitch about | |
| +Z, then yaw about +Y. Bank-into-yaw was the decorative sine wobble; | |
| aRoll=0 leaves the previous pitch-then-yaw pose unchanged. */ | |
| float cr=cos(aRoll),sr=sin(aRoll); | |
| float cp=cos(aPitch),spit=sin(aPitch); | |
| vec3 rolled=vec3(ap.x, ap.y*cr-ap.z*sr, ap.y*sr+ap.z*cr); | |
| vec3 rolledN=vec3(aNrm.x, aNrm.y*cr-aNrm.z*sr, aNrm.y*sr+aNrm.z*cr); | |
| vec3 pitched=vec3(rolled.x*cp-rolled.y*spit,rolled.x*spit+rolled.y*cp,rolled.z); | |
| vec3 pitchedN=vec3(rolledN.x*cp-rolledN.y*spit,rolledN.x*spit+rolledN.y*cp,rolledN.z); | |
| vec3 sp=vec3(pitched.x*aInst.w, pitched.y*aInst.w, pitched.z*aWide); | |
| vec3 p=vec3(sp.x*c - sp.z*s, sp.y, sp.x*s + sp.z*c) + aInst.xyz; | |
| vec3 n=vec3(pitchedN.x*c - pitchedN.z*s, pitchedN.y, pitchedN.x*s + pitchedN.z*c); | |
| /* Negative material id = team livery panel: take the full team colour. | |
| Everything else keeps its own material colour with a light team wash, so | |
| detail survives instead of being flattened into one hue. */ | |
| /* Livery panels take the faction colour outright; everything else takes a | |
| substantial wash of it. Too little and the two armies are both grey; too | |
| much and every surface collapses into one hue. */ | |
| /* Tower finishes are deliberately neutral and separated by roughness/value; | |
| a strong faction wash would collapse them back into one cyan material. | |
| Keep legacy roster readability unchanged and use only a restrained wash on | |
| the dedicated tower atlas range. */ | |
| float towerSurface=step(18.5,matId); | |
| float tw = aMat<0.0 ? 1.0 : mix(0.46,0.14,towerSurface); | |
| /* Opaque structure pulses piggyback above alpha 1.0 in the existing | |
| per-instance tint slot. Keeping the signal per instance lets a whole | |
| faction/type stream stay in one draw call; ordinary alpha remains 0..1. */ | |
| vNrm=n; vCol=aCol*mix(vec3(1.0),aTint.rgb,tw); | |
| vAlpha=min(aTint.a,1.0); vInstEmis=max(0.0,aTint.a-1.0); vWorld=p; | |
| vObj=ap;vState=aState; | |
| vUV=aUV*max(aInst.w,0.001); vMat=matId; // clean id; the bone fraction stays here | |
| /* Aerial perspective from GROUND range, not 3D eye range. CAM_HEIGHT is | |
| 3000, so Euclidean d is ~3000 at the look-at at every zoom β a 2600 | |
| start milky-washed every HQ close-up, and a 3120 start never veils a | |
| vision bubble (the far screen at command altitude is unexplored FOW). | |
| Planar distance from the eye's ground projection is ~cos(pitch)*3000 | |
| at the look-at: tilted command views haze the far side, straight-down | |
| stays readable, tactical close-ups stay clear. */ | |
| 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; | |
| vFowUV=p.xz/MAPSIZE_CONST; | |
| /* BORDER HAZE. The battlefield ends at a hard rectangle; without this the | |
| player sees the void past it. Thickening the fog over the last stretch of | |
| ground turns that edge into distance β the map reads as continuing into | |
| weather rather than stopping at a wall, which is what lets the camera | |
| overhang the border at all. */ | |
| 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 FS3D=`#version 300 es | |
| precision highp float; | |
| in vec3 vNrm; in vec3 vCol; in float vFog; in vec3 vWorld; in vec3 vObj; | |
| in vec2 vFowUV; | |
| uniform sampler2D uFowMap; | |
| uniform float uFowOn; | |
| in float vAlpha; in float vInstEmis; in float vState; | |
| in vec2 vUV; | |
| flat in float vMat; | |
| uniform vec3 uSun; // direction TOWARD the sun | |
| uniform vec3 uSunC; | |
| uniform vec3 uAmbSky; // hemispheric ambient: sky above... | |
| uniform vec3 uAmbGnd; // ...bounce from the ground below | |
| uniform vec3 uFogC; | |
| uniform float uEmis; | |
| uniform float uTime; | |
| uniform float uNight; | |
| /* A full deferred lighting buffer would spend too much bandwidth on mobile for | |
| a strategy camera. These are the handful of strongest, camera-relevant | |
| lights selected on the CPU: HQ windows/reactors, a dropship engine, weapon | |
| impact and critical damage. Most sparks remain cheap emissive billboards. */ | |
| uniform int uLightCount; | |
| uniform vec4 uLightPosR[8]; // xyz world position, w radius | |
| uniform vec4 uLightColI[8]; // rgb linear colour, w intensity | |
| /* Runtime art QA: set window.MFVisualDebug to 1..7 to inspect albedo, normals, | |
| gloss, metalness, emissive, direct light or local-light contribution. This | |
| prevents "make it brighter" guesswork when a model reads poorly. */ | |
| uniform int uDebugMode; | |
| uniform sampler2D uMat; | |
| uniform sampler2D uNrm; | |
| uniform sampler2D uOrm; // r = ambient occlusion, g = gloss, b = emissive, a = metalness | |
| uniform sampler2D uDamageTex; | |
| uniform sampler2D uDetail; // neutral V2 micro-surface grain; shared by every instanced asset | |
| /* PER-ASSET BAKED TRIPLET (artv2). Off unless the draw call declares it. | |
| An asset cannot want both surfacing schemes at once: one that carries its own | |
| baked maps has no use for the shared atlas, and one that uses the atlas has no | |
| baked maps to sample. So lanes 9-10 (vUV) carry whichever meaning THIS draw | |
| declares -- the atlas planar UV as always, or this asset's own 0..1 unwrap -- | |
| and no vertex lane, attribute or stride had to change to make room. | |
| Channel layout is the one materials-v2.js:84-115 already decodes, so the | |
| showcase path and this path read identical files. */ | |
| uniform float uAssetOn; | |
| uniform sampler2D uAssetBase; // rgb albedo, a ambient occlusion | |
| uniform sampler2D uAssetNre; // rg normal xy, b roughness, a emissive | |
| uniform sampler2D uAssetMask; // r metal, g/b team masks, a edge wear | |
| uniform vec3 uHalf; // normalize(sunDir + viewDir), constant under ortho | |
| uniform vec3 uEye; // already uploaded every frame for VS3D; the rim term needs it here too | |
| layout(location=0) out vec4 o; | |
| /* Optional selective-window MRT. The default framebuffer and the ordinary | |
| single-target fallback simply discard location 1; aoBeginScene attaches the | |
| R8 mask only when the complete post path is available. */ | |
| layout(location=1) out vec4 oWindow; | |
| /* Map a tiling UV into one 256px cell of the 4x4 material atlas. fract() gives | |
| the repeat; the inset keeps bilinear taps and mipmaps from bleeding across | |
| the tile boundary into a neighbouring material. */ | |
| /* Architectural surfaces need a much LOWER texture frequency than vehicles. | |
| A tank is twenty units long and wants its plating to read; a headquarters is | |
| ninety and, at the same frequency, repeats the same panel five times in each | |
| direction until the whole roof dissolves into speckle. These materials get | |
| their UVs divided down so one panel covers a real architectural bay. */ | |
| float matFreq(float idx){ | |
| /* The old .34 put less than one authored cell across a 40-unit civic roof. | |
| BUILD/ROOF cells 16/17 are broad square stamps, so that scale made the | |
| whole structure read as one enlarged atlas thumbnail. A little over one | |
| repeat across the same roof preserves architectural bay size without | |
| returning to vehicle-frequency noise. */ | |
| if(idx>=BUILDLO_CONST && idx<=BUILDHI_CONST) return 0.58; | |
| if(idx>=WINLO_CONST && idx<=WINHI_CONST) return 0.11; | |
| if(idx>=TOWERLO_CONST && idx<=TOWERHI_CONST) return 0.48; | |
| /* WORLD_KIT starts as normalised geometry, then worldsites.js converts it | |
| to placed-world scale. Four repeats across a typical 30-unit template is | |
| enough to describe bays; the former effective 9-10 repeats was the grey | |
| checkerboard visible at command-camera distance. */ | |
| if(idx>=108.0 && idx<=111.0) return 0.42; | |
| return 1.0; | |
| } | |
| vec2 matUV(vec2 uv, float id){ | |
| float idx=floor(id+0.5); | |
| /* Keep BUILD/ROOF as semantic ids for facade filtering, roof lights and | |
| damage profiles, but sample the quieter architectural sheets that already | |
| ship in the authored building-v3 atlas. Cell 16 is a six-square facade | |
| thumbnail and 17 is a four-square roof: magnifying either is the reported | |
| placeholder grid. 111 is a restrained wall bay; 108 is a coherent dark | |
| service deck. No extra texture or draw call is introduced. */ | |
| float cellIdx=idx; | |
| if(abs(idx-BUILD_CONST)<0.5) cellIdx=111.0; | |
| if(abs(idx-ROOF_CONST)<0.5) cellIdx=108.0; | |
| vec2 cell=vec2(mod(cellIdx,MTILES_CONST), floor(cellIdx/MTILES_CONST)); | |
| /* 0.004 was one texel at mip 0 and vanished by mip 2 β civic pads | |
| sampled the neighbouring pink/purple cell along every grout line. */ | |
| /* World-kit v3 lives in atlas cells 108..111. Its UV frequency is authored | |
| by worldsites.js, but it needs the same two-texel mip-safe gutter as the | |
| established architectural range or oblique roofs borrow a neighbour. */ | |
| vec2 inset=vec2((idx>=BUILDLO_CONST&&idx<=BUILDHI_CONST)||(idx>=108.0&&idx<=111.0)?0.012:0.008); | |
| /* clamp() held the first/last five source pixels constant on every repeat, | |
| manufacturing a bright/dark border even when the source cell wrapped | |
| exactly. Affine inset keeps taps inside their atlas cell while remaining | |
| periodic: no plateau, no borrowed neighbour and no tiling regression. */ | |
| vec2 tiled=inset+fract(uv*matFreq(idx))*(1.0-2.0*inset); | |
| return (cell+tiled)*MSTEP_CONST; | |
| } | |
| /* Dedicated 2x2 world-kit atlas. A 16px inset inside each 512px quadrant is | |
| mip-safe at the oblique command camera; the source is exactly periodic, so | |
| fract remains seamless instead of borrowing the neighbouring material. */ | |
| vec2 worldKitUV(vec2 uv, float id){ | |
| float idx=clamp(floor(id+0.5)-108.0,0.0,3.0); | |
| vec2 cell=vec2(mod(idx,2.0),floor(idx*0.5)); | |
| vec2 inset=vec2(0.03125); | |
| vec2 tiled=inset+fract(uv*matFreq(floor(id+0.5)))*(1.0-2.0*inset); | |
| return (cell+tiled)*0.5; | |
| } | |
| /* WebGL2 screen-space TBN. dFdx/dFdy are core ES 3.00 (WebGL1 needed | |
| OES_standard_derivatives). UV-Jacobian handedness + Gram-Schmidt keep the | |
| frame orthonormal; exploding derivatives at UV island/wrap discontinuities | |
| are clamped so the frame never NaNs into black or shows a lighting seam. */ | |
| 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 duv1=mfUvGradClamp(dFdx(uv)), duv2=mfUvGradClamp(dFdy(uv)); | |
| vec3 dp2p=cross(dp2,N), dp1p=cross(N,dp1); | |
| vec3 T=dp2p*duv1.x+dp1p*duv2.x; | |
| vec3 B=dp2p*duv1.y+dp1p*duv2.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); | |
| } | |
| /* Trowbridge-Reitz D and Schlick F copied from the V2 lab (materials-v2.js). | |
| Production stays a forward sun+8-local pass: a deferred G-buffer was | |
| rejected because mobile tile GPUs cannot spare that bandwidth for a | |
| strategy camera that already lights thousands of instanced models. */ | |
| float D_GGX(float ndh, float a2){ | |
| float den=ndh*ndh*(a2-1.0)+1.0; | |
| return a2/max(3.14159265*den*den,0.001); | |
| } | |
| vec3 F_Schlick(vec3 f0, float vdh){ | |
| return f0+(1.0-f0)*pow(1.0-vdh,5.0); | |
| } | |
| /* Direct-light Smith-Schlick G with k=(rough+1)^2/8, same as the lab. | |
| ndl is passed in so roof/organic terms keep their existing diffuse shape. | |
| Lambert is NOT divided by PI: the lab does, for one showcase object under a | |
| calibrated key. Production exposure (1-exp(-lit*1.55)) and the sun/ambient | |
| uniforms were authored against the brighter Lambert scale; dividing would | |
| crush the army into the toe of that curve. Specular D_GGX still has 1/PI. | |
| kd must NOT be (1-metal): command cameras almost never hit the GGX lobe on | |
| hull plating, so a conservation term left every HQ/carrier as ambient-only | |
| grey. Keep a sun-facing Lambert even on metal; GGX spec is the extra glint. */ | |
| vec3 evalGGX(vec3 n, vec3 V, vec3 L, float ndl, float ndv, float a2, float k, vec3 alb, vec3 f0, float metal, vec3 lcol, float diffScale, float wrapAmt){ | |
| vec3 H=normalize(V+L); | |
| vec3 F=F_Schlick(f0,max(dot(V,H),0.0)); | |
| float gv=ndv/(ndv*(1.0-k)+k), gl=ndl/(ndl*(1.0-k)+k); | |
| vec3 spec=D_GGX(max(dot(n,H),0.001),a2)*gv*gl*F/max(4.0*ndl*ndv,0.001); | |
| vec3 kd=(1.0-F)*mix(1.0,0.55,metal); | |
| return (kd*alb*(ndl*diffScale+wrapAmt)+spec*ndl)*lcol; | |
| } | |
| void main(){ | |
| /* Windows only on near-vertical facades. |ny|>0.45 missed steep pitches | |
| (ny 0.25β0.44) and left +Y caps sampling the pane tile. */ | |
| float matS=vMat; | |
| float nax=abs(vNrm.x), nay=abs(vNrm.y), naz=abs(vNrm.z); | |
| float facade=step(0.85, max(nax,naz))*(1.0-step(0.22, nay)); | |
| float cap=step(0.55, vNrm.y); | |
| float winTile=step(-0.5,matS-CONC_CONST)*(1.0-step(0.5,matS-CONC_CONST)) | |
| +step(-0.5,matS-BUILD_CONST)*(1.0-step(0.5,matS-BUILD_CONST)) | |
| +step(-0.5,matS-WINLO_CONST)*(1.0-step(0.5,matS-WINHI_CONST)); | |
| winTile=min(winTile,1.0); | |
| /* Albedo/normal: non-facade panes become ROOF so +Y is not a window grid. | |
| Emissive is sampled from the ORIGINAL tile β a command camera looks at | |
| caps, and zeroing those lights is why HQ roofs went dark. */ | |
| float matOrig=vMat; | |
| matS=mix(matS, ROOF_CONST, (1.0-facade)*winTile); | |
| vec2 muv=matUV(vUV,matS); | |
| float worldKitSurface=step(107.5,matS)*(1.0-step(111.5,matS)); | |
| /* fract() inside the coordinate handed to texture() makes the derivative | |
| explode across every tile wrap, so the hardware picks the coarsest mip and | |
| draws a blurry grey line at each repeat β a visible seam on every unit and | |
| building. Supplying the derivatives of the UNWRAPPED coordinate fixes it. */ | |
| vec2 dvx=dFdx(vUV), dvy=dFdy(vUV); | |
| vec2 dxu=dvx*matFreq(floor(matS+0.5))*MSTEP_CONST; | |
| vec2 dyu=dvy*matFreq(floor(matS+0.5))*MSTEP_CONST; | |
| /* The bevels, rivets, louvres and panel breaks all live here rather than in | |
| geometry: perturbing the normal lights them exactly as if they were | |
| modelled, at a fraction of the vertex cost. */ | |
| vec3 tex; vec3 nT; vec4 orm; | |
| if(uAssetOn>1.5&&worldKitSurface>0.5){ | |
| /* Compact architectural PBR: same physical planar coordinate as the | |
| shared atlas path, but one 512px material quadrant rather than a 128px | |
| downsampled cell. Derivatives stay unwrapped so LOD selection cannot | |
| blur every repeat boundary into a grey line. */ | |
| vec2 wuv=worldKitUV(vUV,matS); | |
| float wf=matFreq(floor(matS+0.5))*0.46875; | |
| vec2 dxw=dvx*wf, dyw=dvy*wf; | |
| vec4 ba=textureGrad(uAssetBase,wuv,dxw,dyw); | |
| vec4 nr=textureGrad(uAssetNre,wuv,dxw,dyw); | |
| vec4 mk=textureGrad(uAssetMask,wuv,dxw,dyw); | |
| vec2 nxy=nr.rg*2.0-1.0; | |
| tex=ba.rgb; | |
| nT=vec3(nxy,sqrt(max(0.02,1.0-dot(nxy,nxy)))); | |
| orm=vec4(ba.a,1.0-clamp(nr.b,0.055,1.0),nr.a,mk.r); | |
| }else if(uAssetOn>0.5&&uAssetOn<1.5){ | |
| /* Uniform branch: constant for the whole draw call, so it neither diverges | |
| nor invalidates the derivatives taken above. */ | |
| vec2 dxa=mfUvGradClamp(dvx), dya=mfUvGradClamp(dvy); | |
| vec4 ba=textureGrad(uAssetBase,vUV,dxa,dya), nr=textureGrad(uAssetNre,vUV,dxa,dya), mk=textureGrad(uAssetMask,vUV,dxa,dya); | |
| vec2 nxy=nr.rg*2.0-1.0; | |
| tex=ba.rgb; | |
| nT=vec3(nxy, sqrt(max(0.02,1.0-dot(nxy,nxy)))); | |
| /* FS3D carries GLOSS where the bake stores ROUGHNESS. */ | |
| orm=vec4(ba.a, 1.0-clamp(nr.b,0.055,1.0), nr.a, mk.r); | |
| }else{ | |
| tex=textureGrad(uMat,muv,dxu,dyu).rgb; | |
| nT=textureGrad(uNrm,muv,dxu,dyu).rgb*2.0-1.0; | |
| orm=textureGrad(uOrm,muv,dxu,dyu); | |
| } | |
| vec3 gN=normalize(vNrm); | |
| vec3 n=normalize(cotangent(gN,vWorld,vUV)*nT); | |
| float ao=orm.r, gloss=orm.g, emis=orm.b, metal=orm.a; | |
| /* Caps keep the un-remapped emissive channel so roof lamps read top-down. | |
| Wall panes still require a facade; downward interiors stay dark. */ | |
| if(winTile>0.5&&(uAssetOn<0.5||uAssetOn>1.5)){ | |
| vec2 muvE=matUV(vUV,matOrig); | |
| vec2 dxe=dvx*matFreq(floor(matOrig+0.5))*MSTEP_CONST; | |
| vec2 dye=dvy*matFreq(floor(matOrig+0.5))*MSTEP_CONST; | |
| float srcEmis=textureGrad(uOrm,muvE,dxe,dye).b; | |
| emis=mix(emis, srcEmis, max(facade, cap)); | |
| } | |
| emis*=(1.0-winTile)+winTile*(facade*(1.0-cap)+cap); | |
| /* V2 profile bands are encoded as profile*2 + health damage. Keeping the | |
| default band at 0..1 makes every existing caller backward-compatible while | |
| a hero/landmark can opt into a distinct finish before it receives bespoke | |
| BaseAO/NRE/mask textures. */ | |
| float vProfile=floor(vState*.5); | |
| float state=fract(vState*.5)*2.0; | |
| float commanderProfile=1.0-step(.5,abs(vProfile-1.0)); | |
| float landmarkProfile=1.0-step(.5,abs(vProfile-2.0)); | |
| float structureProfile=1.0-step(.5,abs(vProfile-3.0)); | |
| float airframeProfile=1.0-step(.5,abs(vProfile-4.0)); | |
| /* PRODUCTION MATERIAL V2. The authored showcase uses a per-asset mask map; | |
| the large-army path uses the semantic material id already baked into | |
| every vertex plus one shared triplanar fracture tile. This preserves one | |
| instanced draw per chassis while giving every live unit and building the | |
| same wear/damage hierarchy as the showcase. Object-space projection keeps | |
| cracks attached to the model and avoids UV stretching on long hulls. */ | |
| vec3 dw=pow(abs(normalize(vNrm))+vec3(.0001),vec3(5.0));dw/=dw.x+dw.y+dw.z; | |
| float damageData=texture(uDamageTex,vObj.zy*.070).r*dw.x+ | |
| texture(uDamageTex,vObj.xz*.070).r*dw.y+texture(uDamageTex,vObj.xy*.070).r*dw.z; | |
| /* The V2 detail tile is deliberately low contrast. It is not another panel | |
| texture: it gives broad mechanical armor restrained brushed-metal tooth and | |
| roughness variation at close range while mipmaps remove it at RTS distance. */ | |
| float detailData=texture(uDetail,vObj.zy*.115).r*dw.x+ | |
| texture(uDetail,vObj.xz*.115).r*dw.y+texture(uDetail,vObj.xy*.115).r*dw.z; | |
| /* The Brood roster uses its own atlas band, not the legacy chitin/leaf ids. | |
| Missing that band classed wings, sacs and flesh as machinery, applying | |
| rubbed-metal wear and steel carbonisation while excluding membranes from | |
| the translucency term. Material ids also provide authored per-part | |
| thickness without consuming another vertex attribute. */ | |
| float broodBand=step(BRDLO_CONST-.5,vMat)*(1.0-step(BRDHI_CONST+.5,vMat)); | |
| float surfaceOrganic=max((vMat==CHITIN_CONST||vMat==BIOLEG_CONST)?1.0:0.0,broodBand); | |
| float thinAuth=(vMat==CHITIN_CONST?0.16:0.0)+(vMat==BIOLEG_CONST?0.44:0.0) | |
| +(vMat==BRDMEM_CONST?1.00:0.0)+(vMat==BRDCHI_CONST?0.12:0.0) | |
| +(vMat==BRDSLM_CONST?0.80:0.0)+(vMat==BRDVEIN_CONST?0.60:0.0); | |
| float glassLike=1.0-step(.45,abs(vMat-GLASS_CONST)); | |
| /* Battle damage is a BAND, not a tail. This was an open-ended >=SCORCH_METAL | |
| test, which was correct while damage held the last ids -- but it silently | |
| classed every id above it as non-mechanical, so a new material added past | |
| the damage block would lose its micro-tooth and its commander/landmark | |
| finish with nothing in the console. For ids 0..105 this is bit-identical. */ | |
| float damageBand=step(DAMAGELO_CONST-.5,vMat)*(1.0-step(DAMAGEHI_CONST+.5,vMat)); | |
| float mechanical=(1.0-surfaceOrganic)*(1.0-glassLike)*step(-.5,vMat)*(1.0-damageBand); | |
| float micro=(detailData-.5)*mechanical; | |
| ao=clamp(ao-micro*.055,0.0,1.0); | |
| gloss=clamp(gloss-micro*.13,0.035,0.98); | |
| /* These are intentionally material PROFILE changes, not a whole-model tint. | |
| A command chassis gains a cleaner alloy/spec response around its authored | |
| armor and machine surfaces; an HQ gets a smaller structural finish boost. | |
| The semantic atlas still decides where glass, recesses and emissives live. */ | |
| float profileFinish=(commanderProfile*.34+landmarkProfile*.16)*mechanical; | |
| gloss=clamp(gloss+profileFinish*(.12+gloss*.10),.035,.98); | |
| metal=clamp(metal+profileFinish*.16,0.0,1.0); | |
| /* Crystal geometry is a landmark, not ordinary hull plating. Keep its blue | |
| authored look independent of the packed atlas and tangent frame: a missing | |
| or black material tile must not turn the entire resource field black. */ | |
| if(vMat==CRYST_CONST){ | |
| tex=vec3(0.62,0.86,1.0); | |
| n=normalize(vNrm); | |
| ao=0.82; gloss=0.70; emis=0.028; metal=0.02; | |
| } | |
| // the tile is authored around mid-grey; remapping around 0.5 keeps the | |
| // vertex colour in charge of hue while the texture supplies detail | |
| /* Albedo is a REFLECTANCE: it belongs in 0..1, and nothing real reflects more | |
| light than falls on it. The old remap peaked at 1.5, which combined with | |
| ambient and key drove radiance past 2.4 and pushed every light surface into | |
| the top few code values β the exposure curve was compressing the mistake, | |
| not fixing it. */ | |
| vec3 alb=clamp(vCol*(0.42+tex*0.62), 0.0, 0.88); | |
| if(uAssetOn>1.5&&worldKitSurface>0.5){ | |
| /* The imported kit originally baked its entire finish into vertex colour. | |
| Treating the new 512px architectural sheet as another 0.62 modulation | |
| left that legacy pale grey in charge and hid the authored panels. In the | |
| compact path the PBR sheet is authoritative; retain only a restrained | |
| vertex-colour hue wash so conversion identity survives without turning | |
| blue gunmetal back into chalk concrete. */ | |
| float vcL=max(dot(vCol,vec3(0.2126,0.7152,0.0722)),0.08); | |
| vec3 vcHue=clamp(vCol/vcL,vec3(0.72),vec3(1.28)); | |
| vec3 authored=pow(max(tex,vec3(0.0)),vec3(0.92))*(0.62+vcL*0.10); | |
| alb=clamp(authored*mix(vec3(1.0),vcHue,0.10),0.0,0.76); | |
| } | |
| if(vMat==CRYST_CONST) alb=clamp(vCol*vec3(0.36,0.58,0.82),0.0,0.62); | |
| /* Maintained vehicles still have sparse rubbed corners and service wear. | |
| Normal-map relief supplies the local edge cue; the shared tile breaks it | |
| up so broad faces remain quiet instead of receiving uniform white rims. */ | |
| float microEdge=smoothstep(.12,.54,abs(nT.x)+abs(nT.y)); | |
| float sparseWear=smoothstep(.54,.82,damageData)*microEdge*mechanical; | |
| alb=mix(alb,vec3(.46,.52,.58),sparseWear*(.12+state*.26)); | |
| gloss=mix(gloss,.76,sparseWear*(.10+state*.22)); | |
| metal=mix(metal,.82,sparseWear*(.08+state*.18)); | |
| /* Below roughly 45% health the surface first soots, then carbonises. At | |
| critical health specular polish and metal response are destroyed; narrow | |
| hot fractures remain emissive only while the object is still burning. */ | |
| /* Vehicles stay conservative (embers ~22% HP) so a wounded tank is not | |
| already slag. Towers and landmark HQs must scorch under fire β civic | |
| and defense deaths were reading as a clean pop because the hull never | |
| burned. TWR_* ids 19..24 plus the HQ landmark band. */ | |
| float structBurn=max(max(landmarkProfile,structureProfile), step(18.5,vMat)*(1.0-step(24.5,vMat))); | |
| float critical=smoothstep(mix(.58,.30,structBurn),.97,state); | |
| /* SOOT MUST NOT BE GATED BY THE CRACK MASK. | |
| This multiplied carbonisation by smoothstep(.08,.46,damageData), where | |
| damageData is a triplanar sample of mf2-carbon-cracks-v1.png - a texture | |
| whose bulk luminance is 0.10-0.20. So over ~85% of a hull the term | |
| evaluated to smoothstep(.08,.46,0.15) = 0.088, and a structure at 100% | |
| damage KEPT ~92% OF ITS TEAM COLOUR. Wrecks read as flat saturated red | |
| boxes with faint darker lines - those lines being the only pixels where | |
| the mask rose enough to bite. The comment above promises the surface | |
| 'first soots, then carbonises'; the mask made that impossible. | |
| The crack network should say where soot is HEAVIEST, not whether soot | |
| happens at all. Floor it: uniform carbonisation at critical damage, | |
| deepening along the cracks. */ | |
| float carbon=critical*mix(0.62,1.0,smoothstep(.08,.46,damageData))*mechanical; | |
| /* Critical aircraft expose scorched panels before they become wrecks. This | |
| is profile-gated so tanks/buildings keep their established damage curve, | |
| and mask-driven so it never becomes a whole-hull tint. */ | |
| float airPanelBurn=airframeProfile*smoothstep(.58,.94,state)* | |
| mix(.42,.88,smoothstep(.12,.68,damageData))*mechanical; | |
| carbon=max(carbon,airPanelBurn); | |
| float hotCrack=smoothstep(mix(.78,.38,structBurn),.985,state)*smoothstep(mix(.68,.48,structBurn),.86,damageData)*(1.0-smoothstep(.985,1.0,state)); | |
| hotCrack=max(hotCrack,airframeProfile*smoothstep(.70,.96,state)* | |
| smoothstep(.40,.79,damageData)*(1.0-smoothstep(.985,1.0,state))); | |
| alb=mix(alb,vec3(.016,.013,.011),carbon*.94); | |
| gloss=mix(gloss,.025,carbon);metal=mix(metal,0.0,carbon*.96); | |
| /* Biological damage is wet tissue and dark wounds, never burnt steel. */ | |
| float wound=critical*smoothstep(.18,.62,damageData)*surfaceOrganic; | |
| alb=mix(alb,vec3(.115,.018,.025),wound*.84); | |
| gloss=mix(gloss,.62,wound);metal*=1.0-wound; | |
| float ndl=max(dot(n,uSun),0.0); | |
| float wrap=(dot(n,uSun)*0.5+0.5); | |
| float hemi=n.y*0.5+0.5; | |
| /* Dome bowls and recessed decks are ROOF with ny<0. Lambert then picks | |
| only ambGnd, so a textured roof reads as a hole. |ny| keeps the atlas | |
| visible from the play camera. matS already remapped window tiles. */ | |
| float roofTile=step(-0.5,matS-ROOF_CONST)*(1.0-step(0.5,matS-ROOF_CONST)); | |
| hemi=mix(hemi, abs(vNrm.y)*0.5+0.5, roofTile); | |
| ndl=mix(ndl, max(ndl, abs(dot(n,uSun))*0.42), roofTile); | |
| /* Command cameras see decks, not walls. Horizontal faces share ndlβsunY | |
| so Lambert looks constant-shaded. Push the sun ACROSS the roof. */ | |
| 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)); | |
| /* AO occludes the AMBIENT term only. Sky light is what a crevice can't see; | |
| a direct sunbeam still reaches down into it, and dimming that too just | |
| reads as the surface being dirty rather than deep. */ | |
| vec3 amb=mix(uAmbGnd,uAmbSky,hemi)*ao; | |
| /* The wrap term is fill light, and fill is exactly what a crease cannot see β | |
| leaving it unoccluded capped AO's authority at about a quarter of total | |
| radiance no matter how deep the cavity. */ | |
| /* ---- ORGANIC TRANSLUCENCY ------------------------------------------------- | |
| Chitin, membrane and limb tissue are not opaque. Light entering a thin | |
| shell scatters inside it and leaves on the far side, which is why a real | |
| insect back-lit by the sun glows along its edges and through its legs, and | |
| why one lit with a plain Lambert term looks like painted plastic no matter | |
| how good the albedo is. Two additions, both restricted to organic material | |
| ids so every vehicle in the game is bit-identical to before: | |
| WRAPPED DIFFUSE. Scattering carries light past the terminator, so the | |
| shaded side is never fully dark and the falloff is soft and wide. This | |
| is what removes the hard plastic terminator. | |
| BACK TRANSMISSION. Light travelling THROUGH the body toward the eye: | |
| strongest when looking into the light, and modulated by an inverse-AO | |
| thickness proxy β a crease is thick and blocks, an edge is thin and | |
| glows. Tinted warm because the scattering medium absorbs blue first, | |
| which is the whole reason flesh and shell read as amber against a | |
| bright sky. */ | |
| float mId=vMat; | |
| vec3 V=normalize(uEye-vWorld); | |
| float organic=surfaceOrganic; | |
| float sss=0.0; | |
| vec3 transC=vec3(0.0); | |
| if(organic>0.5){ | |
| /* Refine authored thickness per pixel: silhouettes are optically thinner, | |
| while occluded creases have geometry stacked behind them. */ | |
| float edgeThin=1.0-abs(dot(n,V)); | |
| float aoThin=clamp(1.0-ao,0.0,1.0); | |
| float thick=clamp(thinAuth*(0.68+0.32*aoThin)*(0.72+0.46*edgeThin),0.0,1.0); | |
| /* Wrapped diffuse follows thickness, so membranes carry light past the | |
| terminator while solid carapace keeps a firm lighting break. */ | |
| float w=mix(0.18,0.92,thick); | |
| float wrapD=max(0.0,(dot(n,uSun)+w)/(1.0+w)); | |
| ndl=mix(ndl,wrapD,mix(0.42,0.95,thick)); | |
| /* Normal-aware back transmission follows the tissue geometry instead of | |
| producing a camera-facing haze with the same strength on shell and wing. */ | |
| vec3 Ht=normalize(-uSun+n*0.32); | |
| float back=pow(clamp(dot(V,Ht),0.0,1.0),mix(7.0,1.7,thick)); | |
| float atten=exp(-(1.0-thick)*3.6); | |
| sss=back*atten*(0.35+0.65*thick); | |
| vec3 deepFlesh=vec3(0.70,0.14,0.11); | |
| vec3 thinFlesh=vec3(0.95,0.52,0.46); | |
| vec3 scat=mix(deepFlesh,thinFlesh,thick); | |
| transC=uSunC*mix(scat,scat*(0.45+1.10*alb),0.46)*sss*1.55; | |
| } | |
| /* Gloss is the atlas channel; the lab BRDF wants roughness. 1-gloss keeps | |
| every existing tile's authored polish instead of rebaking ORM. */ | |
| float rough=1.0-gloss; | |
| float a=rough*rough,a2=a*a,k=(rough+1.0)*(rough+1.0)*0.125; | |
| float ndv=max(dot(n,V),0.001); | |
| /* Mobile command cameras see roof metal. 0.86 is the overhead lift that | |
| stopped HQ decks silhouetting. The old landmark mix toward 1.0 plus | |
| GGX edge spec crushed bevels to white. Grazing steel stays 0.70. */ | |
| float metalLift=mix(1.0, mix(0.70, 0.86, smoothstep(0.50, 0.94, ndv)), metal); | |
| vec3 f0=mix(vec3(0.04),alb,metal); | |
| /* Lambert carries day/night. GGX+Blinn on an overhead roof (V and L both | |
| near +Y) made D explode and painted the whole deck one pale sheet β | |
| the carrier read unlit/bleached. Spec is edge-only and camera-faded. */ | |
| /* Transmitted light has already been filtered by tissue; multiplying it by | |
| albedo again cancels the red/pink shift that makes the material read as | |
| flesh. Inorganic pixels still collapse exactly to the old albedo mask. */ | |
| vec3 transMask=mix(alb,vec3(1.0),organic*0.88); | |
| vec3 directLit=alb*metalLift*(amb*0.82 + uSunC*(ndl*0.92 + wrap*0.10*ao)) + transMask*transC; | |
| vec3 specSun=evalGGX(n,V,uSun,ndl,ndv,a2,k,alb,f0,metal,uSunC,0.0,0.0); | |
| float specAtten=mix(1.0,mix(0.16,0.07,landmarkProfile),smoothstep(0.60,0.94,ndv)); | |
| float edge=smoothstep(0.10,0.50,abs(nT.x)+abs(nT.y)); | |
| directLit+=specSun*specAtten*(0.28+0.72*edge); | |
| /* RIM. Sky wrapping around the grazing edge of the silhouette. On a phone at | |
| arm's length a grey hull standing on grey concrete has no readable outline | |
| at all β the key light cannot separate them because both faces point the | |
| same way. This is also the only thing lighting the downward faces the sun | |
| never reaches. Keyed off uAmbSky so it is dusk-blue at dusk rather than a | |
| pasted-on white outline, and multiplied by ao so it does not glow in the | |
| creases it is supposed to leave dark. */ | |
| float rim=pow(1.0-max(dot(n,V),0.0),3.5)*(0.30+0.55*hemi); | |
| directLit+=uAmbSky*alb*rim*0.85*ao; | |
| /* Living tissue carries a fluid-film Fresnel sheen. The organic mask makes | |
| this bit-identical to the prior shader for every mechanical surface. */ | |
| float wetF=pow(1.0-clamp(dot(n,V),0.0,1.0),4.2); | |
| vec3 wetH=normalize(V+uSun); | |
| float wetSpec=pow(max(dot(n,wetH),0.0),68.0); | |
| directLit+=organic*ao*(uSunC*wetSpec*(0.20+0.55*wetF)+uAmbSky*wetF*0.34); | |
| /* LOCAL LIGHTS. Same GGX as the sun, still capped at 8 camera-relevant | |
| sources. The attenuation has a soft inner knee so a nearby building does | |
| not get a hard halo. Clustered 64-light deferred was rejected β this loop | |
| is the mobile budget. UI and particle glows stay unlit and inexpensive. */ | |
| vec3 localLit=vec3(0.0); | |
| for(int li=0;li<8;li++){ | |
| if(li>=uLightCount) break; | |
| vec3 toL=uLightPosR[li].xyz-vWorld; | |
| float lr=length(toL); | |
| float range=max(1.0,uLightPosR[li].w); | |
| float fall=clamp(1.0-lr/range,0.0,1.0); | |
| fall=fall*fall*(3.0-2.0*fall); | |
| vec3 L=toL/max(lr,0.001); | |
| float lndl=max(dot(n,L),0.0); | |
| float lwrap=dot(n,L)*0.35+0.35; | |
| vec3 lcol=uLightColI[li].rgb*uLightColI[li].a*fall; | |
| localLit+=evalGGX(n,V,L,lndl,ndv,a2,k,alb,f0,metal,lcol,0.88,max(0.0,lwrap)*0.22); | |
| } | |
| vec3 lit=directLit+localLit; | |
| /* Sensor darkness FIRST, then aerial veil. Mixing toward uFogC first | |
| re-lit fogged hulls β glowing pieces on a dark board. Same derived | |
| unexplored colour as the terrain shader; the veil must not lift | |
| fogged geometry. Live coverage has alpha 0, so visible armies are | |
| untouched. */ | |
| float fowA=texture(uFowMap,vFowUV).a*uFowOn; | |
| lit=mix(lit,mix(uAmbGnd*0.10,uFogC*0.20,0.5),fowA); | |
| lit=mix(lit,uFogC,vFog*(1.0-fowA)); | |
| /* Emissive is added AFTER fog and is untouched by light β keeps windows, | |
| headlights, neon, and energy conduits glowing vividly in dark nights. */ | |
| /* Time-based pulse is WINDOW TILES ONLY. Uploading uTime (it was 0 forever) | |
| made winFlicker run on every material β crystals, hulls, engines strobed | |
| at 3.5 Hz. That is the shader flicker this session. */ | |
| float isWinBand=step(40.0,vMat)*(1.0-step(71.0,vMat))*winTile; | |
| /* Landmark HQ: only lamps / glass / window tiles may emit. Atlas bleed | |
| and MIRROR_TINT writes used to light the whole metal deck. */ | |
| float glowOk=min(1.0, glassLike | |
| +step(21.5,vMat)*(1.0-step(22.5,vMat)) | |
| +step(39.5,vMat)*(1.0-step(43.5,vMat)) | |
| +step(44.5,vMat)*(1.0-step(45.5,vMat))); | |
| emis=mix(emis, emis*glowOk, landmarkProfile); | |
| /* Daylight: static panes. Night-only 3% pulse. A noon sin() read as shader | |
| flicker on every window band and, before the gate, on hulls too. */ | |
| float animEmis=mix(1.0, 0.97+0.03*sin(uTime*1.6+muv.y*28.0), isWinBand*uNight); | |
| vec3 finalEmis = (alb * (uEmis + vInstEmis)*mix(1.0,glowOk,landmarkProfile) + vCol * emis * 1.45 * animEmis) * (1.18 + uNight * 1.25); | |
| finalEmis*=1.0+commanderProfile*.18+landmarkProfile*.08*glowOk; | |
| finalEmis+=vec3(1.0,.10,.008)*hotCrack*0.9+vec3(.72,.015,.025)*wound*.48; | |
| /* Command camera sees roofs. BUILD/ROOF caps get sparse sharp cyan lamps | |
| that the facade-only window path cannot. Hash is position-stable β no | |
| uTime β so adjacent frames cannot strobe. Landmarks (HQ) already model | |
| TWR_GLOW lamps; painting 11-unit hash cells on top of them was the | |
| white halo / wrong-tile bloom soup. Remaining dots are discs, not tiles. */ | |
| vec2 roofCell=fract(vWorld.xz*0.28); | |
| float roofHash=fract(sin(dot(floor(vWorld.xz*0.28),vec2(12.9898,78.233)))*43758.5453); | |
| float roofDot=cap*step(15.5,vMat)*(1.0-step(17.5,vMat))*(1.0-landmarkProfile) | |
| *step(0.97,roofHash)*step(length(roofCell-vec2(0.5)),0.09); | |
| finalEmis+=vec3(0.38,0.84,1.0)*roofDot*0.48; | |
| /* Windows and running lights dim with the same veil β an emissive that | |
| ignores fog is a beacon advertising every remembered building. | |
| Distance haze also pulls them down so far lamps do not punch a hole | |
| through atmosphere. */ | |
| lit += finalEmis*(1.0-fowA)*(1.0-vFog*0.70); | |
| /* Crystal identity stays blue and translucent-looking, but uses the legacy | |
| display-space lighting above. The newer global sRGB/gamma path made this | |
| branch wash out crystals and every other authored model at once. */ | |
| if(vMat==CRYST_CONST){ | |
| float back=pow(max(dot(-n,uSun),0.0),1.5); | |
| float rim=pow(1.0-abs(dot(n,uHalf)),3.5); | |
| /* Rim 0.28 + alb 0.92 sat past the 0.936 bloom thresh and turned every | |
| mined outcrop into a white disc. Identity stays cyan; energy stays low. */ | |
| lit+=uSunC*vCol*back*0.10 + vec3(0.14,0.48,0.88)*rim*0.09 | |
| + vCol*vec3(0.015,0.05,0.10); | |
| } | |
| /* Diagnostic modes intentionally bypass fog/tonemapping decisions as much | |
| as possible: an artist needs to see the input values, not a pretty frame. */ | |
| if(uDebugMode==1) lit=alb; | |
| else if(uDebugMode==2) lit=n*0.5+0.5; | |
| else if(uDebugMode==3) lit=vec3(gloss); | |
| else if(uDebugMode==4) lit=vec3(metal); | |
| else if(uDebugMode==5) lit=finalEmis; | |
| else if(uDebugMode==6) lit=directLit; | |
| else if(uDebugMode==7) lit=localLit; | |
| /* HIGHLIGHT ROLLOFF. Up-facing surfaces under a near-overhead sun were | |
| driving light metal past 1.0 and clipping to flat white, which erased the | |
| panel work on every roof β the reason the HQ's hull read as a blank slab | |
| the moment its cap started rendering. An exposure curve keeps the shadows | |
| and midtones where they were and compresses only the top end. */ | |
| lit=vec3(1.0)-exp(-lit*1.55); | |
| /* Do NOT gamma-encode here. sunFor() colours are display-picked then | |
| linearized for the BRDF; the exposure curve already writes display | |
| values. A further pow(1/2.2) lifted charcoal into mid-grey and erased | |
| the sun term β hulls read unlit. Civic V2 uses the same filmic write. */ | |
| /* Window bloom is authored by the ORM emissive input, not inferred from | |
| bright scene colour. All other opaque model pixels explicitly write zero, | |
| so later foreground units/buildings erase a window behind them through the | |
| same depth test. Damage, sensor shroud and aerial fog gate the mask before | |
| the post pass ever sees it. */ | |
| float mfWindowSurface=winTile*max(facade,cap); | |
| float mfWindowPower=clamp(emis*mfWindowSurface*(1.0-smoothstep(.12,.72,state)),0.0,1.0); | |
| if(uDebugMode!=0) mfWindowPower=0.0; | |
| oWindow=vec4(mfWindowPower*(1.0-fowA)*(1.0-vFog*.70),0.0,0.0,1.0); | |
| o=vec4(clamp(lit,vec3(0.0),vec3(1.0)),vAlpha); | |
| }`; | |
| /* Unlit additive program: muzzle flash, fire, energy, light shafts. Still real | |
| geometry (shells, cones, cylinders) β just not lit by the sun. */ | |
| const VSG=`#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; | |
| layout(location=11) in float aPitch; | |
| layout(location=12) in float aRoll; | |
| uniform mat4 uVP; | |
| out vec3 vCol; out float vA; out float vRim; | |
| void main(){ | |
| float c=cos(aYaw), s=sin(aYaw); | |
| float cr=cos(aRoll),sr=sin(aRoll); | |
| float cp=cos(aPitch),spit=sin(aPitch); | |
| vec3 rolled=vec3(aPos.x, aPos.y*cr-aPos.z*sr, aPos.y*sr+aPos.z*cr); | |
| vec3 pp=vec3(rolled.x*cp-rolled.y*spit, rolled.x*spit+rolled.y*cp, rolled.z); | |
| vec3 sp=vec3(pp.x*aInst.w, pp.y*aInst.w, pp.z*aWide); | |
| vec3 p=vec3(sp.x*c - sp.z*s, sp.y, sp.x*s + sp.z*c) + aInst.xyz; | |
| vCol=aCol*aTint.rgb; vA=aTint.a; | |
| vRim=1.0; | |
| gl_Position=uVP*vec4(p,1.0); | |
| }`; | |
| const FSG=`#version 300 es | |
| precision highp float; | |
| in vec3 vCol; in float vA; | |
| layout(location=0) out vec4 o; | |
| layout(location=1) out vec4 oWindow; | |
| void main(){ oWindow=vec4(0.0);o=vec4(vCol*vA, vA); }`; | |
| /* Every shader failure this renderer has ever had was INVISIBLE: the compile | |
| error went to a console nobody reads on a phone, the broken program was | |
| returned anyway, and the affected geometry silently drew nothing. That is | |
| how a missing map survives three release attempts. Failures are recorded | |
| where the game can show them, and a program that did not link comes back as | |
| null so callers can fall back instead of drawing into the void. */ | |
| const GL_PROG_ERRORS=[]; | |
| /* World V2 is declared in the next classic-global source file, outside this | |
| post subsystem. Patch only its known fragment contract at compile time so it | |
| participates in the same optional MRT without adding a second geometry pass | |
| or changing that module's public surface. If the source contract ever moves, | |
| leave it untouched and record the miss: ordinary rendering remains valid and | |
| the selective halo simply omits those panes. */ | |
| function mfWindowPatchWorldFS(src,name){ | |
| if(typeof src!=='string'||src.indexOf('oWindow')>=0) return src; | |
| const decl='out vec4 o;'; | |
| /* The opt-in material laboratory can draw during the opaque MRT phase. It | |
| has no window semantics, so explicitly erase attachment 1; some ANGLE | |
| drivers reject a draw when a linked fragment program lacks the enabled | |
| second output rather than merely leaving it unwritten. */ | |
| if(name==='material-v2'){ | |
| const main='void main(){'; | |
| if(src.indexOf(decl)<0||src.indexOf(main)<0){ | |
| if(typeof window!=='undefined') window.__MF_WINDOW_BLOOM_PATCH_MISS=true; | |
| return src; | |
| } | |
| return src.replace(decl,'layout(location=0) out vec4 o;\nlayout(location=1) out vec4 oWindow;') | |
| .replace(main,main+'\n oWindow=vec4(0.0);'); | |
| } | |
| if(name!=='world-structures-v2') return src; | |
| const write=' o=vec4(clamp(lit,vec3(0.0),vec3(1.0)),1.0);'; | |
| if(src.indexOf(decl)<0||src.indexOf(write)<0){ | |
| if(typeof window!=='undefined') window.__MF_WINDOW_BLOOM_PATCH_MISS=true; | |
| return src; | |
| } | |
| src=src.replace(decl,'layout(location=0) out vec4 o;\nlayout(location=1) out vec4 oWindow;'); | |
| return src.replace(write, | |
| ' float mfWindowAuth=(window*winPulse)*.30+emis*.42*on.x;\n'+ | |
| ' float mfWindowPower=clamp(mfWindowAuth*(1.0-smoothstep(.10,.58,vDamage)),0.0,1.0);\n'+ | |
| ' float mfWindowVis=(1.0-fowA)*(1.0-fowA)*(1.0-vFog*(1.0-fowA));\n'+ | |
| ' oWindow=vec4(mfWindowPower*mfWindowVis,0.0,0.0,1.0);\n'+write); | |
| } | |
| /* DEFERRED PROGRAM VALIDATION. | |
| compileShader()/linkProgram() are asynchronous in the driver β measured at | |
| 0.3 ms for all 19 programs. What actually costs is asking for the RESULT: | |
| getShaderParameter(COMPILE_STATUS) and getProgramParameter(LINK_STATUS) each | |
| block until that program has finished, so querying inline serialises every | |
| program behind the previous one. Measured on ANGLE/D3D11 that was 1,549 ms of | |
| main-thread stall at match start β effectively 100% of shader setup cost, and | |
| every driver worth having supports KHR_parallel_shader_compile. | |
| So mkProg() now compiles, attaches and links WITHOUT querying, and the status | |
| read happens once for the whole batch on the next frame, by which point the | |
| driver has done the work in parallel. | |
| Contract note: mkProg() used to return null on failure. Callers that branch | |
| on that (the reversible Material-V2 -> legacy fallbacks) must ask explicitly | |
| with mfProgOk(p), which validates just that program immediately. Callers that | |
| never checked are unaffected: a failed program and a null program both draw | |
| nothing, and GL_PROG_ERRORS is still populated either way. | |
| Verify with: node tools/probe-shader-compile.mjs */ | |
| const GL_PROG_PENDING=[]; | |
| let glProgFlushQueued=false; | |
| function mfProgValidate(entry){ | |
| let ok=true; | |
| for(const [sh,ty,src] of entry.shaders){ | |
| if(!gl.getShaderParameter(sh,gl.COMPILE_STATUS)){ | |
| ok=false; | |
| const log=(gl.getShaderInfoLog(sh)||'').trim().slice(0,240); | |
| GL_PROG_ERRORS.push((entry.name||'?')+' '+(ty===gl.VERTEX_SHADER?'VS':'FS')+': '+log); | |
| console.error('shader',entry.name,log,src.slice(0,200)); | |
| } | |
| } | |
| if(!gl.getProgramParameter(entry.p,gl.LINK_STATUS)){ | |
| ok=false; | |
| const log=(gl.getProgramInfoLog(entry.p)||'').trim().slice(0,240); | |
| GL_PROG_ERRORS.push((entry.name||'?')+' LINK: '+log); | |
| console.error('link',entry.name,log); | |
| } | |
| entry.validated=true; entry.ok=ok; | |
| return ok; | |
| } | |
| /* Validates every program still waiting. Safe to call at any time. */ | |
| function mfProgFlush(){ | |
| glProgFlushQueued=false; | |
| /* A lost context invalidates every pending handle; querying them is both | |
| meaningless and noisy. glrecover rebuilds and re-queues them. */ | |
| if(gl&&gl.isContextLost&&gl.isContextLost()){ GL_PROG_PENDING.length=0; return 0; } | |
| let failures=0; | |
| while(GL_PROG_PENDING.length){ | |
| const entry=GL_PROG_PENDING.shift(); | |
| if(!entry.validated&&!mfProgValidate(entry)) failures++; | |
| } | |
| return failures; | |
| } | |
| /* Immediate, single-program validation for callers that must branch on failure. */ | |
| function mfProgOk(p){ | |
| if(!p) return false; | |
| const i=GL_PROG_PENDING.findIndex(entry=>entry.p===p); | |
| if(i<0) return true; // already validated by the batch flush | |
| const [entry]=GL_PROG_PENDING.splice(i,1); | |
| return mfProgValidate(entry); | |
| } | |
| function mkProg(vsSrc,fsSrc,name){ | |
| fsSrc=mfWindowPatchWorldFS(fsSrc,name); | |
| const p=gl.createProgram(); | |
| const shaders=[]; | |
| for(const [ty,src] of [[gl.VERTEX_SHADER,vsSrc],[gl.FRAGMENT_SHADER,fsSrc]]){ | |
| const sh=gl.createShader(ty); | |
| gl.shaderSource(sh,src); gl.compileShader(sh); | |
| gl.attachShader(p,sh); | |
| shaders.push([sh,ty,src]); | |
| } | |
| gl.linkProgram(p); | |
| GL_PROG_PENDING.push({p,shaders,name,ok:true,validated:false}); | |
| if(!glProgFlushQueued){ | |
| glProgFlushQueued=true; | |
| const later=(typeof requestAnimationFrame==='function')?requestAnimationFrame:(fn=>setTimeout(fn,0)); | |
| later(()=>{ try{ mfProgFlush(); }catch(e){ console.error('program flush',e); } }); | |
| } | |
| return p; | |
| } | |
| /* The terrain gets its own program. Its surface look must come from the | |
| original hand-painted 2048px map canvas β biome blending, moss, cliff | |
| banding, kerbed highways, city aprons, battle scorch β because that art is | |
| what the game looked like and what the flat-shaded vertex-colour version | |
| threw away. The mesh supplies the relief; the canvas supplies the design. | |
| Four authored material sheets replace that macro colour at close range; | |
| one neutral micro-normal prevents magnification without imposing the same | |
| cracks or grain on grass, soil and hardscape. */ | |
| const VST=`#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; | |
| uniform mat4 uVP; | |
| uniform vec3 uEye; | |
| uniform float uHazeQ; | |
| uniform vec2 uPlayBounds; | |
| uniform float uEdgeStyle; | |
| out vec3 vNrm; out vec2 vMapUV; out vec2 vDetUV; out float vFog; out float vBorder; out float vPlayBorder; out float vPlayEdge; out float vOutside; out float vExclusion; out vec3 vCol; | |
| void main(){ | |
| vNrm=aNrm; vCol=aCol; | |
| vMapUV=vec2(aPos.x,aPos.z)/uMapSize; | |
| vDetUV=vec2(aPos.x,aPos.z)*0.021; | |
| /* Same planar aerial start as the model shader β 3D d is ~CAM_HEIGHT at | |
| every zoom, so it cannot carry command-altitude haze. */ | |
| float planar=length(vec2(aPos.x-uEye.x, aPos.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(aPos.x, MAPSIZE_CONST-aPos.x), min(aPos.z, MAPSIZE_CONST-aPos.z)); | |
| vBorder=clamp((BFOG_CONST-bd)/BFOG_CONST,0.0,1.0); | |
| vOutside=max(0.0,-bd); | |
| /* Match the authored theatre silhouette used by the red tactical grid. | |
| Finite superellipse powers keep every biome from ending in the same hard | |
| square, while the harmonics create broad coast/inlet/waste variation | |
| without introducing high-frequency collision traps. */ | |
| vec2 pc=vec2((uPlayBounds.x+uPlayBounds.y)*0.5); | |
| vec2 pd=aPos.xz-pc; | |
| float pa=atan(pd.y,pd.x), ca=abs(cos(pa)), sa=abs(sin(pa)); | |
| float power=uEdgeStyle>0.5&&uEdgeStyle<1.5?3.15:(uEdgeStyle>1.5&&uEdgeStyle<2.5?6.25:(uEdgeStyle>2.5?4.15:5.0)); | |
| float halfSpan=(uPlayBounds.y-uPlayBounds.x)*0.5; | |
| float baseR=halfSpan/pow(pow(ca,power)+pow(sa,power),1.0/power); | |
| float shape=uEdgeStyle>0.5&&uEdgeStyle<1.5 | |
| ?0.945+0.024*sin(pa*3.0+0.65)+0.016*sin(pa*7.0-1.10) | |
| :(uEdgeStyle>1.5&&uEdgeStyle<2.5 | |
| ?0.958+0.020*sin(pa*5.0+0.30)+0.012*sin(pa*9.0+1.20) | |
| :(uEdgeStyle>2.5 | |
| ?0.948+0.025*sin(pa*2.0-0.80)+0.016*sin(pa*6.0+0.45) | |
| :0.966+0.014*sin(pa*4.0+0.35)+0.010*sin(pa*7.0-0.55))); | |
| float pbd=baseR*shape-length(pd); | |
| vPlayBorder=clamp((160.0-pbd)/160.0,0.0,1.0); | |
| /* SYMMETRIC frontier proximity: 1.0 exactly on the boundary line, falling to | |
| 0 in BOTH directions. vPlayBorder cannot do this β it saturates at 1.0 for | |
| every point outside, which is what let the whole surround escape fog. */ | |
| vPlayEdge=clamp(1.0-abs(pbd)/90.0,0.0,1.0); | |
| vExclusion=max(0.0,-pbd); | |
| vFog=max(vFog,vBorder); // border haze | |
| gl_Position=uVP*vec4(aPos,1.0); | |
| }`.replace('uMapSize','MAPSIZE_CONST'); | |
| const FST=`#version 300 es | |
| precision highp float; | |
| in vec3 vNrm; in vec2 vMapUV; in vec2 vDetUV; in float vFog; in float vBorder; in float vPlayBorder; in float vPlayEdge; in float vOutside; in float vExclusion; in vec3 vCol; | |
| uniform sampler2D uMap; | |
| uniform sampler2D uDetail; | |
| uniform sampler2D uFogMap; | |
| uniform sampler2D uGroundT; // seamless, lighting-neutral albedo | |
| uniform sampler2D uSoilT; // companion soil/gravel sheet | |
| uniform sampler2D uPaveT; // poured-panel hardscape sheet | |
| uniform sampler2D uGrassT; // grass tuft sheet β vegetation is now real art too | |
| uniform sampler2D uGroundN; // authored normal RGB, roughness A | |
| uniform sampler2D uSoilN; | |
| uniform sampler2D uPaveN; | |
| uniform sampler2D uGrassN; | |
| uniform sampler2D uGMask; // white = street/pad hardscape (planner-authored) | |
| uniform sampler2D uHeight; // global world-height sheet (R16F, deform-synced) | |
| uniform float uHexelW; // world span of one central-difference step | |
| uniform float uRealTex; // 1 only when the complete albedo+normal set is ready | |
| uniform float uGroundQ; // 0 low, 1 medium, 2 high, 3 cinematic | |
| uniform float uGroundProfile;// 0 natural, 1 urban, 2 industrial, 3 derelict, 4 megacity | |
| uniform vec3 uGroundTint; uniform vec3 uSoilTint; | |
| uniform vec3 uPaveTint; uniform vec3 uGrassTint; uniform vec3 uLocationTint; | |
| uniform int uBurnN; // live impact burns (explosive embers / kinetic churn) | |
| uniform vec4 uBurns[16]; // xy=world, z=radius, w=cool 0..1 (fresh->cold) | |
| uniform float uBurnKind[16]; // 1=explosive, 0=kinetic | |
| uniform float uFogActive; | |
| uniform vec3 uEye; | |
| uniform vec3 uSun; uniform vec3 uSunC; | |
| uniform vec3 uAmbSky; uniform vec3 uAmbGnd; uniform vec3 uFogC; | |
| uniform float uEdgeStyle; uniform float uEdgeTime; uniform vec3 uEdgeTint; | |
| layout(location=0) out vec4 o; | |
| layout(location=1) out vec4 oWindow; | |
| /* Soft coal islands around an impact. Crack-aligned hash grain and floor() | |
| cells read as "embers in the grooves" / a lava QR-code. These are large | |
| irregular beds with a local flicker β real fire remnants, not shader noise. */ | |
| float mfCoalBed(vec2 wx, vec4 B){ | |
| float coals=0.0; | |
| for(int ci=0;ci<4;ci++){ | |
| float fi=float(ci); | |
| float h1=fract(sin(dot(B.xy,vec2(12.9898,78.233))+fi*17.13)*43758.5453); | |
| float h2=fract(sin(dot(B.xy,vec2(78.233,12.9898))+fi*31.71)*23421.631); | |
| float h3=fract(sin(dot(B.xy,vec2(39.346,11.135))+fi*47.27)*19223.117); | |
| float ang=h1*6.2831853; | |
| vec2 cp=B.xy+vec2(cos(ang),sin(ang))*(B.z*(0.08+h2*0.52)); | |
| float cr=B.z*(0.26+h3*0.28); | |
| coals+=1.0-smoothstep(cr*0.12,cr,distance(wx,cp)); | |
| } | |
| return min(coals,1.15); | |
| } | |
| float mfCoalFlick(vec2 c){ | |
| float h=fract(sin(dot(c,vec2(78.233,12.9898)))*23421.631); | |
| return 0.90+0.10*sin(uEdgeTime*(1.15+h*1.45)+h*6.2831853); | |
| } | |
| /* Stable hash remains only for the irregular, event-local burn crust. It is | |
| no longer layered across every unscarred terrain pixel. */ | |
| float mfH21(vec2 p){ | |
| return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453123); | |
| } | |
| /* AUTHORED-SHEET BAND-PASS. Two explicit-mip taps of a material sheet whose | |
| lod window TRACKS the screen: tpx is this uv's texels-per-physical-pixel, | |
| so the returned luminance difference always lives at ~5-20 on-screen | |
| pixels regardless of zoom. That band is what a human reads as soil/rock | |
| patchiness; content below it (single-pixel speckle) is exactly what the | |
| acceptance's Laplacian budget forbids, and the +2.3 floor keeps it out. | |
| Zero-mean by construction, so no theme brightens or darkens. */ | |
| float mfSheetBand(sampler2D s, vec2 uv, float tpx){ | |
| float lo=clamp(log2(max(tpx,0.4))+2.3,0.0,6.5); | |
| float a=dot(textureLod(s,uv,lo).rgb,vec3(0.299,0.587,0.114)); | |
| float b=dot(textureLod(s,uv,lo+2.5).rgb,vec3(0.299,0.587,0.114)); | |
| return a-b; | |
| } | |
| void main(){ | |
| vec3 geomN=normalize(vNrm); | |
| vec3 n=geomN; | |
| /* SHARPENED BILINEAR (texel-AA) for the 2048 macro paint. A bilinear edge | |
| ramps across one full texel - 1.5625 world units, ~3.1 physical px at | |
| span 420 - which is exactly the measured road-edge failure (3.131px vs | |
| the <=2px gate). Tuning downstream smoothsteps cannot fix an albedo that | |
| is itself soft. Remapping the fractional texel coordinate through a | |
| narrowed ramp makes every painted boundary ~1.4px on screen when | |
| magnified. The half-width mw is PIXEL-PROPORTIONAL (hppx = macro texels | |
| per pixel), clamping to 0.5 at minification where the mapping becomes | |
| the identity - distant terrain is bit-identical, so this cannot add pan | |
| shimmer where that gate already passes. Derivatives are computed on the | |
| UNMODIFIED vMapUV, and the remap stays within the texel, so mip | |
| selection is unchanged. */ | |
| vec2 dMx=dFdx(vMapUV), dMy=dFdy(vMapUV); | |
| float hppx=length(abs(dMx)+abs(dMy))*2048.0; | |
| vec2 mtx=vMapUV*2048.0-0.5; | |
| vec2 mfl=floor(mtx), mfr=fract(mtx); | |
| /* SHORE EXEMPTION. Sharpening the shoreline's painted boundary raised the | |
| max adjacent RGB step above the accepted band (131 baseline -> 146). | |
| Water transitions are the one place a soft texel ramp is CORRECT, so the | |
| reconstruction fades to identity (mw 0.5 = plain bilinear) within ~1.2 to | |
| 6 world-metres of the waterline. uHeight is world height, waterline 0. */ | |
| float shoreH=texture(uHeight,vMapUV).r; | |
| float shoreKeep=smoothstep(1.2,6.0,shoreH); | |
| float mw=mix(0.5, clamp(hppx*0.7,0.15,0.43), shoreKeep); | |
| vec2 sfr=clamp((mfr-0.5)/(2.0*mw)+0.5,0.0,1.0); | |
| sfr=sfr*sfr*(3.0-2.0*sfr); | |
| vec3 base=texture(uMap,(mfl+0.5+sfr)/2048.0).rgb; | |
| /* Quality is explicit; range is derivative-driven. This prevents distant | |
| detail shimmer without tying material LOD to one screen resolution. */ | |
| vec2 dDx=dFdx(vDetUV), dDy=dFdy(vDetUV); | |
| float ppx=length(abs(dDx)+abs(dDy))*512.0; | |
| float qMed=step(0.5,uGroundQ), qHigh=step(1.5,uGroundQ); | |
| /* Keep the 1024 authored sheets dominant through the complete span-420 | |
| close view. The former 7..22 gate had already fallen halfway back to the | |
| 2048 macro paint there, which is the broad cloud pattern visible in the | |
| acceptance captures. It still reaches zero before strategic zoom. */ | |
| float closeG=1.0-smoothstep(12.0,40.0,ppx); | |
| /* Measured screen scale at the acceptance spans: 420 -> ppx 12, 700 -> 20, | |
| 1500 -> 43. The former 8..24 normal gate was already 84% gone at span | |
| 700 β the whole mid-tactical band lit no authored relief at all, which | |
| is most of the measured readable-gradient loss there. Mips+aniso own | |
| minification aliasing; the gates only need to die before command zoom. */ | |
| float normalG=qMed*(1.0-smoothstep(14.0,40.0,ppx)); | |
| float secondG=qHigh*(1.0-smoothstep(10.0,28.0,ppx)); | |
| /* One neutral macro variation pair survives at every tier because border | |
| scenery also consumes it. Neither sample contains the old crack cells. */ | |
| vec4 dt1=texture(uDetail,vDetUV*0.31); | |
| vec4 dt2=texture(uDetail,(mat2(.86,-.51,.51,.86)*vDetUV)*0.17+vec2(.37,.61)); | |
| float d1=dt1.r,d2=dt2.r; | |
| /* ARTIFICIAL GROUND STANDS APART. The hardscape mask is read FIRST so that | |
| every natural material below β soil, rock and grass β can stand down | |
| inside a poured surface. A base platform is | |
| engineered: flat, uniform, its own material; nature stops at its edge. */ | |
| /* Keep the semantic hardscape mask on its own unwarped coordinate. Reusing | |
| the macro-albedo texel sharpening magnified individual mask texels into a | |
| checker/stair-step fringe around roads and city yards; beneath aircraft | |
| that fringe looked like a translucent grey fog sheet. textureGrad keeps | |
| the authored road footprint, mip choice and smooth termination values, | |
| while the derivative-aware threshold below still controls edge width. */ | |
| vec2 surfaceMask=textureGrad(uGMask,vMapUV,dMx,dMy).rg; | |
| float mRaw=surfaceMask.r; | |
| float creepMask=smoothstep(0.03,0.72,surfaceMask.g); | |
| /* Organic G is presentation precedence, not a destructive rewrite of the | |
| planner-owned hardscape R. Where creep has grown over an old apron it | |
| suppresses every downstream kerb/pave cue while PASS keeps its original | |
| authoritative surface data. */ | |
| mRaw*=1.0-creepMask; | |
| /* A formed road edge is CRISP: the wide threshold band + strong noise that | |
| blended terminations also smeared every straight kerb into mush. The | |
| band tightens to ~1 texel and the noise bites at a third strength β ends | |
| still crumble, edges read poured. */ | |
| float edgeN=((d1-0.5)*0.012+(d2-0.5)*0.008)*(0.40+0.22*closeG); | |
| /* ADAPTIVE EDGE BAND β the piece the last fix missed. A single tight | |
| threshold made sides crisp but ALSO squeezed a 46-unit end fade back | |
| into a 5-unit material flip: the ramp was in the mask, and the | |
| smoothstep threw it away. fwidth(mRaw) tells the two apart per pixel: | |
| steep formed sides keep the tight band, shallow end ramps get a wide | |
| one, so the material genuinely crossfades along the whole dissolve. */ | |
| float mfw=fwidth(mRaw); | |
| float steep=smoothstep(0.018,0.075,mfw); | |
| float band=mix(0.38,0.010,steep); | |
| /* Wilderness-end noise remains, but a poured side is straight. Averaging | |
| five noisy, laterally shifted transects widened one physical road edge to | |
| 2.713 px even though the opposite side measured 1.955 px. */ | |
| float edgeShift=edgeN*mix(1.0,0.05,steep); | |
| float hm=smoothstep(0.5-band+edgeShift,0.5+band+edgeShift,mRaw); | |
| hm=mix(hm,pow(hm,1.28),0.45*closeG); | |
| float nat=1.0-hm; | |
| float cityYard=smoothstep(0.16,0.40,mRaw); | |
| /* Kerb line: the pale formed rim living in the mask's transition band β | |
| exactly where a real kerb sits. The single strongest "engineered" cue in | |
| the reference roads. */ | |
| float kerb=smoothstep(0.37,0.46,mRaw)*(1.0-smoothstep(0.54,0.63,mRaw)); | |
| /* A kerb exists only where the edge is FORMED. fwidth(mRaw) separates a | |
| poured side (steep transition) from a dissolving end (long shallow one), | |
| so the pale rim never halos a fade-out. */ | |
| kerb*=smoothstep(0.030,0.085,fwidth(mRaw)*(0.5+0.5*closeG)+fwidth(mRaw)); | |
| /* ROAD-EDGE LUMINANCE CONTRAST. The painted albedo ramps ~1-1.7 world | |
| units across a road side (stroke AA + the authored termination blur | |
| bleeding onto sides), which projects wider than the 2-physical-px | |
| acceptance gate at span 420. Interpolation sharpening cannot fix values; | |
| this steepens the luminance ramp only where the mask says a FORMED road | |
| edge lies (same fwidth discriminator as the kerb, so authored | |
| terminations keep their long blend), in the mask transition band only. | |
| Luminance-only: hue cannot shift, unlike the per-channel 2.05x unsharp | |
| that once painted red halos around cyan deposits. */ | |
| /* Band widened to [0.031,0.969]: the 10% crossing of the measured ramp sits | |
| NEAR THE LAND SIDE where mRaw is ~0.9, and the previous [0.11,0.89] band | |
| zeroed the gate exactly at the tail the acceptance metric starts from. */ | |
| /* mfw floor lowered 0.030 -> 0.020: the side that carries the authored | |
| termination blur has a shallower per-pixel mask slope, and the old floor | |
| attenuated the contrast pass exactly on the softer of the two edges. A | |
| dissolving road END is still far below 0.020 and stays fully blended. */ | |
| float edgeG=smoothstep(0.022,0.072,mfw)*smoothstep(0.035,0.16,mRaw*(1.0-mRaw))*closeG; | |
| /* Hardscape interior: the edge->slab and slab->seam stroke boundaries are | |
| canvas-AA value ramps the transition-band gate cannot reach (mRaw~=1 | |
| there). Engineered surfaces take crisp internal boundaries. */ | |
| edgeG=max(edgeG, smoothstep(0.60,0.85,mRaw)*closeG*0.8); | |
| if(edgeG>0.02){ | |
| vec2 mt1=vec2(1.0/2048.0,0.0), mt2=vec2(0.0,1.0/2048.0); | |
| float lC=dot(base,vec3(.2126,.7152,.0722)); | |
| float lB=dot(texture(uMap,vMapUV+mt1).rgb+texture(uMap,vMapUV-mt1).rgb | |
| +texture(uMap,vMapUV+mt2).rgb+texture(uMap,vMapUV-mt2).rgb,vec3(.2126,.7152,.0722))*0.25; | |
| base=max(base+vec3((lC-lB)*1.7*edgeG),vec3(0.0)); | |
| } | |
| vec2 wxz=vDetUV*(1.0/0.021); | |
| float heightC=texture(uHeight,vMapUV).r; | |
| /* PER-PIXEL TERRAIN NORMALS. Vertex normals know the ground at mesh density | |
| (10 m); the height sheet knows it at 1.56 m. Central differences give an | |
| 8x lighting-resolution jump β hillsides gain real shading structure and | |
| crater rims read at any zoom. One global sheet, window-synced by every | |
| deformation, so there are no chunk seams to leak. Faded out at command | |
| range where a screen pixel spans several height texels (no mips on R16F | |
| without EXT_color_buffer_float β the fade IS the anti-alias). */ | |
| /* Span 1500 measures hppx 2.55, so the old 1.3..2.8 fade had erased the | |
| landform normals from the whole strategic-approach band. The adaptive | |
| step below IS the anti-alias (it band-limits the slope field to ~3 | |
| screen px at any zoom), so the gate can survive to command altitude. */ | |
| float hnG=qMed*(1.0-smoothstep(2.6,5.2,hppx)); | |
| if(hnG>0.02){ | |
| vec2 he=1.0/vec2(textureSize(uHeight,0)); | |
| /* DERIVATIVE-MATCHED STEP. A fixed one-texel central difference is a | |
| different animal at every zoom: ~5 screen px at span 420 (smooth | |
| landform shading) but ~1.4 px at span 1500 (pixel-scale flicker that | |
| reads as noise, not relief). Widening the tap with hppx keeps the | |
| reconstructed slope field at a near-constant SCREEN frequency, so the | |
| same strength gives readable hillsides close up without minting | |
| single-pixel busyness at strategic zoom. Slope stays physical: the | |
| vertical term scales with the same step. */ | |
| float hs=max(1.0,hppx*2.2); | |
| vec2 hev=he*hs; | |
| float hL=texture(uHeight,vMapUV-vec2(hev.x,0.0)).r, hR=texture(uHeight,vMapUV+vec2(hev.x,0.0)).r; | |
| float hU=texture(uHeight,vMapUV-vec2(0.0,hev.y)).r, hD=texture(uHeight,vMapUV+vec2(0.0,hev.y)).r; | |
| vec3 hn=normalize(vec3(hL-hR, uHexelW*hs, hU-hD)); | |
| /* The height field carries landform. 0.28 flat had thrown away most of | |
| the terrain's own shading structure β the close view lost its readable | |
| relief along with the rejected turbulence. Strong where the step is | |
| genuinely smooth on screen (close), restrained toward strategic zoom. */ | |
| float hnW=mix(0.72,0.38,smoothstep(0.60,2.60,hppx)); | |
| n=normalize(mix(n,hn,hnG*hnW)); | |
| } | |
| /* The painted 2048 macro map is magnified ~3x at tactical zoom, and plain | |
| bilinear is what read as "blurry ground". A 4-tap unsharp against its own | |
| local blur restores the painted edges (roads, scorch, biome breaks) | |
| without inventing detail the artist never put there. */ | |
| /* hppx at SPAN_MIN on a 412 phone is ~0.5β1.0, but mid-tactical is 2β4 | |
| and the old 0.9β1.8 gate was already 0 there β painted roads went | |
| bilinear-soft the moment the player backed off a few metres. */ | |
| float uGate=qMed*(1.0-smoothstep(1.8,4.2,hppx)); | |
| if(uGate>0.03){ | |
| vec2 texel=1.0/vec2(textureSize(uMap,0)); | |
| vec3 blurM=(texture(uMap,vMapUV+vec2(texel.x,0.0)).rgb | |
| +texture(uMap,vMapUV-vec2(texel.x,0.0)).rgb | |
| +texture(uMap,vMapUV+vec2(0.0,texel.y)).rgb | |
| +texture(uMap,vMapUV-vec2(0.0,texel.y)).rgb)*0.25; | |
| /* SHARPEN LUMINANCE, NOT EACH CHANNEL, AND NOT AT 2.05. | |
| Per-channel unsharp at amount 2.05 (conventional is 0.3-0.8) invents | |
| colour at any high-chroma boundary. paintResourceGroundNode stamps cyan | |
| veins rgba(72,186,220) into this very map over brown ground, so just | |
| outside a vein (base-blurM) is strongly negative in G and B but barely | |
| negative in R; amplified 2.05x and clipped at zero, G and B went to | |
| black while R survived - a dark RED halo around every cyan deposit. The | |
| same clipped undershoot is the black speckle along road edges. | |
| Adding the luminance difference equally to all three channels sharpens | |
| exactly as intended and cannot shift hue. */ | |
| float _lb=dot(blurM,vec3(.2126,.7152,.0722)); | |
| float _lc=dot(base ,vec3(.2126,.7152,.0722)); | |
| /* Amount follows magnification. Close up a macro texel spans ~5 screen px, | |
| so restoring its painted edge is a smooth, readable gradient; near | |
| minification the same kick lands at ~1-texel screen scale and only | |
| buys speckle, so it leans out there. */ | |
| float uAmt=mix(0.62,0.12,smoothstep(0.60,2.60,hppx)); | |
| base=max(base+vec3((_lc-_lb)*(uAmt*uGate)),vec3(0.0)); | |
| } | |
| /* ---- AUTHORED GROUND MATERIALS -------------------------------------- | |
| The macro map owns roads, biomes and scars. Four authored sheets own the | |
| close surface. Low uses one albedo octave; Medium adds its packed normal; | |
| High/Cinematic add one lightly weighted decorrelated octave and a single | |
| micro-normal. There is no universal crack or hash-grain stack. */ | |
| /* Preserve the deformation/height-field normal before authored grass, | |
| paving and soil micro-normal are layered over it. Burn scars restore | |
| this normal so the crater wall survives while grass blades do not. */ | |
| vec3 landformN=n; | |
| float matRough=0.78; | |
| if(uRealTex>0.5){ | |
| vec2 uvA=wxz*0.018; | |
| vec2 uvB=(mat2(0.86,-0.51,0.51,0.86)*wxz)*0.041; | |
| float slopeW=clamp((1.0-n.y)*2.6-0.30,0.0,1.0); | |
| float paintGreen=base.g-max(base.r,base.b); | |
| float biomeVar=d1*0.62+d2*0.38; | |
| /* Material identity follows the authored macro semantics, not a thresholded | |
| noise field. The old biomeVar threshold alternated grass/rock in broad | |
| pale blobs even across uniformly green ground; the soil threshold did | |
| the same to every regional sheet. Noise now modulates only a few percent | |
| while green paint selects grass and the regional SOIL sheet dominates | |
| non-grass terrain. */ | |
| float greenSemantic=smoothstep(0.006,0.060,paintGreen); | |
| float grassMix=greenSemantic*(0.94+0.06*biomeVar)*(1.0-slopeW*0.94); | |
| float soilBase=0.70+(biomeVar-0.5)*0.08; | |
| float soilMix=mix(soilBase,0.90,slopeW)*(1.0-grassMix); | |
| float yard=(1.0-smoothstep(0.012,0.055,paintGreen))*cityYard; | |
| grassMix*=1.0-yard; | |
| soilMix*=1.0-yard*0.70; | |
| /* Brood creep occupies the organic G channel; hardscape remains solely R. | |
| It reuses the biome's existing soil PBR pair, adding no sampler or pass. */ | |
| grassMix*=1.0-creepMask; | |
| soilMix=mix(soilMix,1.0,creepMask); | |
| vec4 aGround=texture(uGroundT,uvA), aSoil=texture(uSoilT,uvA); | |
| vec4 aGrass=texture(uGrassT,uvA*1.35), aPave=texture(uPaveT,wxz*0.030); | |
| aGround.rgb*=uGroundTint; aSoil.rgb*=uSoilTint; | |
| aGrass.rgb*=uGrassTint; aPave.rgb*=uPaveTint*uLocationTint; | |
| vec4 natural=mix(mix(aGround,aSoil,soilMix),aGrass,grassMix); | |
| float cityHard=max(hm, yard*0.82); | |
| vec4 mat=mix(natural,aPave,cityHard); | |
| if(secondG>0.03){ | |
| vec4 bGround=texture(uGroundT,uvB), bSoil=texture(uSoilT,uvB); | |
| vec4 bGrass=texture(uGrassT,uvB*1.35), bPave=texture(uPaveT,wxz*0.061); | |
| bGround.rgb*=uGroundTint; bSoil.rgb*=uSoilTint; | |
| bGrass.rgb*=uGrassTint; bPave.rgb*=uPaveTint*uLocationTint; | |
| vec4 second=mix(mix(mix(bGround,bSoil,soilMix),bGrass,grassMix),bPave,cityHard); | |
| mat=mix(mat,second,0.22*secondG); | |
| } | |
| /* Preserve macro value and theme while making the authored sheet the | |
| dominant close-range signal. Hardscape keeps painted asphalt/plaza | |
| values; the pave art contributes material variation, not one grey road. */ | |
| float texL=dot(mat.rgb,vec3(0.299,0.587,0.114)); | |
| /* MIP-FLATTENING COMPENSATION. At tactical spans the 1024 sheets sit | |
| several mip levels down, and trilinear averaging has already washed | |
| out most of their authored gravel/pebble value contrast before the | |
| splat ever sees them. Re-expanding around the local mean restores the | |
| art's own structure β it cannot invent detail the sheet never had. | |
| Restrained on hardscape so painted asphalt keeps its value. */ | |
| mat.rgb=clamp(vec3(texL)+(mat.rgb-vec3(texL))*(1.0+1.05*closeG*(1.0-cityHard*0.5)),0.0,1.0); | |
| /* Macro paint supplies restrained value variation only. Feeding all three | |
| macro channels back at 0.88 preserved its broad cloud blobs and buried | |
| the authored grass/soil/rock identity; biome tint already lives in the | |
| material profiles above. */ | |
| float macroL=dot(base,vec3(0.2126,0.7152,0.0722)); | |
| vec3 naturalOut=mat.rgb*(0.90+macroL*0.10); | |
| /* Creep's authored purple tissue and veins live in the macro RGB. Merely | |
| selecting SOIL through G turned that art back into ordinary brown Aelos | |
| ground, so retain the painted colour while borrowing the soil sheet's | |
| restrained PBR variation. */ | |
| vec3 creepOut=base*(0.82+texL*0.30)+(mat.rgb-vec3(texL))*0.12; | |
| naturalOut=mix(naturalOut,creepOut,creepMask); | |
| vec3 hardOut=base*(0.82+texL*0.30)+(mat.rgb-vec3(texL))*0.22; | |
| float splat=closeG*(0.78+0.20*qMed); | |
| base=mix(base,mix(naturalOut,hardOut,cityHard),clamp(splat,0.0,0.98)); | |
| /* MID/FAR AUTHORED RELIEF. Two band-passed octaves of the regional soil | |
| sheet modulate ground value at a stable on-screen scale at EVERY span | |
| (see mfSheetBand). This is the authored rock/soil patchiness the | |
| close-gated splat cannot carry to span 700-1500 β structured relief, | |
| not value-noise cloud, and none of it at the pixel scale that reads | |
| as speckle. World-anchored, so panning translates it exactly. Quiet | |
| on hardscape, silent across the shoreline band (shoreKeep) so the | |
| accepted waterline blend is untouched, and leaning out on steep | |
| ground where the landform normals already carry the read. */ | |
| float farFade=1.0-smoothstep(46.0,80.0,ppx); | |
| float bandW=mix(0.40,1.0,nat)*shoreKeep*farFade*mix(1.0,0.5,slopeW); | |
| float band1=mfSheetBand(uSoilT,wxz*0.0011,ppx*0.105); | |
| float band2=mfSheetBand(uSoilT,wxz*0.0051,ppx*0.486); | |
| base*=1.0+(band1*2.2+band2*1.5)*bandW; | |
| /* Packed normal RGB + roughness A. Derivatives are evaluated before the | |
| uniform branch, as required by GLSL ES for textureGrad. */ | |
| vec2 dxA=dFdx(uvA), dyA=dFdy(uvA); | |
| vec2 dxG=dFdx(uvA*1.35), dyG=dFdy(uvA*1.35); | |
| vec2 dxP=dFdx(wxz*0.030), dyP=dFdy(wxz*0.030); | |
| vec2 dxB=dFdx(uvB),dyB=dFdy(uvB); | |
| vec2 dxBG=dFdx(uvB*1.35),dyBG=dFdy(uvB*1.35); | |
| vec2 dxBP=dFdx(wxz*0.061),dyBP=dFdy(wxz*0.061); | |
| if(normalG>0.03){ | |
| vec4 nrA=mix(mix(textureGrad(uGroundN,uvA,dxA,dyA),textureGrad(uSoilN,uvA,dxA,dyA),soilMix), | |
| textureGrad(uGrassN,uvA*1.35,dxG,dyG),grassMix); | |
| nrA=mix(nrA,textureGrad(uPaveN,wxz*0.030,dxP,dyP),cityHard); | |
| if(secondG>0.03){ | |
| vec4 nrB=mix(mix(textureGrad(uGroundN,uvB,dxB,dyB),textureGrad(uSoilN,uvB,dxB,dyB),soilMix), | |
| textureGrad(uGrassN,uvB*1.35,dxBG,dyBG),grassMix); | |
| nrB=mix(nrB,textureGrad(uPaveN,wxz*0.061,dxBP,dyBP),cityHard); | |
| nrA=mix(nrA,nrB,0.22*secondG); | |
| } | |
| vec3 tn=nrA.rgb*2.0-1.0; | |
| vec3 gN=normalize(n); | |
| vec3 T=cross(gN,vec3(0.0,0.0,1.0)); | |
| if(dot(T,T)<1e-4) T=cross(gN,vec3(1.0,0.0,0.0)); | |
| T=normalize(T); | |
| vec3 Bn=cross(gN,T); | |
| vec3 nMapped=normalize(mat3(T,Bn,gN)*tn); | |
| /* One readable fine scale, subordinate to the mesh/landform normal. | |
| 0.55-0.78 at the old un-mipped response was visual static, but the | |
| 0.30-0.42 retreat erased the sheets' lighting structure entirely β | |
| the measured close view kept only a third of its readable gradient | |
| energy. The sheets are minified (mip-filtered) at tactical spans, | |
| so their surviving relief is multi-pixel and smooth; a middle | |
| strength lights it as gravel/soil relief, not speckle. */ | |
| float nStrength=mix(mix(0.62,0.74,soilMix),0.55,grassMix); | |
| nStrength=mix(nStrength,mix(0.42,0.50,step(0.5,uGroundProfile)),cityHard); | |
| n=normalize(mix(gN,nMapped,normalG*nStrength)); | |
| matRough=mix(matRough,clamp(nrA.a,0.34,0.96),normalG); | |
| if(secondG>0.03){ | |
| vec2 fuv=vDetUV*2.8, fdx=dFdx(fuv), fdy=dFdy(fuv); | |
| vec2 micro=textureGrad(uDetail,fuv,fdx,fdy).gb*2.0-1.0; | |
| float microW=secondG*mix(mix(0.045,0.070,soilMix),0.032,grassMix); | |
| microW=mix(microW,0.026*secondG,cityHard); | |
| n=normalize(n+vec3(micro.x,0.0,micro.y)*microW); | |
| } | |
| } | |
| } | |
| base+=vec3(0.055,0.056,0.052)*kerb*(0.30+0.70*closeG); | |
| /* One subtle anti-tiling signal replaces seven competing noise bands. */ | |
| float macroVar=(d1*0.62+d2*0.38)-0.5; | |
| base*=1.0+macroVar*mix(0.018,0.050,nat)*closeG; | |
| /* Damp land bridges the opaque ground and translucent water. It is a | |
| material response above sea level, not a painted shoreline ring. */ | |
| float shoreWet=smoothstep(WATER_HEIGHT_CONST-0.002,WATER_HEIGHT_CONST+0.007,heightC) | |
| *(1.0-smoothstep(WATER_HEIGHT_CONST+0.012,WATER_HEIGHT_CONST+0.060,heightC)); | |
| base=mix(base,base*vec3(0.75,0.80,0.83),shoreWet*0.30); | |
| matRough=mix(matRough,0.26,shoreWet*0.72); | |
| /* ---- IMPACT BURNS ---------------------------------------------------- | |
| An explosive hit chars the ground and leaves irregular coal beds β | |
| warm islands with a local flicker, not crack-aligned grain. Glow dies | |
| first (fast cool), char lingers and fades to ash. Kinetic hits stay | |
| cold: pale, churned earth that settles. | |
| Emissive is accumulated here but added AFTER tonemapping, so embers stay | |
| luminous against a charred, light-absorbing floor. */ | |
| vec3 emberSum=vec3(0.0); | |
| for(int bi=0;bi<uBurnN;bi++){ | |
| vec4 B=uBurns[bi]; | |
| float bd=distance(wxz,B.xy); | |
| /* IRREGULAR EDGE. A radial smoothstep is a perfect circle by definition - | |
| that circle is what read as a 'glowing disc'. Two octaves of angular | |
| noise, seeded from the burn's own world position so it is stable for the | |
| life of the burn, push the rim in and out like a real scorch. */ | |
| vec2 bdir=(wxz-B.xy)/max(bd,0.001); | |
| float bang=atan(bdir.y,bdir.x); | |
| float lobe=sin(bang*3.0+B.x*0.031)*0.5+sin(bang*7.0-B.y*0.027)*0.28 | |
| +sin(bang*13.0+B.x*0.019-B.y*0.013)*0.13; | |
| float bRad=B.z*(0.80+0.26*lobe); | |
| if(bd>=bRad) continue; | |
| float m=1.0-smoothstep(bRad*0.30,bRad,bd); | |
| /* fine crust break-up so the interior is charcoal, not a flat tint */ | |
| float crust=mfH21(wxz*0.62+B.xy*0.05)*0.6+mfH21(wxz*1.9-B.xy*0.03)*0.4; | |
| m*=0.62+0.55*crust; | |
| float cool=B.w; | |
| if(uBurnKind[bi]>2.5){ | |
| /* URBAN ASH / SOOT β explosion aftermath over hardscape pavement. | |
| The pavement is charred to dark soot, with zero open-soil mud or embers. */ | |
| float ashA=m*(1.0-cool*cool*0.9); | |
| base*=1.0-ashA*0.82; | |
| base*=mix(vec3(1.0),vec3(0.32,0.34,0.36),ashA*0.75); | |
| n=normalize(mix(n,landformN,ashA*0.82)); | |
| matRough=mix(matRough,0.98,ashA*0.90); | |
| }else if(uBurnKind[bi]>1.5){ | |
| /* VOID SCAR β singularity aftermath. The ground is vitrified, not | |
| burnt: darker char with a cold blue cast, and violet coal beds | |
| cooling toward deep indigo. */ | |
| float charA=m*(1.0-cool*cool*0.9); | |
| base*=1.0-charA*0.84; | |
| base*=mix(vec3(1.0),vec3(0.82,0.86,1.12),charA*0.5); | |
| n=normalize(mix(n,landformN,charA*0.84)); | |
| matRough=mix(matRough,0.96,charA*0.88); | |
| float heatV=max(0.0,1.0-cool*1.3); | |
| float bedV=mfCoalBed(wxz,B)*m*heatV; | |
| float flickV=mfCoalFlick(B.xy); | |
| emberSum+=mix(vec3(0.52,0.34,0.92),vec3(0.14,0.07,0.42),cool) | |
| *((0.20+bedV*0.62)*flickV); | |
| }else if(uBurnKind[bi]>0.5){ | |
| /* Thermal: dark char with soft coal beds, cooling to black ash. | |
| Crack-locked grain and cell-hash speck read as shader noise. */ | |
| /* The blast consumes the surface material. Preserve the deformed mesh | |
| normal but remove grass/paving micro-normal response, destroy gloss, | |
| and replace the albedo with stable clinker variation. This makes the | |
| crater remain black after the airborne core has vanished instead of | |
| revealing green grass through a weak translucent tint. */ | |
| float charA=clamp(m*(1.0-cool*cool*0.18),0.0,1.0); | |
| vec3 emberAlb=vec3(0.46,0.075,0.008); | |
| vec3 ash=vec3(0.012,0.011,0.010); | |
| float clinker=clamp(crust*0.78+mfH21(wxz*0.19-B.xy*0.017)*0.34,0.0,1.0); | |
| vec3 charTex=ash*mix(0.52,1.82,clinker); | |
| vec3 scar=mix(emberAlb,charTex,smoothstep(0.12,0.72,cool)); | |
| base=mix(base,charTex,charA*mix(0.90,0.985,cool)); | |
| n=normalize(mix(n,landformN,charA*0.86)); | |
| matRough=mix(matRough,0.985,charA*0.94); | |
| float leftoverG=max(0.0,base.g-base.r); | |
| base.g-=leftoverG*charA; | |
| /* Normal burns live 70 s, but visible heat should not. This maps their | |
| normalized clock to a roughly 11-13 s ember window while the dark | |
| textured scar persists for the full aftermath lifetime. */ | |
| float heat=max(0.0,1.0-cool*5.5); | |
| /* Confine ember-red to the coal bed. Applying it across the whole disc | |
| dragged the ash laid down above back toward red. */ | |
| float coal=clamp(mfCoalBed(wxz,B),0.0,1.0); | |
| float bedHot=pow(coal,2.8)*m*heat; | |
| base=mix(base,scar,charA*heat*bedHot*0.18); | |
| /* Push the mask toward its peaks so glow reads as cracks between cooled | |
| clinker instead of a soft wash over the burn. */ | |
| float bed=pow(coal,2.7)*m*heat; | |
| float emberCore=pow(coal,6.0)*m*heat; | |
| float flick=mfCoalFlick(B.xy); | |
| /* No full-footprint emissive floor: that produced a flat orange disc | |
| around every strategic blast. Only sparse peak coal beds glow; the | |
| dominant aftermath is dark, irregular char. */ | |
| emberSum+=mix(vec3(1.08,0.20,0.018),vec3(0.24,0.035,0.003),cool) | |
| *(bed*0.62*flick) | |
| +vec3(1.28,0.42,0.028)*(emberCore*0.46*flick); | |
| }else{ | |
| float st=m*(1.0-cool); | |
| base=mix(base,base*vec3(1.08,1.00,0.86)+vec3(0.045,0.035,0.02),st*0.55); | |
| base*=1.0-st*0.10; // faint bruise, no heat | |
| } | |
| } | |
| float ndl=max(dot(n,uSun),0.0); | |
| float wrap=(dot(n,uSun)*0.5+0.5); | |
| float hemi=n.y*0.5+0.5; | |
| vec3 amb=mix(uAmbGnd,uAmbSky,hemi); | |
| float wrapW=mix(0.24,0.15,matRough); | |
| vec3 lit=base*(amb + uSunC*(ndl*(1.0-wrapW)+wrap*wrapW)); | |
| vec3 viewDir=normalize(uEye-vec3(wxz.x,heightC,wxz.y)); | |
| float wetF=pow(1.0-max(dot(n,viewDir),0.0),4.5)*shoreWet*(1.0-matRough); | |
| float wetSun=pow(max(dot(reflect(-uSun,n),viewDir),0.0),42.0)*shoreWet*(1.0-matRough); | |
| lit+=uAmbSky*wetF*0.16+uSunC*wetSun*0.20; | |
| lit=vec3(1.0)-exp(-lit*1.55); // same curve as the model shader | |
| lit+=emberSum; // embers glow regardless of sun | |
| /* Fog-of-war follows the terrain itself. A screen overlay could not survive | |
| camera tilt and the first ground-quad attempt visibly cut hills into dark | |
| tiles. Sampling the 64x64 sensor texture in map UV space gives smooth, | |
| depth-correct current/explored/unexplored transitions for one texture read. */ | |
| /* Border weather is a physical cover over the sensor map, not another layer | |
| beneath it. The old order fogged the terrain and THEN mixed it toward FOW | |
| black, which cut a navy polygon out of the otherwise matching clear | |
| colour. Fade sensor darkness out as haze starts, apply it to the terrain, | |
| and put the atmospheric veil on last. */ | |
| /* Sensor darkness must not redraw the tactical frontier as a black slab. | |
| Protect both the physical terrain skirt and the currently selected safe | |
| theatre edge; Compact and Standard deliberately end before the mesh does. */ | |
| /* FOG HIDES THE WHOLE WORLD, NOT JUST THE PLAYABLE DISC. | |
| borderProtect used to be driven by vPlayBorder/vBorder, which saturate to | |
| 1.0 everywhere outside the theatre β so fow was forced to ZERO across the | |
| entire surround and the unexplored outer ranges rendered as a fully lit | |
| pale expanse you could read the map from. Protect only the frontier LINE | |
| itself (a narrow band either side of the boundary, so the red tactical | |
| edge is not swallowed by a black slab) and let sensor darkness cover | |
| everything beyond it. vPlayEdge is |distance| to the boundary, so it | |
| feathers symmetrically instead of one-sided outward. */ | |
| float fow=texture(uFogMap,clamp(vMapUV,0.0,1.0)).a*uFogActive*(1.0-smoothstep(0.35,1.0,vPlayEdge)); | |
| /* Unexplored colour is DERIVED, not authored: hardcoded navy read as a blue | |
| cutout on any planet whose atmosphere is not blue. Blending the planet's | |
| own ground ambient and fog colour keeps sensor darkness continuous with | |
| whatever weather this world actually has. */ | |
| lit=mix(lit,mix(uAmbGnd*0.10,uFogC*0.20,0.5),fow); | |
| lit=mix(lit,uFogC,vFog*(1.0-fow)); // the atmospheric veil must not re-light fogged ground | |
| /* The low-density skirt outside the simulation receives a restrained fog | |
| variation. It suggests rolling ground continuing into weather without | |
| becoming navigable terrain or revealing a second hard edge. Both ends of | |
| the band return to the exact fog colour, so the authored seam and the far | |
| framebuffer clear remain continuous. */ | |
| float outerBand=smoothstep(10.0,180.0,vOutside)*(1.0-smoothstep(640.0,940.0,vOutside)); | |
| float outerNoise=(d1*0.68+d2*0.32)-0.5; | |
| vec3 outerFog=uFogC*(0.965+outerNoise*0.065); | |
| lit=mix(lit,outerFog,outerBand*0.72*(1.0-fow)); // never re-light fogged surround | |
| /* The red command boundary is drawn on the last safe ground. Only AFTER | |
| crossing it does this procedural exclusion treatment begin. Detail noise | |
| perturbs the onset so coastlines and wasteland fronts do not read as a | |
| second perfect rectangle, while a long fade returns to atmospheric fog | |
| before the low-density skirt itself ends. */ | |
| float irregular=((d1*0.68+d2*0.32)-0.5)*42.0; | |
| float onset=max(8.0,14.0+irregular); | |
| float zone=smoothstep(onset,max(onset+34.0,82.0+irregular),vExclusion) | |
| *(1.0-smoothstep(760.0,1180.0,vExclusion)); | |
| vec3 zoneCol=mix(uFogC,uEdgeTint,0.42); | |
| if(uEdgeStyle>0.5&&uEdgeStyle<1.5){ | |
| // Coastal/island exclusion: dark water with narrow moving foam trains. | |
| float wave=0.5+0.5*sin(vExclusion*0.064+vDetUV.x*0.31-vDetUV.y*0.19+uEdgeTime*0.72); | |
| float foam=pow(wave,12.0)*(1.0-smoothstep(250.0,560.0,vExclusion)); | |
| zoneCol=mix(uFogC,uEdgeTint,0.58)+vec3(0.10,0.16,0.18)*foam; | |
| }else if(uEdgeStyle>1.5&&uEdgeStyle<2.5){ | |
| // Dry exclusion: ash plates split by ember-lit procedural cracks. | |
| float crack=pow(abs(sin(vDetUV.x*0.73+sin(vDetUV.y*.17))*sin(vDetUV.y*.61-vDetUV.x*.11)),18.0); | |
| zoneCol=mix(uFogC,uEdgeTint,0.56)+vec3(0.20,0.035,0.008)*crack*(0.35+0.20*sin(uEdgeTime*.45)); | |
| }else if(uEdgeStyle>2.5){ | |
| // Highland/arctic exclusion: broad storm bands moving through dense mist. | |
| float storm=0.5+0.5*sin((vDetUV.x+vDetUV.y)*0.34+outerNoise*5.0+uEdgeTime*0.34); | |
| zoneCol=mix(uFogC,uEdgeTint,0.34)*(0.88+storm*0.16); | |
| }else{ | |
| // Temperate maps retain muted terrain colour under deep perimeter mist. | |
| zoneCol=mix(uFogC,uEdgeTint,0.38)*(0.94+outerNoise*0.10); | |
| } | |
| /* The exclusion-zone treatment is scenery, and scenery you have not scouted | |
| must not be visible. Fading it by fow is what turns the outer ranges from a | |
| readable pale shelf into unexplored dark. The same factor also pulls the | |
| procedural band pattern in, which is what read as smeared stripes out | |
| there β six vertex rows cannot carry a 98-unit sinusoid honestly. */ | |
| lit=mix(lit,zoneCol,zone*0.78*(1.0-fow)*(1.0-smoothstep(300.0,760.0,vOutside))); | |
| oWindow=vec4(0.0); | |
| o=vec4(clamp(lit,vec3(0.0),vec3(1.0)),1.0); | |
| }`; | |
| /* ============================================================================ | |
| SCREEN-SPACE AMBIENT OCCLUSION | |
| ---------------------------------------------------------------------------- | |
| Baked AO in the material atlas darkens a surface's OWN crevices, but it knows | |
| nothing about the scene: where a tower meets a roof, where a building meets | |
| its apron, where two structures stand close enough to shade each other. That | |
| contact darkening is a large part of why the reference art reads as solid | |
| mass and untouched geometry reads as a collection of separate objects. | |
| This computes it from the depth buffer. The camera is ORTHOGRAPHIC, which | |
| makes the whole thing unusually cheap and exact: depth is linear in window z, | |
| so a neighbouring texel's depth difference IS its distance in front of this | |
| one, in world units, with no view-ray reconstruction and no perspective | |
| divide. Sample a disc of neighbours, count how many stand in front within a | |
| short range, and darken by the result. | |
| The scene renders into an offscreen target, AO is applied while compositing | |
| into a second target, and the transparent passes then draw on top of that | |
| with the ORIGINAL depth buffer still attached β so glows, smoke and water are | |
| never touched by the AO term. | |
| This is contact creasing only. Cascaded sun-depth lives in csmBegin/csmApply | |
| beside this FBO chain β do not grow AO into a shadow map. | |
| ============================================================================ */ | |
| let aoFB1=null, aoFB2=null, aoColA=null, aoColB=null, aoDepth=null, aoW=0, aoH=0; | |
| let glowFB=null, glowTexA=null, glowTexB=null, glowW=0, glowH=0; | |
| let progBright=null, progBlur=null, UBR={}, UBL={}; | |
| /* Window light has its own authoring mask and narrow convolution. It is not a | |
| lower bright-pass threshold: daylight roofs, water and UI can never enter | |
| this target because only the model/window shaders write COLOR_ATTACHMENT1. */ | |
| let aoWindowMask=null, windowGlowFB=null, windowGlowTexA=null, windowGlowTexB=null; | |
| let windowGlowW=0,windowGlowH=0; | |
| let progWindowExtract=null, progWindowBlur=null, UWX={}, UWB={}; | |
| let aoWindowReady=false, aoWindowActive=false, aoWindowSealed=false; | |
| let aoWindowAttachedFB=null, aoWindowGlowReady=false, aoWindowHDR=false; | |
| const aoWindowInvVP=new Float32Array(16); | |
| const MF_WINDOW_HALO_TEL={version:1,available:false,active:false,presentedFrames:0, | |
| failures:0,mask:'none',target:'none',width:0,height:0,bytes:0,epoch:0,lastError:''}; | |
| if(typeof window!=='undefined') window.MFWindowBloom=MF_WINDOW_HALO_TEL; | |
| let progAO=null, progCopy=null, UAO={}, UCP={}, aoVAO=null, aoOn=true, aoReady=false, aoFailN=0; | |
| let aoDoSSAO=true; // FBO can stay up for bloom/FXAA when SSAO itself is skipped | |
| let aoEpoch=-1, aoGlowDiv=-1, aoWindowAllocWanted=-1; | |
| function gfxTune(){ | |
| const G=(typeof GFX!=='undefined')?GFX:{}; | |
| const q=typeof mfGfxKey==='function'?mfGfxKey():'high'; | |
| return { | |
| ao:G.ao!==false, bloom:G.bloom!==false, | |
| lights:G.lights==null?8:G.lights|0, | |
| aoSamples:G.aoSamples==null?12:G.aoSamples|0, | |
| bloomBlur:G.bloomBlur==null?2:G.bloomBlur|0, | |
| bloomAmt:G.bloomAmt==null?0.14:+G.bloomAmt, | |
| aoAmt:G.aoAmt==null?0.18:+G.aoAmt, | |
| glowDiv:G.glowDiv==null?2:Math.max(2,Math.min(3,G.glowDiv|0)), | |
| shadowQ:G.shadowQ==null?2:G.shadowQ|0, | |
| waterAmp:G.waterAmp==null?1:+G.waterAmp, | |
| /* MEDIUM/LOW: half-res scene+SSAO FBO. HIGH/CINEMATIC stay native canvas. */ | |
| aoDiv:(q==='medium'||q==='low')?1:0, | |
| fxaa:q==='high'||q==='cinematic', | |
| sharp:q==='medium'?0.82:(q==='high'||q==='cinematic'?0.72:0), | |
| cineBloom:q==='cinematic', | |
| /* Faint and deliberately narrow. LOW has no selective post allocation; | |
| Medium keeps it restrained, while the higher tiers only gain precision. */ | |
| windowAmt:q==='low'?0:(q==='medium'?0.13:(q==='cinematic'?0.19:0.16)), | |
| windowGain:q==='medium'?1.18:(q==='cinematic'?1.42:1.30) | |
| }; | |
| } | |
| const VSQ=`#version 300 es | |
| out vec2 vUv; | |
| void main(){ | |
| vec2 p=vec2((gl_VertexID<<1)&2, gl_VertexID&2); // fullscreen triangle, no buffer | |
| vUv=p; gl_Position=vec4(p*2.0-1.0,0.0,1.0); | |
| }`; | |
| const FSAO=`#version 300 es | |
| precision highp float; | |
| precision highp sampler2D; | |
| in vec2 vUv; | |
| uniform sampler2D uCol; | |
| uniform sampler2D uDep; | |
| uniform vec2 uTexel; // 1/resolution | |
| uniform float uRadius; // AO radius in PIXELS | |
| uniform float uWorldPerZ; // world units per unit of window depth | |
| uniform float uRange; // world units: beyond this an occluder is a silhouette, not contact | |
| uniform vec3 uTint; // what the occluded ambient is tinted toward | |
| uniform float uAoAmt; // contact darken 0..1 β mid uses a lighter term | |
| uniform int uAoN; // tap count, max 12; mid runs 6 on the same kernel | |
| out vec4 o; | |
| const vec2 K[12]=vec2[12]( | |
| vec2( 1.0, 0.0),vec2( 0.87, 0.50),vec2( 0.50, 0.87),vec2( 0.0, 1.0), | |
| vec2(-0.50, 0.87),vec2(-0.87, 0.50),vec2(-1.0, 0.0),vec2(-0.87,-0.50), | |
| vec2(-0.50,-0.87),vec2( 0.0,-1.0),vec2( 0.50,-0.87),vec2( 0.87,-0.50)); | |
| void main(){ | |
| vec3 c=texture(uCol,vUv).rgb; | |
| float dz=texture(uDep,vUv).r; | |
| if(dz>=0.9999){ o=vec4(c,1.0); return; } // sky / cleared background | |
| float cz=dz*uWorldPerZ; | |
| /* No per-pixel kernel spin. Hash-rotating 12 taps on terrain depth made | |
| grainy crawling blobs β the "flicker splotches" around buildings. A fixed | |
| star is stable across frames and still finds a crease. */ | |
| float occ=0.0; | |
| int n=uAoN<1?12:uAoN; if(n>12) n=12; | |
| for(int i=0;i<12;i++){ | |
| if(i>=n) break; | |
| vec2 k=K[i]; | |
| float sc=(0.40+0.60*float((i%3)+1)/3.0); | |
| vec2 uv=vUv+k*uRadius*sc*uTexel; | |
| float nz=texture(uDep,uv).r*uWorldPerZ; | |
| float d=cz-nz; | |
| /* Pavement tiles sit 0.5β1.5 units apart in the depth buffer. Treating | |
| that as occlusion painted the grainy dark islands in the live HQ shot. */ | |
| if(d>2.4) occ+=clamp(1.0-(d-2.4)/uRange,0.0,1.0); | |
| } | |
| /* Contact crease only. Mid uses fewer taps AND a lighter amount so the | |
| cheaper kernel cannot read as a missing-shader dirt wash. */ | |
| float ao=1.0-clamp(occ/max(float(n),1.0),0.0,1.0)*uAoAmt; | |
| /* EDGE LINE. Threshold used to start at 0.9 world units, which is also a | |
| painted pavement joint. Roof/parapet lips are several units; ignore the | |
| rest so AO stays a stable contact shadow. */ | |
| float e=0.0; | |
| e=max(e,abs(cz-texture(uDep,vUv+vec2( uTexel.x,0.0)*1.5).r*uWorldPerZ)); | |
| e=max(e,abs(cz-texture(uDep,vUv+vec2(-uTexel.x,0.0)*1.5).r*uWorldPerZ)); | |
| e=max(e,abs(cz-texture(uDep,vUv+vec2(0.0, uTexel.y)*1.5).r*uWorldPerZ)); | |
| e=max(e,abs(cz-texture(uDep,vUv+vec2(0.0,-uTexel.y)*1.5).r*uWorldPerZ)); | |
| ao*=1.0-smoothstep(6.0,16.0,e)*0.10; | |
| /* Occlusion removes AMBIENT light, and ambient here is sky-coloured, so the | |
| shaded result leans toward the ambient tint rather than toward black. That | |
| is the difference between a crease reading as shadow and reading as dirt. */ | |
| o=vec4(mix(c*uTint,c,ao),1.0); | |
| }`; | |
| /* ---- BRIGHT PASS ----------------------------------------------------------- | |
| Bloom is most of what separates the reference art's reactor vents, tracers | |
| and muzzle flashes from a flat lit polygon. This runs at quarter resolution: | |
| it is a blurry glow, so resolution is the one thing it does not need. */ | |
| const FSBRIGHT=`#version 300 es | |
| precision highp float; | |
| in vec2 vUv; uniform sampler2D uCol; uniform vec2 uTexel; uniform float uThresh; | |
| out vec4 o; | |
| void main(){ | |
| // 4-tap box while downsampling: cheaper than blurring the full-res image | |
| vec3 c=(texture(uCol,vUv+vec2( uTexel.x, uTexel.y)).rgb | |
| +texture(uCol,vUv+vec2(-uTexel.x, uTexel.y)).rgb | |
| +texture(uCol,vUv+vec2( uTexel.x,-uTexel.y)).rgb | |
| +texture(uCol,vUv+vec2(-uTexel.x,-uTexel.y)).rgb)*0.25; | |
| float l=dot(c,vec3(0.2126,0.7152,0.0722)); | |
| /* Remapping excess through (1-thresh) made every texel at ~0.93 (noon | |
| water, pale roofs, foam) a FULL-intensity bloom source. Tracers still | |
| sit at 1.0 and take the old curve; the band below that keeps excess. */ | |
| float excess=max(0.0,l-uThresh); | |
| float remap=excess/max(1e-4,1.0-uThresh); | |
| float tracer=smoothstep(0.96,1.0,l); | |
| o=vec4(c*mix(excess*1.7, remap, tracer),1.0); | |
| }`; | |
| const FSBLUR=`#version 300 es | |
| precision highp float; | |
| in vec2 vUv; uniform sampler2D uCol; uniform vec2 uDir; out vec4 o; | |
| void main(){ | |
| // 9-tap gaussian folded into 5 bilinear fetches | |
| vec3 c=texture(uCol,vUv).rgb*0.227027; | |
| c+=(texture(uCol,vUv+uDir*1.3846).rgb+texture(uCol,vUv-uDir*1.3846).rgb)*0.316216; | |
| c+=(texture(uCol,vUv+uDir*3.2308).rgb+texture(uCol,vUv-uDir*3.2308).rgb)*0.070270; | |
| o=vec4(c,1.0); | |
| }`; | |
| /* ---- SELECTIVE WINDOW HALO ------------------------------------------------- | |
| The R8 MRT is authoritative. Scene luminance never decides eligibility, so | |
| this pass can lift a dim authored pane without blooming a white roof beside | |
| it. RGB comes from the already fogged/damaged scene, preserving the authored | |
| cyan/amber colour. Alpha carries the closest source depth through both blur | |
| axes so foreground terrain and hulls stop the halo instead of being painted | |
| over by a screen-space glow. */ | |
| const FSWINDOWEXTRACT=`#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| uniform sampler2D uCol; | |
| uniform sampler2D uMask; | |
| uniform sampler2D uDep; | |
| uniform vec2 uFootprint; | |
| uniform float uGain; | |
| out vec4 o; | |
| void main(){ | |
| vec3 cSum=vec3(0.0);float mSum=0.0,mMax=0.0,sd=1.0,n=0.0; | |
| /* Fetch every source texel belonging to this output footprint. Mask, colour | |
| and D24 depth use the SAME integer coordinate, so a facade-edge mask can | |
| never borrow the nearer/background depth selected by a filtered neighbor. | |
| ceil-sized 1/4 targets cap the rectangle at 5x5 even for odd dimensions. */ | |
| ivec2 srcN=textureSize(uMask,0),dst=ivec2(gl_FragCoord.xy); | |
| ivec2 lo=clamp(ivec2(floor(vec2(dst)*uFootprint)),ivec2(0),srcN-1); | |
| ivec2 hi=clamp(ivec2(ceil(vec2(dst+ivec2(1))*uFootprint)),lo+1,srcN); | |
| for(int iy=0;iy<5;iy++)for(int ix=0;ix<5;ix++){ | |
| ivec2 p=lo+ivec2(ix,iy); | |
| if(p.x>=hi.x||p.y>=hi.y)continue; | |
| float m=texelFetch(uMask,p,0).r;n+=1.0; | |
| mSum+=m;mMax=max(mMax,m); | |
| if(m>.001){cSum+=texelFetch(uCol,p,0).rgb*m;sd=min(sd,texelFetch(uDep,p,0).r);} | |
| } | |
| /* Maximum coverage keeps one narrow pane alive; the bounded sum lets a row | |
| of authored panes carry more energy without making a full facade bloom. */ | |
| float normSum=mSum*16.0/max(n,1.0); | |
| float strength=clamp(mMax*.82+min(1.0,normSum*.13),0.0,1.0); | |
| vec3 c=mSum>.0001?cSum/mSum*strength*uGain:vec3(0.0); | |
| o=vec4(c,sd); | |
| }`; | |
| const FSWINDOWBLUR=`#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| uniform sampler2D uCol; | |
| uniform sampler2D uDep; | |
| uniform sampler2D uFogMap; | |
| uniform vec2 uDir; | |
| uniform mat4 uInvVP; | |
| uniform float uFowOn; | |
| uniform float uMapInv; | |
| uniform float uFinal; | |
| out vec4 o; | |
| float live(vec4 s){return step(0.00001,max(s.r,max(s.g,s.b)));} | |
| float visibleAt(vec4 s,float dz){ | |
| /* Normal WebGL depth: a smaller destination is nearer than the light. */ | |
| return live(s)*step(s.a-0.00008,dz); | |
| } | |
| float destinationVisible(float dz){ | |
| if(uFowOn<.5)return 1.0; | |
| vec4 w=uInvVP*vec4(vUv*2.0-1.0,dz*2.0-1.0,1.0); | |
| vec2 mapUV=clamp((w.xz/max(abs(w.w),1e-6))*uMapInv,vec2(0.0),vec2(1.0)); | |
| return 1.0-clamp(texture(uFogMap,mapUV).a*uFowOn,0.0,1.0); | |
| } | |
| void main(){ | |
| float dz=texture(uDep,vUv).r; | |
| float fowVis=destinationVisible(dz); | |
| vec4 s0=texture(uCol,vUv); | |
| vec4 s1=texture(uCol,vUv+uDir),s2=texture(uCol,vUv-uDir); | |
| vec4 s3=texture(uCol,vUv+uDir*2.0),s4=texture(uCol,vUv-uDir*2.0); | |
| float g0=visibleAt(s0,dz),g1=visibleAt(s1,dz),g2=visibleAt(s2,dz); | |
| float g3=visibleAt(s3,dz),g4=visibleAt(s4,dz); | |
| vec3 c=(s0.rgb*(.36*g0)+(s1.rgb*g1+s2.rgb*g2)*.24+ | |
| (s3.rgb*g3+s4.rgb*g4)*.08)*fowVis; | |
| float sd=1.0; | |
| if(g0>0.0)sd=min(sd,s0.a);if(g1>0.0)sd=min(sd,s1.a); | |
| if(g2>0.0)sd=min(sd,s2.a);if(g3>0.0)sd=min(sd,s3.a); | |
| if(g4>0.0)sd=min(sd,s4.a); | |
| /* Horizontal alpha remains precise source depth for the vertical rejection. | |
| The final target no longer needs it, so carry destination visibility into | |
| present and gate the bilinear upsample at the concealment boundary too. */ | |
| o=vec4(c,mix(sd,fowVis,uFinal)); | |
| }`; | |
| /* ---- PRESENT: FXAA + bloom composite --------------------------------------- | |
| The frame already paid for a full-screen copy that did nothing but move | |
| bytes. The context is created without multisampling, so every roof edge and | |
| tank silhouette was hard-aliased at command zoom. This makes that pass earn | |
| its bandwidth: edge-directed antialiasing, then the glow added on top. */ | |
| const FSCOPY=`#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| uniform sampler2D uCol; | |
| uniform sampler2D uBloom; | |
| uniform sampler2D uWindow; | |
| uniform sampler2D uDep; | |
| uniform sampler2D uFogMap; | |
| uniform vec2 uTexel; | |
| uniform float uBloomAmt; | |
| uniform float uWindowAmt; | |
| uniform float uFxaa; | |
| uniform float uSharp; | |
| uniform mat4 uInvVP; | |
| uniform float uFowOn; | |
| uniform float uMapInv; | |
| out vec4 o; | |
| float lum(vec3 c){ return dot(c,vec3(0.299,0.587,0.114)); } | |
| float windowDestinationVisible(){ | |
| if(uFowOn<.5)return 1.0; | |
| float dz=texture(uDep,vUv).r; | |
| vec4 w=uInvVP*vec4(vUv*2.0-1.0,dz*2.0-1.0,1.0); | |
| vec2 mapUV=clamp((w.xz/max(abs(w.w),1e-6))*uMapInv,vec2(0.0),vec2(1.0)); | |
| return 1.0-clamp(texture(uFogMap,mapUV).a*uFowOn,0.0,1.0); | |
| } | |
| void main(){ | |
| vec3 rgbM=texture(uCol,vUv).rgb; | |
| vec3 col=rgbM; | |
| vec3 rgbNW=texture(uCol,vUv+vec2(-uTexel.x,-uTexel.y)).rgb; | |
| vec3 rgbNE=texture(uCol,vUv+vec2( uTexel.x,-uTexel.y)).rgb; | |
| vec3 rgbSW=texture(uCol,vUv+vec2(-uTexel.x, uTexel.y)).rgb; | |
| vec3 rgbSE=texture(uCol,vUv+vec2( uTexel.x, uTexel.y)).rgb; | |
| if(uFxaa>0.5){ | |
| float lNW=lum(rgbNW); | |
| float lNE=lum(rgbNE); | |
| float lSW=lum(rgbSW); | |
| float lSE=lum(rgbSE); | |
| float lM =lum(rgbM); | |
| float lMin=min(lM,min(min(lNW,lNE),min(lSW,lSE))); | |
| float lMax=max(lM,max(max(lNW,lNE),max(lSW,lSE))); | |
| if(lMax-lMin > max(0.045, lMax*0.14)){ | |
| vec2 dir=vec2(-((lNW+lNE)-(lSW+lSE)), ((lNW+lSW)-(lNE+lSE))); | |
| float red=max((lNW+lNE+lSW+lSE)*0.25*0.10, 1.0/8.0); | |
| float rcp=1.0/(min(abs(dir.x),abs(dir.y))+red); | |
| dir=clamp(dir*rcp, vec2(-8.0), vec2(8.0))*uTexel; | |
| vec3 a=0.5*(texture(uCol,vUv+dir*(1.0/3.0-0.5)).rgb | |
| +texture(uCol,vUv+dir*(2.0/3.0-0.5)).rgb); | |
| vec3 b=a*0.5+0.25*(texture(uCol,vUv-dir*0.5).rgb+texture(uCol,vUv+dir*0.5).rgb); | |
| float lB=lum(b); | |
| col=(lB<lMin||lB>lMax)?a:b; | |
| } | |
| } | |
| /* Mild contrast-adaptive scene sharpening recovers material/road edges | |
| softened by presentation scaling and FXAA. The signed correction is | |
| clamped to a maximum 4% display-range correction, keeping halos below | |
| the explicit 5% acceptance ceiling. */ | |
| if(uSharp>0.0){ | |
| vec3 avg=(rgbNW+rgbNE+rgbSW+rgbSE)*0.25; | |
| float localRange=max(max(abs(lum(rgbNW)-lum(rgbM)),abs(lum(rgbNE)-lum(rgbM))), | |
| max(abs(lum(rgbSW)-lum(rgbM)),abs(lum(rgbSE)-lum(rgbM)))); | |
| float sg=smoothstep(0.025,0.18,localRange); | |
| col+=clamp(col-avg,vec3(-0.055),vec3(0.055))*uSharp*sg; | |
| } | |
| vec3 bloom=texture(uBloom,vUv).rgb*uBloomAmt; | |
| /* Already-white pixels (sky, foam, pale roofs) do not take a second full | |
| add β that was the HIGH noon haze. Tracers still get 32% so their halo | |
| survives after the 0.936 bright-pass. */ | |
| float gate=1.0-smoothstep(0.88,1.05,lum(col)); | |
| col+=bloom*mix(0.32,1.0,gate); | |
| /* Authored-window-only halo. Its eligibility came from the R8 MRT, not this | |
| colour buffer, so it remains independent of the global bright pass. Apply | |
| FOW again at the native presentation pixel: gating only the low-resolution | |
| blur target lets bilinear upsampling leak a fractional halo across a sharp | |
| concealment boundary. */ | |
| vec4 windowHalo=texture(uWindow,vUv); | |
| col+=windowHalo.rgb*uWindowAmt*windowHalo.a*windowDestinationVisible(); | |
| o=vec4(col,1.0); | |
| }`; | |
| function aoAlloc(w,h){ | |
| /* A zero-sized target (the canvas during a rotation or a backgrounded app) | |
| produces an incomplete framebuffer, which used to latch AO off for the rest | |
| of the session β and worse, left the present pass sampling whatever texture | |
| happened to be on unit 0. That is the material atlas, which is exactly what | |
| "the whole screen turned into texture swatches" was. */ | |
| if(w<=0||h<=0) return; | |
| const tune=gfxTune(),glowDiv=tune.glowDiv; | |
| const windowWanted=tune.bloom&&tune.windowAmt>0?1:0; | |
| if(aoW===w&&aoH===h&&aoGlowDiv===glowDiv&&aoWindowAllocWanted===windowWanted) return; | |
| MF_WINDOW_HALO_TEL.lastError=''; | |
| /* Size is NOT committed here. It used to be, and that turned a one-frame | |
| allocation failure into a permanent outage: the guard above short-circuits | |
| on the committed size, so a failed alloc was never retried. Measured at 316 | |
| consecutive frames with aoReady=false and no further attempts β AO, FXAA | |
| and bloom off for the rest of the session from a single bad resize. */ | |
| const mkAt=(tw,th,fmt,ifmt,type,filter)=>{ | |
| const t=gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D,t); | |
| gl.texImage2D(gl.TEXTURE_2D,0,ifmt,tw,th,0,fmt,type,null); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,filter); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,filter); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); | |
| return t; | |
| }; | |
| const mk=(fmt,ifmt,type,filter)=>mkAt(w,h,fmt,ifmt,type,filter); | |
| /* Detach BEFORE deleting. A framebuffer attachment keeps a deleted texture | |
| alive at its old size, and an FBO left pointing at one silently fails | |
| dimension validation later against the new-size attachments. */ | |
| for(const [fb,att] of [[aoFB1,'COLOR_ATTACHMENT0'],[aoFB1,'COLOR_ATTACHMENT1'],[aoFB1,'DEPTH_ATTACHMENT'], | |
| [aoFB2,'COLOR_ATTACHMENT0'],[aoFB2,'COLOR_ATTACHMENT1'],[aoFB2,'DEPTH_ATTACHMENT'], | |
| [glowFB,'COLOR_ATTACHMENT0'],[windowGlowFB,'COLOR_ATTACHMENT0']]){ | |
| if(!fb) continue; | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,fb); | |
| if(fb===aoFB1||fb===aoFB2) gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl[att],gl.TEXTURE_2D,null,0); | |
| } | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,null); | |
| for(const t of [aoColA,aoColB,aoDepth,aoWindowMask,windowGlowTexA,windowGlowTexB]) | |
| if(t) gl.deleteTexture(t); | |
| aoWindowMask=windowGlowTexA=windowGlowTexB=null; | |
| aoWindowReady=aoWindowActive=aoWindowSealed=aoWindowGlowReady=false; | |
| aoWindowAttachedFB=null;aoWindowHDR=false; | |
| aoColA=mk(gl.RGBA,gl.RGBA8,gl.UNSIGNED_BYTE,gl.LINEAR); | |
| aoColB=mk(gl.RGBA,gl.RGBA8,gl.UNSIGNED_BYTE,gl.LINEAR); | |
| aoDepth=mk(gl.DEPTH_COMPONENT,gl.DEPTH_COMPONENT24,gl.UNSIGNED_INT,gl.NEAREST); | |
| glowW=Math.max(1,w>>glowDiv); glowH=Math.max(1,h>>glowDiv); | |
| /* The selective target never exceeds a 4x4 AO footprint. MEDIUM's ordinary | |
| bloom is intentionally 1/8 AO resolution, but using that size for windows | |
| made one NEAREST depth sample stand in for a paired mask sample at facade | |
| silhouettes. A dedicated 1/4 target keeps every extraction tap aligned to | |
| one AO texel and prevents distant panes from popping while panning. */ | |
| windowGlowW=Math.max(1,Math.ceil(w/4));windowGlowH=Math.max(1,Math.ceil(h/4)); | |
| const mkS=()=>mkAt(glowW,glowH,gl.RGBA,gl.RGBA8,gl.UNSIGNED_BYTE,gl.LINEAR); | |
| for(const t of [glowTexA,glowTexB]) if(t) gl.deleteTexture(t); | |
| glowTexA=mkS(); glowTexB=mkS(); | |
| if(!glowFB) glowFB=gl.createFramebuffer(); | |
| if(!aoFB1) aoFB1=gl.createFramebuffer(); | |
| if(!aoFB2) aoFB2=gl.createFramebuffer(); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,aoFB1); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,aoColA,0); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,aoDepth,0); | |
| const ok=gl.checkFramebufferStatus(gl.FRAMEBUFFER)===gl.FRAMEBUFFER_COMPLETE; | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,aoFB2); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,aoColB,0); | |
| /* aoResolve() ends EVERY frame by attaching aoDepth to aoFB2 (so the | |
| translucent passes still occlude), so aoFB2 carries a depth attachment | |
| into this function. Left pointing at the just-deleted old-height texture, | |
| status returns INCOMPLETE_DIMENSIONS against the new-height colour β which | |
| is the whole flicker: aoReady goes false and the scene skips AO, FXAA and | |
| bloom for those frames. Clearing it is the fix; aoResolve re-attaches the | |
| current one on its next pass. */ | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,null,0); | |
| const ok2=gl.checkFramebufferStatus(gl.FRAMEBUFFER)===gl.FRAMEBUFFER_COMPLETE; | |
| /* Optional R8 scene mask. Prove it as a second render target against BOTH AO | |
| targets now; the frame loop may select either depending on whether SSAO is | |
| enabled. It is detached again so allocation failure cannot poison AO. */ | |
| let maskOk=false,windowTargetOk=false; | |
| if(ok&&ok2&&windowWanted){ | |
| aoWindowMask=mk(gl.RED,gl.R8,gl.UNSIGNED_BYTE,gl.LINEAR); | |
| maskOk=!!aoWindowMask; | |
| for(const fb of [aoFB1,aoFB2]){ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,fb); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT1,gl.TEXTURE_2D,aoWindowMask,0); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0,gl.COLOR_ATTACHMENT1]); | |
| maskOk=maskOk&&gl.checkFramebufferStatus(gl.FRAMEBUFFER)===gl.FRAMEBUFFER_COMPLETE; | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT1,gl.TEXTURE_2D,null,0); | |
| } | |
| if(!maskOk){ | |
| gl.deleteTexture(aoWindowMask);aoWindowMask=null; | |
| MF_WINDOW_HALO_TEL.lastError='R8 MRT unavailable'; | |
| } | |
| } | |
| /* Keep the selective convolution independent of the established RGBA8 | |
| bright-pass pair. Alpha transports source D24 depth between blur axes, so | |
| it MUST remain float32: RGBA8 quantises one step to ~59 world units and | |
| RGBA16F still loses several units around mid-depth. If precise, linearly | |
| filterable rendering is unavailable, the atomic fallback is NO HALO. */ | |
| if(maskOk){ | |
| if(!windowGlowFB) windowGlowFB=gl.createFramebuffer(); | |
| const makeWindowPair=(ifmt,type)=>{ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,windowGlowFB); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,null,0); | |
| for(const t of [windowGlowTexA,windowGlowTexB]) if(t) gl.deleteTexture(t); | |
| windowGlowTexA=mkAt(windowGlowW,windowGlowH,gl.RGBA,ifmt,type,gl.LINEAR); | |
| windowGlowTexB=mkAt(windowGlowW,windowGlowH,gl.RGBA,ifmt,type,gl.LINEAR); | |
| let pairOk=!!windowGlowTexA&&!!windowGlowTexB; | |
| for(const t of [windowGlowTexA,windowGlowTexB]){ | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,t,0); | |
| pairOk=pairOk&&gl.checkFramebufferStatus(gl.FRAMEBUFFER)===gl.FRAMEBUFFER_COMPLETE; | |
| } | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D, | |
| pairOk?windowGlowTexA:null,0); | |
| return pairOk; | |
| }; | |
| /* A renderable float texture is not necessarily LINEAR-filterable. */ | |
| const floatRT=!!gl.getExtension('EXT_color_buffer_float')&& | |
| !!gl.getExtension('OES_texture_float_linear'); | |
| if(floatRT){ windowTargetOk=makeWindowPair(gl.RGBA32F,gl.FLOAT);aoWindowHDR=windowTargetOk; } | |
| if(!windowTargetOk){ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,windowGlowFB); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,null,0); | |
| for(const t of [windowGlowTexA,windowGlowTexB]) if(t) gl.deleteTexture(t); | |
| windowGlowTexA=windowGlowTexB=null; | |
| MF_WINDOW_HALO_TEL.lastError='precise window depth target unavailable; halo disabled'; | |
| } | |
| } | |
| if(maskOk&&!windowTargetOk){gl.deleteTexture(aoWindowMask);aoWindowMask=null;maskOk=false;} | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,null); | |
| aoReady=ok&&ok2&&!!aoColA&&!!aoColB&&!!aoDepth; | |
| aoWindowReady=aoReady&&maskOk&&windowTargetOk; | |
| MF_WINDOW_HALO_TEL.available=aoWindowReady&&!!progWindowExtract&&!!progWindowBlur; | |
| MF_WINDOW_HALO_TEL.active=false; | |
| MF_WINDOW_HALO_TEL.mask=maskOk?'R8':'none'; | |
| MF_WINDOW_HALO_TEL.target=windowTargetOk?'RGBA32F':'none'; | |
| MF_WINDOW_HALO_TEL.width=windowGlowW;MF_WINDOW_HALO_TEL.height=windowGlowH; | |
| MF_WINDOW_HALO_TEL.bytes=maskOk?w*h+(windowTargetOk?windowGlowW*windowGlowH*32:0):0; | |
| MF_WINDOW_HALO_TEL.epoch=glEpoch; | |
| /* Commit the size only once the allocation is known good. On failure the | |
| size stays uncommitted so the next frame retries β bounded, because an | |
| unbounded retry turns a permanent outage into a per-frame realloc storm, | |
| which is worse. After the budget is spent the size is committed anyway and | |
| AO stays off until something resizes again. */ | |
| if(aoReady){ aoW=w; aoH=h; aoGlowDiv=glowDiv;aoWindowAllocWanted=windowWanted;aoFailN=0; } | |
| else if(++aoFailN>=4){ aoW=w; aoH=h; aoGlowDiv=glowDiv;aoWindowAllocWanted=windowWanted; } | |
| } | |
| function initAO(){ | |
| /* Context restoration leaves every WebGL handle truthy but dead. aoAlloc() | |
| keys its work by width/height, so without clearing the cached dimensions it | |
| returned early and composited from dead textures: models and buildings | |
| disappeared while the HTML HUD survived. A new GL epoch owns an entirely | |
| new post chain. */ | |
| if(aoEpoch!==glEpoch){ | |
| aoEpoch=glEpoch;aoW=aoH=glowW=glowH=windowGlowW=windowGlowH=0;aoGlowDiv=-1;aoWindowAllocWanted=-1; | |
| aoReady=false;aoFailN=0; | |
| aoColA=aoColB=aoDepth=glowTexA=glowTexB=aoWindowMask=null; | |
| windowGlowTexA=windowGlowTexB=null; | |
| aoFB1=aoFB2=glowFB=windowGlowFB=aoVAO=null; | |
| aoWindowReady=aoWindowActive=aoWindowSealed=aoWindowGlowReady=aoWindowHDR=false; | |
| aoWindowAttachedFB=null; | |
| MF_WINDOW_HALO_TEL.available=false;MF_WINDOW_HALO_TEL.active=false; | |
| MF_WINDOW_HALO_TEL.mask='none';MF_WINDOW_HALO_TEL.target='none'; | |
| MF_WINDOW_HALO_TEL.bytes=0; | |
| MF_WINDOW_HALO_TEL.epoch=glEpoch;MF_WINDOW_HALO_TEL.lastError=''; | |
| } | |
| progAO =mkProg(VSQ,FSAO); | |
| progCopy =mkProg(VSQ,FSCOPY); | |
| progBright=mkProg(VSQ,FSBRIGHT); | |
| progBlur =mkProg(VSQ,FSBLUR); | |
| progWindowExtract=mkProg(VSQ,FSWINDOWEXTRACT,'window-halo-extract'); | |
| progWindowBlur=mkProg(VSQ,FSWINDOWBLUR,'window-halo-blur'); | |
| for(const k of ['uCol','uDep','uTexel','uRadius','uWorldPerZ','uRange','uTint','uAoAmt','uAoN']) | |
| UAO[k]=gl.getUniformLocation(progAO,k); | |
| for(const k of ['uCol','uBloom','uWindow','uDep','uFogMap','uTexel','uBloomAmt','uWindowAmt','uFxaa','uSharp','uInvVP','uFowOn','uMapInv']) | |
| UCP[k]=gl.getUniformLocation(progCopy,k); | |
| for(const k of ['uCol','uTexel','uThresh']) UBR[k]=gl.getUniformLocation(progBright,k); | |
| for(const k of ['uCol','uDir']) UBL[k]=gl.getUniformLocation(progBlur,k); | |
| if(progWindowExtract) for(const k of ['uCol','uMask','uDep','uFootprint','uGain']) | |
| UWX[k]=gl.getUniformLocation(progWindowExtract,k); | |
| if(progWindowBlur) for(const k of ['uCol','uDep','uFogMap','uDir','uInvVP','uFowOn','uMapInv','uFinal']) | |
| UWB[k]=gl.getUniformLocation(progWindowBlur,k); | |
| aoVAO=gl.createVertexArray(); | |
| } | |
| /* Bright-pass then optional separable blurs. Mid skips the extra fullscreen | |
| blur for fillrate but still allocates, CLEARS, and composites the glow | |
| target β skipping the write is the flicker class. */ | |
| function bloomPass(){ | |
| if(!glowFB||!glowTexA) return false; | |
| const T=gfxTune(); | |
| const wasFB=gl.getParameter(gl.FRAMEBUFFER_BINDING); | |
| const wasBlend=gl.isEnabled(gl.BLEND), wasCull=gl.isEnabled(gl.CULL_FACE); | |
| const wasDepth=gl.isEnabled(gl.DEPTH_TEST), wasMask=gl.getParameter(gl.DEPTH_WRITEMASK); | |
| /* Combat fills the bright-pass with tracers. Attaching glowTexA while it is | |
| still bound on unit 5 (last aoPresent) or ping-ponging a blur while the | |
| destination is still the sampler on unit 6 is framebuffer feedback β | |
| ANGLE D3D11 returns undefined colour, which reads as every material | |
| popping. Placeholder-bind 4/5/6 BEFORE any attach. */ | |
| const ph=matTex||aoColB; | |
| gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); gl.disable(gl.DEPTH_TEST); gl.depthMask(false); | |
| gl.disable(gl.SCISSOR_TEST); | |
| gl.bindVertexArray(aoVAO); | |
| gl.viewport(0,0,glowW,glowH); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,glowFB); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,glowTexA,0); | |
| gl.clearColor(0,0,0,1); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| gl.useProgram(progBright); | |
| gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D,aoColB); | |
| gl.uniform1i(UBR.uCol,6); | |
| gl.uniform2f(UBR.uTexel,1/aoW,1/aoH); | |
| /* Scene colour is already display-referred (filmic 1-exp, no extra gamma). | |
| 0.925 keeps only emissives, tracers and muzzle flashes β 0.90 let noon | |
| water and pale roofs fill the glow target (over-bloom). */ | |
| gl.uniform1f(UBR.uThresh,0.936); | |
| gl.drawArrays(gl.TRIANGLES,0,3); | |
| /* The 4-tap downsample IS a blur. Mid stops there. High/cinematic still | |
| run the two-axis gaussian into glowTexA so present always samples A. */ | |
| if(T.bloomBlur>=2 && glowTexB){ | |
| gl.useProgram(progBlur); | |
| gl.uniform1i(UBL.uCol,6); | |
| gl.activeTexture(gl.TEXTURE6); | |
| for(const [src,dst,dx,dy] of [[glowTexA,glowTexB,1/glowW,0],[glowTexB,glowTexA,0,1/glowH]]){ | |
| gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,dst,0); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| gl.bindTexture(gl.TEXTURE_2D,src); | |
| gl.uniform2f(UBL.uDir,dx,dy); | |
| gl.drawArrays(gl.TRIANGLES,0,3); | |
| } | |
| if(T.cineBloom){ | |
| for(const [src,dst,dx,dy] of [[glowTexA,glowTexB,1.65/glowW,0],[glowTexB,glowTexA,0,1.65/glowH]]){ | |
| gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,dst,0); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| gl.bindTexture(gl.TEXTURE_2D,src); | |
| gl.uniform2f(UBL.uDir,dx,dy); | |
| gl.drawArrays(gl.TRIANGLES,0,3); | |
| } | |
| } | |
| } | |
| gl.viewport(0,0,aoW,aoH); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,wasFB); | |
| if(wasDepth) gl.enable(gl.DEPTH_TEST); else gl.disable(gl.DEPTH_TEST); | |
| if(wasCull) gl.enable(gl.CULL_FACE); else gl.disable(gl.CULL_FACE); | |
| if(wasBlend) gl.enable(gl.BLEND); else gl.disable(gl.BLEND); | |
| gl.depthMask(wasMask); | |
| gl.activeTexture(gl.TEXTURE0); | |
| return true; | |
| } | |
| const MF_WINDOW_ZERO4=new Float32Array([0,0,0,0]); | |
| /* gl.clear(COLOR_BUFFER_BIT) clears every enabled MRT to the fog clear colour. | |
| The render hook calls this immediately afterwards so an untouched pixel can | |
| never masquerade as a window source. */ | |
| function aoWindowMaskClear(){ | |
| if(!aoWindowActive||!aoWindowMask||!aoWindowAttachedFB) return false; | |
| const was=gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING); | |
| const wasScissor=gl.isEnabled(gl.SCISSOR_TEST),cm=gl.getParameter(gl.COLOR_WRITEMASK); | |
| if(was!==aoWindowAttachedFB)gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,aoWindowAttachedFB); | |
| if(wasScissor)gl.disable(gl.SCISSOR_TEST); | |
| gl.colorMask(true,true,true,true); | |
| gl.clearBufferfv(gl.COLOR,1,MF_WINDOW_ZERO4); | |
| gl.colorMask(cm[0],cm[1],cm[2],cm[3]); | |
| if(wasScissor)gl.enable(gl.SCISSOR_TEST); | |
| if(was!==aoWindowAttachedFB)gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,was); | |
| return true; | |
| } | |
| /* Stop writes after the last opaque mesh. The mask remains attached/readable | |
| until extraction, but decals, shields and volumetrics cannot contaminate it. */ | |
| function aoWindowMaskSeal(){ | |
| if(!aoWindowActive||!aoWindowAttachedFB) return false; | |
| const was=gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING); | |
| if(was!==aoWindowAttachedFB) gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,aoWindowAttachedFB); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| if(was!==aoWindowAttachedFB) gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,was); | |
| aoWindowSealed=true; | |
| return true; | |
| } | |
| function aoWindowDetachMask(){ | |
| if(!aoWindowAttachedFB) return; | |
| const was=gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING); | |
| gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,aoWindowAttachedFB); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| gl.framebufferTexture2D(gl.DRAW_FRAMEBUFFER,gl.COLOR_ATTACHMENT1,gl.TEXTURE_2D,null,0); | |
| gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,was); | |
| aoWindowAttachedFB=null;aoWindowActive=false;aoWindowSealed=false; | |
| MF_WINDOW_HALO_TEL.active=false; | |
| } | |
| /* Two-axis window-only convolution with exact state restoration. This pass is | |
| optional: any missing shader/attachment or runtime failure returns false, | |
| and present binds a harmless placeholder with uWindowAmt=0. */ | |
| function windowBloomPass(){ | |
| const T=gfxTune(); | |
| if(!aoWindowReady||!aoWindowActive||!aoWindowSealed||!progWindowExtract|| | |
| !progWindowBlur||!windowGlowFB||!windowGlowTexA||!windowGlowTexB|| | |
| !aoWindowMask||!aoColB||!aoDepth||T.windowAmt<=0) return false; | |
| const wasRead=gl.getParameter(gl.READ_FRAMEBUFFER_BINDING); | |
| const wasDraw=gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING); | |
| const wasViewport=gl.getParameter(gl.VIEWPORT); | |
| const wasProgram=gl.getParameter(gl.CURRENT_PROGRAM); | |
| const wasVAO=gl.getParameter(gl.VERTEX_ARRAY_BINDING); | |
| const wasActive=gl.getParameter(gl.ACTIVE_TEXTURE); | |
| const savedTex=[]; | |
| for(const unit of [4,5,6,7]){ | |
| gl.activeTexture(gl.TEXTURE0+unit); | |
| savedTex.push(gl.getParameter(gl.TEXTURE_BINDING_2D)); | |
| } | |
| const wasBlend=gl.isEnabled(gl.BLEND),wasCull=gl.isEnabled(gl.CULL_FACE); | |
| const wasDepth=gl.isEnabled(gl.DEPTH_TEST),wasScissor=gl.isEnabled(gl.SCISSOR_TEST); | |
| const wasMask=gl.getParameter(gl.DEPTH_WRITEMASK); | |
| const wasColorMask=gl.getParameter(gl.COLOR_WRITEMASK); | |
| const wasClear=gl.getParameter(gl.COLOR_CLEAR_VALUE); | |
| const ph=matTex||aoColB,attach=tex=>{ | |
| /* Destination must not still be a sampler on any live texture unit. */ | |
| gl.activeTexture(gl.TEXTURE6);gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,tex,0); | |
| return gl.checkFramebufferStatus(gl.FRAMEBUFFER)===gl.FRAMEBUFFER_COMPLETE; | |
| }; | |
| let ok=false; | |
| try{ | |
| for(const unit of [4,5,6,7]){ | |
| gl.activeTexture(gl.TEXTURE0+unit);gl.bindTexture(gl.TEXTURE_2D,ph); | |
| } | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,windowGlowFB); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| gl.viewport(0,0,windowGlowW,windowGlowH); | |
| gl.disable(gl.BLEND);gl.disable(gl.CULL_FACE);gl.disable(gl.DEPTH_TEST); | |
| gl.disable(gl.SCISSOR_TEST);gl.depthMask(false);gl.colorMask(true,true,true,true); | |
| gl.bindVertexArray(aoVAO);gl.clearColor(0,0,0,1); | |
| if(!attach(windowGlowTexA)) throw new Error('extract framebuffer incomplete'); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| gl.useProgram(progWindowExtract); | |
| gl.activeTexture(gl.TEXTURE6);gl.bindTexture(gl.TEXTURE_2D,aoColB); | |
| gl.activeTexture(gl.TEXTURE5);gl.bindTexture(gl.TEXTURE_2D,aoWindowMask); | |
| gl.activeTexture(gl.TEXTURE4);gl.bindTexture(gl.TEXTURE_2D,aoDepth); | |
| gl.uniform1i(UWX.uCol,6);gl.uniform1i(UWX.uMask,5);gl.uniform1i(UWX.uDep,4); | |
| gl.uniform2f(UWX.uFootprint,aoW/windowGlowW,aoH/windowGlowH); | |
| gl.uniform1f(UWX.uGain,T.windowGain); | |
| gl.drawArrays(gl.TRIANGLES,0,3); | |
| gl.useProgram(progWindowBlur); | |
| gl.uniform1i(UWB.uCol,6);gl.uniform1i(UWB.uDep,4);gl.uniform1i(UWB.uFogMap,7); | |
| m4invert(aoWindowInvVP,matVP);gl.uniformMatrix4fv(UWB.uInvVP,false,aoWindowInvVP); | |
| const fowOn=typeof fogGameplayActive==='function'&&fogGameplayActive()&& | |
| !(typeof demoMode!=='undefined'&&demoMode)&&typeof fogTex!=='undefined'&&fogTex?1:0; | |
| gl.uniform1f(UWB.uFowOn,fowOn);gl.uniform1f(UWB.uMapInv,1/Math.max(1,MAP)); | |
| gl.activeTexture(gl.TEXTURE4);gl.bindTexture(gl.TEXTURE_2D,aoDepth); | |
| gl.activeTexture(gl.TEXTURE7);gl.bindTexture(gl.TEXTURE_2D, | |
| (typeof fogTex!=='undefined'&&fogTex)||ph); | |
| if(!attach(windowGlowTexB)) throw new Error('horizontal framebuffer incomplete'); | |
| gl.activeTexture(gl.TEXTURE6);gl.bindTexture(gl.TEXTURE_2D,windowGlowTexA); | |
| gl.uniform1f(UWB.uFinal,0);gl.uniform2f(UWB.uDir,1/windowGlowW,0);gl.clear(gl.COLOR_BUFFER_BIT); | |
| gl.drawArrays(gl.TRIANGLES,0,3); | |
| if(!attach(windowGlowTexA)) throw new Error('vertical framebuffer incomplete'); | |
| gl.activeTexture(gl.TEXTURE6);gl.bindTexture(gl.TEXTURE_2D,windowGlowTexB); | |
| gl.uniform1f(UWB.uFinal,1);gl.uniform2f(UWB.uDir,0,1/windowGlowH);gl.clear(gl.COLOR_BUFFER_BIT); | |
| gl.drawArrays(gl.TRIANGLES,0,3); | |
| ok=true;MF_WINDOW_HALO_TEL.lastError=''; | |
| }catch(e){ | |
| MF_WINDOW_HALO_TEL.failures++; | |
| MF_WINDOW_HALO_TEL.lastError=String(e&&e.message||e); | |
| }finally{ | |
| /* Restore framebuffer bindings before sampler bindings: a previous frame | |
| may legitimately have windowGlowTexA on unit 4, and rebinding it while | |
| windowGlowFB is the draw target would recreate a feedback loop. */ | |
| gl.bindFramebuffer(gl.READ_FRAMEBUFFER,wasRead); | |
| gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER,wasDraw); | |
| for(let i=0;i<4;i++){ | |
| gl.activeTexture(gl.TEXTURE0+4+i);gl.bindTexture(gl.TEXTURE_2D,savedTex[i]); | |
| } | |
| gl.activeTexture(wasActive);gl.useProgram(wasProgram);gl.bindVertexArray(wasVAO); | |
| gl.viewport(wasViewport[0],wasViewport[1],wasViewport[2],wasViewport[3]); | |
| gl.clearColor(wasClear[0],wasClear[1],wasClear[2],wasClear[3]); | |
| if(wasDepth)gl.enable(gl.DEPTH_TEST);else gl.disable(gl.DEPTH_TEST); | |
| if(wasCull)gl.enable(gl.CULL_FACE);else gl.disable(gl.CULL_FACE); | |
| if(wasBlend)gl.enable(gl.BLEND);else gl.disable(gl.BLEND); | |
| if(wasScissor)gl.enable(gl.SCISSOR_TEST);else gl.disable(gl.SCISSOR_TEST); | |
| gl.depthMask(wasMask);gl.colorMask(wasColorMask[0],wasColorMask[1],wasColorMask[2],wasColorMask[3]); | |
| } | |
| return ok; | |
| } | |
| /* Capture colour while the source still contains opaque lighting only. Global | |
| bloom is intentionally extracted later so volumetric fire can enter it; the | |
| selective window target must not borrow an explosion's colour merely because | |
| that transparent effect crossed a pane on screen. */ | |
| function aoExtractWindowBloom(){ | |
| aoWindowGlowReady=!!windowBloomPass(); | |
| aoWindowDetachMask(); | |
| return aoWindowGlowReady; | |
| } | |
| /* Bind the offscreen scene target. Returns false if AO is unavailable, in which | |
| case the caller just renders straight to the screen as before. */ | |
| function aoBeginScene(){ | |
| /* Whenever the offscreen path is off the scene must go straight to the | |
| canvas, so bind the default target explicitly rather than trusting that | |
| nothing left an offscreen one bound. */ | |
| const off=()=>{ | |
| aoWindowGlowReady=false; | |
| if(aoWindowAttachedFB) aoWindowDetachMask(); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,null);aoDoSSAO=false; | |
| MF_WINDOW_HALO_TEL.active=false; | |
| return false; | |
| }; | |
| if(!progCopy||!progAO) return off(); | |
| const demo=(typeof demoMode!=='undefined'&&demoMode); | |
| const gfxAO=aoOn&&((typeof GFX==='undefined')||GFX.ao!==false)&&!demo; | |
| const gfxBloom=((typeof GFX==='undefined')||GFX.bloom!==false)&&!demo; | |
| /* Volumetric FX consume the same resolved scene colour/depth target, but are | |
| not an AO or bloom feature. Advanced settings may disable both while a | |
| Medium+/High/Cinematic preset still requests raymarched impacts. Keep the | |
| offscreen path alive for that independent consumer so render3d cannot skip | |
| the volume and reveal a giant camera-facing fallback card. */ | |
| const gfxVol=!demo&&((typeof volFxEnabled==='function'&&volFxEnabled())|| | |
| (typeof GFX!=='undefined'&&GFX&&GFX.volSteps>0)); | |
| const pinned=(typeof GFX!=='undefined')&&GFX.fxFloor>0; | |
| const scaleOk=pinned||!(typeof perfScale!=='undefined'&&perfScale<0.5); | |
| /* SSAO is the expensive half. Bloom/FXAA share this FBO β if they ride | |
| the SSAO gate, a 0.25 perfBand (one fps dip) also kills glow, which is | |
| why live crystals read as stickers and buildings sat on the grass with | |
| no contact shadow. Keep the offscreen path up whenever bloom is on. */ | |
| aoDoSSAO=gfxAO&&scaleOk; | |
| if(!aoDoSSAO&&!gfxBloom&&!gfxVol){ | |
| return off(); | |
| } | |
| /* MEDIUM/LOW half-res the scene+SSAO target. HIGH/CINEMATIC keep canvas size. | |
| Viewport is set by the caller from aoW/aoH after this returns. */ | |
| { const div=gfxTune().aoDiv|0; | |
| aoAlloc(Math.max(1,cv.width>>div), Math.max(1,cv.height>>div)); } | |
| if(!aoReady){ | |
| return off(); | |
| } | |
| /* SSAO needs A as the opaque target then resolves into B. Bloom-only | |
| draws opaques straight into B, the same buffer transparents use. */ | |
| const fb=aoDoSSAO?aoFB1:aoFB2; | |
| if(aoWindowAttachedFB) aoWindowDetachMask(); | |
| aoWindowActive=false;aoWindowSealed=false;aoWindowGlowReady=false; | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,fb); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,aoDepth,0); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| const patchOK=typeof window==='undefined'||!window.__MF_WINDOW_BLOOM_PATCH_MISS; | |
| const windowCan=aoWindowReady&&!!progWindowExtract&&!!progWindowBlur&&patchOK&& | |
| gfxTune().bloom&&gfxTune().windowAmt>0; | |
| if(windowCan){ | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT1,gl.TEXTURE_2D,aoWindowMask,0); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0,gl.COLOR_ATTACHMENT1]); | |
| if(gl.checkFramebufferStatus(gl.FRAMEBUFFER)===gl.FRAMEBUFFER_COMPLETE){ | |
| aoWindowAttachedFB=fb;aoWindowActive=true; | |
| }else{ | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT1,gl.TEXTURE_2D,null,0); | |
| aoWindowReady=false;MF_WINDOW_HALO_TEL.failures++; | |
| MF_WINDOW_HALO_TEL.lastError='scene MRT became incomplete'; | |
| } | |
| }else if(!patchOK){ | |
| MF_WINDOW_HALO_TEL.lastError='world shader window-output contract changed'; | |
| } | |
| MF_WINDOW_HALO_TEL.available=aoWindowReady&&!!progWindowExtract&&!!progWindowBlur&&patchOK; | |
| MF_WINDOW_HALO_TEL.active=aoWindowActive; | |
| return true; | |
| } | |
| /* Composite opaque scene + AO into the second target, then hand the depth | |
| buffer back so the transparent passes still occlude correctly. */ | |
| function aoResolve(tint){ | |
| if(!aoDoSSAO) return; | |
| /* This runs in the middle of the frame, between the opaque pass and the | |
| translucent overlays, so it must leave the pipeline EXACTLY as it found it. | |
| The first version reset blend, depth-write and culling to opaque defaults | |
| on the way out β the three states the overlay pass had just configured β | |
| so selection rings and build-zone plates drew solid and depth-writing | |
| whenever AO was on. It also left the scene image bound to unit 0, which | |
| the model program samples as its material atlas, so those overlays | |
| textured themselves with the previous frame. */ | |
| const wasBlend=gl.getParameter(gl.BLEND), wasCull=gl.getParameter(gl.CULL_FACE); | |
| const wasDepth=gl.getParameter(gl.DEPTH_TEST), wasMask=gl.getParameter(gl.DEPTH_WRITEMASK); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,aoFB2); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,null,0); | |
| gl.disable(gl.DEPTH_TEST); gl.depthMask(false); gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); | |
| /* The post chain samples on HIGH texture units and never touches unit 0. | |
| Unit 0 is the material atlas for models and the map for terrain, and a | |
| sampler that silently falls back to it is why a failed bind painted the | |
| entire screen with the material atlas instead of the frame. */ | |
| gl.useProgram(progAO); | |
| gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D,aoColA); | |
| gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,aoDepth); | |
| gl.uniform1i(UAO.uCol,6); gl.uniform1i(UAO.uDep,4); | |
| gl.uniform2f(UAO.uTexel,1/aoW,1/aoH); | |
| /* Radius is specified in WORLD units and converted to pixels here, so a | |
| crease stays the same physical size as the player zooms instead of | |
| swelling into a smear. */ | |
| gl.uniform1f(UAO.uRadius, Math.min(5, 4*aoH/Math.max(1,orthoSpan)+1.2)); | |
| gl.uniform1f(UAO.uWorldPerZ, 15000.0); | |
| gl.uniform1f(UAO.uRange, 8.0); | |
| gl.uniform3f(UAO.uTint, tint[0], tint[1], tint[2]); | |
| { const T=gfxTune(); | |
| gl.uniform1f(UAO.uAoAmt, T.aoAmt); | |
| gl.uniform1i(UAO.uAoN, Math.max(1, Math.min(12, T.aoSamples))); } | |
| gl.bindVertexArray(aoVAO); | |
| gl.drawArrays(gl.TRIANGLES,0,3); | |
| gl.activeTexture(gl.TEXTURE0); // leave the active unit where callers expect it | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,aoDepth,0); | |
| if(wasDepth) gl.enable(gl.DEPTH_TEST); else gl.disable(gl.DEPTH_TEST); | |
| if(wasCull) gl.enable(gl.CULL_FACE); else gl.disable(gl.CULL_FACE); | |
| if(wasBlend) gl.enable(gl.BLEND); else gl.disable(gl.BLEND); | |
| gl.depthMask(wasMask); | |
| } | |
| /* Bloom from the opaque+decal+unit buffer BEFORE water writes into B. | |
| Noon oceans sat in the bright-pass (even at thresh 0.925) and the two-axis | |
| blur flattened HIGH water into a solid slab. Tracers still additively draw | |
| after water; they do not need the bloom target to read. */ | |
| let aoGlowReady=false; | |
| function aoExtractBloom(){ | |
| aoGlowReady=false; | |
| if(!aoReady||!aoColB){aoWindowDetachMask();return;} | |
| aoGlowReady=!!bloomPass(); | |
| if(!aoWindowGlowReady) aoWindowGlowReady=!!windowBloomPass(); | |
| aoWindowDetachMask(); | |
| /* bloomPass restores the caller's FB but the glow pass rebound attachments. | |
| Transparents still need aoFB2 + the scene depth. */ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,aoFB2); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,aoColB,0); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,aoDepth,0); | |
| gl.viewport(0,0,aoW,aoH); | |
| } | |
| /* Put the finished frame on the screen. */ | |
| function aoPresent(){ | |
| if(!aoReady||!aoColB){aoWindowDetachMask();return;} | |
| const wasBlend=gl.isEnabled(gl.BLEND), wasCull=gl.isEnabled(gl.CULL_FACE); | |
| const wasDepth=gl.isEnabled(gl.DEPTH_TEST), wasMask=gl.getParameter(gl.DEPTH_WRITEMASK); | |
| const wasScissor=gl.isEnabled(gl.SCISSOR_TEST); | |
| const wasActive=gl.getParameter(gl.ACTIVE_TEXTURE),savedEdgeTex=[]; | |
| for(const unit of [3,7]){ | |
| gl.activeTexture(gl.TEXTURE0+unit);savedEdgeTex.push(gl.getParameter(gl.TEXTURE_BINDING_2D)); | |
| } | |
| gl.disable(gl.DEPTH_TEST); gl.depthMask(false); gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); | |
| gl.disable(gl.SCISSOR_TEST); | |
| const glow=aoGlowReady?true:bloomPass(); | |
| aoGlowReady=false; | |
| const windowGlow=aoWindowGlowReady?true:windowBloomPass(); | |
| aoWindowGlowReady=false; | |
| aoWindowDetachMask(); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,null); | |
| gl.viewport(0,0,cv.width,cv.height); | |
| gl.useProgram(progCopy); | |
| const ph=matTex||aoColB; | |
| gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D,aoColB); | |
| gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D,glow?glowTexA:aoColB); | |
| gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,windowGlow?windowGlowTexA:aoColB); | |
| gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D,aoDepth); | |
| gl.activeTexture(gl.TEXTURE7); gl.bindTexture(gl.TEXTURE_2D, | |
| (typeof fogTex!=='undefined'&&fogTex)||ph); | |
| gl.uniform1i(UCP.uCol,6); gl.uniform1i(UCP.uBloom,5);gl.uniform1i(UCP.uWindow,4); | |
| gl.uniform1i(UCP.uDep,3);gl.uniform1i(UCP.uFogMap,7); | |
| gl.uniform2f(UCP.uTexel,1/aoW,1/aoH); | |
| { const T=gfxTune(); | |
| gl.uniform1f(UCP.uBloomAmt,glow?T.bloomAmt:0.0); | |
| gl.uniform1f(UCP.uWindowAmt,windowGlow?T.windowAmt:0.0); | |
| if(UCP.uFxaa) gl.uniform1f(UCP.uFxaa,T.fxaa?1:0); | |
| if(UCP.uSharp) gl.uniform1f(UCP.uSharp,T.sharp||0); | |
| const fowOn=typeof fogGameplayActive==='function'&&fogGameplayActive()&& | |
| !(typeof demoMode!=='undefined'&&demoMode)&&typeof fogTex!=='undefined'&&fogTex?1:0; | |
| m4invert(aoWindowInvVP,matVP);gl.uniformMatrix4fv(UCP.uInvVP,false,aoWindowInvVP); | |
| gl.uniform1f(UCP.uFowOn,fowOn);gl.uniform1f(UCP.uMapInv,1/Math.max(1,MAP)); } | |
| gl.bindVertexArray(aoVAO); | |
| gl.drawArrays(gl.TRIANGLES,0,3); | |
| if(windowGlow) MF_WINDOW_HALO_TEL.presentedFrames++; | |
| /* Drop FBO textures off 4/5/6 so the next frame cannot sample-while-write. */ | |
| gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.activeTexture(gl.TEXTURE3);gl.bindTexture(gl.TEXTURE_2D,savedEdgeTex[0]); | |
| gl.activeTexture(gl.TEXTURE7);gl.bindTexture(gl.TEXTURE_2D,savedEdgeTex[1]); | |
| gl.activeTexture(wasActive); | |
| if(wasDepth) gl.enable(gl.DEPTH_TEST); else gl.disable(gl.DEPTH_TEST); | |
| if(wasCull) gl.enable(gl.CULL_FACE); else gl.disable(gl.CULL_FACE); | |
| if(wasBlend) gl.enable(gl.BLEND); else gl.disable(gl.BLEND); | |
| if(wasScissor) gl.enable(gl.SCISSOR_TEST); else gl.disable(gl.SCISSOR_TEST); | |
| gl.depthMask(wasMask); | |
| } | |
| /* ============================================================================ | |
| CASCADED SUN-DEPTH (HIGH / CINEMATIC only) | |
| ---------------------------------------------------------------------------- | |
| MEDIUM/LOW keep the cheap footprint blobs in drawShadows and never enter | |
| this pass. HIGH uses 2 clips, CINEMATIC 3. Atlas is a depth texture. | |
| Texture unit: 4, and only during csmApply. Terrain already occupies | |
| 0/1/2/3/7β15; the model pass borrows 4/5/6 for asset skins; ads stay on 7; | |
| post owns 4/5/6 after opaques. Unit 4 is free for this multiply apply | |
| because it runs after colour flushes and before AO samples unit 4 as | |
| aoDepth. Atlas stays on 0. Never bind the sun-depth on unit 0. | |
| Casters are queued AFTER terrain, so the atlas cannot be sampled in the | |
| terrain shader this frame without a second ground draw. Apply is a | |
| fullscreen multiply from reconstructed world position (ortho camera β | |
| inv(VP) is exact). That is still a real sun-depth CSM, not a second blob | |
| layer. | |
| Depth VS matches the colour pose: SERVO/BIOLEG walk plus the rigid FK | |
| chain when a mesh uploads bones. HIGH uploads palettes only for marked | |
| casters (commanders / large / nearby); CINEMATIC skins every boned mesh. | |
| MEDIUM/LOW never enter this pass. Terrain casts on HIGH (near clip only) | |
| and CINEMATIC (near+mid). The far CINEMATIC clip is skipped β a full | |
| 205k-tri grid into a 1900-radius 1024Β² tile is fillrate-unsafe. Apply | |
| stays a screen-space multiply: sampling the atlas in FS3D / civic V2 | |
| would steal unit 4 from asset skins and rewrite the filmic 1-exp write. | |
| No film-grain, no god-rays. | |
| ============================================================================ */ | |
| const MF_CSM_TEX_UNIT=4; | |
| const VSCSM=`#version 300 es | |
| layout(location=0) in vec3 aPos; | |
| layout(location=4) in float aMat; | |
| layout(location=5) in vec4 aInst; | |
| layout(location=6) in float aYaw; | |
| layout(location=8) in float aWide; | |
| layout(location=9) in float aAnim; | |
| layout(location=11) in float aPitch; | |
| layout(location=12) in float aRoll; | |
| uniform mat4 uVP; | |
| uniform int uBoneN; | |
| uniform vec4 uJoint[80]; | |
| uniform vec4 uAxis[80]; | |
| uniform vec2 uSwing[80]; | |
| vec3 rotAxis(vec3 v, vec3 k, float a){ | |
| float c=cos(a), s=sin(a); | |
| return v*c + cross(k,v)*s + k*dot(k,v)*(1.0-c); | |
| } | |
| void main(){ | |
| float c=cos(aYaw),s=sin(aYaw); | |
| vec3 ap=aPos; | |
| float aMatAbs=abs(aMat); | |
| float aBone=floor(fract(aMatAbs)*128.0+0.5)-1.0; | |
| if(uBoneN>0 && aBone>=0.0){ | |
| int cur=int(aBone+0.5); | |
| for(int k=0;k<8;k++){ | |
| if(cur<0 || cur>=uBoneN) break; | |
| vec4 J=uJoint[cur]; vec4 A=uAxis[cur]; vec2 S=uSwing[cur]; | |
| float ang = S.y + S.x*sin(aAnim + A.w); | |
| ap = J.xyz + rotAxis(ap - J.xyz, A.xyz, ang); | |
| cur = int(J.w); | |
| } | |
| } | |
| float matId=floor(aMatAbs)-1.0; | |
| float leg = (matId == SERVO_CONST) ? 1.0 : 0.0; | |
| float bioLimb = (matId == BIOLEG_CONST) ? 1.0 : 0.0; | |
| float bioBody = (matId == CHITIN_CONST) ? 1.0 : 0.0; | |
| if(aAnim!=0.0){ | |
| float side = aPos.z<0.0 ? 1.0 : -1.0; | |
| float sidePhase = aPos.z<0.0 ? 0.0 : 3.14159; | |
| float sw = sin(aAnim + sidePhase); | |
| float below= max(0.0, HIP_CONST - aPos.y); | |
| ap.x += leg * sw * below * 0.36; | |
| ap.y += leg * max(0.0,sw) * below * 0.10; | |
| ap.y += (1.0-leg) * abs(sin(aAnim)) * 0.32; | |
| float loose = aBone<0.0 ? 1.0 : 0.0; | |
| float flex=clamp((aPos.y+.35)/7.5,0.12,1.0)*loose; | |
| float lead=sin(aAnim+aPos.x*.22+side*.55); | |
| float lag=sin(aAnim*.73-1.05+aPos.x*.13-side*.32); | |
| float bend=lead*.68+lag*.32; | |
| ap.x += bioLimb*bend*flex*.46; | |
| ap.z += bioLimb*(lead-lag)*flex*.20; | |
| ap.y += bioLimb*abs(lag)*flex*.12; | |
| float breath=sin(aAnim*.43+aPos.x*.11)*.035; | |
| ap.y += bioBody*breath*max(0.0,aPos.y)*.32; | |
| ap.z *= 1.0+bioBody*breath; | |
| } | |
| float cr=cos(aRoll),sr=sin(aRoll); | |
| float cp=cos(aPitch),spit=sin(aPitch); | |
| vec3 rolled=vec3(ap.x, ap.y*cr-ap.z*sr, ap.y*sr+ap.z*cr); | |
| vec3 pp=vec3(rolled.x*cp-rolled.y*spit,rolled.x*spit+rolled.y*cp,rolled.z); | |
| vec3 sp=vec3(pp.x*aInst.w,pp.y*aInst.w,pp.z*aWide); | |
| vec3 p=vec3(sp.x*c-sp.z*s,sp.y,sp.x*s+sp.z*c)+aInst.xyz; | |
| gl_Position=uVP*vec4(p,1.0); | |
| }`; | |
| const FSCSM=`#version 300 es | |
| precision highp float; | |
| out vec4 o; | |
| void main(){ o=vec4(0.0); }`; | |
| /* Terrain positions are already world-space. The instanced depth VS would | |
| read a stale aAnim (terrain VAO does not pin location 9) and walk the | |
| ground. One attrib, no bones, no instance stream. */ | |
| const VSCSMT=`#version 300 es | |
| layout(location=0) in vec3 aPos; | |
| uniform mat4 uVP; | |
| void main(){ gl_Position=uVP*vec4(aPos,1.0); }`; | |
| const FSCSMAPP=`#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| uniform sampler2D uDep; | |
| uniform sampler2D uShadow; | |
| uniform mat4 uInvVP; | |
| uniform mat4 uSunVP[3]; | |
| uniform vec4 uTile[3]; | |
| uniform int uN; | |
| uniform vec2 uTexel; | |
| uniform float uBias; | |
| out vec4 o; | |
| void main(){ | |
| float d=texture(uDep,vUv).r; | |
| if(d>=0.999){ o=vec4(1.0); return; } | |
| vec4 ndc=vec4(vUv*2.0-1.0, d*2.0-1.0, 1.0); | |
| vec4 w=uInvVP*ndc; | |
| vec3 world=w.xyz/max(w.w,1e-6); | |
| float lit=1.0; | |
| for(int i=0;i<3;i++){ | |
| if(i>=uN) break; | |
| vec4 c=uSunVP[i]*vec4(world,1.0); | |
| vec3 t=c.xyz; | |
| vec2 uv=t.xy*0.5+0.5; | |
| float z=t.z*0.5+0.5; | |
| if(uv.x<=0.02||uv.x>=0.98||uv.y<=0.02||uv.y>=0.98||z<=0.0||z>=1.0) continue; | |
| vec2 a=uv*uTile[i].xy+uTile[i].zw; | |
| vec2 px=uTexel; | |
| float sh=0.0; | |
| sh+=step(z-uBias, texture(uShadow,a+vec2(-px.x,-px.y)).r); | |
| sh+=step(z-uBias, texture(uShadow,a+vec2( px.x,-px.y)).r); | |
| sh+=step(z-uBias, texture(uShadow,a+vec2(-px.x, px.y)).r); | |
| sh+=step(z-uBias, texture(uShadow,a+vec2( px.x, px.y)).r); | |
| lit=sh*0.25; | |
| break; | |
| } | |
| o=vec4(vec3(mix(0.54,1.0,lit)),1.0); | |
| }`; | |
| let progCSM=null, progCSMApp=null, progCSMTerr=null, UCSM={}, UCSMA={}, UCSMT={}; | |
| let csmFB=null, csmTex=null, csmColor=null, csmW=0, csmH=0, csmClips=0; | |
| let csmEpoch=-1, csmReady=false, csmLive=false, csmCleared=false, csmBound=false; | |
| let csmN=0, csmDraws=0, csmSkinDraws=0, csmTerrDraws=0, csmBonesOn=false; | |
| const csmRad=[0,0,0]; | |
| let csmLookY=0; | |
| const csmVP=[m4(),m4(),m4()], csmVPFlat=new Float32Array(48), csmInvVP=m4(); | |
| const csmTile=new Float32Array(12); | |
| const csmView=new Int32Array(12); | |
| const csmSave={fb:null,vp:null,blend:false,cull:false,depth:false,mask:true}; | |
| const MF_CSM_TELEMETRY={unit:MF_CSM_TEX_UNIT,ready:false,live:false,clips:0,draws:0,skin:0,terr:0,walk:true,w:0,h:0,err:0}; | |
| if(typeof window!=='undefined') window.MFCsm=MF_CSM_TELEMETRY; | |
| function csmWanted(){ | |
| const q=typeof mfGfxKey==='function'?mfGfxKey():'high'; | |
| if(q!=='high'&&q!=='cinematic') return false; | |
| const sq=(typeof GFX!=='undefined'&&GFX.shadowQ!=null)?GFX.shadowQ:2; | |
| return sq>=2; | |
| } | |
| function csmActive(){ | |
| /* drawShadows reads this before casters exist. aoReady is known after | |
| aoBeginScene β without a readable scene depth the apply pass cannot run, | |
| so HIGH falls back to the blob cast rather than a hole. */ | |
| return csmWanted()&&csmReady&&aoReady&&!!aoDepth; | |
| } | |
| function csmInit(){ | |
| if(csmEpoch!==glEpoch){ | |
| csmEpoch=glEpoch; csmFB=csmTex=csmColor=null; csmW=csmH=csmClips=0; csmReady=false; | |
| } | |
| const VSM=VSCSM | |
| .replace(/SERVO_CONST/g,MAT.SERVO.toFixed(1)) | |
| .replace(/BIOLEG_CONST/g,MAT.LEAF.toFixed(1)) | |
| .replace(/CHITIN_CONST/g,MAT.CHITIN.toFixed(1)) | |
| .replace(/HIP_CONST/g,'11.0'); | |
| progCSM=mkProg(VSM,FSCSM,'csm-depth'); | |
| progCSMApp=mkProg(VSQ,FSCSMAPP,'csm-apply'); | |
| UCSM.uVP=progCSM?gl.getUniformLocation(progCSM,'uVP'):null; | |
| UCSM.uBoneN=progCSM?gl.getUniformLocation(progCSM,'uBoneN'):null; | |
| UCSM.uJoint=progCSM?gl.getUniformLocation(progCSM,'uJoint[0]'):null; | |
| UCSM.uAxis=progCSM?gl.getUniformLocation(progCSM,'uAxis[0]'):null; | |
| UCSM.uSwing=progCSM?gl.getUniformLocation(progCSM,'uSwing[0]'):null; | |
| if(progCSM&&UCSM.uBoneN){ gl.useProgram(progCSM); gl.uniform1i(UCSM.uBoneN,0); } | |
| progCSMTerr=mkProg(VSCSMT,FSCSM,'csm-terrain'); | |
| UCSMT.uVP=progCSMTerr?gl.getUniformLocation(progCSMTerr,'uVP'):null; | |
| if(progCSMApp){ | |
| for(const k of ['uDep','uShadow','uInvVP','uN','uTexel','uBias']) | |
| UCSMA[k]=gl.getUniformLocation(progCSMApp,k); | |
| UCSMA.uSunVP=gl.getUniformLocation(progCSMApp,'uSunVP[0]'); | |
| UCSMA.uTile=gl.getUniformLocation(progCSMApp,'uTile[0]'); | |
| } | |
| csmReady=!!(progCSM&&progCSMApp); | |
| MF_CSM_TELEMETRY.ready=csmReady; | |
| } | |
| function csmAlloc(clips){ | |
| clips=clips>=3?3:2; | |
| const w=2048, h=clips>=3?2048:1024; | |
| if(csmEpoch!==glEpoch){ csmFB=csmTex=csmColor=null; csmW=csmH=csmClips=0; } | |
| if(csmW===w&&csmH===h&&csmClips===clips&&csmTex&&csmFB) return true; | |
| if(csmFB){ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,csmFB); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,null,0); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,null,0); | |
| } | |
| if(csmTex) gl.deleteTexture(csmTex); | |
| if(csmColor) gl.deleteTexture(csmColor); | |
| csmTex=gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D,csmTex); | |
| gl.texImage2D(gl.TEXTURE_2D,0,gl.DEPTH_COMPONENT16,w,h,0,gl.DEPTH_COMPONENT,gl.UNSIGNED_SHORT,null); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.NEAREST); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); | |
| if(!csmFB) csmFB=gl.createFramebuffer(); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,csmFB); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,csmTex,0); | |
| gl.drawBuffers([gl.NONE]); | |
| let ok=gl.checkFramebufferStatus(gl.FRAMEBUFFER)===gl.FRAMEBUFFER_COMPLETE; | |
| if(!ok){ | |
| /* Some ANGLE builds refuse a depth-only FBO. A same-size colour target | |
| is only a completeness dummy β colorMask stays off. */ | |
| csmColor=gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D,csmColor); | |
| gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA8,w,h,0,gl.RGBA,gl.UNSIGNED_BYTE,null); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.NEAREST); | |
| gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,csmColor,0); | |
| gl.drawBuffers([gl.COLOR_ATTACHMENT0]); | |
| ok=gl.checkFramebufferStatus(gl.FRAMEBUFFER)===gl.FRAMEBUFFER_COMPLETE; | |
| } | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,null); | |
| if(!ok){ csmReady=false; MF_CSM_TELEMETRY.ready=false; return false; } | |
| csmW=w; csmH=h; csmClips=clips; | |
| MF_CSM_TELEMETRY.w=w; MF_CSM_TELEMETRY.h=h; | |
| return true; | |
| } | |
| function csmBuild(Sun){ | |
| const q=typeof mfGfxKey==='function'?mfGfxKey():'high'; | |
| const clips=q==='cinematic'?3:2; | |
| if(!csmAlloc(clips)) return false; | |
| csmN=clips; | |
| const s=orthoSpan, dir=Sun.dir; | |
| const rad=clips>=3 | |
| ?[Math.max(220,Math.min(380,s*0.36)),Math.max(480,Math.min(980,s*0.82)),Math.max(900,Math.min(1900,s*1.35))] | |
| :[Math.max(260,Math.min(460,s*0.42)),Math.max(700,Math.min(1500,s*1.12))]; | |
| const cx0=cam.x, cy0=cam.y, gh=terrainH(cx0,cy0); | |
| const look=m4(), proj=m4(); | |
| for(let i=0;i<clips;i++){ | |
| const R=rad[i], texel=(R*2)/1024; | |
| csmRad[i]=R; csmLookY=cy0; | |
| const cx=Math.round(cx0/texel)*texel, cy=Math.round(cy0/texel)*texel; | |
| const dist=R+720; | |
| m4look(look, cx+dir[0]*dist, gh+dir[1]*dist, cy+dir[2]*dist, cx,gh,cy, 0,1,0); | |
| m4ortho(proj, -R,R, -R,R, -R*0.45, dist+R+360); | |
| m4mul(csmVP[i], proj, look); | |
| csmVPFlat.set(csmVP[i], i*16); | |
| if(clips>=3){ | |
| const col=i&1, row=i>>1; | |
| csmView[i*4]=col*1024; csmView[i*4+1]=row*1024; csmView[i*4+2]=1024; csmView[i*4+3]=1024; | |
| csmTile[i*4]=0.5; csmTile[i*4+1]=0.5; csmTile[i*4+2]=col*0.5; csmTile[i*4+3]=row*0.5; | |
| } else { | |
| csmView[i*4]=i*1024; csmView[i*4+1]=0; csmView[i*4+2]=1024; csmView[i*4+3]=1024; | |
| csmTile[i*4]=0.5; csmTile[i*4+1]=1; csmTile[i*4+2]=i*0.5; csmTile[i*4+3]=0; | |
| } | |
| } | |
| m4invert(csmInvVP, matVP); | |
| MF_CSM_TELEMETRY.clips=clips; | |
| return true; | |
| } | |
| function csmPrepare(Sun){ | |
| csmLive=false; csmCleared=false; csmBound=false; csmDraws=0; csmSkinDraws=0; csmTerrDraws=0; csmBonesOn=false; | |
| MF_CSM_TELEMETRY.live=false; MF_CSM_TELEMETRY.draws=0; MF_CSM_TELEMETRY.skin=0; MF_CSM_TELEMETRY.terr=0; | |
| if(!csmWanted()||!csmReady||!aoReady||!aoDepth) return false; | |
| return csmBuild(Sun); | |
| } | |
| function csmBegin(clear){ | |
| if(!csmReady||!csmFB||!csmTex) return false; | |
| if(csmBound) return true; | |
| csmSave.fb=gl.getParameter(gl.FRAMEBUFFER_BINDING); | |
| csmSave.vp=gl.getParameter(gl.VIEWPORT); | |
| csmSave.blend=gl.isEnabled(gl.BLEND); | |
| csmSave.cull=gl.isEnabled(gl.CULL_FACE); | |
| csmSave.depth=gl.isEnabled(gl.DEPTH_TEST); | |
| csmSave.mask=gl.getParameter(gl.DEPTH_WRITEMASK); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,csmFB); | |
| gl.disable(gl.BLEND); | |
| gl.enable(gl.CULL_FACE); | |
| gl.cullFace(gl.BACK); | |
| gl.enable(gl.DEPTH_TEST); | |
| gl.depthFunc(gl.LEQUAL); | |
| gl.depthMask(true); | |
| gl.colorMask(false,false,false,false); | |
| gl.disable(gl.SCISSOR_TEST); | |
| gl.enable(gl.POLYGON_OFFSET_FILL); | |
| gl.polygonOffset(1.8, 6.0); | |
| if(clear||!csmCleared){ | |
| gl.viewport(0,0,csmW,csmH); | |
| gl.clearDepth(1); | |
| gl.clear(gl.DEPTH_BUFFER_BIT); | |
| csmCleared=true; | |
| } | |
| gl.useProgram(progCSM); | |
| if(UCSM.uBoneN) gl.uniform1i(UCSM.uBoneN,0); | |
| csmBonesOn=false; | |
| csmBound=true; | |
| return true; | |
| } | |
| function csmBindSkin(M){ | |
| const q=typeof mfGfxKey==='function'?mfGfxKey():'high'; | |
| const want=!!(M.bones&&UCSM.uBoneN&&(q==='cinematic'||M.csmSkin)); | |
| if(want){ | |
| gl.uniform1i(UCSM.uBoneN,M.bones); | |
| gl.uniform4fv(UCSM.uJoint,M.jointBuf); | |
| gl.uniform4fv(UCSM.uAxis,M.axisBuf); | |
| gl.uniform2fv(UCSM.uSwing,M.swingBuf); | |
| csmBonesOn=true; | |
| csmSkinDraws++; | |
| } else if(csmBonesOn){ | |
| gl.uniform1i(UCSM.uBoneN,0); | |
| csmBonesOn=false; | |
| } | |
| M.csmSkin=0; | |
| } | |
| function csmDrawMesh(M){ | |
| if(!csmBound||!M||!M.bindShadow||!M.bindShadow(gl)){ if(M) M.csmSkin=0; return; } | |
| csmBindSkin(M); | |
| for(let i=0;i<csmN;i++){ | |
| gl.viewport(csmView[i*4],csmView[i*4+1],csmView[i*4+2],csmView[i*4+3]); | |
| gl.uniformMatrix4fv(UCSM.uVP,false,csmVP[i]); | |
| M.drawShadow(gl); | |
| csmDraws++; | |
| } | |
| } | |
| function csmDrawTerrain(){ | |
| /* HIGH: near clip only. CINEMATIC: near+mid. Far clip stays units/buildings | |
| β 205k tris into the 1900-radius tile is the fillrate skip. */ | |
| if(!csmBound||!progCSMTerr||typeof csmTerrainSpan!=='function') return; | |
| if(typeof terrVAO==='undefined'||!terrVAO) return; | |
| const q=typeof mfGfxKey==='function'?mfGfxKey():'high'; | |
| const nTerr=q==='cinematic'?Math.min(2,csmN):1; | |
| gl.useProgram(progCSMTerr); | |
| gl.polygonOffset(3.4, 14.0); | |
| for(let i=0;i<nTerr;i++){ | |
| const R=csmRad[i]||0; | |
| const span=csmTerrainSpan(csmLookY-R*1.15, csmLookY+R*1.15); | |
| if(!span) continue; | |
| gl.bindVertexArray(terrVAO); | |
| gl.viewport(csmView[i*4],csmView[i*4+1],csmView[i*4+2],csmView[i*4+3]); | |
| gl.uniformMatrix4fv(UCSMT.uVP,false,csmVP[i]); | |
| gl.drawElements(gl.TRIANGLES,span.count,gl.UNSIGNED_INT,span.first*4); | |
| drawCalls++; | |
| csmDraws++; | |
| csmTerrDraws++; | |
| } | |
| gl.polygonOffset(1.8, 6.0); | |
| gl.useProgram(progCSM); | |
| if(UCSM.uBoneN) gl.uniform1i(UCSM.uBoneN,0); | |
| csmBonesOn=false; | |
| } | |
| function csmEnd(nA){ | |
| if(!csmBound) return; | |
| gl.disable(gl.POLYGON_OFFSET_FILL); | |
| gl.colorMask(true,true,true,true); | |
| gl.cullFace(gl.BACK); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,csmSave.fb); | |
| if(csmSave.vp) gl.viewport(csmSave.vp[0],csmSave.vp[1],csmSave.vp[2],csmSave.vp[3]); | |
| if(csmSave.blend) gl.enable(gl.BLEND); else gl.disable(gl.BLEND); | |
| if(csmSave.cull) gl.enable(gl.CULL_FACE); else gl.disable(gl.CULL_FACE); | |
| if(csmSave.depth) gl.enable(gl.DEPTH_TEST); else gl.disable(gl.DEPTH_TEST); | |
| gl.depthMask(csmSave.mask); | |
| csmBound=false; | |
| csmLive=csmCleared; | |
| MF_CSM_TELEMETRY.live=csmLive; | |
| MF_CSM_TELEMETRY.draws=csmDraws; | |
| MF_CSM_TELEMETRY.skin=csmSkinDraws; | |
| MF_CSM_TELEMETRY.terr=csmTerrDraws; | |
| if(typeof begin3D==='function') begin3D(nA); | |
| } | |
| function csmApply(){ | |
| if(!csmLive||!csmTex||!aoDepth||!progCSMApp) return false; | |
| const wasFB=gl.getParameter(gl.FRAMEBUFFER_BINDING); | |
| const wasBlend=gl.isEnabled(gl.BLEND), wasCull=gl.isEnabled(gl.CULL_FACE); | |
| const wasDepth=gl.isEnabled(gl.DEPTH_TEST), wasMask=gl.getParameter(gl.DEPTH_WRITEMASK); | |
| /* aoDepth is still attached to the opaque target. Sampling it while bound | |
| is framebuffer feedback β ANGLE D3D11 returns INVALID_OPERATION (1282) | |
| and the multiply is undefined. Detach for the apply, then put it back | |
| so water/decals still occlude. */ | |
| if(wasFB){ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,wasFB); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,null,0); | |
| } | |
| gl.disable(gl.CULL_FACE); | |
| gl.disable(gl.DEPTH_TEST); | |
| gl.depthMask(false); | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.ZERO,gl.SRC_COLOR); | |
| gl.useProgram(progCSMApp); | |
| /* Unit 4 = sun-depth atlas. Unit 5 = scene depth. Post will rebind 4 as | |
| aoDepth in aoResolve; we put matTex back on the way out. */ | |
| gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,csmTex); | |
| gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D,aoDepth); | |
| gl.uniform1i(UCSMA.uShadow,4); | |
| gl.uniform1i(UCSMA.uDep,5); | |
| gl.uniformMatrix4fv(UCSMA.uInvVP,false,csmInvVP); | |
| gl.uniformMatrix4fv(UCSMA.uSunVP,false,csmVPFlat); | |
| gl.uniform4fv(UCSMA.uTile,csmTile); | |
| gl.uniform1i(UCSMA.uN,csmN); | |
| gl.uniform2f(UCSMA.uTexel,1/csmW,1/csmH); | |
| gl.uniform1f(UCSMA.uBias,0.0024); | |
| gl.bindVertexArray(aoVAO); | |
| gl.drawArrays(gl.TRIANGLES,0,3); | |
| const ph=matTex||aoColB; | |
| gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D,ph); | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); | |
| if(wasFB){ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER,wasFB); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.TEXTURE_2D,aoDepth,0); | |
| } | |
| 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); | |
| return true; | |
| } | |
| let prog3D=null, progG=null, progT=null, U3={}, UG={}, UT={}; | |
| /* CONTEXT EPOCH β the number that makes "is my GL object still real?" a cheap | |
| JS comparison instead of a guess. | |
| The terrain self-heal shipped in 1.32.45 only fired when its VAO handle was | |
| FALSY. But a lost or replaced context does not null anything: every handle | |
| stays a perfectly truthy JS object that simply no longer refers to anything | |
| on the GPU. So the ground kept "drawing" into a dead VAO, the heal never | |
| triggered, and the map stayed missing β in a match AND on the menu diorama, | |
| which is exactly what the device kept showing. | |
| Every path that creates a fresh context runs initGL3D, so bumping a counter | |
| there gives every cached GL object a way to notice it belongs to a previous | |
| life. No GL queries, no per-frame cost. */ | |
| let glEpoch=0; | |
| let terrainProgOK=true; | |
| /* One string that answers "why is the map missing?" without a USB cable. */ | |
| function mfGraphicsDiag(){ | |
| const yn=v=>v?'ok':'MISSING'; | |
| const parts=[ | |
| 'v'+((typeof APP_VERSION!=='undefined'&&APP_VERSION)||'?'), | |
| 'ctx '+(typeof gl!=='undefined'&&gl&&!gl.isContextLost()?'ok':'LOST'), | |
| 'epoch '+(typeof glEpoch!=='undefined'?glEpoch:'?'), | |
| 'model '+yn(typeof prog3D!=='undefined'&&prog3D), | |
| 'terrainProg '+yn(typeof progT!=='undefined'&&progT), | |
| 'terrainMesh '+yn(typeof terrVAO!=='undefined'&&terrVAO), | |
| 'tris '+((typeof terrIdxCount!=='undefined'?terrIdxCount/3:0)|0), | |
| 'mapTex '+yn(typeof terrainTex!=='undefined'&&terrainTex), | |
| 'dpr '+((typeof DPR!=='undefined'?DPR:0).toFixed?DPR.toFixed(2):DPR), | |
| 'px '+((typeof cv!=='undefined'&&cv)?((cv.width*cv.height/1e6).toFixed(2)+'M'):'?'), | |
| ]; | |
| if(typeof GL_PROG_ERRORS!=='undefined'&&GL_PROG_ERRORS.length) | |
| parts.push('ERR '+GL_PROG_ERRORS.slice(0,2).join(' | ')); | |
| return parts.join(' Β· '); | |
| } | |
| function initGL3D(){ | |
| glEpoch++; // everything cached against the old context is now stale | |
| const VSM=VS3D.replace(/MAPSIZE_CONST/g,MAP.toFixed(1)).replace(/BFOG_CONST/g,'430.0') | |
| .replace(/SERVO_CONST/g,MAT.SERVO.toFixed(1)) | |
| .replace(/BIOLEG_CONST/g,MAT.LEAF.toFixed(1)).replace(/CHITIN_CONST/g,MAT.CHITIN.toFixed(1)) | |
| .replace(/HIP_CONST/g,'11.0'); | |
| const FSM=FS3D.replace(/MTILES_CONST/g,MAT_TILES.toFixed(1)) | |
| .replace(/MSTEP_CONST/g,(1/MAT_TILES).toFixed(6)) | |
| .replace(/BUILDLO_CONST/g,MAT.BUILD.toFixed(1)) | |
| .replace(/BUILDHI_CONST/g,MAT.ROOF.toFixed(1)) | |
| .replace(/BUILD_CONST/g,MAT.BUILD.toFixed(1)) | |
| .replace(/ROOF_CONST/g,MAT.ROOF.toFixed(1)) | |
| .replace(/TRIM_CONST/g,MAT.TRIM.toFixed(1)) | |
| .replace(/CONC_CONST/g,MAT.CONC.toFixed(1)) | |
| .replace(/WINLO_CONST/g,MAT.BUILD_OFFICE_LIT.toFixed(1)) | |
| .replace(/WINHI_CONST/g,MAT.BUILD_SHOPFRONT.toFixed(1)) | |
| .replace(/TOWERLO_CONST/g,MAT.TWR_ARMOR.toFixed(1)) | |
| .replace(/TOWERHI_CONST/g,MAT.TWR_BORE.toFixed(1)) | |
| /* The organic ids were substituted into the VERTEX stage only, | |
| because until now only the animation used them. The | |
| translucency term needs them in the fragment stage too. */ | |
| .replace(/BIOLEG_CONST/g,MAT.LEAF.toFixed(1)) | |
| .replace(/CHITIN_CONST/g,MAT.CHITIN.toFixed(1)) | |
| .replace(/GLASS_CONST/g,MAT.GLASS.toFixed(1)) | |
| .replace(/DAMAGELO_CONST/g,MAT.SCORCH_METAL.toFixed(1)) | |
| .replace(/DAMAGEHI_CONST/g,MAT.FALLOUT_GLOW.toFixed(1)) | |
| .replace(/BRDLO_CONST/g,MAT.BROOD_MEMBRANE.toFixed(1)) | |
| .replace(/BRDHI_CONST/g,MAT.BROOD_VEIN.toFixed(1)) | |
| .replace(/BRDMEM_CONST/g,MAT.BROOD_MEMBRANE.toFixed(1)) | |
| .replace(/BRDCHI_CONST/g,MAT.BROOD_CHITIN.toFixed(1)) | |
| .replace(/BRDSLM_CONST/g,MAT.BROOD_SLIME.toFixed(1)) | |
| .replace(/BRDVEIN_CONST/g,MAT.BROOD_VEIN.toFixed(1)) | |
| .replace(/CRYST_CONST/g,MAT.CRYST.toFixed(1)); | |
| prog3D=mkProg(VSM,FSM,'model'); | |
| progG =mkProg(VSG,FSG,'glow'); | |
| /* Instance wrap β a lost context gives us a fresh `gl`, so this re-binds. | |
| Do not getParameter(CURRENT_PROGRAM) on the flush path. */ | |
| if(gl&&!gl._mfUseTracked){ | |
| const _use=gl.useProgram.bind(gl); | |
| gl.useProgram=function(p){ MF_PROG_MODEL=!!(prog3D&&p===prog3D); return _use(p); }; | |
| gl._mfUseTracked=1; | |
| } | |
| for(const k of ['uVP','uEye','uHazeQ','uSun','uSunC','uAmbSky','uAmbGnd','uFogC','uEmis','uTime','uNight','uDebugMode','uMat','uNrm','uOrm','uDamageTex','uDetail','uHalf','uFowMap','uFowOn', | |
| 'uBoneN','uJoint','uAxis','uSwing']) | |
| U3[k]=gl.getUniformLocation(prog3D,k); | |
| /* Array uniforms are addressed by their [0] element in WebGL. Keeping these | |
| locations beside the ordinary model uniforms makes a context restore use | |
| the exact same local-light path as a cold boot. */ | |
| U3.uLightCount=gl.getUniformLocation(prog3D,'uLightCount'); | |
| U3.uLightPosR=gl.getUniformLocation(prog3D,'uLightPosR[0]'); | |
| U3.uLightColI=gl.getUniformLocation(prog3D,'uLightColI[0]'); | |
| gl.useProgram(prog3D); | |
| /* Boneless models must not inherit the last skeleton uploaded β the program | |
| is shared, so a stale uBoneN would try to swing a tank's turret about a | |
| Ravager's knee. */ | |
| if(U3.uBoneN) gl.uniform1i(U3.uBoneN,0); | |
| gl.uniform1i(U3.uMat,0); gl.uniform1i(U3.uDamageTex,1); gl.uniform1i(U3.uNrm,2); gl.uniform1i(U3.uOrm,3); gl.uniform1i(U3.uDetail,7); | |
| /* Units 4-6 were the only free ones in the model pass (0,1,2,3,7,8 taken). */ | |
| U3.uAssetOn=gl.getUniformLocation(prog3D,'uAssetOn'); | |
| U3.uAssetBase=gl.getUniformLocation(prog3D,'uAssetBase'); | |
| U3.uAssetNre=gl.getUniformLocation(prog3D,'uAssetNre'); | |
| U3.uAssetMask=gl.getUniformLocation(prog3D,'uAssetMask'); | |
| gl.uniform1i(U3.uAssetBase,4); gl.uniform1i(U3.uAssetNre,5); gl.uniform1i(U3.uAssetMask,6); | |
| /* Same discipline as uBoneN above: the program is shared, so an asset with no | |
| baked maps must actively switch this OFF or it inherits the previous draw's | |
| triplet and samples another model's skin through its own UVs. */ | |
| if(U3.uAssetOn) gl.uniform1f(U3.uAssetOn,0.0); | |
| UG.uVP=gl.getUniformLocation(progG,'uVP'); | |
| progT=mkProg(VST.replace(/MAPSIZE_CONST/g,MAP.toFixed(1)).replace(/BFOG_CONST/g,'430.0'), | |
| FST.replace(/WATER_HEIGHT_CONST/g,WATER_H.toFixed(4)),'terrain'); | |
| /* THE GROUND MUST DRAW. If the terrain's own program will not build on this | |
| GPU the map is simply absent β units, buildings and scenery all render | |
| through other programs and look fine, which is exactly the "everything but | |
| the ground" report. The terrain VAO already uses the model program's | |
| vertex layout (pos/nrm/col/uv/mat, instance attributes pinned to | |
| constants), so the model program can draw it: no painted satellite map, | |
| but real lit ground instead of a void. */ | |
| terrainProgOK=!!progT; | |
| for(const k of ['uVP','uEye','uHazeQ','uSun','uSunC','uAmbSky','uAmbGnd','uFogC','uMap','uDetail','uFogMap','uFogActive', | |
| 'uPlayBounds','uEdgeStyle','uEdgeTime','uEdgeTint','uGroundT','uSoilT','uPaveT','uGMask', | |
| 'uHeight','uHexelW','uRealTex','uGroundQ','uGroundProfile','uGroundTint','uSoilTint','uPaveTint','uGrassTint','uLocationTint', | |
| 'uBurnN','uGrassT','uGroundN','uSoilN','uPaveN','uGrassN']) | |
| UT[k]=gl.getUniformLocation(progT,k); | |
| UT.uBurns=gl.getUniformLocation(progT,'uBurns[0]'); | |
| UT.uBurnKind=gl.getUniformLocation(progT,'uBurnKind[0]'); | |
| gl.useProgram(progT); gl.uniform1i(UT.uMap,0); gl.uniform1i(UT.uDetail,1); gl.uniform1i(UT.uFogMap,7); | |
| gl.uniform1i(UT.uGroundT,8); gl.uniform1i(UT.uGMask,9); gl.uniform1i(UT.uHeight,10); | |
| gl.uniform1i(UT.uSoilT,11); gl.uniform1i(UT.uPaveT,12); gl.uniform1i(UT.uGrassT,13); | |
| gl.uniform1i(UT.uGroundN,2); gl.uniform1i(UT.uSoilN,3); gl.uniform1i(UT.uPaveN,14); gl.uniform1i(UT.uGrassN,15); | |
| gl.uniform1f(UT.uHexelW,2*MAP/TS); | |
| gl.uniform1f(UT.uGroundQ,2.0); | |
| gl.uniform1f(UT.uGroundProfile,0.0); | |
| gl.uniform3f(UT.uGroundTint,1,1,1); gl.uniform3f(UT.uSoilTint,1,1,1); | |
| gl.uniform3f(UT.uPaveTint,1,1,1); gl.uniform3f(UT.uGrassTint,1,1,1); | |
| gl.uniform3f(UT.uLocationTint,1,1,1); | |
| initAO(); | |
| csmInit(); | |
| gl.enable(gl.DEPTH_TEST); | |
| gl.depthFunc(gl.LEQUAL); | |
| gl.enable(gl.CULL_FACE); | |
| gl.cullFace(gl.BACK); | |
| mfWarmKick(); | |
| } | |
| /* SHADER WARM-UP. | |
| Not every program is built here. volfx compiles its two programs on the | |
| FIRST detonation (volfx.js volFxActive -> volFxInit), so the opening | |
| explosion of a match pays the link cost as a frame hitch. Warming means | |
| doing that during load instead. | |
| This deliberately goes through volFxActive() rather than volFxInit(): that | |
| is the function carrying the real gates (quality ceiling, gl, aoReady, | |
| aoW/aoH, the failure latch), so warming can never compile something the | |
| current quality tier would not have used anyway, and never bypasses the | |
| latch. initAO() runs just above, but aoReady is not necessarily set on this | |
| same tick, so retry across frames until the gates open. */ | |
| let mfWarmDone=false, mfWarmTries=0; | |
| function mfWarmShaders(){ | |
| if(mfWarmDone) return true; | |
| if(typeof gl==='undefined'||!gl||(gl.isContextLost&&gl.isContextLost())) return false; | |
| let ready=true; | |
| /* gpufx builds its two transform-feedback programs on the first muzzle flash | |
| (gpufx.js gpfxBurst -> gpfxInit), i.e. during the opening exchange. It is | |
| not quality-gated, so every match pays it. gpfxInit() is self-guarded and | |
| idempotent (returns early once built or latched), so warming is safe. */ | |
| if(typeof gpfxInit==='function'){ | |
| try{ if(typeof gpfxCtxCheck==='function') gpfxCtxCheck(); gpfxInit(); } | |
| catch(e){ console.error('gpufx warm',e); } | |
| } | |
| if(typeof volFxActive==='function'){ | |
| if(typeof aoReady!=='undefined'&&aoReady) volFxActive(); | |
| else ready=false; | |
| } | |
| /* Give the gates ~3 s of frames to open, then warm whatever is available and | |
| stop: a tier that never sets aoReady must not retry forever. */ | |
| if(!ready&&++mfWarmTries<180) return false; | |
| mfProgFlush(); | |
| mfWarmDone=true; | |
| return true; | |
| } | |
| function mfWarmKick(){ | |
| mfWarmDone=false; mfWarmTries=0; // a context rebuild re-warms | |
| const later=(typeof requestAnimationFrame==='function')?requestAnimationFrame:(fn=>setTimeout(fn,16)); | |
| const step=()=>{ try{ if(!mfWarmShaders()) later(step); }catch(e){ console.error('shader warm-up',e); } }; | |
| later(step); | |
| } | |
| /* ============================================================================ | |
| CAMERA β a real orbiting perspective camera. | |
| `cam.x/cam.y` is the ground point being looked at; yaw orbits around it, | |
| pitch raises the eye, dist pulls back. Everything the player asked for β | |
| pan, rotate, tilt, zoom, double-tap-to-focus β falls out of these four | |
| numbers, and unlike the old fake tilt it is geometrically correct, so | |
| picking, occlusion and perspective all agree with each other. | |
| ============================================================================ */ | |
| const cam={x:MAP*0.5, y:MAP*0.5, z:1}; // z kept as a legacy zoom proxy | |
| let camYaw=0, camPitch=1.02, camDist=1400; | |
| let yawTarget=0, pitchTarget=1.02, distTarget=1400; | |
| let camFollow=-1, camFollowT=0; | |
| /* ---- ORTHOGRAPHIC COMMAND VIEW ------------------------------------------- | |
| This is a strategy game, so the camera is an orthographic overhead orbit, | |
| not a free perspective camera. That choice does real work: | |
| * No perspective convergence, so a base grid reads as a grid and two | |
| equal-size units are the same size wherever they are on screen β you | |
| can compare force strength across the map at a glance. | |
| * The pitch is clamped to a high band. Ground-level views look dramatic | |
| for a screenshot and are useless for actually playing: you lose the | |
| overview, and the terrain occludes half your army. | |
| * The zoom range stops well short of the models, so nothing is ever | |
| inspected closely enough for polygon budget to be the limiting factor | |
| on how good it looks. | |
| `orthoSpan` is the world height covered by the viewport β the honest | |
| equivalent of a zoom level for an orthographic camera. */ | |
| let orthoSpan=1500; | |
| const SPAN_MIN=420, SPAN_MAX=3400; // never closer than a company view | |
| /* Pitch stays steep. A shallow angle puts the horizon and empty sky on screen, | |
| which looks like a bug in an overhead strategy game and costs the player | |
| their overview. This band runs from "clearly three-dimensional" to | |
| "straight down" and never further. */ | |
| const PITCH_MIN=1.05, PITCH_MAX=1.50; | |
| const CAM_HEIGHT=3000; // eye distance: irrelevant to ortho size | |
| const matV=m4(), matP=m4(), matVP=m4(); | |
| let eyeX=0, eyeY=0, eyeZ=0; | |
| function camEye(){ | |
| const hor=Math.cos(camPitch)*CAM_HEIGHT; | |
| eyeX=cam.x - Math.cos(camYaw)*hor; | |
| eyeZ=cam.y - Math.sin(camYaw)*hor; | |
| eyeY=Math.sin(camPitch)*CAM_HEIGHT + terrainH(cam.x,cam.y); | |
| } | |
| function camAspect(){ | |
| return (cv&&cv.width>0&&cv.height>0)?(cv.width/cv.height):(VW/Math.max(1,VH)); | |
| } | |
| function camUpdateMatrices(){ | |
| camEye(); | |
| const gh=terrainH(cam.x,cam.y); | |
| /* Drawing-buffer aspect, not CSS VW/VH. A one-pixel DPR round or a | |
| contain-letterbox frame must not squash the ortho frustum. */ | |
| const asp=camAspect(); | |
| const hh=orthoSpan*0.5, hw=hh*asp; | |
| m4ortho(matP,-hw,hw,-hh,hh,-6000,9000); | |
| m4look(matV,eyeX,eyeY,eyeZ, cam.x,gh,cam.y, 0,1,0); | |
| m4mul(matVP,matP,matV); | |
| } | |
| /* Under an orthographic projection every eye ray is PARALLEL, so screen->world | |
| is exact: offset the camera position by the screen offset along the camera's | |
| own right/up axes, then drop straight down the view direction onto the | |
| terrain. No projection division, no perspective error. */ | |
| function s2w(sx,sy){ | |
| const m=matV; | |
| const rx=m[0], ry=m[4], rz=m[8]; | |
| const ux=m[1], uy=m[5], uz=m[9]; | |
| const bx=m[2], by=m[6], bz=m[10]; | |
| const asp=camAspect(); | |
| const hh=orthoSpan*0.5, hw=hh*asp; | |
| const ndx=((sx/VW)*2-1)*hw, ndy=(1-(sy/VH)*2)*hh; | |
| const ox=eyeX+rx*ndx+ux*ndy, oy=eyeY+ry*ndx+uy*ndy, oz=eyeZ+rz*ndx+uz*ndy; | |
| const dx=-bx, dy=-by, dz=-bz; // view direction | |
| let t=0, lastT=0, hit=false; | |
| const step=Math.max(9,orthoSpan/90); | |
| for(let k=0;k<900;k++){ | |
| lastT=t; t+=step; | |
| const px=ox+dx*t, py=oy+dy*t, pz=oz+dz*t; | |
| if(py<=terrainH(px,pz)){ hit=true; break; } | |
| if(t>16000) break; | |
| } | |
| if(!hit){ | |
| if(dy>=-1e-5) return [cam.x,cam.y]; | |
| const tt=-oy/dy; | |
| return [ox+dx*tt, oz+dz*tt]; | |
| } | |
| let lo=lastT, hi=t; | |
| for(let k=0;k<26;k++){ | |
| const mid=(lo+hi)*0.5; | |
| const mx=ox+dx*mid, my=oy+dy*mid, mz=oz+dz*mid; | |
| if(my<=terrainH(mx,mz)) hi=mid; else lo=mid; | |
| } | |
| const ft=(lo+hi)*0.5; | |
| return [ox+dx*ft, oz+dz*ft]; | |
| } | |
| function w2s(wx,wy,wh){ | |
| const h=wh===undefined?terrainH(wx,wy):wh; | |
| const m=matVP; | |
| const cx=m[0]*wx+m[4]*h+m[8]*wy+m[12]; | |
| const cy=m[1]*wx+m[5]*h+m[9]*wy+m[13]; | |
| return [(cx*0.5+0.5)*VW, (0.5-cy*0.5)*VH]; | |
| } | |
| const _camB={x0:0,y0:0,x1:0,y1:0,hw:0,hh:0}; | |
| function camBounds(){ | |
| /* PER-AXIS extents, not one symmetric reach. Taking the max of both axes and | |
| padding it hard made the clamp far stricter than the view actually needs, | |
| which is why a start position near the map edge could never be centred. | |
| The ground footprint only stretches along the VIEW axis as the camera | |
| tilts, and that axis rotates with the yaw β so project both. */ | |
| /* Reused object: render + sim + HUD all call this every frame. A fresh | |
| literal was mid-tier GC for identical numbers. Callers read immediately. */ | |
| const asp=camAspect(); | |
| const hh=orthoSpan*0.5, hw=hh*asp; | |
| const depth=hh/Math.max(0.30,Math.sin(camPitch)); // along-view ground span | |
| const c=Math.abs(Math.cos(camYaw)), s=Math.abs(Math.sin(camYaw)); | |
| const ex=hw*c+depth*s+60, ey=hw*s+depth*c+60; | |
| _camB.x0=cam.x-ex; _camB.y0=cam.y-ey; _camB.x1=cam.x+ex; _camB.y1=cam.y+ey; | |
| _camB.hw=ex; _camB.hh=ey; | |
| return _camB; | |
| } | |
| function clampCam(){ | |
| orthoSpan=clamp(orthoSpan,SPAN_MIN,SPAN_MAX); | |
| camDist=orthoSpan; // keep the legacy name meaningful | |
| camPitch=clamp(camPitch,PITCH_MIN,PITCH_MAX); | |
| /* Keep the VIEW inside the battlefield, not just the camera point. Letting | |
| the viewport run off the edge shows the void beyond the map β the same | |
| "player can see sky" problem, just sideways. Clamping the hull means the | |
| ground fills the screen at every angle and zoom. */ | |
| /* While the dropship is still flying, the clamp relaxes a long way. Being | |
| unable to CENTRE your own ship β because it started near a map edge and | |
| the view refused to follow β is far worse than briefly seeing past the | |
| border. Once you've deployed, the strict clamp returns. */ | |
| /* The clamp used to keep the whole VIEW HULL inside the battlefield. Near a | |
| corner that hull is what the clamp pushes on, so rotating the camera β | |
| which changes the hull's shape β shoved the focus point around and then | |
| refused to let it back. A base in the corner meant a camera you could | |
| neither turn nor pan. | |
| So the clamp now holds the LOOK-AT POINT, not the hull, and lets the view | |
| overhang the border freely. Off-map area is hidden by dense border haze | |
| (see the fog term in the shaders) rather than by a wall, which reads as | |
| the world continuing past the edge instead of stopping at one. */ | |
| /* The bound is deliberately YAW-INVARIANT. That is the whole fix: the old | |
| clamp used the view hull, whose shape changes as you turn, so every degree | |
| of rotation moved the legal region out from under the camera and shoved | |
| the focus point. Using the hull's circumscribed radius instead gives a | |
| bound that rotation cannot change, so turning at the map edge is free. | |
| A generous slack lets the view hang over the border into the haze. */ | |
| /* Clamp the LOOK-AT POINT to the battlefield plus a little overhang, and | |
| nothing else. Two earlier versions got this wrong in opposite directions: | |
| the first clamped the view HULL, whose shape changes with yaw, so rotating | |
| at a corner shoved the camera; the second used the hull's circumscribed | |
| radius, which is rotation-proof but grows with zoom β at full zoom-out the | |
| legal region collapsed to a box a couple of hundred units wide and the | |
| camera snapped back on every pan. | |
| The bound is now independent of both yaw and zoom, so panning is stable at | |
| every zoom level. Overhang shrinks as you zoom out, because a wide view | |
| already shows the whole map and does not need to leave it. */ | |
| const OVER=20+48*(1-clamp(orthoSpan/SPAN_MAX,0,1)); | |
| cam.x=clamp(cam.x, -OVER, MAP+OVER); | |
| cam.y=clamp(cam.y, -OVER, MAP+OVER); | |
| cam.z=1400/orthoSpan; // legacy zoom proxy for old UI code | |
| } | |
| function camTick(dt){ | |
| if(camFollow>=0){ | |
| if(ualive[camFollow]){ cam.x+=(ux[camFollow]-cam.x)*Math.min(1,dt*6); cam.y+=(uy[camFollow]-cam.y)*Math.min(1,dt*6); } | |
| else camFollow=-1; | |
| } | |
| const k=Math.min(1,dt*8); | |
| let dy=yawTarget-camYaw; | |
| while(dy>Math.PI) dy-=TAU; while(dy<-Math.PI) dy+=TAU; | |
| camYaw+=dy*k; | |
| camPitch+=(pitchTarget-camPitch)*k; | |
| orthoSpan+=(distTarget-orthoSpan)*k; | |
| clampCam(); | |
| camUpdateMatrices(); | |
| } | |
| function zoomBy(f){ distTarget=clamp(distTarget/f,SPAN_MIN,SPAN_MAX); } | |