From a4d51996bc5e0e3000589620100723837bd1156f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:06:55 +0000 Subject: [PATCH] fix(rendering): size gaussian blur and bloom in css pixels Since rendering moved to the display's device pixel ratio, render targets are sized in device pixels, and both effects sized their kernels in texels of those targets, so the same settings looked different on every display. - Gaussian blur steps one CSS pixel per tap. On a high-DPI display it box-downsamples the scene to CSS-pixel resolution, blurs there, and upsamples on the last pass, rather than spacing taps pixelRatio texels apart (which skips texels and stripes thin details). - Bloom's downsample block is 4 CSS pixels (4 * pixelRatio texels, rounded). The threshold pass averages every texel in the block, and the blur step is computed from the full-resolution target so rounding the block doesn't change the glow's reach. - Adds an e2e spec comparing each effect's on-screen luminance profile at device scale factors 1 and 2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BiAVgvaR1vQhqx1xN871Np --- AGENTS.md | 7 + CHANGELOG.md | 4 + .../docs/docs/rendering/bloom.md | 26 +- .../docs/docs/rendering/gaussian-blur.md | 42 ++- .../scenes/post-process-pixel-ratio.ts | 197 ++++++++++++++ e2e/specs/post-process-pixel-ratio.spec.ts | 137 ++++++++++ .../components/gaussian-blur-component.ts | 10 +- .../post-process/bloom-threshold.frag.glsl | 41 +-- .../post-process/box-downsample.frag.glsl | 32 +++ src/rendering/shaders/post-process/index.ts | 2 + src/rendering/systems/bloom-system.test.ts | 131 ++++++++- src/rendering/systems/bloom-system.ts | 55 +++- .../systems/gaussian-blur-system.test.ts | 256 +++++++++++++++++- src/rendering/systems/gaussian-blur-system.ts | 250 +++++++++++------ .../utilities/create-shader-cache.ts | 2 + 15 files changed, 1051 insertions(+), 141 deletions(-) create mode 100644 e2e/fixtures/scenes/post-process-pixel-ratio.ts create mode 100644 e2e/specs/post-process-pixel-ratio.spec.ts create mode 100644 src/rendering/shaders/post-process/box-downsample.frag.glsl diff --git a/AGENTS.md b/AGENTS.md index d345f4bf..1f9d3cf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -706,6 +706,13 @@ won't catch it because jsdom's `devicePixelRatio` is `1`. Test such code with a mocked `RenderContext` whose two sizes differ, or a `deviceScaleFactor: 2` e2e test (see `e2e/specs/high-dpi-canvas.spec.ts`). +Screen-space effect sizes (blur radii, bloom spread) are sized in CSS +pixels too, so they look the same on every display: the Gaussian blur +system averages the scene down to CSS-pixel resolution before blurring, +and bloom's downsample block is `4 * pixelRatio` render-target texels. +Don't step a kernel `pixelRatio` texels apart on the full-resolution +texture instead - it skips the texels in between and stripes thin details. + ### Readonly Fields Use `readonly` for fields that shouldn't change after construction: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e46341a..0a601095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +#### Fixed + +- **rendering:** Gaussian blur and bloom are now sized in CSS pixels instead of render target (device) pixels, so the same `passes`, `threshold` and `intensity` look the same at every display pixel ratio. Since 0.25.6, a high-DPI display made the blur and the bloom halo spread less far on screen, and made bloom much brighter close to small bright sprites. Looks at a pixel ratio of 1 are unchanged. On a high-DPI display the Gaussian blur now runs at CSS-pixel resolution, which also makes it cheaper there + ## [0.25.6] - 2026-10-03 #### Added diff --git a/documentation-site/docs/docs/rendering/bloom.md b/documentation-site/docs/docs/rendering/bloom.md index a1545239..19d80779 100644 --- a/documentation-site/docs/docs/rendering/bloom.md +++ b/documentation-site/docs/docs/rendering/bloom.md @@ -25,8 +25,8 @@ Each frame, for every distinct bloomed render target: sharply) are kept, into a scratch buffer. 2. **Blur** — that scratch buffer is blurred with the same two-pass horizontal/vertical technique as `createGaussianBlurEcsSystem`, `passes` - times, at a quarter of the camera's render target resolution (see - below). + times, on a downsampled copy where each texel covers a 4×4 block of CSS + pixels (see below). 3. **Composite** — the blurred bright pixels are added back onto the original (unblurred), full-resolution scene, scaled by `intensity`. @@ -34,12 +34,24 @@ The blur chain runs downsampled because the blur shader's kernel only samples a handful of texels per pass: at full render target resolution, that reach is a handful of _screen_ pixels, which on a large canvas barely registers as a glow no matter how many `passes` you throw at it. Running -the same kernel and pass count on a quarter-resolution buffer instead makes -each texel already cover several source pixels, so the glow visibly spreads +the same kernel and pass count on a buffer downsampled by 4 in each +direction instead makes each texel already cover several source pixels, so +the glow visibly spreads well past a sprite's edges with a modest, cheap `passes` count. The composite pass upsamples it back implicitly, via the bloom texture's own linear-filtered sampling. +The downsampling is measured in CSS pixels, not render target pixels, so +bloom looks the same at any +[`RenderContext.pixelRatio`](/Forge/docs/api/classes/RenderContext#pixelratio) +(see [High-DPI displays](./world-units-and-cameras.md#high-dpi-displays)): at a +pixel ratio of 2, each downsampled texel covers an 8×8 block of the +render target, which is still 4×4 CSS pixels. Every texel in the block is +thresholded individually and averaged, so a small bright sprite contributes +the same share of its block, and the glow spreads the same distance on +screen, on every display. This assumes the camera's `renderTarget` is sized +to the canvas (`renderContext.width`/`height`). + The thresholded buffer's alpha carries how strongly each pixel contributes to the glow, not the source pixel's original transparency, so the blur can spread the glow's own opacity out past a sprite's silhouette into @@ -169,8 +181,10 @@ Bloom costs one threshold pass, two full-screen draws per blur `passes` one composite pass, and one final copy back into the camera's `renderTarget` — `2 * passes + 3` full-screen draws in total, regardless of `intensity`. The threshold and blur passes are far cheaper than that count -suggests, though: they run at a quarter of the render target's resolution -(a sixteenth of the fragment shader invocations per draw), which is also +suggests, though: they run at a quarter of the canvas's CSS-pixel +resolution in each direction (a sixteenth of the fragment shader invocations +per draw on a standard display, and the same number of invocations on a +high-DPI one, since the downsampling scales with `pixelRatio`), which is also why a small `passes` count already produces a wide glow (see Tuning, above). diff --git a/documentation-site/docs/docs/rendering/gaussian-blur.md b/documentation-site/docs/docs/rendering/gaussian-blur.md index 8f38ad60..0e76f724 100644 --- a/documentation-site/docs/docs/rendering/gaussian-blur.md +++ b/documentation-site/docs/docs/rendering/gaussian-blur.md @@ -91,6 +91,25 @@ gameplay layer on top of it), give those cameras _separate_ render targets instead of a shared one, and attach `GaussianBlurEcsComponent` only to the one that should be blurred: see [Layering multiple render targets](./multipass-rendering.md#layering-multiple-render-targets). +## Same look on every display + +The blur is sized in CSS pixels, not render target pixels: each tap of the +kernel is one CSS pixel apart, so a given `passes` value spreads the same +distance on screen at any +[`RenderContext.pixelRatio`](/Forge/docs/api/classes/RenderContext#pixelratio) +(see [High-DPI displays](./world-units-and-cameras.md#high-dpi-displays)). This +assumes the camera's `renderTarget` is sized to the canvas +(`renderContext.width`/`height`), as in the example above. + +On a high-DPI display (`pixelRatio` above `1`) the blur chain doesn't run on +the full-resolution scene: it first averages the scene down to CSS-pixel +resolution, blurs that, and the last pass scales the result back up into the +camera's `renderTarget`. Stepping one CSS pixel across the full-resolution +texture instead would skip the texels in between (see the caution below). +Because the scene is already blurred by the time it's scaled back up, this +costs no visible sharpness, and it keeps the blur's cost close to what it is +on a standard display. + ## Tuning strength: passes vs. intensity There are two, deliberately different, knobs on @@ -118,7 +137,7 @@ entirely and behaves exactly like earlier versions of this system that only had `passes`. :::caution -Each individual pass only samples 9 adjacent texels, so `passes` (or +Each individual pass only samples 9 adjacent texels (one CSS pixel apart, see above), so `passes` (or blending toward the sharp image via `intensity`) are the _only_ supported ways to change blur strength: don't try to widen the blur by spacing the samples further apart (for example scaling the texel-size uniform) instead. @@ -136,15 +155,20 @@ lets each pass cover more visual area per texel without under-sampling. ## Performance note -Each pass costs two full-screen draws (`sceneTarget.width * -sceneTarget.height` fragment shader invocations each, 9 texture samples -per fragment), so total cost scales linearly with `passes`. A fractional -`intensity` (anything other than exactly `0` or `1`) adds three more -full-screen draws regardless of `passes`: one to snapshot the sharp scene -before blurring, one to blend it against the blurred result, and one to -copy that blend back into the camera's `renderTarget`. There's also one +Each pass costs two full-screen draws (9 texture samples per fragment), so +total cost scales linearly with `passes`. The blur passes run at CSS-pixel +resolution (`sceneTarget.width / pixelRatio` by `sceneTarget.height / +pixelRatio` fragment shader invocations each), so they cost about the same +on a high-DPI display as on a standard one; only the last draw, which +writes back into the full-resolution `renderTarget`, and, when `pixelRatio` +is above `1`, one extra draw that averages the scene down first, scale with +the display's resolution. A fractional `intensity` (anything other than +exactly `0` or `1`) adds two more full-screen draws regardless of `passes`: +one to blend the sharp scene against the blurred result, and one to copy +that blend back into the camera's `renderTarget`. There's also one lazily-allocated internal [`PingPongTarget`](/Forge/docs/api/classes/PingPongTarget) -pair (plus, for a fractional `intensity`, one more snapshot buffer) per +pair (at CSS-pixel resolution), plus, for a fractional `intensity`, one +full-resolution buffer for the blend, per distinct render target the first time it's blurred, resized (or recreated) automatically if that target's dimensions change, and disposed automatically when the world stops. Because every pass and helper draw share materials diff --git a/e2e/fixtures/scenes/post-process-pixel-ratio.ts b/e2e/fixtures/scenes/post-process-pixel-ratio.ts new file mode 100644 index 00000000..bc9dd281 --- /dev/null +++ b/e2e/fixtures/scenes/post-process-pixel-ratio.ts @@ -0,0 +1,197 @@ +import { + addBloomComponent, + addGaussianBlurComponent, + Color, + createBloomEcsSystem, + createCamera, + createCanvas, + createContainerResizeSync, + createGaussianBlurEcsSystem, + createImageSprite, + createPresentEcsSystem, + createRenderContext, + createRenderEcsSystem, + createRenderTarget, + createTransformEcsSystem, + EcsWorld, + positionId, + spriteId, + Time, +} from '../../../src/index.js'; +import { createWhiteSquareImage } from './create-white-square-image.js'; +import { CreateScene, SceneHandle } from './scene.js'; + +const defaultStepDeltaMilliseconds = 16.6666; + +// 600 CSS pixels tall (see e2e/fixtures/index.html) over 60 world units, so +// one world unit is 10 CSS pixels at any pixel ratio. +const verticalWorldUnits = 60; +const squareWorldSize = 1; + +/** How far out from the square's center `measureLuminanceProfile` reads. */ +const profileLengthInCssPixels = 60; + +/** The post-processing effect a scene instance applies, from `?effect=`. */ +export type PostProcessEffect = 'bloom' | 'blur'; + +/** The handle `post-process-pixel-ratio.spec.ts` drives and asserts against. */ +export interface PostProcessPixelRatioSceneHandle extends SceneHandle { + /** `RenderContext.pixelRatio`. */ + readonly pixelRatio: number; + /** + * Reads the rendered canvas's luminance (`0`-`255`) along a horizontal + * line through the square's center, from its center outwards to the + * right, one entry per CSS pixel (each read at the middle of that CSS + * pixel). Must be called in the same `page.evaluate` task as the + * preceding `step()`. + */ + measureLuminanceProfile(): number[]; +} + +/** + * Reads the post-processing effect to apply from the page's `?effect=` + * query param. + * @returns The effect. + * @throws An error if the param is missing or not a known effect. + */ +function readEffect(): PostProcessEffect { + const effect = new URLSearchParams(window.location.search).get('effect'); + + if (effect === 'bloom' || effect === 'blur') { + return effect; + } + + throw new Error(`Expected ?effect=bloom or ?effect=blur, got "${effect}".`); +} + +/** + * Builds a scene for checking that blur and bloom are sized in CSS pixels: + * a small white square on black at the canvas's center, rendered into a + * canvas-sized render target that gets either bloom or a Gaussian blur + * (picked by `?effect=`) before being presented. Rendered at two device + * pixel ratios, the square's glow (or blur) should look the same in CSS + * pixels. + * @param container - The element to render the scene's canvas into. + * @returns The scene's handle. + */ +export const createScene: CreateScene = async ( + container: HTMLElement, +): Promise => { + const effect = readEffect(); + const time = new Time(); + const world = new EcsWorld(); + const canvas = createCanvas(container); + const renderContext = createRenderContext(canvas, { + preserveDrawingBuffer: true, + }); + + createContainerResizeSync(container, [renderContext]); + + const sceneTarget = createRenderTarget( + renderContext.gl, + renderContext.width, + renderContext.height, + ); + + const cameraEntity = createCamera(world, { + isStatic: true, + clearColor: new Color(0, 0, 0, 1), + verticalWorldUnits, + renderTarget: sceneTarget, + }); + + const squareImage = await createWhiteSquareImage(); + const squareSprite = createImageSprite(squareImage, renderContext, { + pixelsPerUnit: 1, + }); + const square = world.createEntity(); + + world.addComponent(square, positionId, { + local: { x: 0, y: 0 }, + world: { x: 0, y: 0 }, + }); + world.addComponent(square, spriteId, { + ...squareSprite, + width: squareWorldSize, + height: squareWorldSize, + }); + + world.addSystem(createTransformEcsSystem()); + world.addSystem(createRenderEcsSystem(renderContext)); + + if (effect === 'bloom') { + addBloomComponent(world, cameraEntity, { + threshold: 0.5, + passes: 2, + intensity: 1, + }); + world.addSystem(createBloomEcsSystem(renderContext)); + } else { + addGaussianBlurComponent(world, cameraEntity, { passes: 3 }); + world.addSystem(createGaussianBlurEcsSystem(renderContext)); + } + + world.addSystem(createPresentEcsSystem(renderContext)); + + let clockInMilliseconds = 0; + + return { + step(deltaMilliseconds: number = defaultStepDeltaMilliseconds): void { + // Keep the render target matched to the drawing buffer, in case the + // resize sync resized the canvas after the target was created. + if ( + sceneTarget.width !== renderContext.width || + sceneTarget.height !== renderContext.height + ) { + sceneTarget.resize( + renderContext.gl, + renderContext.width, + renderContext.height, + ); + } + + clockInMilliseconds += deltaMilliseconds; + time.update(clockInMilliseconds); + world.update(); + }, + + get pixelRatio(): number { + return renderContext.pixelRatio; + }, + + measureLuminanceProfile(): number[] { + const sampleCanvas = document.createElement('canvas'); + + sampleCanvas.width = canvas.width; + sampleCanvas.height = canvas.height; + + const context2d = sampleCanvas.getContext('2d'); + + if (!context2d) { + throw new Error('2D canvas context not available'); + } + + context2d.drawImage(canvas, 0, 0); + + const { pixelRatio, cssWidth, cssHeight } = renderContext; + const toDevicePixel = (cssPixel: number): number => + Math.floor((cssPixel + 0.5) * pixelRatio); + const centerX = cssWidth / 2; + const y = toDevicePixel(cssHeight / 2); + const { data } = context2d.getImageData(0, y, canvas.width, 1); + const profile: number[] = []; + + for (let d = 0; d < profileLengthInCssPixels; d++) { + const offset = toDevicePixel(centerX + d) * 4; + + profile.push( + 0.2126 * data[offset] + + 0.7152 * data[offset + 1] + + 0.0722 * data[offset + 2], + ); + } + + return profile; + }, + }; +}; diff --git a/e2e/specs/post-process-pixel-ratio.spec.ts b/e2e/specs/post-process-pixel-ratio.spec.ts new file mode 100644 index 00000000..16dcded1 --- /dev/null +++ b/e2e/specs/post-process-pixel-ratio.spec.ts @@ -0,0 +1,137 @@ +import { expect, test } from '@playwright/test'; +import type { + PostProcessEffect, + PostProcessPixelRatioSceneHandle, +} from '../fixtures/scenes/post-process-pixel-ratio.js'; + +// `window.__forgeTestHooks` is declared globally (as the base `SceneHandle`) +// by `harness.ts`. Each `page.evaluate` callback below narrows it to this +// spec's own scene handle type inline - see camera-pan-zoom.spec.ts's `Hooks` +// comment for why. +type Hooks = PostProcessPixelRatioSceneHandle; +type Browser = import('@playwright/test').Browser; + +// Luminance at or below this counts as background: the glow has ended. +const backgroundLuminance = 2; + +// Luminance at or above this counts as the square itself, not its glow. +const squareLuminance = 250; + +interface Profile { + pixelRatio: number; + luminance: number[]; +} + +/** + * Renders the scene with `effect` at `deviceScaleFactor` in its own browser + * context and reads its luminance profile, in CSS pixels. + * @param browser - The test's browser. + * @param effect - The post-processing effect to apply. + * @param deviceScaleFactor - The device pixel ratio to render at. + * @returns The scene's pixel ratio and luminance profile. + */ +const renderProfile = async ( + browser: Browser, + effect: PostProcessEffect, + deviceScaleFactor: number, +): Promise => { + const { baseURL } = test.info().project.use; + const context = await browser.newContext({ deviceScaleFactor }); + + try { + const page = await context.newPage(); + let pageError: Error | undefined; + + page.once('pageerror', (error) => { + pageError = error; + }); + + await page.goto( + `${baseURL}/?scene=post-process-pixel-ratio&effect=${effect}`, + ); + + try { + await page.waitForFunction(() => Boolean(window.__forgeTestHooks)); + } catch (timeoutError) { + throw pageError ?? timeoutError; + } + + return await page.evaluate(() => { + const scene = window.__forgeTestHooks as unknown as Hooks; + + scene.step(); + + return { + pixelRatio: scene.pixelRatio, + luminance: scene.measureLuminanceProfile(), + }; + }); + } finally { + await context.close(); + } +}; + +/** + * The CSS-pixel distance from the square's center to its edge: the first + * reading that's no longer the square's own full brightness. + * @param luminance - The profile. + * @returns The distance, in CSS pixels. + */ +const squareEdge = (luminance: number[]): number => + luminance.findIndex((value) => value < squareLuminance); + +/** + * The CSS-pixel distance from the square's center to where its glow (or + * blur) fades into the background. + * @param luminance - The profile. + * @returns The distance, in CSS pixels. + */ +const glowExtent = (luminance: number[]): number => + luminance.findIndex((value) => value <= backgroundLuminance); + +for (const effect of ['bloom', 'blur'] as const) { + test(`${effect} looks the same in CSS pixels at device scale factors 1 and 2`, async ({ + browser, + }) => { + const standard = await test.step('render at device scale factor 1', () => + renderProfile(browser, effect, 1)); + const highDpi = await test.step('render at device scale factor 2', () => + renderProfile(browser, effect, 2)); + + expect(standard.pixelRatio).toBe(1); + expect(highDpi.pixelRatio).toBe(2); + + // Bloom keeps the square's own core at full brightness; a blur dims + // even its center, so only bloom has an edge to compare. + if (effect === 'bloom') { + expect( + Math.abs( + squareEdge(highDpi.luminance) - squareEdge(standard.luminance), + ), + ).toBeLessThanOrEqual(1); + } + + const standardExtent = glowExtent(standard.luminance); + const highDpiExtent = glowExtent(highDpi.luminance); + + // The effect actually ran: the square (5 CSS pixels from center to + // edge) visibly spreads past its edge. + expect(standardExtent).toBeGreaterThan(8); + + // Sized in device pixels, the glow at a pixel ratio of 2 ended several + // CSS pixels sooner (15 vs. 24 for bloom, 8 vs. 12 for a blur). + expect(Math.abs(highDpiExtent - standardExtent)).toBeLessThanOrEqual(2); + + // ...and bloom was more than twice as bright just outside the square. + // Compare each CSS pixel from the center out to where the glow ends. + for (let d = 0; d < standardExtent; d++) { + const expected = standard.luminance[d]; + const tolerance = Math.max(12, expected * 0.25); + + expect( + Math.abs(highDpi.luminance[d] - expected), + `luminance ${d} CSS pixels from the center`, + ).toBeLessThanOrEqual(tolerance); + } + }); +} diff --git a/src/rendering/components/gaussian-blur-component.ts b/src/rendering/components/gaussian-blur-component.ts index 33ccfb87..b5be15aa 100644 --- a/src/rendering/components/gaussian-blur-component.ts +++ b/src/rendering/components/gaussian-blur-component.ts @@ -9,10 +9,12 @@ import { EcsWorld } from '../../ecs/ecs-world.js'; export interface GaussianBlurEcsComponent { /** * How many times to run the horizontal+vertical blur pair. Each pass - * samples adjacent texels only, so increasing `passes` (rather than the - * distance between samples) is what makes the blur stronger: repeated - * narrow passes compose into a wide, smooth blur, whereas spacing samples - * further apart undersamples the image and produces visible banding. + * samples adjacent texels only, one CSS pixel apart (so the blur looks the + * same at any `RenderContext.pixelRatio`), so increasing `passes` (rather + * than the distance between samples) is what makes the blur stronger: + * repeated narrow passes compose into a wide, smooth blur, whereas spacing + * samples further apart undersamples the image and produces visible + * banding. */ passes: number; diff --git a/src/rendering/shaders/post-process/bloom-threshold.frag.glsl b/src/rendering/shaders/post-process/bloom-threshold.frag.glsl index 6081833a..10834796 100644 --- a/src/rendering/shaders/post-process/bloom-threshold.frag.glsl +++ b/src/rendering/shaders/post-process/bloom-threshold.frag.glsl @@ -7,6 +7,7 @@ precision mediump float; uniform sampler2D u_texture; uniform float u_threshold; uniform vec2 u_texelSize; // 1 / full-resolution source texture size +uniform int u_blockSize; // source texels per destination texel, along each axis in vec2 v_texCoord; out vec4 fragColor; @@ -15,28 +16,30 @@ out vec4 fragColor; // full strength. Softens the cutoff into a fade instead of a hard edge. const float knee = 0.1; -const float sampleCount = 16.0; - void main() { - // Each destination texel here covers a 4x4 block of the full-resolution - // source (see bloomDownsampleFactor in bloom-system.ts). A single point - // sample at this resolution can miss a small or thin bright source - // entirely if it doesn't land on a sample point - a bullet or spark only - // a few source pixels wide would simply fall between texels and never - // reach the bright-pass buffer. Thresholding every one of the 4x4 block's - // texels individually, then averaging, is a proper box-filter downsample: - // a small bright texel still clears the threshold on its own merits (so - // it isn't missed), while its color is preserved and blended with its - // neighbors' rather than one texel's color winning outright - keeping a - // white-hot core distinct from a dimmer, differently-colored surrounding - // glow (a bullet's yellow tail, say) instead of flattening the whole - // block to whichever single texel happened to be brightest. + // Each destination texel here covers a u_blockSize x u_blockSize block of + // the full-resolution source (see downsampleBlockSize in + // bloom-system.ts: a fixed size in CSS pixels, so more source texels on + // a HiDPI display). A single point sample at this resolution can miss a + // small or thin bright source entirely if it doesn't land on a sample + // point - a bullet or spark only a few source pixels wide would simply + // fall between texels and never reach the bright-pass buffer. + // Thresholding every one of the block's texels individually, then + // averaging, is a proper box-filter downsample: a small bright texel + // still clears the threshold on its own merits (so it isn't missed), + // while its color is preserved and blended with its neighbors' rather + // than one texel's color winning outright - keeping a white-hot core + // distinct from a dimmer, differently-colored surrounding glow (a + // bullet's yellow tail, say) instead of flattening the whole block to + // whichever single texel happened to be brightest. vec3 accumulatedColor = vec3(0.0); float accumulatedContribution = 0.0; + float blockSize = float(u_blockSize); + vec2 firstOffset = vec2(0.5 - blockSize * 0.5); - for (int y = -2; y < 2; y++) { - for (int x = -2; x < 2; x++) { - vec2 offset = (vec2(float(x), float(y)) + 0.5) * u_texelSize; + for (int y = 0; y < u_blockSize; y++) { + for (int x = 0; x < u_blockSize; x++) { + vec2 offset = (firstOffset + vec2(float(x), float(y))) * u_texelSize; vec4 sampleColor = texture(u_texture, v_texCoord + offset); float sampleBrightness = dot(sampleColor.rgb, vec3(0.2126, 0.7152, 0.0722)); float sampleContribution = smoothstep(u_threshold, u_threshold + knee, sampleBrightness); @@ -46,6 +49,8 @@ void main() { } } + float sampleCount = blockSize * blockSize; + // Alpha carries the averaged contribution itself, not the source pixels' // original alpha: this buffer gets blurred next, and the glow needs to // spread its own opacity outward past the source sprite's silhouette diff --git a/src/rendering/shaders/post-process/box-downsample.frag.glsl b/src/rendering/shaders/post-process/box-downsample.frag.glsl new file mode 100644 index 00000000..12eb9e30 --- /dev/null +++ b/src/rendering/shaders/post-process/box-downsample.frag.glsl @@ -0,0 +1,32 @@ +#version 300 es + +#pragma forge name(box-downsample.frag) + +precision mediump float; + +uniform sampler2D u_texture; +uniform vec2 u_texelSize; // 1 / source texture size +uniform int u_blockSize; // source texels per destination texel, along each axis + +in vec2 v_texCoord; +out vec4 fragColor; + +void main() { + // Averages the u_blockSize x u_blockSize block of source texels this + // destination texel covers. A single (even bilinear) sample would read + // at most a 2x2 corner of a larger block and skip the rest, so a detail + // narrower than the block could vanish or flicker as it moves. + vec4 accumulatedColor = vec4(0.0); + float blockSize = float(u_blockSize); + vec2 firstOffset = vec2(0.5 - blockSize * 0.5); + + for (int y = 0; y < u_blockSize; y++) { + for (int x = 0; x < u_blockSize; x++) { + vec2 offset = (firstOffset + vec2(float(x), float(y))) * u_texelSize; + + accumulatedColor += texture(u_texture, v_texCoord + offset); + } + } + + fragColor = accumulatedColor / (blockSize * blockSize); +} diff --git a/src/rendering/shaders/post-process/index.ts b/src/rendering/shaders/post-process/index.ts index 1cfccdf3..c2435f57 100644 --- a/src/rendering/shaders/post-process/index.ts +++ b/src/rendering/shaders/post-process/index.ts @@ -1,5 +1,6 @@ import bloomCompositeFragmentShaderSource from './bloom-composite.frag.glsl?raw'; import bloomThresholdFragmentShaderSource from './bloom-threshold.frag.glsl?raw'; +import boxDownsampleFragmentShaderSource from './box-downsample.frag.glsl?raw'; import crossFadeFragmentShaderSource from './cross-fade.frag.glsl?raw'; import gaussianBlurFragmentShaderSource from './gaussian-blur.frag.glsl?raw'; import passthroughFragmentShaderSource from './passthrough.frag.glsl?raw'; @@ -12,4 +13,5 @@ export const gaussianBlurFragmentShader = gaussianBlurFragmentShaderSource; export const crossFadeFragmentShader = crossFadeFragmentShaderSource; export const bloomThresholdFragmentShader = bloomThresholdFragmentShaderSource; export const bloomCompositeFragmentShader = bloomCompositeFragmentShaderSource; +export const boxDownsampleFragmentShader = boxDownsampleFragmentShaderSource; export const toneMappingFragmentShader = toneMappingFragmentShaderSource; diff --git a/src/rendering/systems/bloom-system.test.ts b/src/rendering/systems/bloom-system.test.ts index 284c6ace..e5b638a3 100644 --- a/src/rendering/systems/bloom-system.test.ts +++ b/src/rendering/systems/bloom-system.test.ts @@ -38,6 +38,7 @@ describe('createBloomEcsSystem', () => { let sceneTextureLocation: WebGLUniformLocation; let bloomTextureLocation: WebGLUniformLocation; let intensityLocation: WebGLUniformLocation; + let blockSizeLocation: WebGLUniformLocation; const addCameraEntity = ( renderTarget?: CameraEcsComponent['renderTarget'], @@ -77,6 +78,7 @@ describe('createBloomEcsSystem', () => { sceneTextureLocation = {}; bloomTextureLocation = {}; intensityLocation = {}; + blockSizeLocation = {}; mockGl = { VERTEX_SHADER: 'VERTEX_SHADER', @@ -129,7 +131,7 @@ describe('createBloomEcsSystem', () => { getProgramParameter: vi .fn() .mockImplementation((_program: unknown, pname: unknown) => - pname === 'ACTIVE_UNIFORMS' ? 7 : true, + pname === 'ACTIVE_UNIFORMS' ? 8 : true, ), getProgramInfoLog: vi.fn().mockReturnValue(''), @@ -148,6 +150,7 @@ describe('createBloomEcsSystem', () => { { name: 'u_sceneTexture', type: 0x8b5e /* SAMPLER_2D */, size: 1 }, { name: 'u_bloomTexture', type: 0x8b5e /* SAMPLER_2D */, size: 1 }, { name: 'u_intensity', type: 0x1406 /* FLOAT */, size: 1 }, + { name: 'u_blockSize', type: 0x1404 /* INT */, size: 1 }, ][index] ?? null, ), getUniformLocation: vi @@ -177,6 +180,10 @@ describe('createBloomEcsSystem', () => { return intensityLocation; } + if (name === 'u_blockSize') { + return blockSizeLocation; + } + return textureLocation; }), useProgram: vi.fn(), @@ -346,7 +353,7 @@ describe('createBloomEcsSystem', () => { ([location]) => location === texelSizeLocation, ); - // The threshold pass samples a 4x4 block of the full-resolution source + // The threshold pass samples a block of the full-resolution source // per downsampled destination texel (see bloom-threshold.frag.glsl), so // it needs the full-resolution texel size, not the downsampled one the // blur passes use. @@ -379,6 +386,126 @@ describe('createBloomEcsSystem', () => { } }); + it('passes a 4x4 block size to the threshold pass at a pixel ratio of 1', () => { + const target = new RenderTarget(mockGl, 256, 128); + + addBloomedCameraEntity(target, { passes: 1 }); + + world.update(); + + expect(mockGl.uniform1i).toHaveBeenCalledWith(blockSizeLocation, 4); + }); + + describe('pixel ratio', () => { + const getBlurTexelSizes = (): number[][] => + (mockGl.uniform2fv as Mock).mock.calls + .filter(([location]) => location === texelSizeLocation) + .slice(1) + .map(([, value]) => Array.from(value as Float32Array)); + + it('scales the threshold block size with the pixel ratio', () => { + renderContext.pixelRatio = 2; + + const target = new RenderTarget(mockGl, 512, 256); + + addBloomedCameraEntity(target, { passes: 1 }); + + world.update(); + + // Each bright-pass texel covers 4x4 CSS pixels, which is 8x8 device + // pixels at a pixel ratio of 2. + expect(mockGl.uniform1i).toHaveBeenCalledWith(blockSizeLocation, 8); + }); + + it('rounds a fractional block size to whole texels', () => { + renderContext.pixelRatio = 1.5; + + const target = new RenderTarget(mockGl, 384, 192); + + addBloomedCameraEntity(target, { passes: 1 }); + + world.update(); + + expect(mockGl.uniform1i).toHaveBeenCalledWith(blockSizeLocation, 6); + }); + + it('sizes the downsampled buffers by the scaled block size', () => { + renderContext.pixelRatio = 2; + + const target = new RenderTarget(mockGl, 512, 256); + + (mockGl.texImage2D as Mock).mockClear(); + + addBloomedCameraEntity(target, { passes: 1 }); + + world.update(); + + const allocatedSizes = (mockGl.texImage2D as Mock).mock.calls.map( + ([, , , width, height]: unknown[]) => [Number(width), Number(height)], + ); + + // brightTarget (1) + ping-pong (2) at 512/8 x 256/8, then the + // full-resolution compositeTarget (1). + expect(allocatedSizes).toEqual([ + [64, 32], + [64, 32], + [64, 32], + [512, 256], + ]); + }); + + it('steps the blur the same number of CSS pixels at any pixel ratio', () => { + const cssWidth = 256; + const cssHeight = 128; + + const blurTexelSizeInCssPixels = (pixelRatio: number): number[] => { + (mockGl.uniform2fv as Mock).mockClear(); + renderContext.pixelRatio = pixelRatio; + + const width = cssWidth * pixelRatio; + const height = cssHeight * pixelRatio; + const target = new RenderTarget(mockGl, width, height); + const entity = addBloomedCameraEntity(target, { passes: 1 }); + + world.update(); + world.removeEntity(entity); + + const [horizontal] = getBlurTexelSizes(); + + return [horizontal[0] * cssWidth, horizontal[1] * cssHeight]; + }; + + for (const pixelRatio of [1, 1.1, 1.5, 2, 3]) { + const [x, y] = blurTexelSizeInCssPixels(pixelRatio); + + expect(x).toBeCloseTo(4); + expect(y).toBeCloseTo(4); + } + }); + + it('recreates the downsampled buffers when the pixel ratio changes', () => { + const target = new RenderTarget(mockGl, 512, 256); + + addBloomedCameraEntity(target, { passes: 1 }); + + world.update(); + (mockGl.texImage2D as Mock).mockClear(); + + renderContext.pixelRatio = 2; + world.update(); + + const allocatedSizes = (mockGl.texImage2D as Mock).mock.calls.map( + ([, , , width, height]: unknown[]) => [Number(width), Number(height)], + ); + + expect(allocatedSizes).toEqual([ + [64, 32], + [64, 32], + [64, 32], + ]); + }); + }); + it('blooms multiple cameras independently', () => { const targetA = new RenderTarget(mockGl, 128, 128); const targetB = new RenderTarget(mockGl, 64, 64); diff --git a/src/rendering/systems/bloom-system.ts b/src/rendering/systems/bloom-system.ts index 979ff918..8be5e84e 100644 --- a/src/rendering/systems/bloom-system.ts +++ b/src/rendering/systems/bloom-system.ts @@ -21,10 +21,27 @@ import { createRenderTarget, RenderTarget } from '../render-target.js'; // first means each texel already covers several source pixels, so the same // kernel and pass count produce a much wider, softer glow, for a fraction // of the fragment shader cost to boot. +// +// Measured in CSS pixels, not render-target (device) pixels: each +// downsampled texel covers this many CSS pixels square, whatever the +// display's pixel ratio. In device pixels, a small bright sprite would fill +// more of each block on a HiDPI display (so its glow starts brighter) and +// the blur would step half as far on screen (so the glow is shorter), +// making the same settings look different on every display. const bloomDownsampleFactor = 4; -const downsampledSize = (size: number): number => - Math.max(1, Math.round(size / bloomDownsampleFactor)); +/** + * The width (and height) in render-target texels of the block each + * downsampled bright-pass texel covers: `bloomDownsampleFactor` CSS pixels, + * converted to device pixels and rounded to a whole number of texels. + * @param pixelRatio - The render context's device pixels per CSS pixel. + * @returns The block size in render-target texels, at least 1. + */ +const downsampleBlockSize = (pixelRatio: number): number => + Math.max(1, Math.round(bloomDownsampleFactor * pixelRatio)); + +const downsampledSize = (size: number, blockSize: number): number => + Math.max(1, Math.round(size / blockSize)); // Shared, read-only direction constants for the two blur passes: passed // straight through as the `u_direction` uniform's `Float32Array` value, so @@ -88,9 +105,12 @@ export const createBloomEcsSystem = ( const pingPongByTarget = new WeakMap(); const compositeTargetByTarget = new WeakMap(); - const getBrightTarget = (target: RenderTarget): RenderTarget => { - const width = downsampledSize(target.width); - const height = downsampledSize(target.height); + const getBrightTarget = ( + target: RenderTarget, + blockSize: number, + ): RenderTarget => { + const width = downsampledSize(target.width, blockSize); + const height = downsampledSize(target.height, blockSize); const existing = brightTargetByTarget.get(target); const isStale = existing !== undefined && @@ -111,9 +131,12 @@ export const createBloomEcsSystem = ( return brightTarget; }; - const getPingPongTarget = (target: RenderTarget): PingPongTarget => { - const width = downsampledSize(target.width); - const height = downsampledSize(target.height); + const getPingPongTarget = ( + target: RenderTarget, + blockSize: number, + ): PingPongTarget => { + const width = downsampledSize(target.width, blockSize); + const height = downsampledSize(target.height, blockSize); const existing = pingPongByTarget.get(target); const isStale = existing !== undefined && @@ -209,12 +232,15 @@ export const createBloomEcsSystem = ( processedTargetsThisFrame.add(renderTarget); - const brightTarget = getBrightTarget(renderTarget); + const { pixelRatio } = renderContext; + const blockSize = downsampleBlockSize(pixelRatio); + const brightTarget = getBrightTarget(renderTarget, blockSize); beginFullscreenReplacePass(renderContext, brightTarget); thresholdMaterial.setUniform('u_texture', renderTarget.colorTexture); thresholdMaterial.setUniform('u_threshold', bloom.threshold); + thresholdMaterial.setUniform('u_blockSize', blockSize); thresholdMaterial.setUniform( 'u_texelSize', new Float32Array([1 / renderTarget.width, 1 / renderTarget.height]), @@ -222,10 +248,15 @@ export const createBloomEcsSystem = ( drawFullscreenQuad(renderContext, thresholdMaterial); - const pingPong = getPingPongTarget(renderTarget); + const pingPong = getPingPongTarget(renderTarget, blockSize); + // One kernel step per `bloomDownsampleFactor` CSS pixels, computed + // from the full-resolution target rather than `brightTarget`'s own + // size, so rounding `blockSize` to whole texels doesn't change how + // far the glow reaches on screen (at a pixel ratio of 1.1, say, a + // block is 4 texels but `bloomDownsampleFactor` CSS pixels is 4.4). const texelSize = new Float32Array([ - 1 / brightTarget.width, - 1 / brightTarget.height, + (bloomDownsampleFactor * pixelRatio) / renderTarget.width, + (bloomDownsampleFactor * pixelRatio) / renderTarget.height, ]); // Same two-pass separable technique as createGaussianBlurEcsSystem: diff --git a/src/rendering/systems/gaussian-blur-system.test.ts b/src/rendering/systems/gaussian-blur-system.test.ts index c9823371..d244c6b0 100644 --- a/src/rendering/systems/gaussian-blur-system.test.ts +++ b/src/rendering/systems/gaussian-blur-system.test.ts @@ -13,6 +13,7 @@ import { RenderContext } from '../render-context'; import { RenderTarget } from '../render-target'; import { ImageCache } from '../../asset-loading'; import { + boxDownsampleFragmentShader, crossFadeFragmentShader, ForgeShaderSource, gaussianBlurFragmentShader, @@ -31,6 +32,7 @@ describe('createGaussianBlurEcsSystem', () => { let world: EcsWorld; let directionLocation: WebGLUniformLocation; let texelSizeLocation: WebGLUniformLocation; + let blockSizeLocation: WebGLUniformLocation; let textureLocation: WebGLUniformLocation; let fromTextureLocation: WebGLUniformLocation; let toTextureLocation: WebGLUniformLocation; @@ -69,6 +71,7 @@ describe('createGaussianBlurEcsSystem', () => { directionLocation = {}; texelSizeLocation = {}; + blockSizeLocation = {}; textureLocation = {}; fromTextureLocation = {}; toTextureLocation = {}; @@ -122,7 +125,7 @@ describe('createGaussianBlurEcsSystem', () => { getProgramParameter: vi .fn() .mockImplementation((_program: unknown, pname: unknown) => - pname === 'ACTIVE_UNIFORMS' ? 6 : true, + pname === 'ACTIVE_UNIFORMS' ? 7 : true, ), getProgramInfoLog: vi.fn().mockReturnValue(''), @@ -140,6 +143,7 @@ describe('createGaussianBlurEcsSystem', () => { { name: 'u_fromTexture', type: 0x8b5e /* SAMPLER_2D */, size: 1 }, { name: 'u_toTexture', type: 0x8b5e /* SAMPLER_2D */, size: 1 }, { name: 'u_factor', type: 0x1406 /* FLOAT */, size: 1 }, + { name: 'u_blockSize', type: 0x1404 /* INT */, size: 1 }, ][index] ?? null, ), getUniformLocation: vi @@ -153,6 +157,10 @@ describe('createGaussianBlurEcsSystem', () => { return texelSizeLocation; } + if (name === 'u_blockSize') { + return blockSizeLocation; + } + if (name === 'u_fromTexture') { return fromTextureLocation; } @@ -191,7 +199,8 @@ describe('createGaussianBlurEcsSystem', () => { .addShader(new ForgeShaderSource(passthroughVertexShader)) .addShader(new ForgeShaderSource(passthroughFragmentShader)) .addShader(new ForgeShaderSource(gaussianBlurFragmentShader)) - .addShader(new ForgeShaderSource(crossFadeFragmentShader)); + .addShader(new ForgeShaderSource(crossFadeFragmentShader)) + .addShader(new ForgeShaderSource(boxDownsampleFragmentShader)); renderContext = new RenderContext(shaderCache, new ImageCache(), canvas); world = new EcsWorld(); @@ -324,6 +333,237 @@ describe('createGaussianBlurEcsSystem', () => { } }); + describe('never samples the texture it is drawing into', () => { + /** + * Gives every framebuffer and texture its own identity, and records each + * draw that samples the color texture attached to the framebuffer it's + * drawing into: a feedback loop, which WebGL leaves undefined (in + * practice, a black or garbage result). + * @returns The draws found to read their own destination, by index. + */ + const trackFeedbackLoops = (): number[] => { + const attachments = new Map(); + const sampledTextures = new Set(); + const feedbackDraws: number[] = []; + let boundFramebuffer: unknown = null; + let drawIndex = 0; + + (mockGl.createFramebuffer as Mock).mockImplementation(() => ({})); + (mockGl.createTexture as Mock).mockImplementation( + () => new WebGLTexture(), + ); + (mockGl.bindFramebuffer as Mock).mockImplementation( + (_target: unknown, framebuffer: unknown) => { + boundFramebuffer = framebuffer; + sampledTextures.clear(); + }, + ); + (mockGl.framebufferTexture2D as Mock).mockImplementation( + ( + _target: unknown, + _attachment: unknown, + _texTarget: unknown, + texture: unknown, + ) => { + attachments.set(boundFramebuffer, texture); + }, + ); + (mockGl.bindTexture as Mock).mockImplementation( + (_target: unknown, texture: unknown) => { + sampledTextures.add(texture); + }, + ); + (mockGl.drawArrays as Mock).mockImplementation(() => { + if (sampledTextures.has(attachments.get(boundFramebuffer))) { + feedbackDraws.push(drawIndex); + } + + drawIndex++; + sampledTextures.clear(); + }); + + return feedbackDraws; + }; + + for (const pixelRatio of [1, 2]) { + for (const intensity of [1, 0.5]) { + it(`at a pixel ratio of ${pixelRatio} and an intensity of ${intensity}`, () => { + const feedbackDraws = trackFeedbackLoops(); + + renderContext.pixelRatio = pixelRatio; + + const target = new RenderTarget( + mockGl, + 256 * pixelRatio, + 128 * pixelRatio, + ); + + addBlurredCameraEntity(target, { passes: 3, intensity }); + + world.update(); + + expect(mockGl.drawArrays).toHaveBeenCalled(); + expect(feedbackDraws).toEqual([]); + }); + } + } + }); + + describe('pixel ratio', () => { + const getAllocatedSizes = (): number[][] => + (mockGl.texImage2D as Mock).mock.calls.map( + ([, , , width, height]: unknown[]) => [Number(width), Number(height)], + ); + + const getDrawTargets = (): unknown[] => + (mockGl.bindFramebuffer as Mock).mock.calls.map( + ([, framebuffer]: unknown[]) => framebuffer, + ); + + it('blurs at full resolution, without a downsample pass, at a pixel ratio of 1', () => { + const target = new RenderTarget(mockGl, 256, 128); + + (mockGl.texImage2D as Mock).mockClear(); + + addBlurredCameraEntity(target, { passes: 1 }); + + world.update(); + + expect(getAllocatedSizes()).toEqual([ + [256, 128], + [256, 128], + ]); + // Just the horizontal and vertical blur: no downsample pass. + expect(mockGl.drawArrays).toHaveBeenCalledTimes(2); + }); + + it('blurs at CSS-pixel resolution on a high-DPI display', () => { + renderContext.pixelRatio = 2; + + const target = new RenderTarget(mockGl, 512, 256); + + (mockGl.texImage2D as Mock).mockClear(); + + addBlurredCameraEntity(target, { passes: 1 }); + + world.update(); + + // The ping-pong pair is sized in CSS pixels: 512x256 device pixels at + // a pixel ratio of 2. + expect(getAllocatedSizes()).toEqual([ + [256, 128], + [256, 128], + ]); + + // A downsample pass averaging each 2x2 block, then the horizontal and + // vertical blur. + expect(mockGl.drawArrays).toHaveBeenCalledTimes(3); + expect(mockGl.uniform1i).toHaveBeenCalledWith(blockSizeLocation, 2); + }); + + it('steps the kernel one CSS pixel per tap at any pixel ratio', () => { + const cssWidth = 256; + const cssHeight = 128; + + for (const pixelRatio of [1, 1.5, 2, 3]) { + (mockGl.uniform2fv as Mock).mockClear(); + renderContext.pixelRatio = pixelRatio; + + const target = new RenderTarget( + mockGl, + cssWidth * pixelRatio, + cssHeight * pixelRatio, + ); + const entity = addBlurredCameraEntity(target, { passes: 1 }); + + world.update(); + world.removeEntity(entity); + + const blurTexelSizes = (mockGl.uniform2fv as Mock).mock.calls.filter( + ([location]) => location === texelSizeLocation, + ); + + // The first call is the downsample pass's, when there is one. + const [x, y] = Array.from( + blurTexelSizes[blurTexelSizes.length - 1][1] as Float32Array, + ); + + expect(x * cssWidth).toBeCloseTo(1); + expect(y * cssHeight).toBeCloseTo(1); + } + }); + + it('upsamples back into the camera render target on the last pass', () => { + renderContext.pixelRatio = 2; + + // Distinct framebuffer objects, so draws into the camera's target can be told + // apart from draws into the ping-pong pair. + (mockGl.createFramebuffer as Mock).mockImplementation(() => ({})); + + const target = new RenderTarget(mockGl, 512, 256); + + addBlurredCameraEntity(target, { passes: 3 }); + + (mockGl.bindFramebuffer as Mock).mockClear(); + + world.update(); + + const drawTargets = getDrawTargets().filter( + (framebuffer) => framebuffer !== null, + ); + + // Only the very last draw writes to the full-resolution target; every + // earlier pass stays in the downsampled ping-pong pair. + expect(drawTargets[drawTargets.length - 1]).toBe(target.framebuffer); + expect( + drawTargets.filter((framebuffer) => framebuffer === target.framebuffer), + ).toHaveLength(1); + }); + + it('cross-fades against the full-resolution scene for a fractional intensity', () => { + renderContext.pixelRatio = 2; + + const target = new RenderTarget(mockGl, 512, 256); + + (mockGl.texImage2D as Mock).mockClear(); + + addBlurredCameraEntity(target, { passes: 1, intensity: 0.5 }); + + world.update(); + + // Downsampled ping-pong pair, then a full-resolution blend target. + expect(getAllocatedSizes()).toEqual([ + [256, 128], + [256, 128], + [512, 256], + ]); + + // Downsample + 2 blur draws + mix + copy-back. + expect(mockGl.drawArrays).toHaveBeenCalledTimes(5); + expect(mockGl.bindFramebuffer).toHaveBeenLastCalledWith( + mockGl.FRAMEBUFFER, + target.framebuffer, + ); + }); + + it('recreates the ping-pong pair when the pixel ratio changes', () => { + const target = new RenderTarget(mockGl, 512, 256); + + addBlurredCameraEntity(target, { passes: 1 }); + + world.update(); + (mockGl.texImage2D as Mock).mockClear(); + + renderContext.pixelRatio = 2; + world.update(); + + expect(getAllocatedSizes()).toEqual([ + [256, 128], + [256, 128], + ]); + }); + }); + it('presents multiple cameras independently', () => { const targetA = new RenderTarget(mockGl, 128, 128); const targetB = new RenderTarget(mockGl, 64, 64); @@ -388,7 +628,7 @@ describe('createGaussianBlurEcsSystem', () => { expect(mockGl.deleteTexture).toHaveBeenCalledTimes(2); }); - it('also disposes the sharp snapshot target when intensity is fractional', () => { + it('also disposes the blend target when intensity is fractional', () => { const target = new RenderTarget(mockGl, 128, 128); addBlurredCameraEntity(target, { passes: 1, intensity: 0.5 }); @@ -399,7 +639,7 @@ describe('createGaussianBlurEcsSystem', () => { world.stop(); - // Ping-pong target (2 render targets) + sharp snapshot (1 render target). + // Ping-pong target (2 render targets) + blend target (1 render target). expect(mockGl.deleteFramebuffer).toHaveBeenCalledTimes(3); expect(mockGl.deleteTexture).toHaveBeenCalledTimes(3); }); @@ -448,8 +688,8 @@ describe('createGaussianBlurEcsSystem', () => { world.update(); - // 1 pass (2 draws) + snapshot copy + mix + final copy-back = 5. - expect(mockGl.drawArrays).toHaveBeenCalledTimes(5); + // 1 pass (2 draws) + mix + final copy-back = 4. + expect(mockGl.drawArrays).toHaveBeenCalledTimes(4); const intensityCalls = (mockGl.uniform1f as Mock).mock.calls.filter( ([location]) => location === factorLocation, @@ -514,8 +754,8 @@ describe('createGaussianBlurEcsSystem', () => { world.update(); - // Dropping below 1 adds the snapshot/mix/copy-back draws. - expect(mockGl.drawArrays).toHaveBeenCalledTimes(5); + // Dropping below 1 adds the mix and copy-back draws. + expect(mockGl.drawArrays).toHaveBeenCalledTimes(4); }); }); }); diff --git a/src/rendering/systems/gaussian-blur-system.ts b/src/rendering/systems/gaussian-blur-system.ts index 1c34f797..873eb0ee 100644 --- a/src/rendering/systems/gaussian-blur-system.ts +++ b/src/rendering/systems/gaussian-blur-system.ts @@ -25,11 +25,16 @@ const verticalBlurDirection = new Float32Array([0, 1]); * Creates a two-pass separable Gaussian blur post-processing system. * * For each camera with both a `renderTarget` and a `GaussianBlurEcsComponent`, - * blurs that target's contents in place: a horizontal pass renders into an - * internal scratch buffer, then a vertical pass reads that scratch buffer - * and renders the result back into the camera's `renderTarget`. Cameras - * without a `renderTarget`, or without a `GaussianBlurEcsComponent` - * (attach one with `addGaussianBlurComponent`), are left untouched. + * blurs that target's contents in place: each pass is a horizontal then a + * vertical blur through internal scratch buffers, and the last pass renders + * the result back into the camera's `renderTarget`. Cameras without a + * `renderTarget`, or without a `GaussianBlurEcsComponent` (attach one with + * `addGaussianBlurComponent`), are left untouched. + * + * The blur is sized in CSS pixels, so the same `passes` look the same on + * every display: on a high-DPI display (`RenderContext.pixelRatio` above + * `1`) the scene is first averaged down to CSS-pixel resolution, blurred + * there, and scaled back up by the last pass. * * Must be registered after the render system (so there's a scene to blur) * and before the present system (so the blurred result gets drawn to the @@ -52,6 +57,11 @@ export const createGaussianBlurEcsSystem = ( shaderCache.getShader('passthrough.frag'), gl, ); + const downsampleMaterial = new Material( + shaderCache.getShader('passthrough.vert'), + shaderCache.getShader('box-downsample.frag'), + gl, + ); const crossFadeMaterial = new Material( shaderCache.getShader('passthrough.vert'), shaderCache.getShader('cross-fade.frag'), @@ -59,18 +69,23 @@ export const createGaussianBlurEcsSystem = ( ); // Scratch GPU resources, one entry per distinct `renderTarget` in use by a - // blurred camera, sized to match it and recreated on resize. Owned by this - // system (not module-level state) and disposed via `cleanup` when - // the world stops. + // blurred camera, recreated on resize. `pingPongByTarget` holds the blur + // chain, at CSS-pixel resolution (see `update`); `blendTargetByTarget` + // matches the camera's `renderTarget` resolution, since the cross-fade's + // output replaces that target's contents. Owned by this system (not + // module-level state) and disposed via `cleanup` when the world stops. const pingPongByTarget = new WeakMap(); - const sharpSnapshotByTarget = new WeakMap(); + const blendTargetByTarget = new WeakMap(); - const getPingPongTarget = (target: RenderTarget): PingPongTarget => { + const getPingPongTarget = ( + target: RenderTarget, + width: number, + height: number, + ): PingPongTarget => { const existing = pingPongByTarget.get(target); const isStale = existing !== undefined && - (existing.read.width !== target.width || - existing.read.height !== target.height); + (existing.read.width !== width || existing.read.height !== height); if (existing && !isStale) { return existing; @@ -80,20 +95,15 @@ export const createGaussianBlurEcsSystem = ( existing.dispose(gl); } - const pingPong = new PingPongTarget( - gl, - target.width, - target.height, - target.format, - ); + const pingPong = new PingPongTarget(gl, width, height, target.format); pingPongByTarget.set(target, pingPong); return pingPong; }; - const getSharpSnapshotTarget = (target: RenderTarget): RenderTarget => { - const existing = sharpSnapshotByTarget.get(target); + const getBlendTarget = (target: RenderTarget): RenderTarget => { + const existing = blendTargetByTarget.get(target); const isStale = existing !== undefined && (existing.width !== target.width || existing.height !== target.height); @@ -106,16 +116,16 @@ export const createGaussianBlurEcsSystem = ( existing.dispose(gl); } - const snapshot = createRenderTarget( + const blendTarget = createRenderTarget( gl, target.width, target.height, target.format, ); - sharpSnapshotByTarget.set(target, snapshot); + blendTargetByTarget.set(target, blendTarget); - return snapshot; + return blendTarget; }; const drawPass = ( @@ -145,6 +155,135 @@ export const createGaussianBlurEcsSystem = ( drawFullscreenQuad(renderContext, copyMaterial); }; + const downsample = ( + source: RenderTarget, + blockSize: number, + destination: RenderTarget, + ): void => { + beginFullscreenReplacePass(renderContext, destination); + + downsampleMaterial.setUniform('u_texture', source.colorTexture); + downsampleMaterial.setUniform('u_blockSize', blockSize); + downsampleMaterial.setUniform( + 'u_texelSize', + new Float32Array([1 / source.width, 1 / source.height]), + ); + + drawFullscreenQuad(renderContext, downsampleMaterial); + }; + + /** + * Runs `passes` horizontal+vertical blur pairs over `renderTarget`. + * @param renderTarget - The camera's render target, holding the scene to blur. + * @param passes - How many blur pairs to run, at least 1. + * @param keepSharpScene - Whether `renderTarget` must keep the sharp scene, + * for a cross-fade afterwards. If so, the blurred result is left in the + * returned ping-pong pair's `read` target instead of `renderTarget`. + * @returns The ping-pong pair the blur ran through. + */ + const blurPasses = ( + renderTarget: RenderTarget, + passes: number, + keepSharpScene: boolean, + ): PingPongTarget => { + // The 9-tap kernel steps one CSS pixel per tap, not one render-target + // texel: the target is sized in device pixels, so a texel step would + // blur half as far on screen at a pixel ratio of 2 as at 1. Stepping a + // CSS pixel across a device-pixel texture would skip the texels in + // between, though (at a ratio of 2, odd and even columns would never + // mix, striping thin details), so on a high-DPI display the blur chain + // runs on a copy averaged down to CSS-pixel resolution instead, where + // one CSS pixel is one texel again. The last pass's linear-filtered + // sampling scales it back up. + const { pixelRatio } = renderContext; + const downsampleScale = Math.max(1, pixelRatio); + const pingPong = getPingPongTarget( + renderTarget, + Math.max(1, Math.round(renderTarget.width / downsampleScale)), + Math.max(1, Math.round(renderTarget.height / downsampleScale)), + ); + const isDownsampled = + pingPong.read.width !== renderTarget.width || + pingPong.read.height !== renderTarget.height; + const texelSize = new Float32Array([ + pixelRatio / renderTarget.width, + pixelRatio / renderTarget.height, + ]); + + if (isDownsampled) { + downsample( + renderTarget, + Math.max(1, Math.round(downsampleScale)), + pingPong.write, + ); + pingPong.swap(); + } + + // Each iteration reads the previous iteration's result and writes the + // next, more-blurred version, so `passes` composes into a wider blur + // without ever widening the individual 9-tap kernel. + for (let p = 0; p < passes; p++) { + const source = p === 0 && !isDownsampled ? renderTarget : pingPong.read; + + drawPass( + blurMaterial, + source.colorTexture, + horizontalBlurDirection, + texelSize, + pingPong.write, + ); + pingPong.swap(); + + // Picked only after the swap above: before it, `pingPong.write` is the + // buffer the horizontal pass just wrote, which this pass reads from. + const isLastPass = p + 1 >= passes; + const destination: RenderTarget = + isLastPass && !keepSharpScene ? renderTarget : pingPong.write; + + drawPass( + blurMaterial, + pingPong.read.colorTexture, + verticalBlurDirection, + texelSize, + destination, + ); + + if (destination !== renderTarget) { + pingPong.swap(); + } + } + + return pingPong; + }; + + /** + * Cross-fades the still-sharp `renderTarget` into `blurred` (upsampled + * implicitly by its linear-filtered sampling, if the blur ran + * downsampled), into a full-resolution scratch buffer, then copies that + * blend back into `renderTarget`, since consumers (like the present + * system) always read the blur's output from there. + * @param renderTarget - The camera's render target, holding the sharp scene. + * @param blurred - The fully-blurred scene. + * @param intensity - How much of `blurred` to show, from `0` to `1`. + */ + const crossFade = ( + renderTarget: RenderTarget, + blurred: RenderTarget, + intensity: number, + ): void => { + const blendTarget = getBlendTarget(renderTarget); + + beginFullscreenReplacePass(renderContext, blendTarget); + + crossFadeMaterial.setUniform('u_fromTexture', renderTarget.colorTexture); + crossFadeMaterial.setUniform('u_toTexture', blurred.colorTexture); + crossFadeMaterial.setUniform('u_factor', intensity); + + drawFullscreenQuad(renderContext, crossFadeMaterial); + + copyTexture(blendTarget.colorTexture, renderTarget); + }; + const processedTargetsThisFrame = new Set(); return { @@ -161,6 +300,7 @@ export const createGaussianBlurEcsSystem = ( if ( !renderTarget || intensity <= 0 || + blur.passes <= 0 || processedTargetsThisFrame.has(renderTarget) ) { continue; @@ -169,65 +309,11 @@ export const createGaussianBlurEcsSystem = ( processedTargetsThisFrame.add(renderTarget); const needsBlend = intensity < 1; - const sharpSnapshot = needsBlend - ? getSharpSnapshotTarget(renderTarget) - : null; - - if (sharpSnapshot) { - copyTexture(renderTarget.colorTexture, sharpSnapshot); - } - - const pingPong = getPingPongTarget(renderTarget); - const texelSize = new Float32Array([ - 1 / renderTarget.width, - 1 / renderTarget.height, - ]); - - // Each iteration reads the previous iteration's result back out of - // `renderTarget` (itself, for the first iteration, the freshly - // rendered scene) and writes the next, more-blurred version back - // into it, so `passes` composes into a wider blur without ever - // widening the individual 9-tap kernel. - for (let p = 0; p < blur.passes; p++) { - drawPass( - blurMaterial, - renderTarget.colorTexture, - horizontalBlurDirection, - texelSize, - pingPong.write, - ); - pingPong.swap(); - - drawPass( - blurMaterial, - pingPong.read.colorTexture, - verticalBlurDirection, - texelSize, - renderTarget, - ); - } + const pingPong = blurPasses(renderTarget, blur.passes, needsBlend); - if (!sharpSnapshot) { - continue; + if (needsBlend) { + crossFade(renderTarget, pingPong.read, intensity); } - - // Cross-fade the untouched sharp snapshot into the fully-blurred - // result, into a scratch buffer (safe to reuse now that the loop - // above is done with it), then copy that blend back into - // `renderTarget`, since consumers (like the present system) always - // read the blur's output from there. - beginFullscreenReplacePass(renderContext, pingPong.write); - - crossFadeMaterial.setUniform( - 'u_fromTexture', - sharpSnapshot.colorTexture, - ); - crossFadeMaterial.setUniform('u_toTexture', renderTarget.colorTexture); - crossFadeMaterial.setUniform('u_factor', intensity); - - drawFullscreenQuad(renderContext, crossFadeMaterial); - - copyTexture(pingPong.write.colorTexture, renderTarget); } }, cleanup: (world) => { @@ -245,8 +331,8 @@ export const createGaussianBlurEcsSystem = ( pingPongByTarget.get(renderTarget)?.dispose(gl); pingPongByTarget.delete(renderTarget); - sharpSnapshotByTarget.get(renderTarget)?.dispose(gl); - sharpSnapshotByTarget.delete(renderTarget); + blendTargetByTarget.get(renderTarget)?.dispose(gl); + blendTargetByTarget.delete(renderTarget); } }, }; diff --git a/src/rendering/utilities/create-shader-cache.ts b/src/rendering/utilities/create-shader-cache.ts index 2d3bf309..4f82128c 100644 --- a/src/rendering/utilities/create-shader-cache.ts +++ b/src/rendering/utilities/create-shader-cache.ts @@ -1,6 +1,7 @@ import { bloomCompositeFragmentShader, bloomThresholdFragmentShader, + boxDownsampleFragmentShader, crossFadeFragmentShader, cubicShaderInclude, ForgeShaderSource, @@ -78,6 +79,7 @@ export function createShaderCache(): ShaderCache { .addShader(new ForgeShaderSource(passthroughVertexShader)) .addShader(new ForgeShaderSource(gaussianBlurFragmentShader)) .addShader(new ForgeShaderSource(crossFadeFragmentShader)) + .addShader(new ForgeShaderSource(boxDownsampleFragmentShader)) .addShader(new ForgeShaderSource(bloomThresholdFragmentShader)) .addShader(new ForgeShaderSource(bloomCompositeFragmentShader)) .addShader(new ForgeShaderSource(toneMappingFragmentShader))