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>
This commit is contained in:
Evan
2026-06-17 20:10:34 -07:00
committed by GitHub
parent 661d96ba28
commit ca5342d6bf
3 changed files with 232 additions and 152 deletions
+119 -50
View File
@@ -2,23 +2,39 @@
* SpawnOverlayPass — spawn phase tile highlights + breathing rings.
*
* Active only during spawn phase. Renders:
* 1. Colored highlights on unowned tiles within radius 9 of each human
* player's spawn center (blinks every 5th tick).
* 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.
*
* Uses a fullscreen map quad (reuses overlay.vert.glsl) so the fragment
* shader can sample tileTex for ownership and compute distance-based
* effects in tile-space coordinates.
* 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 { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils";
import { createProgram, shaderSrc } from "../utils/GlUtils";
import { TILE_DEFINES } from "../utils/TileCodec";
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
import spawnFragSrc from "../shaders/spawn-overlay/spawn-overlay.frag.glsl?raw";
import overlayVertSrc from "../shaders/spawn-overlay/spawn-overlay.vert.glsl?raw";
const MAX_SPAWNS = 32;
// 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;
@@ -34,16 +50,13 @@ export class SpawnOverlayPass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private tileTex: WebGLTexture;
private instanceBuf: DynamicInstanceBuffer;
private settings: RenderSettings["spawnOverlay"];
// Uniforms
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uSpawnCount: WebGLUniformLocation;
private uBreathRadius: WebGLUniformLocation;
private uSpawnA: WebGLUniformLocation;
private uSpawnB: WebGLUniformLocation;
private uHighlightRadiusSq: WebGLUniformLocation;
private uHighlightAlpha: WebGLUniformLocation;
private uSelfRadii: WebGLUniformLocation;
@@ -52,10 +65,11 @@ export class SpawnOverlayPass {
private mapW: number;
private mapH: number;
private tileTex: WebGLTexture;
// State
private active = false;
private centers: SpawnCenter[] = [];
private instanceCount = 0;
private animTime = 0;
private lastTime = 0;
@@ -75,15 +89,12 @@ export class SpawnOverlayPass {
this.program = createProgram(
gl,
overlayVertSrc,
shaderSrc(spawnFragSrc, { MAX_SPAWNS, ...TILE_DEFINES }),
shaderSrc(spawnFragSrc, { ...TILE_DEFINES }),
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uSpawnCount = gl.getUniformLocation(this.program, "uSpawnCount")!;
this.uBreathRadius = gl.getUniformLocation(this.program, "uBreathRadius")!;
this.uSpawnA = gl.getUniformLocation(this.program, "uSpawnA")!;
this.uSpawnB = gl.getUniformLocation(this.program, "uSpawnB")!;
this.uHighlightRadiusSq = gl.getUniformLocation(
this.program,
"uHighlightRadiusSq",
@@ -102,17 +113,101 @@ export class SpawnOverlayPass {
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
this.vao = createMapQuad(gl, mapW, mapH);
// 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 frame. */
/** Update spawn overlay state each tick. */
update(inSpawnPhase: boolean, centers: SpawnCenter[]): void {
this.active = inSpawnPhase && centers.length > 0;
this.centers = centers;
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) return;
if (!this.active || this.instanceCount === 0) return;
const gl = this.gl;
const s = this.settings;
@@ -129,7 +224,6 @@ export class SpawnOverlayPass {
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1i(this.uSpawnCount, Math.min(this.centers.length, MAX_SPAWNS));
gl.uniform1f(this.uBreathRadius, breathRadius);
// Settings-driven uniforms
@@ -142,43 +236,18 @@ export class SpawnOverlayPass {
gl.uniform4f(this.uMateRadii, s.mateMinRad, s.mateMaxRad, 0, 0);
gl.uniform2f(this.uGradientStops, s.gradientInnerEdge, s.gradientSolidEnd);
// Upload spawn center data as vec4 arrays
const count = Math.min(this.centers.length, MAX_SPAWNS);
const dataA = new Float32Array(count * 4);
const dataB = new Float32Array(count * 4);
for (let i = 0; i < count; i++) {
const c = this.centers[i];
dataA[i * 4 + 0] = c.x;
dataA[i * 4 + 1] = c.y;
if (c.isSelf) {
// Self ring pulses white (1,1,1) → its center color (gold when
// teamless, team color in team games) in phase with the breath so
// one end of the pulse always contrasts with the terrain.
dataA[i * 4 + 2] = 1 - (1 - c.r) * breathRadius;
dataA[i * 4 + 3] = 1 - (1 - c.g) * breathRadius;
dataB[i * 4 + 0] = 1 - (1 - c.b) * breathRadius;
} else {
dataA[i * 4 + 2] = c.r;
dataA[i * 4 + 3] = c.g;
dataB[i * 4 + 0] = c.b;
}
dataB[i * 4 + 1] = c.isSelf ? 1 : 0;
dataB[i * 4 + 2] = c.isTeammate ? 1 : 0;
dataB[i * 4 + 3] = 0;
}
gl.uniform4fv(this.uSpawnA, dataA);
gl.uniform4fv(this.uSpawnB, dataB);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
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
}
@@ -1,102 +1,81 @@
#version 300 es
precision highp float;
precision highp usampler2D;
uniform usampler2D uTileTex;
uniform vec2 uMapSize;
// Spawn center data packed as vec4 pairs:
// A[i] = (x, y, r, g)
// B[i] = (b, isSelf, isTeammate, _)
uniform vec4 uSpawnA[MAX_SPAWNS];
uniform vec4 uSpawnB[MAX_SPAWNS];
uniform int uSpawnCount;
uniform float uBreathRadius; // normalized [0..1], animated via sin
// Configurable parameters (from render settings)
uniform float uHighlightRadiusSq; // tile highlight radius squared
uniform float uHighlightAlpha; // tile highlight opacity
uniform vec4 uSelfRadii; // (minR, maxR, _, _)
uniform vec4 uMateRadii; // (minR, maxR, _, _)
uniform vec2 uGradientStops; // (innerEdge, solidEnd)
in vec2 vWorldPos;
out vec4 fragColor;
void main() {
ivec2 tc = ivec2(floor(vWorldPos));
if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y))
discard;
uint raw = texelFetch(uTileTex, tc, 0).r;
uint owner = raw & uint(OWNER_MASK);
bool unowned = (owner == 0u);
vec4 result = vec4(0.0);
for (int i = 0; i < MAX_SPAWNS; i++) {
if (i >= uSpawnCount) break;
vec2 center = uSpawnA[i].xy;
vec3 color = vec3(uSpawnA[i].zw, uSpawnB[i].x);
float isSelf = uSpawnB[i].y;
float isTeammate = uSpawnB[i].z;
float dx = vWorldPos.x - center.x;
float dy = vWorldPos.y - center.y;
float distSq = dx * dx + dy * dy;
float dist = sqrt(distSq);
// --- Tile highlights (not for self or teammates) ---
if (isSelf < 0.5 && isTeammate < 0.5 && unowned && distSq <= uHighlightRadiusSq) {
float a = uHighlightAlpha;
result.rgb = mix(result.rgb, color, a * (1.0 - result.a));
result.a = result.a + a * (1.0 - result.a);
}
// --- Breathing rings (self or teammate only) ---
float minR, maxR;
if (isSelf > 0.5) {
minR = uSelfRadii.x;
maxR = uSelfRadii.y;
} else if (isTeammate > 0.5) {
minR = uMateRadii.x;
maxR = uMateRadii.y;
} else {
continue;
}
// Breathing ring: the gradient halo shrinks/expands in radius AND its
// opacity pulses in phase with the breath — both driven by uBreathRadius.
// Smooth bell shape: glow ramps up from center to the inner edge, stays
// solid through the ring's body, then fades out past solidEnd. No hard
// cutoffs at either side.
float scale = 0.5 + 0.65 * uBreathRadius; // 0.5 → 1.15 of base radius
float bMinR = minR * scale;
float bMaxR = maxR * scale;
float range = bMaxR - bMinR;
float t = (dist - bMinR) / range;
float solidEnd = uGradientStops.y;
float alpha = 0.0;
if (dist < bMinR) {
// Inner glow: transparent at the center (so your territory shows through)
// ramping up to fully solid at the ring's inner edge.
alpha = dist / max(bMinR, 0.001);
} else if (t < solidEnd) {
alpha = 1.0;
} else if (t < 1.0) {
alpha = 1.0 - (t - solidEnd) / (1.0 - solidEnd);
}
if (alpha > 0.0) {
// Opacity pulses 65% → 100% in phase with the radius.
alpha *= 0.65 + 0.35 * uBreathRadius;
result.rgb = mix(result.rgb, color, alpha * (1.0 - result.a));
result.a = result.a + alpha * (1.0 - result.a);
}
}
if (result.a < 0.001) discard;
// result is premultiplied; convert to straight for SRC_ALPHA blending
fragColor = vec4(result.rgb / result.a, result.a);
}
#version 300 es
precision highp float;
precision highp usampler2D;
uniform usampler2D uTileTex;
uniform vec2 uMapSize;
uniform float uBreathRadius; // normalized [0..1], animated via sin
// Configurable parameters (from render settings)
uniform float uHighlightRadiusSq; // tile highlight radius squared
uniform float uHighlightAlpha; // tile highlight opacity
uniform vec4 uSelfRadii; // (minR, maxR, _, _)
uniform vec4 uMateRadii; // (minR, maxR, _, _)
uniform vec2 uGradientStops; // (innerEdge, solidEnd)
in vec2 vWorldPos;
flat in vec2 vCenter;
flat in float vKind; // 0 = enemy highlight, 1 = self ring, 2 = teammate ring
flat in vec3 vColor;
out vec4 fragColor;
void main() {
float dx = vWorldPos.x - vCenter.x;
float dy = vWorldPos.y - vCenter.y;
float distSq = dx * dx + dy * dy;
float dist = sqrt(distSq);
// --- Enemy tile highlights: only unowned tiles within radius ---
if (vKind < 0.5) {
ivec2 tc = ivec2(floor(vWorldPos));
if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y))
discard;
if (distSq > uHighlightRadiusSq) discard;
uint raw = texelFetch(uTileTex, tc, 0).r;
if ((raw & uint(OWNER_MASK)) != 0u) discard; // owned tile → no highlight
fragColor = vec4(vColor, uHighlightAlpha);
return;
}
// --- Breathing rings (self or teammate) ---
float minR, maxR;
vec3 color;
if (vKind > 1.5) {
minR = uMateRadii.x;
maxR = uMateRadii.y;
color = vColor;
} else {
minR = uSelfRadii.x;
maxR = uSelfRadii.y;
// Self ring pulses white → its base color in phase with the breath so one
// end of the pulse always contrasts with the terrain.
color = mix(vec3(1.0), vColor, uBreathRadius);
}
// Breathing ring: the gradient halo shrinks/expands in radius AND its
// opacity pulses in phase with the breath — both driven by uBreathRadius.
// Smooth bell shape: glow ramps up from center to the inner edge, stays
// solid through the ring's body, then fades out past solidEnd.
float scale = 0.5 + 0.65 * uBreathRadius; // 0.5 → 1.15 of base radius
float bMinR = minR * scale;
float bMaxR = maxR * scale;
float range = bMaxR - bMinR;
float t = (dist - bMinR) / range;
float solidEnd = uGradientStops.y;
float alpha = 0.0;
if (dist < bMinR) {
// Inner glow: transparent at the center (so your territory shows through)
// ramping up to fully solid at the ring's inner edge.
alpha = dist / max(bMinR, 0.001);
} else if (t < solidEnd) {
alpha = 1.0;
} else if (t < 1.0) {
alpha = 1.0 - (t - solidEnd) / (1.0 - solidEnd);
}
if (alpha <= 0.0) discard;
// Opacity pulses 65% → 100% in phase with the radius.
alpha *= 0.65 + 0.35 * uBreathRadius;
fragColor = vec4(color, alpha);
}
@@ -0,0 +1,32 @@
#version 300 es
precision highp float;
// Unit quad [0,1]
layout(location = 0) in vec2 aPos;
// Per-instance: centerX, centerY, quadRadius, kind
// kind: 0 = enemy tile highlight, 1 = self ring, 2 = teammate ring
layout(location = 1) in vec4 aInstance;
// Per-instance: r, g, b (base color)
layout(location = 2) in vec3 aColor;
uniform mat3 uCamera;
out vec2 vWorldPos; // tile-space position of this fragment
flat out vec2 vCenter; // spawn center (tile coords)
flat out float vKind;
flat out vec3 vColor;
void main() {
vec2 local = aPos * 2.0 - 1.0; // [-1, +1]
vec2 center = aInstance.xy;
float r = aInstance.z;
vKind = aInstance.w;
vColor = aColor;
vCenter = center;
vec2 worldPos = center + local * r;
vWorldPos = worldPos;
vec3 clip = uCamera * vec3(worldPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
}