Files
OpenFrontIO/src/client/render/gl/passes/SpawnOverlayPass.ts
T
Evan ca5342d6bf Render spawn overlay with instancing to support large lobbies (#4322)
## Problem

The spawn-phase overlay stored every human's spawn center in GLSL
**uniform arrays** (capped at `MAX_SPAWNS = 32`) and looped over all of
them **per screen pixel** in a fullscreen pass.

In lobbies with more than 32 humans, centers past the cap were silently
dropped in join order — so a few seconds into the spawn phase the
**local player's own ring could disappear while the phase was still
active**. Team modes make this worse: `playerTeams` can be a raw team
count, so a single team can have far more than 32 members, all of which
need rings.

The two walls that blocked simply raising the constant:
- **Uniform arrays cap out ~96** against WebGL2's 224-vec4 fragment
floor — 1024 would never link.
- The **fullscreen per-pixel loop** over every spawn is `O(pixels ×
spawns)` — raising the cap makes it a GPU hazard during the spawn phase.

## Fix

Rewrite `SpawnOverlayPass` to draw **one instanced quad per spawn
center**, sized to that center's influence radius (mirroring
`SAMRadiusPass`). This removes the uniform-array limit and the per-pixel
loop, so cost scales with the number of spawns rather than screen area,
and the overlay supports the renderer's full ~1024-player ceiling.

Instances are ordered **enemies → teammates → self** so the local
player's ring composites on top under normal alpha blending.
Self/teammate render as breathing rings; enemies render as tile-fill
highlights on unowned tiles — identical visuals and render-settings to
before.

## Changes
- `gl/passes/SpawnOverlayPass.ts` — instanced rendering via
`DynamicInstanceBuffer` + `drawArraysInstanced`; no `MAX_SPAWNS` cap.
- `shaders/spawn-overlay/spawn-overlay.frag.glsl` — per-instance
(kind-dispatched) instead of a uniform-array loop; self white→color
pulse moved into the shader.
- `shaders/spawn-overlay/spawn-overlay.vert.glsl` — new instanced vertex
shader.

## Testing
- `tsc` (full project) + `eslint` clean.
- Headless WebGL run: shaders **compile and link** (game starts normally
with 123 players), and the genuine `updateSpawnOverlay → update() →
drawArraysInstanced()` path renders self/teammate rings and enemy tile
highlights with **no GL errors**.
- ⚠️ Not yet verified end-to-end in a real 30+ human FFA lobby (the
original repro) — that needs multiple real clients. The instanced draw
path and rendering were confirmed in singleplayer with the overlay
force-activated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:10:34 -07:00

255 lines
7.8 KiB
TypeScript

