File size: 11,059 Bytes
b51e03a
3597f5a
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
3597f5a
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
fcce7e3
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
 
 
 
3597f5a
 
 
 
 
 
fcce7e3
 
 
3597f5a
 
 
 
 
 
fcce7e3
 
 
 
 
3597f5a
 
 
 
fcce7e3
3597f5a
 
 
 
 
 
 
 
 
 
fcce7e3
3597f5a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
;
;
/* ============================================================================
   BILLBOARD LAYER
   ----------------------------------------------------------------------------
   Not everything should be geometry. Smoke, fire, muzzle flash, energy glow and
   dust have no surface to model β€” they're volumes of light, and the old
   procedural sprite atlas already renders them better than any polygon shell
   could. Drawing them as camera-facing textured quads is also several times
   cheaper: one quad instead of a 100-triangle sphere, and it stays one draw
   call for the whole battlefield.

   These are NOT the old fake-3D billboards. They live in the same depth buffer
   as everything else, expanded around a real world-space anchor point using the
   camera's right/up axes, so a plume behind a hill is correctly occluded by
   that hill.

   Instance layout: x,y,h, size, rot, r,g,b,a, u0,v0,u1,v1, aspect = 14 floats.
   ============================================================================ */
const BB_STRIDE=14*4;
const VSBB=`#version 300 es
layout(location=0) in vec2 aCorner;
layout(location=5) in vec4 aPS;      // xyz world anchor, w = size
layout(location=6) in float aRot;
layout(location=7) in vec4 aCol;
layout(location=8) in vec4 aUVR;     // u0,v0,u1,v1
layout(location=9) in float aAspect; // width / height
uniform mat4 uVP;
uniform vec2 uScale;             // world-size to clip-space, x/y
out vec2 vUV; out vec4 vCol;
void main(){
  float c=cos(aRot), s=sin(aRot);
  vec2 q=vec2(aCorner.x*c - aCorner.y*s, aCorner.x*s + aCorner.y*c)*aPS.w;
  q.x*=aAspect;
  /* Expand in clip space. The previous world-basis expansion was mathematically
     valid but produced driver-dependent horizontal streaks for instanced quads
     in very large battles. The anchor is still a genuine 3D point (and keeps
     its depth); only the camera-facing corners are screen-space, which is the
     conventional stable billboard formulation. */
  vec4 p=uVP*vec4(aPS.xyz,1.0);
  /* w<=0 is behind/on the near plane. Clip-space expansion then divides by a
     tiny or negative w and the quad becomes a full-screen streak β€” the same
     visual as the old instanced TRIANGLE_STRIP join. */
  if(p.w<=0.02){ gl_Position=vec4(2.0,2.0,2.0,1.0); vUV=vec2(0.0); vCol=vec4(0.0); return; }
  p.xy+=q*uScale*p.w;
  vUV=mix(aUVR.xy,aUVR.zw,aCorner+0.5);
  vCol=aCol;
  gl_Position=p;
}`;
const FSBB=`#version 300 es
precision highp float;
in vec2 vUV; in vec4 vCol;
uniform sampler2D uTex;
out vec4 o;
void main(){
  vec4 t=texture(uTex,vUV);
  o=vec4(t.rgb*vCol.rgb, t.a*vCol.a);
  if(o.a<0.004) discard;
}`;
let progBB=null, UBB={};
class BBBatch{
  constructor(gl,cap){
    this.gl=gl; this.cap=cap; this.n=0;
    this.data=new Float32Array(cap*14);
    this.vao=gl.createVertexArray();
    gl.bindVertexArray(this.vao);
    const qb=gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER,qb);
    /* Two explicit triangles, not an instanced TRIANGLE_STRIP. Some mobile and
       browser WebGL drivers incorrectly carry strip connectivity across
       instances, joining distant sprites with full-screen streaks. Six corner
       vertices keep the same one-draw-call instancing without that ambiguity. */
    gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([
      -.5,-.5,  .5,-.5,  .5,.5,
      -.5,-.5,  .5,.5,  -.5,.5
    ]),gl.STATIC_DRAW);
    gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,2,gl.FLOAT,false,8,0);
    /* Indexed triangles, not drawArraysInstanced. Some D3D11 translation
       layers still stitch sequential instanced arrays as a strip. The element
       buffer is captured on this VAO. */
    const ib=gl.createBuffer();
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ib);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint16Array([0,1,2,3,4,5]),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,BB_STRIDE,0);  gl.vertexAttribDivisor(5,1);
    gl.enableVertexAttribArray(6); gl.vertexAttribPointer(6,1,gl.FLOAT,false,BB_STRIDE,16); gl.vertexAttribDivisor(6,1);
    gl.enableVertexAttribArray(7); gl.vertexAttribPointer(7,4,gl.FLOAT,false,BB_STRIDE,20); gl.vertexAttribDivisor(7,1);
    gl.enableVertexAttribArray(8); gl.vertexAttribPointer(8,4,gl.FLOAT,false,BB_STRIDE,36); gl.vertexAttribDivisor(8,1);
    gl.enableVertexAttribArray(9); gl.vertexAttribPointer(9,1,gl.FLOAT,false,BB_STRIDE,52); gl.vertexAttribDivisor(9,1);
    gl.bindVertexArray(null);
  }
  grow(){
    /* MEDIUM starts the ring small. HIGH allocates the full fight budget
       up front so a flagship barrage never reallocates mid-frame. */
    if(this.cap>=90000) return false;
    const next=Math.min(90000,this.cap<4096?this.cap*2:Math.ceil(this.cap*1.5));
    const data=new Float32Array(next*14); 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);
    return true;
  }
  add(uv,x,y,h,size,rot,r,g,b,a){
    if(this.n>=this.cap&&!this.grow()) return;
    if(!_bbAccept(x,y,size,size,a)) return;
    const o=this.n*14, d=this.data;
    d[o]=x; d[o+1]=h; d[o+2]=y; d[o+3]=size;
    /* Keep particle rotation in a compact positive range. Several effects use
       the animation clock when they spin; after a long-running session those
       angles used to fall below the negative rectangle sentinel and the
       shader mistook a flame/smoke sprite for a kilometre-wide health bar. */
    const rawRot=rot||0;
    d[o+4]=((rawRot%TAU)+TAU)%TAU;
    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]=uv[0]; d[o+10]=uv[1]; d[o+11]=uv[2]; d[o+12]=uv[3];
    d[o+13]=1;
    this.n++;
  }
  addRect(uv,x,y,h,width,height,r,g,b,a){
    this.addOrientedRect(uv,x,y,h,width,height,0,r,g,b,a);
  }
  addOrientedRect(uv,x,y,h,width,height,rot,r,g,b,a){
    if(width<=0||height<=0) return;
    if(this.n>=this.cap&&!this.grow()) return;
    if(!_bbAccept(x,y,width,height,a)) return;
    const o=this.n*14, d=this.data;
    d[o]=x; d[o+1]=h; d[o+2]=y; d[o+3]=height;
    const rawRot=rot||0;
    d[o+4]=((rawRot%TAU)+TAU)%TAU;
    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]=uv[0]; d[o+10]=uv[1]; d[o+11]=uv[2]; d[o+12]=uv[3];
    d[o+13]=width/height;
    this.n++;
  }
  flush(gl){
    if(!this.n) return;
    gl.bindVertexArray(this.vao);
    gl.bindBuffer(gl.ARRAY_BUFFER,this.ivb);
    /* Offset/length form β€” subarray() allocated a view per flush, every frame. */
    gl.bufferSubData(gl.ARRAY_BUFFER,0,this.data,0,this.n*14);
    gl.drawElementsInstanced(gl.TRIANGLES,6,gl.UNSIGNED_SHORT,0,this.n);
    drawCalls++; triCount+=2*this.n;
    this.n=0;
  }
}
let bbAdd=null, bbAlpha=null;
/* Frustum + screen-footprint gate, refreshed once per beginBB. Local constants
   keyed off the existing quality name β€” meta.js is not the owner of draw cost.
   HIGH/CINEMATIC only drop invisible (sub-pixel / offscreen) work. */
