Files
OpenFrontIO/src/client/render/gl/passes/SmallPlayerGlowPass.ts
T
4544e18ce6 refactor(glow): move small-player glow strength into graphics overrides (#4634)
## Summary

Moves the small-player glow strength from a standalone UserSettings key
(`settings.highlightGlowStrength`) into the graphics overrides blob
(`smallPlayerGlow.strength`), and moves its slider from the basic
settings modal into the **Effects** section of the graphics settings
modal.

The pass now reads strength from its live `RenderSettings` slice like
every other graphics knob, flowing through `applyGraphicsOverrides`.
This removes the dedicated push chain (ClientGameRunner listener →
MapRenderer → Renderer → pass setter) and MapRenderer's context-restore
reapply — strength survives WebGL context restores automatically now,
participates in the graphics modal's "Reset to defaults", and shows up
in the render debug GUI.

## Bug fix

The old slider had no visible effect below ~115%: strength only drove
the blur-iteration count (`round(strength ** 2.74)`, floored at 1 pass),
and the composite intensity (`alpha * pulse * sqrt(passes)`) never
factored strength directly — so 10% rendered identical to 100%.

Strength is now a linear opacity fade on a fixed-width aura:

- 100% = the aura's full brightness, 10% = barely visible, 0% = off
- Slider capped at 100% (the >100% aura-widening mechanism became
unreachable and is removed: `passesFor`, `lastPasses`, and the
blur-iteration loop)
- Default is 25%
- The pass clamps persisted values above 1 so stale settings from the
old 500% range can't over-brighten

## Notes

- Reuses the existing `user_setting.highlight_glow_strength_label` /
`highlight_small_players_desc` translation keys — no en.json change
- The legacy `settings.highlightGlowStrength` localStorage key is not
migrated (the slider only shipped in #4588 a few days ago); users reset
to the 25% default

## Test plan

- [x] `tests/GraphicsOverrides.test.ts` — schema validation + apply
coverage for the new override group
- [x] Full suite (171 tests), tsc, eslint all pass

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:09:07 -07:00

248 lines
8.6 KiB
TypeScript

/**
* SmallPlayerGlowPass — a soft, breathing red aura around "small" players'
* territory. Tile-space bloom (extract mask -> separable blur -> additive
* composite), so it's camera-independent and cheap; mirrors FalloutBloomPass.
* A no-op unless the highlight set is non-empty.
*/
import type { RenderSettings } from "../RenderSettings";
import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw";
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
import compositeVertSrc from "../shaders/shared/map-quad.vert.glsl?raw";
import compositeFragSrc from "../shaders/small-player-glow/composite.frag.glsl?raw";
import extractFragSrc from "../shaders/small-player-glow/extract.frag.glsl?raw";
import { getPaletteSize } from "../utils/ColorUtils";
import {
createFullscreenQuad,
createMapQuad,
createProgram,
createRenderTarget,
createTexture2D,
type RenderTarget,
shaderSrc,
toScreen,
toTarget,
} from "../utils/GlUtils";
import { TILE_DEFINES } from "../utils/TileCodec";
const SET_TEX_WIDTH = getPaletteSize(); // 1 px per owner smallID
const BLOOM_TILE_SCALE = 4; // bloom buffers run at 1/scale tile resolution
export class SmallPlayerGlowPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings["smallPlayerGlow"];
private mapW: number;
private mapH: number;
private tileTex: WebGLTexture;
private extractProg: WebGLProgram;
private blurProg: WebGLProgram;
private compositeProg: WebGLProgram;
private uExtractMapSize: WebGLUniformLocation;
private uBlurDir: WebGLUniformLocation;
private uCompositeCam: WebGLUniformLocation;
private uCompositeMapSize: WebGLUniformLocation;
private uGlowColor: WebGLUniformLocation;
private uIntensity: WebGLUniformLocation;
private setTex: WebGLTexture;
private targetA: RenderTarget;
private targetB: RenderTarget;
private mapVao: WebGLVertexArrayObject;
private quadVao: WebGLVertexArrayObject;
private active = false;
private dirty = false; // aura needs rebuilding (set changed)
private animTime = 0;
private lastTime = 0;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
settings: RenderSettings["smallPlayerGlow"],
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.extractProg = createProgram(
gl,
fullscreenNoUvVertSrc,
shaderSrc(extractFragSrc, {
...TILE_DEFINES,
TILE_SCALE: BLOOM_TILE_SCALE,
}),
);
this.uExtractMapSize = gl.getUniformLocation(this.extractProg, "uMapSize")!;
gl.useProgram(this.extractProg);
gl.uniform1i(gl.getUniformLocation(this.extractProg, "uTileTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.extractProg, "uHighlightSet"), 1);
this.blurProg = createProgram(gl, fullscreenVertSrc, blurFragSrc);
this.uBlurDir = gl.getUniformLocation(this.blurProg, "uDir")!;
gl.useProgram(this.blurProg);
gl.uniform1i(gl.getUniformLocation(this.blurProg, "uTex"), 0);
this.compositeProg = createProgram(gl, compositeVertSrc, compositeFragSrc);
this.uCompositeCam = gl.getUniformLocation(this.compositeProg, "uCamera")!;
this.uCompositeMapSize = gl.getUniformLocation(
this.compositeProg,
"uMapSize",
)!;
this.uGlowColor = gl.getUniformLocation(this.compositeProg, "uGlowColor")!;
this.uIntensity = gl.getUniformLocation(this.compositeProg, "uIntensity")!;
gl.useProgram(this.compositeProg);
gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uTex"), 0);
this.setTex = createTexture2D(gl, {
width: SET_TEX_WIDTH,
height: 1,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: null,
filter: gl.NEAREST,
});
// ceil (not floor) so a partial tile block at the map's right/bottom edge
// still gets a bloom cell — otherwise an edge player on a map whose size
// isn't a multiple of the scale gets no glow.
const bw = Math.max(1, Math.ceil(mapW / BLOOM_TILE_SCALE));
const bh = Math.max(1, Math.ceil(mapH / BLOOM_TILE_SCALE));
this.targetA = createRenderTarget(gl, bw, bh);
this.targetB = createRenderTarget(gl, bw, bh);
this.mapVao = createMapQuad(gl, mapW, mapH);
this.quadVao = createFullscreenQuad(gl);
}
/** Push the highlight set (1 byte per owner smallID), or null to disable. */
update(set: Uint8Array | null): void {
if (set === null) {
this.active = false;
return;
}
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.setTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
Math.min(set.length, SET_TEX_WIDTH),
1,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
set,
);
this.active = true;
this.dirty = true;
}
// One separable-blur axis: sample `src`, write the blurred result into `dst`.
private blurAxis(
src: RenderTarget,
dst: RenderTarget,
dx: number,
dy: number,
): void {
const gl = this.gl;
toTarget(gl, dst, () => {
gl.uniform2f(this.uBlurDir, dx, dy);
gl.bindTexture(gl.TEXTURE_2D, src.tex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
}
draw(cameraMatrix: Float32Array): void {
// Strength is a graphics override read from the live settings slice each
// frame (not tick-gated), so a slider change is live even while the sim is
// paused (e.g. the graphics settings modal is open). Clamped to 1 so a
// value persisted from the old 500% range can't over-brighten.
const strength = Math.min(1, this.settings.strength);
if (!this.active || strength <= 0) return;
const gl = this.gl;
const s = this.settings;
const canvas = gl.canvas as HTMLCanvasElement;
const a = this.targetA;
const b = this.targetB;
const now = performance.now();
if (this.lastTime > 0) {
// Clamp the delta so a long gap (grace period, or a backgrounded tab
// pausing rAF) doesn't leap the pulse to a random phase on resume.
this.animTime += Math.min(now - this.lastTime, 100) * s.pulseSpeed;
}
this.lastTime = now;
const pulse = 0.5 + 0.5 * Math.sin(this.animTime);
// Rebuild the blurred aura only when the set changed (~1/s); its inputs
// don't move faster than that. The composite below still runs every frame.
if (this.dirty) {
gl.disable(gl.BLEND);
// Extract the small-player mask at sub-tile resolution.
gl.useProgram(this.extractProg);
gl.uniform2f(this.uExtractMapSize, this.mapW, this.mapH);
toTarget(gl, a, () => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.setTex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
// Separable blur: horizontal A->B, then vertical B->A.
gl.useProgram(this.blurProg);
gl.activeTexture(gl.TEXTURE0);
this.blurAxis(a, b, 1 / a.w, 0); // horizontal A->B
this.blurAxis(b, a, 0, 1 / b.h); // vertical B->A
this.dirty = false;
}
// Composite the cached aura over the map every frame. Premultiplied-over
// (not pure additive) so the glow keeps its color over bright terrain
// instead of washing out to white.
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
toScreen(gl, canvas.width, canvas.height, () => {
gl.useProgram(this.compositeProg);
gl.uniformMatrix3fv(this.uCompositeCam, false, cameraMatrix);
gl.uniform2f(this.uCompositeMapSize, this.mapW, this.mapH);
gl.uniform3fv(this.uGlowColor, s.color);
// Strength fades the glow linearly: 1 = the aura's full alpha, 0.1 =
// barely visible.
gl.uniform1f(this.uIntensity, s.alpha * pulse * strength);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, a.tex);
gl.bindVertexArray(this.mapVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
gl.bindVertexArray(null);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // restore overlay default
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.extractProg);
gl.deleteProgram(this.blurProg);
gl.deleteProgram(this.compositeProg);
gl.deleteTexture(this.setTex);
gl.deleteTexture(this.targetA.tex);
gl.deleteTexture(this.targetB.tex);
gl.deleteFramebuffer(this.targetA.fbo);
gl.deleteFramebuffer(this.targetB.fbo);
gl.deleteVertexArray(this.mapVao);
gl.deleteVertexArray(this.quadVao);
// tileTex owned by GPUResources
}
}