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 c0532e4e6..c750d5578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### 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` - **ecs:** Entities are now generational handles, so a reference to a removed entity can never point at an unrelated entity that took its place. A handle packs a slot index and a generation into the same `number`; a reused slot gets a new handle, so the removed entity's handle stops matching anything: `getComponent` returns `null` for it, the new `EcsWorld.isAlive(entity)` returns `false`, and `removeEntity` on it does nothing and returns `false` (it now returns `true` when it removes an entity). Removing the same entity twice in a tick no longer hands its id to two later entities, and the least recently freed slot is reused first. An entity now stays alive until `removeEntity`: `removeComponent` no longer removes an entity whose last component it removed, so call `removeEntity` yourself if you relied on that. `addComponent` and `addTag` now throw for a removed entity (or a handle the world didn't create) instead of silently writing to it. Entities of reused slots are no longer small numbers, so don't do arithmetic on handles or use them as array indices. New exports from `ecs`: `entityIndex`, `entityGeneration` and `formatEntity`, for debugging; error messages that name an entity now print its index and generation (e.g. `12v3`) - **math:** Every angle and direction now follows one convention: radians, `0` along `+X`, positive turning towards `+Y` (counter-clockwise, since the world is Y-up). `Vec2.up` is now `(0, 1)` and `Vec2.down` is `(0, -1)`; if you used `Vec2.up` to mean "down the screen", use `Vec2.down`. `radiansToVector(angle)` now returns `(cos angle, sin angle)`, so `radiansToVector(0)` is `(1, 0)` and it's the inverse of `vectorToRadians`; drop any `+ Math.PI / 2` you added to round-trip between them, and add `- Math.PI / 2` when facing a direction with art drawn facing up. `applyExplosiveForce` and circle-circle collisions now push coincident bodies up rather than down - **particles:** `ParticleEmitter`'s `directionRange` and `rotationRange` are now in radians, following the same convention (`directionRange` defaults to `{ min: 0, max: 2 * Math.PI }`). Convert an old `directionRange` (degrees clockwise from up) as `{ min: Math.PI / 2 - degreesToRadians(oldMax), max: Math.PI / 2 - degreesToRadians(oldMin) }` (note that `min` and `max` swap), and an old `rotationRange` with `degreesToRadians`. An emitter's spawn shape and `directionRange` now turn with the world rotation of the entity it's on, so an emitter on a child entity follows its parent; if an emitter's entity has a rotation and you want world-space directions, move the emitter to an unrotated entity. `emitParticleBurst` takes a `rotation` option to turn a burst the same way 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 9e861f596..7ff7dc317 100644 --- a/e2e/fixtures/scenes/camera-pan-zoom.ts +++ b/e2e/fixtures/scenes/camera-pan-zoom.ts @@ -29,7 +29,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; @@ -42,7 +42,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, @@ -184,7 +184,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 651aef028..0d9b5880b 100644 --- a/e2e/fixtures/scenes/high-dpi-canvas.ts +++ b/e2e/fixtures/scenes/high-dpi-canvas.ts @@ -17,7 +17,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, @@ -113,7 +113,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();