Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 20 additions & 6 deletions documentation-site/docs/docs/rendering/bloom.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,33 @@ 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`.

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
Expand Down Expand Up @@ -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).

Expand Down
42 changes: 33 additions & 9 deletions documentation-site/docs/docs/rendering/gaussian-blur.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
197 changes: 197 additions & 0 deletions e2e/fixtures/scenes/post-process-pixel-ratio.ts
Original file line number Diff line number Diff line change
@@ -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<PostProcessPixelRatioSceneHandle> => {
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;
},
};
};
Loading
Loading