From 0e9c17509918fc958438668583030f11eb340f4f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:01:04 +0000 Subject: [PATCH] fix(rendering): let colors go brighter than white Color no longer clamps red, green and blue to 1, so a tint can brighten a sprite past its texture and, on an hdr camera, bloom more than a white-tinted one. Negative channels are still clamped to 0 and alpha to [0, 1]; toRGBAString writes channels above 1 as 255, and fromHSLA throws for a saturation or lightness outside 0-100. Adds e2e coverage for both cases (8-bit canvas and hdr + bloom) and updates the bloom, HDR and UI button guides. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EBx4SStcu1rxjYyqgkEC1n --- .claude/skills/write-e2e-test/SKILL.md | 2 +- CHANGELOG.md | 4 + .../docs/docs/rendering/bloom.md | 36 ++-- .../docs/docs/rendering/hdr-rendering.md | 8 +- .../docs/docs/ui/buttons-and-interaction.md | 24 +++ e2e/fixtures/scenes/bloom-over-background.ts | 4 +- e2e/fixtures/scenes/camera-pan-zoom.ts | 6 +- ...square-image.ts => create-square-image.ts} | 17 +- e2e/fixtures/scenes/gamepad-input.ts | 4 +- e2e/fixtures/scenes/hdr-tint-bloom.ts | 185 ++++++++++++++++++ e2e/fixtures/scenes/high-dpi-canvas.ts | 4 +- e2e/fixtures/scenes/keyboard-input.ts | 4 +- e2e/fixtures/scenes/mouse-input.ts | 4 +- .../scenes/post-process-pixel-ratio.ts | 4 +- .../scenes/tint-brighter-than-texture-tint.ts | 9 + .../scenes/tint-brighter-than-texture.ts | 147 ++++++++++++++ .../scenes/translucent-ui-compositing.ts | 4 +- e2e/specs/hdr-tint-bloom.spec.ts | 76 +++++++ e2e/specs/tint-brighter-than-texture.spec.ts | 72 +++++++ src/rendering/color.test.ts | 37 +++- src/rendering/color.ts | 41 +++- src/ui/systems/ui-transition-system.test.ts | 39 +++- 22 files changed, 672 insertions(+), 59 deletions(-) rename e2e/fixtures/scenes/{create-white-square-image.ts => create-square-image.ts} (61%) create mode 100644 e2e/fixtures/scenes/hdr-tint-bloom.ts create mode 100644 e2e/fixtures/scenes/tint-brighter-than-texture-tint.ts create mode 100644 e2e/fixtures/scenes/tint-brighter-than-texture.ts create mode 100644 e2e/specs/hdr-tint-bloom.spec.ts create mode 100644 e2e/specs/tint-brighter-than-texture.spec.ts diff --git a/.claude/skills/write-e2e-test/SKILL.md b/.claude/skills/write-e2e-test/SKILL.md index 47e62362c..5eb94a329 100644 --- a/.claude/skills/write-e2e-test/SKILL.md +++ b/.claude/skills/write-e2e-test/SKILL.md @@ -57,7 +57,7 @@ Give the scene something visible to look at. A flat clear color or a plain, uncolored sprite looks identical whether the feature under test worked or not, both on screen and in the recorded video. `camera-pan-zoom.ts`'s pattern - a tinted checkerboard grid built from one generated white-square -image (`create-white-square-image.ts`), recolored per instance via +image (`create-square-image.ts`), recolored per instance via `SpriteEcsComponent.tintColor`, with one distinctly colored marker - is the reusable template: cheap (no static asset files, keeping `/e2e` dependent only on `/src`), and gives you a landmark to measure against. diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2f7e2ce..0d5c4d8e0 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] +#### Changed + +- **rendering:** `Color`'s red, green and blue are no longer clamped to `1`, so a tint can make a sprite brighter than its texture: a button can rest at `Color.white` and brighten on hover with a `hoverColor` such as `new Color(1.2, 1.2, 1.2)`, and on an `hdr` camera a sprite tinted `new Color(3, 3, 3)` blooms more than a white-tinted one. On the canvas or an 8-bit render target, each channel of the result still stops at full brightness. Negative channels are still clamped to `0` and alpha to `[0, 1]`, and `toRGBAString` writes channels above `1` as `255`. If you dimmed sprites below `1` at rest so they could brighten, tint them `Color.white` at rest and above `1` when brightened instead. If you relied on values above `1` being clamped (for example a color eased with an overshooting easing), clamp them yourself. `Color.fromHSLA` now throws for a saturation or lightness outside `0`-`100` + ## [0.25.8] - 2026-10-03 #### Fixed diff --git a/documentation-site/docs/docs/rendering/bloom.md b/documentation-site/docs/docs/rendering/bloom.md index c79328c58..37458bedd 100644 --- a/documentation-site/docs/docs/rendering/bloom.md +++ b/documentation-site/docs/docs/rendering/bloom.md @@ -163,16 +163,17 @@ addBloomComponent(world, camera, { threshold: 0.6, passes: 6, intensity: 1.5 }); By default, [`RenderTarget`](/Forge/docs/api/classes/RenderTarget) uses an 8-bit-per-channel color texture, so scene colors are clamped to `[0, 1]` before bloom ever sees them: `threshold` is comparing against already-clamped -brightness, and there's no way to make one white sprite bloom more than -another equally white sprite by giving it a brighter-than-white color. -`threshold` and `intensity` are still enough to make specific bright -elements (lasers, explosions, magic effects) pop against a duller -background within that constraint — but if you want a sprite to bloom -based on true HDR brightness (for example an emissive map on an otherwise -unlit surface, see [Emissive-driven bloom](#emissive-driven-bloom) below), -give the camera's render target `RENDER_TARGET_FORMAT.hdr` instead and pair -it with `addToneMappingComponent`. See [HDR Rendering & -Tone Mapping](./hdr-rendering.md). +brightness, and a white sprite tinted brighter than white blooms no more +than one tinted `Color.white`. `threshold` and `intensity` are still enough +to make specific bright elements (lasers, explosions, magic effects) pop +against a duller background within that constraint — but if you want a +sprite to bloom based on true HDR brightness, give the camera's render +target `RENDER_TARGET_FORMAT.hdr` instead and pair it with +`addToneMappingComponent`. There, a sprite's `tintColor` can go above `1` +to make it glow: a sprite tinted `new Color(3, 3, 3)` blooms more than one +tinted `Color.white`. See [HDR Rendering & Tone +Mapping](./hdr-rendering.md), and [Emissive-driven +bloom](#emissive-driven-bloom) below for making only part of a sprite glow. ::: ## Performance note @@ -249,13 +250,14 @@ world.addSystem(createToneMapEcsSystem(renderContext)); world.addSystem(createPresentEcsSystem(renderContext)); ``` -Without the emissive map, `threshold` is the only way to make part of a -sprite glow more than the rest, and it can't distinguish "this part is -meant to be a light source" from "this part happens to be pale" — both -read as the same brightness once clamped to `[0, 1]`. The emissive map -sidesteps that: its contribution is added _after_ the albedo sample, so it -can push specific pixels arbitrarily bright regardless of the sprite's own -tint or texture color, without lightening the rest of the sprite. See [HDR +A tint above `1` brightens the whole sprite. Without the emissive map, +`threshold` is the only way to make part of a sprite glow more than the +rest, and it can't distinguish "this part is meant to be a light source" +from "this part happens to be pale" — both read as the same brightness. +The emissive map sidesteps that: its contribution is added _after_ the +albedo sample, so it can push specific pixels arbitrarily bright +regardless of the sprite's own tint or texture color, without lightening +the rest of the sprite. See [HDR Rendering & Tone Mapping](./hdr-rendering.md) for how the `hdr` render target and `addToneMappingComponent` work together to make this look right once presented. diff --git a/documentation-site/docs/docs/rendering/hdr-rendering.md b/documentation-site/docs/docs/rendering/hdr-rendering.md index 2c36ea5cc..e29469e84 100644 --- a/documentation-site/docs/docs/rendering/hdr-rendering.md +++ b/documentation-site/docs/docs/rendering/hdr-rendering.md @@ -9,9 +9,11 @@ stores 8-bit-per-channel color: every value gets clamped to `[0, 1]` the moment a fragment shader writes it, regardless of what the shader actually computed. `RENDER_TARGET_FORMAT.hdr` switches a render target to half-float (`RGBA16F`) storage instead, so values above `1` survive intermediate -passes — which matters for [Bloom](./bloom.md#emissive-driven-bloom): an -emissive-mapped light source can genuinely be brighter than white, instead -of just hitting the same `1.0` ceiling as a plain white sprite. +passes — which matters for [Bloom](./bloom.md): a sprite tinted brighter +than white (e.g. `new Color(3, 3, 3)`), or one with an +[emissive map](./bloom.md#emissive-driven-bloom), can genuinely be brighter +than white, instead of hitting the same `1.0` ceiling as a plain white +sprite. `createToneMapEcsSystem` then compresses that HDR range back into displayable `[0, 1]` before the camera is presented. diff --git a/documentation-site/docs/docs/ui/buttons-and-interaction.md b/documentation-site/docs/docs/ui/buttons-and-interaction.md index 82f24809c..d809faa9a 100644 --- a/documentation-site/docs/docs/ui/buttons-and-interaction.md +++ b/documentation-site/docs/docs/ui/buttons-and-interaction.md @@ -43,6 +43,30 @@ piece is independently useful: add `UiInteractableEcsComponent` to any rect (a toggle, a list row, a close icon) to make it clickable, hoverable, and focus-navigable without it being a "button" at all. +## Hover and press colors + +`UiColorTransitionEcsComponent` eases the sprite's `tintColor` towards +`normalColor`, `hoverColor`, `pressedColor` or `disabledColor` as the +element's state changes. Tints multiply the sprite's texture, and a +[`Color`](/Forge/docs/api/classes/Color) channel can go above `1`, so a +button can rest at `Color.white` (its art as authored) and brighten on +hover: + +```ts +const play = createButton(world, canvas, { + // ... + transition: { + hoverColor: new Color(1.2, 1.2, 1.2), + pressedColor: new Color(0.8, 0.8, 0.8), + }, +}); +``` + +On the canvas or an 8-bit render target, each channel of the result stops +at full brightness, so a hover color above `1` only brightens art that +isn't already white there. An `easing` that overshoots (`easeInOutBack`, +`easeInOutElastic`) briefly passes the target color, including above `1`. + ## Source-agnostic invocation `onInvoke` is raised the same way whether a pointer click, a gamepad/ diff --git a/e2e/fixtures/scenes/bloom-over-background.ts b/e2e/fixtures/scenes/bloom-over-background.ts index b234e878a..ae458bdeb 100644 --- a/e2e/fixtures/scenes/bloom-over-background.ts +++ b/e2e/fixtures/scenes/bloom-over-background.ts @@ -17,7 +17,7 @@ import { createRenderTarget, spriteId, } from '../../../src/rendering/index.js'; -import { createWhiteSquareImage } from './create-white-square-image.js'; +import { createSquareImage } from './create-square-image.js'; import { CreateScene, SceneHandle } from './scene.js'; const defaultStepDeltaMilliseconds = 16.6666; @@ -130,7 +130,7 @@ export const createScene: CreateScene = async ( }); addBloomComponent(world, glowCameraEntity, bloomSettings); - const squareImage = await createWhiteSquareImage(); + const squareImage = await createSquareImage('#fff'); const sprite = createImageSprite(squareImage, renderContext, { pixelsPerUnit: 1, layer: glowRenderCategory, diff --git a/e2e/fixtures/scenes/camera-pan-zoom.ts b/e2e/fixtures/scenes/camera-pan-zoom.ts index 711cb6ab2..e849bef3e 100644 --- a/e2e/fixtures/scenes/camera-pan-zoom.ts +++ b/e2e/fixtures/scenes/camera-pan-zoom.ts @@ -28,7 +28,7 @@ import { Vec2, } from '../../../src/index.js'; import { clearColorRgb } from './camera-pan-zoom-clear-color.js'; -import { createWhiteSquareImage } from './create-white-square-image.js'; +import { createSquareImage } from './create-square-image.js'; import { CreateScene, SceneHandle } from './scene.js'; const defaultStepDeltaMilliseconds = 16.6666; @@ -41,7 +41,7 @@ const clearColor = new Color( 1, ); -// A checkerboard of tinted squares (see `createWhiteSquareImage`), spanning +// A checkerboard of tinted squares (see `createSquareImage`), spanning // world coordinates [-300, 300] on both axes, with a distinct green marker // at the origin. This is what makes a recording of the suite (`video: 'on'` // in playwright.config.ts) actually show the camera panning/zooming, @@ -175,7 +175,7 @@ export const createScene: CreateScene = async ( // camera's `cullingMask` via bitwise AND), not a draw-order layer - a // category of `0` can never match any mask and would silently render // nothing. - const squareImage = await createWhiteSquareImage(); + const squareImage = await createSquareImage('#fff'); const squareSprite = createImageSprite(squareImage, renderContext, { pixelsPerUnit: 1, }); diff --git a/e2e/fixtures/scenes/create-white-square-image.ts b/e2e/fixtures/scenes/create-square-image.ts similarity index 61% rename from e2e/fixtures/scenes/create-white-square-image.ts rename to e2e/fixtures/scenes/create-square-image.ts index a4664c58f..6a868b486 100644 --- a/e2e/fixtures/scenes/create-white-square-image.ts +++ b/e2e/fixtures/scenes/create-square-image.ts @@ -1,15 +1,20 @@ /** - * Draws a solid white square onto an offscreen `` and resolves it as - * a loaded `HTMLImageElement`, for scenes to hand to `createImageSprite`. + * Draws a solid square onto an offscreen `` and resolves it as a + * loaded `HTMLImageElement`, for scenes to hand to `createImageSprite`. * `SpriteEcsComponent.tintColor` multiplies against the sampled texture, so * a plain white square becomes a flat, freely re-colorable chip per sprite * instance - letting a scene render distinct visible shapes without any * static asset file (keeping `/e2e` dependent only on `/src`, not on - * `/demo`'s or `/documentation-site`'s asset folders). + * `/demo`'s or `/documentation-site`'s asset folders). A gray square is for + * scenes that tint a sprite brighter than its texture. + * @param fillStyle - The square's CSS color, e.g. `'#fff'`. * @param size - The width and height of the generated square, in pixels. * @returns The loaded image. */ -export function createWhiteSquareImage(size = 64): Promise { +export function createSquareImage( + fillStyle: string, + size = 64, +): Promise { const canvas = document.createElement('canvas'); canvas.width = size; @@ -21,7 +26,7 @@ export function createWhiteSquareImage(size = 64): Promise { throw new Error('2D canvas context not available'); } - context.fillStyle = '#fff'; + context.fillStyle = fillStyle; context.fillRect(0, 0, size, size); const image = new Image(); @@ -29,7 +34,7 @@ export function createWhiteSquareImage(size = 64): Promise { return new Promise((resolve, reject) => { image.onload = () => resolve(image); image.onerror = () => - reject(new Error('Failed to load generated white square image')); + reject(new Error('Failed to load generated square image')); image.src = canvas.toDataURL(); }); } diff --git a/e2e/fixtures/scenes/gamepad-input.ts b/e2e/fixtures/scenes/gamepad-input.ts index 4e3515f70..fafc1db07 100644 --- a/e2e/fixtures/scenes/gamepad-input.ts +++ b/e2e/fixtures/scenes/gamepad-input.ts @@ -28,7 +28,7 @@ import { Time, TriggerAction, } from '../../../src/index.js'; -import { createWhiteSquareImage } from './create-white-square-image.js'; +import { createSquareImage } from './create-square-image.js'; import { inputSceneColors } from './input-scene-colors.js'; import { matchesColor, @@ -278,7 +278,7 @@ export const createScene: CreateScene = async ( verticalWorldUnits: canvas.height, }); - const squareImage = await createWhiteSquareImage(); + const squareImage = await createSquareImage('#fff'); const squareSprite = createImageSprite(squareImage, renderContext, { pixelsPerUnit: 1, }); diff --git a/e2e/fixtures/scenes/hdr-tint-bloom.ts b/e2e/fixtures/scenes/hdr-tint-bloom.ts new file mode 100644 index 000000000..a84fccacf --- /dev/null +++ b/e2e/fixtures/scenes/hdr-tint-bloom.ts @@ -0,0 +1,185 @@ +import { + addPositionComponent, + createTransformEcsSystem, + Time, +} from '../../../src/common/index.js'; +import { EcsWorld } from '../../../src/ecs/index.js'; +import { + addBloomComponent, + addToneMappingComponent, + Color, + createBloomEcsSystem, + createCamera, + createCanvas, + createImageSprite, + createPresentEcsSystem, + createRenderContext, + createRenderEcsSystem, + createRenderTarget, + createToneMapEcsSystem, + RENDER_TARGET_FORMAT, + RENDER_TARGET_FORMAT_KEYS, + spriteId, +} from '../../../src/rendering/index.js'; +import { createSquareImage } from './create-square-image.js'; +import { CreateScene, SceneHandle } from './scene.js'; + +const defaultStepDeltaMilliseconds = 16.6666; + +// One world unit is one CSS pixel (see `verticalWorldUnits` below). +const spriteSizeInPixels = 32; +const spriteOffsetInPixels = 120; + +// Far enough past each sprite's edge to be clear of the sprite itself, close +// enough that both halos still clearly show there. +const haloOffsetInPixels = 12; + +// A threshold below `1`, so the white-tinted sprite blooms too and the +// comparison is between two glows, not a glow and nothing. +const bloomSettings = { threshold: 0.8, passes: 6, intensity: 1 }; + +const brightTint = 3; + +/** A sampled pixel's color, `0`-`255` per channel. */ +export interface SampledColor { + r: number; + g: number; + b: number; +} + +/** Everything `hdr-tint-bloom.spec.ts` asserts against, from one frame. */ +export interface HdrTintBloomMeasurement { + /** The camera render target's resolved storage format. */ + format: RENDER_TARGET_FORMAT_KEYS; + + /** The halo just outside the white-tinted sprite's outer edge. */ + whiteTintedHalo: SampledColor; + + /** The halo the same distance outside the sprite tinted above white. */ + brightTintedHalo: SampledColor; +} + +/** The handle `hdr-tint-bloom.spec.ts` drives and asserts against. */ +export interface HdrTintBloomSceneHandle extends SceneHandle { + /** + * Reads back the presented canvas at the same distance outside each + * sprite's outer edge, on the row through their centers. Must be called + * in the same `page.evaluate` task as the preceding `step()` - see + * `SceneHandle.step` and `camera-pan-zoom.ts`'s `measureGreenSquareBounds` + * for why. + */ + measure(): HdrTintBloomMeasurement; +} + +/** + * Builds a minimal scene for a tint above `1` on an HDR camera: two white + * sprites, the left tinted `Color.white` and the right tinted `3` on every + * channel, drawn by a camera with an `hdr` render target, bloom and tone + * mapping. `hdr-tint-bloom.spec.ts` checks the brighter one blooms more. + * @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 time = new Time(); + const world = new EcsWorld(); + const canvas = createCanvas(container); + + canvas.width = 400; + canvas.height = 300; + + // See `measureGreenSquareBounds` in `camera-pan-zoom.ts` for why this is + // required for a reliable same-run pixel readback. + const renderContext = createRenderContext(canvas, { + preserveDrawingBuffer: true, + }); + + const renderTarget = createRenderTarget( + renderContext.gl, + renderContext.width, + renderContext.height, + RENDER_TARGET_FORMAT.hdr, + ); + const cameraEntity = createCamera(world, { + isStatic: true, + clearColor: Color.black, + verticalWorldUnits: renderContext.cssHeight, + renderTarget, + }); + + addBloomComponent(world, cameraEntity, bloomSettings); + addToneMappingComponent(world, cameraEntity); + + const squareImage = await createSquareImage('#fff'); + const sprite = createImageSprite(squareImage, renderContext, { + pixelsPerUnit: 1, + }); + + const addSquare = (x: number, tintColor: Color): void => { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { local: { x, y: 0 } }); + world.addComponent(entity, spriteId, { + ...sprite, + width: spriteSizeInPixels, + height: spriteSizeInPixels, + tintColor, + }); + }; + + addSquare(-spriteOffsetInPixels, Color.white); + addSquare( + spriteOffsetInPixels, + new Color(brightTint, brightTint, brightTint), + ); + + world.addSystem(createTransformEcsSystem()); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createBloomEcsSystem(renderContext)); + world.addSystem(createToneMapEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + let clockInMilliseconds = 0; + + return { + step(deltaMilliseconds: number = defaultStepDeltaMilliseconds): void { + clockInMilliseconds += deltaMilliseconds; + time.update(clockInMilliseconds); + world.update(); + }, + + measure(): HdrTintBloomMeasurement { + const { gl, width, height, pixelRatio } = renderContext; + const pixels = new Uint8Array(width * height * 4); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + + const pixelAt = (x: number, y: number): SampledColor => { + const index = (Math.round(y) * width + Math.round(x)) * 4; + + return { r: pixels[index], g: pixels[index + 1], b: pixels[index + 2] }; + }; + + // The drawing buffer is `pixelRatio` times the canvas's CSS size. + // Each halo is sampled outside its sprite's outer edge, away from the + // other sprite, so neither glow reaches the other's sample. + const haloDistanceFromCenter = + (spriteOffsetInPixels + spriteSizeInPixels / 2 + haloOffsetInPixels) * + pixelRatio; + + return { + format: renderTarget.format, + whiteTintedHalo: pixelAt( + width / 2 - haloDistanceFromCenter, + height / 2, + ), + brightTintedHalo: pixelAt( + width / 2 + haloDistanceFromCenter, + height / 2, + ), + }; + }, + }; +}; diff --git a/e2e/fixtures/scenes/high-dpi-canvas.ts b/e2e/fixtures/scenes/high-dpi-canvas.ts index 1344f187d..052571eb7 100644 --- a/e2e/fixtures/scenes/high-dpi-canvas.ts +++ b/e2e/fixtures/scenes/high-dpi-canvas.ts @@ -19,7 +19,7 @@ import { Time, Vector2, } from '../../../src/index.js'; -import { createWhiteSquareImage } from './create-white-square-image.js'; +import { createSquareImage } from './create-square-image.js'; import { inputSceneColors } from './input-scene-colors.js'; import { matchesColor, @@ -116,7 +116,7 @@ export const createScene: CreateScene = async ( verticalWorldUnits, }); - const squareImage = await createWhiteSquareImage(); + const squareImage = await createSquareImage('#fff'); const squareSprite = createImageSprite(squareImage, renderContext, { pixelsPerUnit: 1, }); diff --git a/e2e/fixtures/scenes/keyboard-input.ts b/e2e/fixtures/scenes/keyboard-input.ts index c31fec403..a50b2d040 100644 --- a/e2e/fixtures/scenes/keyboard-input.ts +++ b/e2e/fixtures/scenes/keyboard-input.ts @@ -29,7 +29,7 @@ import { Time, TriggerAction, } from '../../../src/index.js'; -import { createWhiteSquareImage } from './create-white-square-image.js'; +import { createSquareImage } from './create-square-image.js'; import { matchesColor, PixelBounds, @@ -165,7 +165,7 @@ export const createScene: CreateScene = async ( verticalWorldUnits: canvas.height, }); - const squareImage = await createWhiteSquareImage(); + const squareImage = await createSquareImage('#fff'); const squareSprite = createImageSprite(squareImage, renderContext, { pixelsPerUnit: 1, }); diff --git a/e2e/fixtures/scenes/mouse-input.ts b/e2e/fixtures/scenes/mouse-input.ts index fb8d20118..088cc14a8 100644 --- a/e2e/fixtures/scenes/mouse-input.ts +++ b/e2e/fixtures/scenes/mouse-input.ts @@ -29,7 +29,7 @@ import { Time, TriggerAction, } from '../../../src/index.js'; -import { createWhiteSquareImage } from './create-white-square-image.js'; +import { createSquareImage } from './create-square-image.js'; import { inputSceneColors } from './input-scene-colors.js'; import { matchesColor, @@ -163,7 +163,7 @@ export const createScene: CreateScene = async ( verticalWorldUnits: canvas.height, }); - const squareImage = await createWhiteSquareImage(); + const squareImage = await createSquareImage('#fff'); const squareSprite = createImageSprite(squareImage, renderContext, { pixelsPerUnit: 1, }); diff --git a/e2e/fixtures/scenes/post-process-pixel-ratio.ts b/e2e/fixtures/scenes/post-process-pixel-ratio.ts index 7b0ab897b..de1a6f32b 100644 --- a/e2e/fixtures/scenes/post-process-pixel-ratio.ts +++ b/e2e/fixtures/scenes/post-process-pixel-ratio.ts @@ -18,7 +18,7 @@ import { spriteId, Time, } from '../../../src/index.js'; -import { createWhiteSquareImage } from './create-white-square-image.js'; +import { createSquareImage } from './create-square-image.js'; import { CreateScene, SceneHandle } from './scene.js'; const defaultStepDeltaMilliseconds = 16.6666; @@ -100,7 +100,7 @@ export const createScene: CreateScene = async ( renderTarget: sceneTarget, }); - const squareImage = await createWhiteSquareImage(); + const squareImage = await createSquareImage('#fff'); const squareSprite = createImageSprite(squareImage, renderContext, { pixelsPerUnit: 1, }); diff --git a/e2e/fixtures/scenes/tint-brighter-than-texture-tint.ts b/e2e/fixtures/scenes/tint-brighter-than-texture-tint.ts new file mode 100644 index 000000000..281f0e7d5 --- /dev/null +++ b/e2e/fixtures/scenes/tint-brighter-than-texture-tint.ts @@ -0,0 +1,9 @@ +/** + * The tint the `tint-brighter-than-texture` scene's brighter sprite is + * drawn with, on every channel. Kept in its own module, free of any `/src` + * import, so `tint-brighter-than-texture.spec.ts` (which runs under Node, + * not the Vite-bundled browser) can import it directly without dragging in + * engine internals it can't parse (e.g. `.glsl` shader sources, only + * loadable through Vite's browser build). + */ +export const brightTint = 1.5; diff --git a/e2e/fixtures/scenes/tint-brighter-than-texture.ts b/e2e/fixtures/scenes/tint-brighter-than-texture.ts new file mode 100644 index 000000000..bc68d472a --- /dev/null +++ b/e2e/fixtures/scenes/tint-brighter-than-texture.ts @@ -0,0 +1,147 @@ +import { + addPositionComponent, + createTransformEcsSystem, + Time, +} from '../../../src/common/index.js'; +import { EcsWorld } from '../../../src/ecs/index.js'; +import { + Color, + createCamera, + createCanvas, + createImageSprite, + createPresentEcsSystem, + createRenderContext, + createRenderEcsSystem, + spriteId, +} from '../../../src/rendering/index.js'; +import { createSquareImage } from './create-square-image.js'; +import { CreateScene, SceneHandle } from './scene.js'; +import { brightTint } from './tint-brighter-than-texture-tint.js'; + +const defaultStepDeltaMilliseconds = 16.6666; + +// Mid-gray (102 / 255 = 0.4 per channel), so a tint above 1 has room to +// brighten it before the canvas's 8-bit channels reach full brightness. +const textureFill = '#666'; + +// One world unit is one CSS pixel (see `verticalWorldUnits` below). +const spriteSizeInPixels = 48; +const spriteOffsetInPixels = 80; + +/** A sampled pixel's color, `0`-`255` per channel. */ +export interface SampledColor { + r: number; + g: number; + b: number; +} + +/** Everything `tint-brighter-than-texture.spec.ts` asserts against, from one frame. */ +export interface TintBrighterThanTextureMeasurement { + /** The center of the sprite tinted `Color.white`, i.e. its texture as is. */ + whiteTinted: SampledColor; + + /** The center of the identical sprite tinted `brightTint` on every channel. */ + brightTinted: SampledColor; +} + +/** The handle `tint-brighter-than-texture.spec.ts` drives and asserts against. */ +export interface TintBrighterThanTextureSceneHandle extends SceneHandle { + /** + * Reads back the presented canvas at each sprite's center. Must be called + * in the same `page.evaluate` task as the preceding `step()` - see + * `SceneHandle.step` and `camera-pan-zoom.ts`'s `measureGreenSquareBounds` + * for why. + */ + measure(): TintBrighterThanTextureMeasurement; +} + +/** + * Builds a minimal scene for a tint above `1` on the canvas's 8-bit + * channels: two identical gray sprites, the left tinted `Color.white` and + * the right tinted `brightTint` on every channel. + * `tint-brighter-than-texture.spec.ts` checks the right one draws brighter + * than its texture. + * @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 time = new Time(); + const world = new EcsWorld(); + const canvas = createCanvas(container); + + canvas.width = 400; + canvas.height = 300; + + // See `measureGreenSquareBounds` in `camera-pan-zoom.ts` for why this is + // required for a reliable same-run pixel readback. + const renderContext = createRenderContext(canvas, { + preserveDrawingBuffer: true, + }); + + createCamera(world, { + isStatic: true, + clearColor: Color.black, + verticalWorldUnits: renderContext.cssHeight, + }); + + const squareImage = await createSquareImage(textureFill); + const sprite = createImageSprite(squareImage, renderContext, { + pixelsPerUnit: 1, + }); + + const addSquare = (x: number, tintColor: Color): void => { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { local: { x, y: 0 } }); + world.addComponent(entity, spriteId, { + ...sprite, + width: spriteSizeInPixels, + height: spriteSizeInPixels, + tintColor, + }); + }; + + addSquare(-spriteOffsetInPixels, Color.white); + addSquare( + spriteOffsetInPixels, + new Color(brightTint, brightTint, brightTint), + ); + + world.addSystem(createTransformEcsSystem()); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + let clockInMilliseconds = 0; + + return { + step(deltaMilliseconds: number = defaultStepDeltaMilliseconds): void { + clockInMilliseconds += deltaMilliseconds; + time.update(clockInMilliseconds); + world.update(); + }, + + measure(): TintBrighterThanTextureMeasurement { + const { gl, width, height, pixelRatio } = renderContext; + const pixels = new Uint8Array(width * height * 4); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + + const pixelAt = (x: number, y: number): SampledColor => { + const index = (Math.round(y) * width + Math.round(x)) * 4; + + return { r: pixels[index], g: pixels[index + 1], b: pixels[index + 2] }; + }; + + // The drawing buffer is `pixelRatio` times the canvas's CSS size. + const offset = spriteOffsetInPixels * pixelRatio; + + return { + whiteTinted: pixelAt(width / 2 - offset, height / 2), + brightTinted: pixelAt(width / 2 + offset, height / 2), + }; + }, + }; +}; diff --git a/e2e/fixtures/scenes/translucent-ui-compositing.ts b/e2e/fixtures/scenes/translucent-ui-compositing.ts index bce3cb93c..b30adc291 100644 --- a/e2e/fixtures/scenes/translucent-ui-compositing.ts +++ b/e2e/fixtures/scenes/translucent-ui-compositing.ts @@ -22,7 +22,7 @@ import { registerUiSystems, UiAxis, } from '../../../src/ui/index.js'; -import { createWhiteSquareImage } from './create-white-square-image.js'; +import { createSquareImage } from './create-square-image.js'; import { CreateScene, SceneHandle } from './scene.js'; const defaultStepDeltaMilliseconds = 16.6666; @@ -178,7 +178,7 @@ export const createScene: CreateScene = async ( referenceResolution: { x: canvas.width, y: canvas.height }, }); - const panelImage = await createWhiteSquareImage(); + const panelImage = await createSquareImage('#fff'); const panelEntities = panelAlphas.map((alpha, index) => { const panelSprite = createImageSprite(panelImage, renderContext, { diff --git a/e2e/specs/hdr-tint-bloom.spec.ts b/e2e/specs/hdr-tint-bloom.spec.ts new file mode 100644 index 000000000..9c04d1265 --- /dev/null +++ b/e2e/specs/hdr-tint-bloom.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; +import type { + HdrTintBloomMeasurement, + HdrTintBloomSceneHandle, +} from '../fixtures/scenes/hdr-tint-bloom.js'; + +// See `translucent-ui-compositing.spec.ts` for why the hooks are cast inline +// rather than declared per scene. +type Hooks = HdrTintBloomSceneHandle; + +// How much brighter the halo around the sprite tinted above white must be +// than the white-tinted one's, on each channel, to prove the extra +// brightness reached bloom. A tint clamped to `1` leaves the two halos +// identical. +const minimumHaloGain = 10; + +/** + * Advances the scene by one frame and samples the presented canvas in the + * same task - see AGENTS.md's "Be wary of pixel-level rendering assertions" + * for why `step()` and any pixel read must happen in the same + * `page.evaluate` call. + */ +const captureMeasurement = ( + page: import('@playwright/test').Page, +): Promise => + page.evaluate(() => { + const scene = window.__forgeTestHooks as unknown as Hooks; + + scene.step(); + + return scene.measure(); + }); + +test.describe('a tint brighter than white on an HDR camera', () => { + test.beforeEach(async ({ page }) => { + await test.step('load the hdr-tint-bloom scene', async () => { + // See `translucent-ui-compositing.spec.ts` for why page errors are + // captured here. + let pageError: Error | undefined; + + page.once('pageerror', (error) => { + pageError = error; + }); + + await page.goto('/?scene=hdr-tint-bloom'); + + try { + await page.waitForFunction(() => Boolean(window.__forgeTestHooks)); + } catch (timeoutError) { + throw pageError ?? timeoutError; + } + }); + }); + + test('blooms more than a white-tinted sprite', async ({ page }) => { + const { format, whiteTintedHalo, brightTintedHalo } = + await test.step('render one frame and read back the canvas', () => + captureMeasurement(page)); + + await test.step('assert the camera renders in HDR', () => { + expect( + format, + 'the browser should support EXT_color_buffer_float, or the render target falls back to ldr and clamps the tint', + ).toBe('hdr'); + }); + + await test.step('assert the brighter sprite has the brighter halo', () => { + for (const channel of ['r', 'g', 'b'] as const) { + expect( + brightTintedHalo[channel], + `the ${channel} channel of the halo around the sprite tinted above white should be brighter than the white-tinted sprite's (${whiteTintedHalo[channel]})`, + ).toBeGreaterThan(whiteTintedHalo[channel] + minimumHaloGain); + } + }); + }); +}); diff --git a/e2e/specs/tint-brighter-than-texture.spec.ts b/e2e/specs/tint-brighter-than-texture.spec.ts new file mode 100644 index 000000000..1cc1730a7 --- /dev/null +++ b/e2e/specs/tint-brighter-than-texture.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import type { + TintBrighterThanTextureMeasurement, + TintBrighterThanTextureSceneHandle, +} from '../fixtures/scenes/tint-brighter-than-texture.js'; +import { brightTint } from '../fixtures/scenes/tint-brighter-than-texture-tint.js'; + +// See `translucent-ui-compositing.spec.ts` for why the hooks are cast inline +// rather than declared per scene. +type Hooks = TintBrighterThanTextureSceneHandle; + +// How far the measured brightening may stray from `brightTint`: absorbs +// 8-bit rounding of both samples, while still catching a tint clamped to +// `1`, which leaves the ratio at exactly `1`. +const ratioTolerance = 0.05; + +/** + * Advances the scene by one frame and samples the presented canvas in the + * same task - see AGENTS.md's "Be wary of pixel-level rendering assertions" + * for why `step()` and any pixel read must happen in the same + * `page.evaluate` call. + */ +const captureMeasurement = ( + page: import('@playwright/test').Page, +): Promise => + page.evaluate(() => { + const scene = window.__forgeTestHooks as unknown as Hooks; + + scene.step(); + + return scene.measure(); + }); + +test.describe('a tint brighter than white', () => { + test.beforeEach(async ({ page }) => { + await test.step('load the tint-brighter-than-texture scene', async () => { + // See `translucent-ui-compositing.spec.ts` for why page errors are + // captured here. + let pageError: Error | undefined; + + page.once('pageerror', (error) => { + pageError = error; + }); + + await page.goto('/?scene=tint-brighter-than-texture'); + + try { + await page.waitForFunction(() => Boolean(window.__forgeTestHooks)); + } catch (timeoutError) { + throw pageError ?? timeoutError; + } + }); + }); + + test('draws a sprite brighter than its texture', async ({ page }) => { + const { whiteTinted, brightTinted } = + await test.step('render one frame and read back the canvas', () => + captureMeasurement(page)); + + await test.step('assert each channel brightened by the tint', () => { + for (const channel of ['r', 'g', 'b'] as const) { + expect( + brightTinted[channel] / whiteTinted[channel], + `the ${channel} channel of the sprite tinted ${brightTint} (${brightTinted[channel]}) should be ${brightTint} times the white-tinted one's (${whiteTinted[channel]})`, + ).toBeGreaterThan(brightTint - ratioTolerance); + expect(brightTinted[channel] / whiteTinted[channel]).toBeLessThan( + brightTint + ratioTolerance, + ); + } + }); + }); +}); diff --git a/src/rendering/color.test.ts b/src/rendering/color.test.ts index 05c5b631e..e3959b2c7 100644 --- a/src/rendering/color.test.ts +++ b/src/rendering/color.test.ts @@ -11,12 +11,39 @@ describe('Color', () => { expect(color.toRGBAString()).toBe('rgba(255, 0, 128, 1)'); }); - it('should clamp RGB values to the valid range (0-1)', () => { - const color = new Color(1.2, -0.2, 0.5); + it('keeps RGB values above 1, for colors brighter than white', () => { + const color = new Color(1.5, 3, 0.5); - expect(color.r).toBe(1); // Clamped to 1 - expect(color.g).toBe(0); // Clamped to 0 - expect(color.b).toBe(0.5); // Unchanged + expect(color.r).toBe(1.5); + expect(color.g).toBe(3); + expect(color.b).toBe(0.5); + expect(color.toFloat32Array()).toEqual(new Float32Array([1.5, 3, 0.5, 1])); + }); + + it('clamps negative RGB values to 0', () => { + const color = new Color(-0.2, -1, 0.5); + + expect(color.r).toBe(0); + expect(color.g).toBe(0); + expect(color.b).toBe(0.5); + }); + + it('clamps alpha to [0, 1]', () => { + expect(new Color(1, 1, 1, 1.5).a).toBe(1); + expect(new Color(1, 1, 1, -0.5).a).toBe(0); + }); + + it('clamps RGB values above 1 to 255 in its CSS string', () => { + const color = new Color(1.5, 0.5, 2, 0.5); + + expect(color.toRGBAString()).toBe('rgba(255, 128, 255, 0.5)'); + }); + + it('throws for an HSL saturation or lightness outside 0-100', () => { + expect(() => Color.fromHSLA(0, 120, 50)).toThrow(); + expect(() => Color.fromHSLA(0, -1, 50)).toThrow(); + expect(() => Color.fromHSLA(0, 100, 150)).toThrow(); + expect(() => Color.fromHSLA(0, 100, -1)).toThrow(); }); it('should create a color using HSL values', () => { diff --git a/src/rendering/color.ts b/src/rendering/color.ts index c8b83046d..1f0fe2f60 100644 --- a/src/rendering/color.ts +++ b/src/rendering/color.ts @@ -2,6 +2,12 @@ import { clamp } from '../math/index.js'; /** * The `Color` class represents a color that can be created using RGB(A) or HSL(A). + * + * Red, green and blue have no upper bound: a value above `1` is brighter + * than white. Used as a tint, it brightens a sprite past its texture. On an + * 8-bit render target or the canvas, each channel of the result is clamped + * to full brightness when it's written. On an HDR render target + * (`RENDER_TARGET_FORMAT.hdr`) the value survives to bloom and tone mapping. */ export class Color { private readonly _r: number; @@ -24,15 +30,21 @@ export class Color { /** * Constructs a new `Color` instance using RGBA values. - * @param r - The red component (0-1). - * @param g - The green component (0-1). - * @param b - The blue component (0-1). - * @param a - The alpha component (0-1). Defaults to 1 (fully opaque). + * @param r - The red component. `1` is full brightness; higher values are + * brighter than white. Negative values are clamped to `0`. + * @param g - The green component. `1` is full brightness; higher values + * are brighter than white. Negative values are clamped to `0`. + * @param b - The blue component. `1` is full brightness; higher values are + * brighter than white. Negative values are clamped to `0`. + * @param a - The alpha component (0-1), clamped to that range. Defaults to + * 1 (fully opaque). */ constructor(r: number, g: number, b: number, a: number = 1) { - this._r = clamp(r, 0, 1); - this._g = clamp(g, 0, 1); - this._b = clamp(b, 0, 1); + // Negative light has no meaning, and alpha outside [0, 1] would turn the + // premultiplied blend factors negative on a float render target. + this._r = Math.max(r, 0); + this._g = Math.max(g, 0); + this._b = Math.max(b, 0); this._a = clamp(a, 0, 1); } @@ -43,6 +55,7 @@ export class Color { * @param l - The lightness (0-100). * @param a - The alpha component (0-1). Defaults to 1 (fully opaque). * @returns A new `Color` instance. + * @throws An error if `s` or `l` is outside 0-100. */ public static fromHSLA( h: number, @@ -50,6 +63,12 @@ export class Color { l: number, a: number = 1, ): Color { + if (s < 0 || s > 100 || l < 0 || l > 100) { + throw new Error( + `Unable to create a color from HSLA(${h}, ${s}, ${l}, ${a}): saturation and lightness must be between 0 and 100.`, + ); + } + const normalizedH = h / 360; const normalizedS = s / 100; const normalizedL = l / 100; @@ -120,11 +139,15 @@ export class Color { } /** - * Converts the color to a CSS-compatible RGBA string. + * Converts the color to a CSS-compatible RGBA string. CSS colors can't be + * brighter than white, so channels above `1` are written as `255`. * @returns The RGBA string (e.g., `rgba(255, 0, 0, 1)`). */ public toRGBAString(): string { - return `rgba(${Math.round(this._r * 255)}, ${Math.round(this._g * 255)}, ${Math.round(this._b * 255)}, ${this._a})`; + const toByte = (channel: number): number => + Math.min(Math.round(channel * 255), 255); + + return `rgba(${toByte(this._r)}, ${toByte(this._g)}, ${toByte(this._b)}, ${this._a})`; } /** diff --git a/src/ui/systems/ui-transition-system.test.ts b/src/ui/systems/ui-transition-system.test.ts index 23a883604..ec8310917 100644 --- a/src/ui/systems/ui-transition-system.test.ts +++ b/src/ui/systems/ui-transition-system.test.ts @@ -10,7 +10,10 @@ import { } from '../../rendering/index.js'; import { addUiColorTransitionComponent } from '../components/ui-color-transition-component.js'; import { addUiInteractableComponent } from '../components/ui-interactable-component.js'; -import { linear } from '../../animations/easing-functions/index.js'; +import { + easeInOutBack, + linear, +} from '../../animations/easing-functions/index.js'; const buildRenderable = (): Renderable => ({}) as Renderable; @@ -79,6 +82,40 @@ describe('createUiTransitionEcsSystem', () => { expect(done.b).toBeCloseTo(1); }); + it('brightens the tint past white, and past hoverColor while an easing overshoots', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + const hoverColor = new Color(1.2, 1.2, 1.2, 1); + + const interactable = addUiInteractableComponent(world, entity); + addSpriteComponent(world, entity, { + width: 1, + height: 1, + renderable: buildRenderable(), + }); + addUiColorTransitionComponent(world, entity, { + hoverColor, + duration: 100, + easing: easeInOutBack, + }); + + interactable.isHovered = true; + + world.addSystem(createUiTransitionEcsSystem(buildTime(80))); + world.update(); + + const overshooting = world.getComponent(entity, spriteId)!.tintColor; + + expect(overshooting.r).toBeCloseTo(1 + 0.2 * easeInOutBack(0.8)); + expect(overshooting.r).toBeGreaterThan(hoverColor.r); + + world.update(); + + expect(world.getComponent(entity, spriteId)!.tintColor.r).toBeCloseTo( + hoverColor.r, + ); + }); + it('restarts the tween from the color actually on screen when the state changes mid-tween', () => { const world = new EcsWorld(); const entity = world.createEntity();