/**
* SpawnOverlayPass — spawn phase tile highlights + breathing rings.
*
* Active only during spawn phase. Renders:
* 1. Colored highlights on unowned tiles within radius of each enemy human
* player's spawn center.
* 2. Animated breathing rings around the local player and teammates.
*
* One instanced quad is drawn per spawn center (sized to that center's
* influence radius), so cost scales with the number of spawns rather than
* screen area — this supports the renderer's full player ceiling (~1024)
* without the uniform-array limit or a per-pixel loop over every spawn.
*
* Instances are ordered enemies → teammates → self so that, under standard
* over-blending, the local player's ring composites on top.
*/
import { DynamicInstanceBuffer } from "../DynamicBuffer";
import type { RenderSettings } from "../RenderSettings";
import { createProgram, shaderSrc } from "../utils/GlUtils";
import { TILE_DEFINES } from "../utils/TileCodec";
import spawnFragSrc from "../shaders/spawn-overlay/spawn-overlay.frag.glsl?raw";
import overlayVertSrc from "../shaders/spawn-overlay/spawn-overlay.vert.glsl?raw";
// Per-instance: centerX, centerY, quadRadius, kind, r, g, b
const FLOATS_PER_INSTANCE = 7;
// Quad must cover the ring at its largest breath expansion (scale tops out at
// 0.5 + 0.65 = 1.15), plus a margin so the antialiased edge isn't clipped.
const MAX_BREATH_SCALE = 1.15;
const RADIUS_MARGIN = 1;
// Instance kinds (must match spawn-overlay.frag.glsl).
const KIND_ENEMY = 0;
const KIND_SELF = 1;
const KIND_TEAMMATE = 2;
export interface SpawnCenter {
x: number;
y: number;
r: number;
g: number;
b: number;
isSelf: boolean;
isTeammate: boolean;
}
export class SpawnOverlayPass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private settings: RenderSettings["spawnOverlay"];
// Uniforms
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uBreathRadius: WebGLUniformLocation;
private uHighlightRadiusSq: WebGLUniformLocation;
private uHighlightAlpha: WebGLUniformLocation;
private uSelfRadii: WebGLUniformLocation;
private uMateRadii: WebGLUniformLocation;
private uGradientStops: WebGLUniformLocation;
private mapW: number;
private mapH: number;
private tileTex: WebGLTexture;
// State
private active = false;
private instanceCount = 0;
private animTime = 0;
private lastTime = 0;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
settings: RenderSettings["spawnOverlay"],
) {
this.gl = gl;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.settings = settings;
this.program = createProgram(
gl,
overlayVertSrc,
shaderSrc(spawnFragSrc, { ...TILE_DEFINES }),
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uBreathRadius = gl.getUniformLocation(this.program, "uBreathRadius")!;
this.uHighlightRadiusSq = gl.getUniformLocation(
this.program,
"uHighlightRadiusSq",
)!;
this.uHighlightAlpha = gl.getUniformLocation(
this.program,
"uHighlightAlpha",
)!;
this.uSelfRadii = gl.getUniformLocation(this.program, "uSelfRadii")!;
this.uMateRadii = gl.getUniformLocation(this.program, "uMateRadii")!;
this.uGradientStops = gl.getUniformLocation(
this.program,
"uGradientStops",
)!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
// VAO
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
// Attribute 0: unit quad [0,1] (two triangles)
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Instance buffer: [x, y, radius, kind, r, g, b]
const glBuf = gl.createBuffer()!;
this.instanceBuf = new DynamicInstanceBuffer(
gl,
glBuf,
64,
FLOATS_PER_INSTANCE,
);
gl.bindBuffer(gl.ARRAY_BUFFER, glBuf);
const stride = FLOATS_PER_INSTANCE * 4;
// Attribute 1: per-instance vec4 (x, y, radius, kind)
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, stride, 0);
gl.vertexAttribDivisor(1, 1);
// Attribute 2: per-instance vec3 (r, g, b)
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 3, gl.FLOAT, false, stride, 16);
gl.vertexAttribDivisor(2, 1);
gl.bindVertexArray(null);
}
/** Update spawn overlay state each tick. */
update(inSpawnPhase: boolean, centers: SpawnCenter[]): void {
this.active = inSpawnPhase && centers.length > 0;
if (!this.active) {
this.instanceCount = 0;
return;
}
const s = this.settings;
const selfRadius = s.selfMaxRad * MAX_BREATH_SCALE + RADIUS_MARGIN;
const mateRadius = s.mateMaxRad * MAX_BREATH_SCALE + RADIUS_MARGIN;
const enemyRadius = s.highlightRadius + RADIUS_MARGIN;
this.instanceBuf.ensureCapacity(centers.length);
const data = this.instanceBuf.float32;
let count = 0;
const write = (c: SpawnCenter, kind: number, radius: number) => {
const off = count * FLOATS_PER_INSTANCE;
data[off + 0] = c.x;
data[off + 1] = c.y;
data[off + 2] = radius;
data[off + 3] = kind;
data[off + 4] = c.r;
data[off + 5] = c.g;
data[off + 6] = c.b;
count++;
};
// Draw order = buffer order; over-blending puts later instances on top.
// Enemies first, then teammates, then self so the local ring wins.
for (const c of centers) {
if (!c.isSelf && !c.isTeammate) write(c, KIND_ENEMY, enemyRadius);
}
for (const c of centers) {
if (c.isTeammate) write(c, KIND_TEAMMATE, mateRadius);
}
for (const c of centers) {
if (c.isSelf) write(c, KIND_SELF, selfRadius);
}
this.instanceCount = count;
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceBuf.float32,
0,
count * FLOATS_PER_INSTANCE,
);
}
draw(cameraMatrix: Float32Array): void {
if (!this.active || this.instanceCount === 0) return;
const gl = this.gl;
const s = this.settings;
const now = performance.now();
// Advance animation time
if (this.lastTime > 0) {
this.animTime += (now - this.lastTime) * s.animSpeed;
}
this.lastTime = now;
const breathRadius = 0.5 + 0.5 * Math.sin(this.animTime);
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uBreathRadius, breathRadius);
// Settings-driven uniforms
gl.uniform1f(
this.uHighlightRadiusSq,
s.highlightRadius * s.highlightRadius,
);
gl.uniform1f(this.uHighlightAlpha, s.highlightAlpha);
gl.uniform4f(this.uSelfRadii, s.selfMinRad, s.selfMaxRad, 0, 0);
gl.uniform4f(this.uMateRadii, s.mateMinRad, s.mateMaxRad, 0, 0);
gl.uniform2f(this.uGradientStops, s.gradientInnerEdge, s.gradientSolidEnd);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.bindVertexArray(this.vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount);
gl.bindVertexArray(null);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
this.instanceBuf.dispose();
gl.deleteVertexArray(this.vao);
// tileTex owned by GPUResources
}
}