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
2 changes: 1 addition & 1 deletion .claude/skills/write-e2e-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 19 additions & 17 deletions documentation-site/docs/docs/rendering/bloom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions documentation-site/docs/docs/rendering/hdr-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 24 additions & 0 deletions documentation-site/docs/docs/ui/buttons-and-interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 2 additions & 2 deletions e2e/fixtures/scenes/bloom-over-background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions e2e/fixtures/scenes/camera-pan-zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
/**
* Draws a solid white square onto an offscreen `<canvas>` and resolves it as
* a loaded `HTMLImageElement`, for scenes to hand to `createImageSprite`.
* Draws a solid square onto an offscreen `<canvas>` 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<HTMLImageElement> {
export function createSquareImage(
fillStyle: string,
size = 64,
): Promise<HTMLImageElement> {
const canvas = document.createElement('canvas');

canvas.width = size;
Expand All @@ -21,15 +26,15 @@ export function createWhiteSquareImage(size = 64): Promise<HTMLImageElement> {
throw new Error('2D canvas context not available');
}

context.fillStyle = '#fff';
context.fillStyle = fillStyle;
context.fillRect(0, 0, size, size);

const image = new Image();

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();
});
}
4 changes: 2 additions & 2 deletions e2e/fixtures/scenes/gamepad-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
Loading
Loading