mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user