let _bbX0=0,_bbX1=0,_bbY0=0,_bbY1=0,_bbMinPx=0.65,_bbWpx=1,_bbReady=0;
let _bbSub=0,_bbBudget=24000,_bbMinPxSoft=1.8;
function _bbGfxKey(){
  if(typeof qualityKey==='function') return qualityKey();
  const q=typeof META!=='undefined'&&META.settings&&META.settings.quality;
  return q==='low'||q==='medium'||q==='cinematic'?q:'high';
}
function _bbSyncCull(){
  const asp=VW/Math.max(1,VH);
  const hh=orthoSpan*0.5, hw=hh*asp;
  const pitch=typeof camPitch==='number'?camPitch:1.19;
  const yaw=typeof camYaw==='number'?camYaw:0;
  const depth=hh/Math.max(0.30,Math.sin(pitch));
  const c=Math.abs(Math.cos(yaw)), s=Math.abs(Math.sin(yaw));
  const ex=hw*c+depth*s+90, ey=hw*s+depth*c+90;
  _bbX0=cam.x-ex; _bbX1=cam.x+ex; _bbY0=cam.y-ey; _bbY1=cam.y+ey;
  _bbWpx=Math.max(1,VH)/Math.max(1,orthoSpan);
  const q=_bbGfxKey();
  /* MEDIUM drops sparks that cover <2 CSS px. HIGH still submits those; they
     are the close-up punch the preset promises. */
  _bbMinPx=q==='low'?3.4:q==='medium'?2.1:q==='cinematic'?0.40:0.65;
  /* Soft fillrate budget after the hard pixel gate. HIGH/CINEMATIC sit above
     any real fight (health bars + smoke). MEDIUM starts dropping extra
     sub-6px sparks once ~3.2k quads are already in β€” bars and icons stay. */
  _bbBudget=q==='low'?1400:q==='medium'?3200:q==='cinematic'?1e9:24000;
  _bbMinPxSoft=_bbMinPx*2.8;
  _bbSub=0;
  _bbReady=1;
}
function _bbAccept(x,y,w,h,a){
  if(!_bbReady) _bbSyncCull();
  const aa=a===undefined?255:a;
  if(aa<4) return false;
  const pad=w>h?w:h;
  if(x<_bbX0-pad||x>_bbX1+pad||y<_bbY0-pad||y>_bbY1+pad) return false;
  const px=pad*_bbWpx;
  if(px<_bbMinPx) return false;
  if(_bbSub>=_bbBudget&&px<_bbMinPxSoft) return false;
  _bbSub++;
  return true;
}
function initBillboards(){
  progBB=mkProg(VSBB,FSBB);
  UBB.uVP=gl.getUniformLocation(progBB,'uVP');
  UBB.uScale=gl.getUniformLocation(progBB,'uScale');
  UBB.uTex=gl.getUniformLocation(progBB,'uTex');
  /* HIGH keeps the large ring so a flagship fight never reallocates.
     MEDIUM starts smaller β€” add() already drops offscreen / sub-pixel β€”
     and grow() only if a barrage actually fills it. */
  const q=_bbGfxKey();
  bbAdd=new BBBatch(gl,q==='low'?2400:q==='medium'?3600:9000);
  bbAlpha=new BBBatch(gl,q==='low'?16000:q==='medium'?24000:72000);
}
/* Orthographic world-size to clip-size. X includes the viewport aspect ratio;
   the anchor projection already contains camera orbit and terrain depth. */
