Downsample fallout bloom + light extract for fillrate-bound GPUs (#4157)

## Description:

On low-end machines, the fillrate was too high causing framerate to
drop. The graphical difference is pretty negligible since fallout &
light are meant to be blurred anyways.

Reduces fillrate cost of the fallout bloom and fallout-light passes on
low-end GPUs:

- Extract step now renders at `mapW/8 × mapH/8` (64× fewer fragments).
Output is heavily blurred + LINEAR-magnified, so the visual difference
is minimal.
- Bloom blur reduced from 2× 9-tap to 1× 5-tap Gaussian (the smaller
kernel is sufficient given the lower-res source).


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-06-04 16:53:03 -07:00
committed by GitHub
parent 00a7b6d14d
commit 2c2390d0cb
5 changed files with 89 additions and 52 deletions
+41 -24
View File
@@ -2,13 +2,20 @@
* FalloutBloomPass — soft radioactive glow around irradiated tiles.
*
* Tile-space pipeline (camera-independent, zero shimmer):
* 1. Extract — compute per-tile bloom at map resolution (mapW x mapH)
* 2. Blur — two iterations of separable 9-tap Gaussian in tile space
* 1. Extract — compute per-tile bloom at mapW/BLOOM_TILE_SCALE resolution
* 2. Blur — one separable 5-tap Gaussian pass
* 3. Composite — camera-projected map quad samples blurred texture (LINEAR)
*
* Bloom buffers are sub-tile resolution because the output is heavily blurred
* and composited with LINEAR sampling — going to 1/16 the fragments cuts
* fill-rate cost on low-end GPUs (fillrate-bound by the per-fragment Gaussian
* texture reads).
*
* Heat management is handled by HeatManager (shared with LightmapPass).
*/
const BLOOM_TILE_SCALE = 8;
import type { RenderSettings } from "../RenderSettings";
import {
createFullscreenQuad,
@@ -42,6 +49,7 @@ export class FalloutBloomPass {
// Uniforms — extract
private uExtractMapSize: WebGLUniformLocation;
private uExtractTick: WebGLUniformLocation;
private uExtractTileScale: WebGLUniformLocation;
private uBroilSpeedCold: WebGLUniformLocation;
private uBroilSpeedHot: WebGLUniformLocation;
private uNoiseFreq1: WebGLUniformLocation;
@@ -73,7 +81,9 @@ export class FalloutBloomPass {
// Uniforms — blur
private uBlurDir: WebGLUniformLocation;
// FBOs (map resolution — fixed size)
// FBOs (mapW/BLOOM_TILE_SCALE × mapH/BLOOM_TILE_SCALE — fixed size)
private bloomW: number;
private bloomH: number;
private fboA: WebGLFramebuffer;
private fboB: WebGLFramebuffer;
private texA: WebGLTexture;
@@ -106,6 +116,10 @@ export class FalloutBloomPass {
);
this.uExtractMapSize = gl.getUniformLocation(this.extractProg, "uMapSize")!;
this.uExtractTick = gl.getUniformLocation(this.extractProg, "uTick")!;
this.uExtractTileScale = gl.getUniformLocation(
this.extractProg,
"uTileScale",
)!;
this.uBroilSpeedCold = gl.getUniformLocation(
this.extractProg,
"uBroilSpeedCold",
@@ -206,9 +220,11 @@ export class FalloutBloomPass {
gl.useProgram(this.compositeProg);
gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uTex"), 0);
// --- FBO textures (map resolution) ---
this.texA = this.createBloomTex(mapW, mapH);
this.texB = this.createBloomTex(mapW, mapH);
// --- FBO textures (sub-tile resolution) ---
this.bloomW = Math.max(1, Math.floor(mapW / BLOOM_TILE_SCALE));
this.bloomH = Math.max(1, Math.floor(mapH / BLOOM_TILE_SCALE));
this.texA = this.createBloomTex(this.bloomW, this.bloomH);
this.texB = this.createBloomTex(this.bloomW, this.bloomH);
this.fboA = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA);
gl.framebufferTexture2D(
@@ -264,10 +280,12 @@ export class FalloutBloomPass {
const ch = canvas.height;
const mw = this.mapW;
const mh = this.mapH;
const bw = this.bloomW;
const bh = this.bloomH;
// --- 1. Extract: tile-space bloom ---
// --- 1. Extract: sub-tile-space bloom ---
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA);
gl.viewport(0, 0, mw, mh);
gl.viewport(0, 0, bw, bh);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.BLEND);
@@ -275,6 +293,7 @@ export class FalloutBloomPass {
gl.useProgram(this.extractProg);
gl.uniform2f(this.uExtractMapSize, mw, mh);
gl.uniform1f(this.uExtractTick, tick);
gl.uniform1f(this.uExtractTileScale, BLOOM_TILE_SCALE);
const fb = this.settings.falloutBloom;
gl.uniform1f(this.uBroilSpeedCold, fb.broilSpeedCold);
@@ -317,26 +336,24 @@ export class FalloutBloomPass {
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// --- 2. Blur: 2 iterations of separable H+V Gaussian ---
// --- 2. Blur: single separable H+V 5-tap Gaussian ---
gl.useProgram(this.blurProg);
gl.bindVertexArray(this.quadVao);
for (let iter = 0; iter < 2; iter++) {
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboB);
gl.viewport(0, 0, mw, mh);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(this.uBlurDir, 1.0 / mw, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texA);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboB);
gl.viewport(0, 0, bw, bh);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(this.uBlurDir, 1.0 / bw, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texA);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA);
gl.viewport(0, 0, mw, mh);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(this.uBlurDir, 0, 1.0 / mh);
gl.bindTexture(gl.TEXTURE_2D, this.texB);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA);
gl.viewport(0, 0, bw, bh);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(this.uBlurDir, 0, 1.0 / bh);
gl.bindTexture(gl.TEXTURE_2D, this.texB);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// --- 3. Composite: camera-projected map quad → screen ---
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
@@ -2,11 +2,17 @@
* FalloutLightPass — tile-space fallout light extraction + composite.
*
* Extracted from LightmapPass. Two-step:
* 1. Extract fallout light at tile resolution (mapW x mapH) — reads heat and
* computes the same particle flicker as FalloutBloomPass inline
* 1. Extract fallout light at mapW/LIGHT_TILE_SCALE × mapH/LIGHT_TILE_SCALE
* — reads heat and computes the same particle flicker as FalloutBloomPass inline
* 2. Composite into the target lightmap FBO via camera-projected map quad (additive)
*
* Extract runs at sub-tile resolution because the lightmap chain blurs the
* combined output afterward — going to 1/64 the fragments cuts fill-rate cost
* on low-end GPUs at no perceptible quality loss.
*/
const LIGHT_TILE_SCALE = 8;
import type { RenderSettings } from "../RenderSettings";
import {
createFullscreenQuad,
@@ -39,6 +45,7 @@ export class FalloutLightPass {
private uEmberLightColor: WebGLUniformLocation;
private uEmberLightIntensity: WebGLUniformLocation;
private uFalloutTick: WebGLUniformLocation;
private uFalloutTileScale: WebGLUniformLocation;
private uParticleThresholdUnowned: WebGLUniformLocation;
private uParticleThresholdOwned: WebGLUniformLocation;
private uParticleFlickerSpeed: WebGLUniformLocation;
@@ -49,7 +56,9 @@ export class FalloutLightPass {
private uFalloutCompositeCam: WebGLUniformLocation;
private uFalloutCompositeMapSize: WebGLUniformLocation;
// Tile-space FBO
// Sub-tile-space FBO (mapW/LIGHT_TILE_SCALE × mapH/LIGHT_TILE_SCALE)
private lightW: number;
private lightH: number;
private falloutFbo: WebGLFramebuffer;
private falloutTex: WebGLTexture;
@@ -103,6 +112,10 @@ export class FalloutLightPass {
"uEmberLightIntensity",
)!;
this.uFalloutTick = gl.getUniformLocation(this.falloutLightProg, "uTick")!;
this.uFalloutTileScale = gl.getUniformLocation(
this.falloutLightProg,
"uTileScale",
)!;
this.uParticleThresholdUnowned = gl.getUniformLocation(
this.falloutLightProg,
"uParticleThresholdUnowned",
@@ -140,8 +153,10 @@ export class FalloutLightPass {
gl.useProgram(this.falloutCompositeProg);
gl.uniform1i(gl.getUniformLocation(this.falloutCompositeProg, "uTex"), 0);
// Tile-space FBO (map resolution)
this.falloutTex = this.createRGBA8Tex(mapW, mapH);
// Sub-tile-space FBO
this.lightW = Math.max(1, Math.floor(mapW / LIGHT_TILE_SCALE));
this.lightH = Math.max(1, Math.floor(mapH / LIGHT_TILE_SCALE));
this.falloutTex = this.createRGBA8Tex(this.lightW, this.lightH);
this.falloutFbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.falloutFbo);
gl.framebufferTexture2D(
@@ -195,15 +210,16 @@ export class FalloutLightPass {
const dn = this.settings.lighting;
const fb = this.settings.falloutBloom;
// Step 1: Extract fallout light in tile space
// Step 1: Extract fallout light in sub-tile space
gl.bindFramebuffer(gl.FRAMEBUFFER, this.falloutFbo);
gl.viewport(0, 0, this.mapW, this.mapH);
gl.viewport(0, 0, this.lightW, this.lightH);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.BLEND);
gl.useProgram(this.falloutLightProg);
gl.uniform2f(this.uFalloutMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uFalloutTileScale, LIGHT_TILE_SCALE);
gl.uniform3f(
this.uFalloutLightColor,
dn.falloutLightR,
@@ -5,6 +5,7 @@ uniform sampler2D uHeatTex;
uniform usampler2D uTileTex;
uniform vec2 uMapSize;
uniform float uTick;
uniform float uTileScale;
uniform vec3 uFalloutLightColor;
uniform float uFalloutLightIntensity;
uniform float uFalloutLightThreshold;
@@ -16,7 +17,9 @@ uniform float uParticleFlickerSpeed;
uniform float uParticleFreshScale;
out vec4 fragColor;
void main() {
ivec2 tc = ivec2(gl_FragCoord.xy);
// FBO is mapW/uTileScale × mapH/uTileScale; each output pixel samples one
// tile near the center of its uTileScale×uTileScale source block.
ivec2 tc = ivec2(gl_FragCoord.xy * uTileScale);
if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard;
uint raw = texelFetch(uTileTex, tc, 0).r;
@@ -4,6 +4,7 @@ precision highp usampler2D;
uniform usampler2D uTileTex;
uniform vec2 uMapSize;
uniform float uTick;
uniform float uTileScale;
uniform float uBroilSpeedCold;
uniform float uBroilSpeedHot;
@@ -54,10 +55,10 @@ float vnoise3(vec3 p) {
}
void main() {
// Tile-space: viewport is mapW x mapH, one fragment per tile.
// gl_FragCoord.xy gives exact integer tile coords — completely
// deterministic, independent of camera position/zoom.
ivec2 tc = ivec2(gl_FragCoord.xy);
// Bloom FBO is mapW/uTileScale × mapH/uTileScale; each output pixel maps
// to the center tile of its uTileScale×uTileScale block. Still deterministic
// and camera-independent — just sparser than 1:1.
ivec2 tc = ivec2(gl_FragCoord.xy * uTileScale);
if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard;
uint raw = texelFetch(uTileTex, tc, 0).r;
@@ -1,16 +1,16 @@
#version 300 es
precision highp float;
uniform sampler2D uTex;
uniform vec2 uDir;
in vec2 vUV;
out vec4 fragColor;
const float w[5] = float[5](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);
void main() {
vec4 result = texture(uTex, vUV) * w[0];
for (int i = 1; i < 5; i++) {
vec2 off = uDir * float(i);
result += texture(uTex, vUV + off) * w[i];
result += texture(uTex, vUV - off) * w[i];
}
fragColor = result;
}
#version 300 es
precision highp float;
uniform sampler2D uTex;
uniform vec2 uDir;
in vec2 vUV;
out vec4 fragColor;
const float w[3] = float[3](0.375, 0.25, 0.0625);
void main() {
vec4 result = texture(uTex, vUV) * w[0];
for (int i = 1; i < 3; i++) {
vec2 off = uDir * float(i);
result += texture(uTex, vUV + off) * w[i];
result += texture(uTex, vUV - off) * w[i];
}
fragColor = result;
}