diff --git a/src/client/render/gl/passes/SpawnOverlayPass.ts b/src/client/render/gl/passes/SpawnOverlayPass.ts index c17d012b5..1492c164c 100644 --- a/src/client/render/gl/passes/SpawnOverlayPass.ts +++ b/src/client/render/gl/passes/SpawnOverlayPass.ts @@ -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 } diff --git a/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl b/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl index 0fc7cba8a..03060982e 100644 --- a/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl +++ b/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl @@ -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); +} diff --git a/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.vert.glsl b/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.vert.glsl new file mode 100644 index 000000000..9fd3bc7ca --- /dev/null +++ b/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.vert.glsl @@ -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); +}