/* `tex` lets a caller run this same program over a different sheet β€” the
   tactical icons live in their own atlas because the shared one is exactly
   full. Omitting it keeps the original behaviour for every existing caller. */
function beginBB(tex){
  if(typeof MF_PROG_MODEL!=='undefined') MF_PROG_MODEL=false;
  _bbSyncCull();
  gl.useProgram(progBB);
  gl.uniformMatrix4fv(UBB.uVP,false,matVP);
  const asp=VW/Math.max(1,VH);
  gl.uniform2f(UBB.uScale,2/(Math.max(1,orthoSpan)*asp),2/Math.max(1,orthoSpan));
  gl.uniform1i(UBB.uTex,0);
  /* Unit 0 is the material atlas for models. Binding a different sheet here is
     required (the icon atlas is a second texture) but MUST stay on 0 only for
     this program β€” post-process lives on 4/5/6 and must never be borrowed.
     Callers restore the model atlas with begin3D(S_nA) after the flush. */
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D,tex||atlasTex);
  /* InstMesh opaque flushes re-enable CULL. Billboard quads are not closed
     meshes β€” a leftover CULL_FACE ate engine glow and crystal halos. */
  gl.disable(gl.CULL_FACE);
  gl.enable(gl.BLEND);
  /* Additive and alpha batches share this program. A leftover DEPTH_WRITEMASK
     from an opaque pass would punch holes through smoke; the icon pass in
     render3d already clears it, but every other beginBB caller was relying on
     the previous pass having done the same. */
  gl.depthMask(false);
}