massfront-playtest / src /engine /billboard.js
CREATORJD's picture
Publish MASSFRONT web 1.33.33 (part 3)
3597f5a verified
Raw
History Blame Contribute Delete
11.1 kB
;
;
/* ============================================================================
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);
}