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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

#### Added

- **rendering:** `getCameraView(world, camera, renderContext)` (and `computeCameraView(camera, position, renderContext)` for systems that already hold the camera's components) returns what a camera sees: the world area it shows (`bounds`, `size`, accounting for its position and zoom), its `pixelsPerUnit` in CSS pixels, and `worldToViewport`/`viewportToWorld` conversions to and from CSS pixels on the canvas. To place something drawn by one camera over something drawn by another, convert through the viewport: `hudView.viewportToWorld(gameView.worldToViewport(position))`

#### Changed

- **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
- **physics:** `TerrainCollider` and `createTerrainMesh` now put the solid ground below the surface points (toward `-y`), as the Y-up world expects, instead of above them. Author terrain points as the ground's surface in world coordinates on an unrotated entity: if you rotated the terrain entity by `Math.PI` and negated and reversed its points to get ground underneath, remove all three. `TerrainCollider.bottomY` is now `depth` below the lowest point (`min(y) - depth`) and surface normals point towards `+y`
- **rendering:** The render system no longer draws sprites, nine-slice regions or text glyphs whose quads are entirely outside a camera's view. Code that disabled sprites only to save drawing them while off screen can be deleted. A material with a custom vertex shader that moves vertices beyond the sprite's quad may be skipped while partly visible
- **rendering:** `createProjectionMatrix` now takes the world-space `Rect` to show, such as `getCameraView(...).bounds`, instead of `(width, height, cameraPosition, zoom, pixelsPerUnit)`
- **ui:** A `'screenPixels'`-unit size or margin, and `UiSafeAreaEcsComponent` insets, now follow the canvas camera's `zoom`, so they keep their on-screen size on a world-space canvas whose camera zooms. A screen-space canvas's root rect now fills its camera's view, so it follows a moved or zoomed UI camera

#### Removed

- **rendering:** `calculateVisibleWorldSize`, `calculatePixelsPerUnit`, `screenToWorldSpace`, `worldToScreenSpace` and `canvasToWorldSpace` are removed; use the camera's view instead. `calculateVisibleWorldSize(width, height, verticalWorldUnits)` becomes `getCameraView(world, camera, renderContext).size`, which also accounts for zoom; `screenToWorldSpace(pointer, ...)` becomes `view.viewportToWorld(pointer)`; `worldToScreenSpace(...)` becomes `view.worldToViewport(position)`, which unlike the old function flips Y to the Y-down viewport; `calculatePixelsPerUnit(...)` becomes `view.pixelsPerUnit`. Constants that mirrored a camera's `verticalWorldUnits` for these calls can be deleted
- **rendering:** `CameraEcsComponent.scissorRect`, which nothing read, is removed

## [0.25.8] - 2026-10-03

Expand Down
14 changes: 7 additions & 7 deletions demo/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
addPositionComponent,
addRotationComponent,
addSpriteComponent,
calculateVisibleWorldSize,
Color,
createCamera,
createGame,
Expand All @@ -12,6 +11,7 @@ import {
degreesToRadians,
EcsSystem,
EcsWorld,
getCameraView,
PositionEcsComponent,
positionId,
Random,
Expand Down Expand Up @@ -241,7 +241,7 @@ function createTriangleCollider(): PolygonCollider {

const { game, world, renderContext, time } = createGame('demo-container');

createCamera(world, { verticalWorldUnits });
const camera = createCamera(world, { verticalWorldUnits });

const { imageCache } = renderContext;

Expand Down Expand Up @@ -287,11 +287,11 @@ const shapeTemplates: ShapeTemplate[] = [
},
];

const { x: visibleWidth, y: visibleHeight } = calculateVisibleWorldSize(
renderContext.width,
renderContext.height,
verticalWorldUnits,
);
const { x: visibleWidth, y: visibleHeight } = getCameraView(
world,
camera,
renderContext,
).size;
const halfWidth = visibleWidth / 2;
const halfHeight = visibleHeight / 2;

Expand Down
7 changes: 4 additions & 3 deletions documentation-site/docs/docs/ecs/game.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ meant to track the canvas:
stays exactly as it was created - see the caution in
[Multipass Rendering](../rendering/multipass-rendering.md) for how to keep
one in sync.
- Anything you sized once from `calculateVisibleWorldSize`/`RenderContext.width`/
`height` at startup (a background quad meant to always fill the camera's
view, a shader uniform driven by the canvas resolution) needs to be
- Anything you sized once from a camera's view (`getCameraView(...).size`)
or `RenderContext.width`/`height` at startup (a background quad meant to
always fill the camera's view, a shader uniform driven by the canvas
resolution) needs to be
recomputed by your own system each time those dimensions change, the same
way `createUiLayoutEcsSystem` already does for UI.

Expand Down
35 changes: 11 additions & 24 deletions documentation-site/docs/docs/physics/forces.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,42 +290,29 @@ so it never imparts spin. Entities with no `RigidBodyEcsComponent` (static
geometry) and bodies at or beyond `radius` are untouched.

A common use case is triggering an explosion at a clicked point. The physics
demo converts the screen-space mouse position to world space and calls
demo converts the mouse position to world space through the camera's view
(see [World Units and Cameras](../rendering/world-units-and-cameras.md)) and calls
`applyExplosiveForce` on click:

```ts
import { Vec2 } from '@forge-game-engine/forge/math';
import {
calculatePixelsPerUnit,
screenToWorldSpace,
} from '@forge-game-engine/forge/rendering';
import { getCameraView } from '@forge-game-engine/forge/rendering';
import { applyExplosiveForce } from '@forge-game-engine/forge/physics';

// world and renderContext come from your game setup; verticalWorldUnits
// matches whatever was passed to createCamera.
// world and renderContext come from your game setup; camera is the entity
// createCamera returned.
renderContext.canvas.addEventListener('mousedown', (event: MouseEvent) => {
const canvasBounds = renderContext.canvas.getBoundingClientRect();

const screenPosition = {
const viewportPosition = {
x: event.clientX - canvasBounds.left,
y: event.clientY - canvasBounds.top,
};

// screenPosition is in CSS pixels, so convert it against the canvas's
// CSS size rather than its (pixel-ratio-scaled) drawing buffer.
const pixelsPerUnit = calculatePixelsPerUnit(
renderContext.cssHeight,
verticalWorldUnits,
);

const worldPosition = screenToWorldSpace(
screenPosition,
Vec2.zero,
1,
renderContext.cssWidth,
renderContext.cssHeight,
pixelsPerUnit,
);
const worldPosition = getCameraView(
world,
camera,
renderContext,
).viewportToWorld(viewportPosition);

applyExplosiveForce(world, worldPosition, 1_000_000, 600);
});
Expand Down
162 changes: 103 additions & 59 deletions documentation-site/docs/docs/rendering/world-units-and-cameras.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,23 +48,19 @@ its sprite sizes and physics shapes re-tuned in world-unit terms.
## Pixels per unit

The number of screen pixels one world unit occupies (its "pixels per unit",
or PPU) is derived, not configured directly: it's recomputed every frame
from the camera's `verticalWorldUnits` and the current render destination's
height, via
[`calculatePixelsPerUnit`](/Forge/docs/api/functions/calculatePixelsPerUnit):
or PPU) is derived, not configured directly. It follows from the camera's
`verticalWorldUnits`, its `zoom` and the canvas's current height:

```
pixelsPerUnit = canvasHeight / verticalWorldUnits
pixelsPerUnit = canvasHeight * zoom / verticalWorldUnits
```

For example, a camera with `verticalWorldUnits: 10` rendering to a 1080px-tall
canvas gets `1080 / 10 = 108` pixels per unit; the same camera rendering to a
540px-tall canvas gets `54` pixels per unit, half as many, so the same 10
world units still fill the whole vertical extent of a shorter canvas. This
is why resizing the window doesn't need any manual handling: on the next
frame, `RenderContext.height` reflects the new size and the projection
matrix's scale updates with it, whether the render system is drawing sprites
or `createTerrainRenderEcsSystem` is drawing terrain geometry.
For example, a camera with `verticalWorldUnits: 10` on a 1080px-tall canvas
gets `1080 / 10 = 108` pixels per unit; the same camera on a 540px-tall
canvas gets `54`, half as many, so the same 10 world units still fill the
canvas vertically. Resizing the window needs no handling of your own: the
render system and `createTerrainRenderEcsSystem` recompute every camera's
projection each frame.

[`SpriteEcsComponent.width`/`height`](/Forge/docs/api/interfaces/SpriteEcsComponent)
and physics shape sizes are authored in world units, not pixels; they don't
Expand Down Expand Up @@ -135,10 +131,9 @@ That gives a `RenderContext` two sizes:
| `width` / `height` | Device pixels (the drawing buffer) | Anything rendered into and then shown on the canvas: sizing a `RenderTarget`, a shader uniform compared against `gl_FragCoord`, the WebGL viewport |
| `cssWidth` / `cssHeight` | CSS pixels (the canvas's on-page size) | Anything measured by the DOM: `MouseInputSource.position`, `getSafeAreaInsets()`, element sizes |

`pixelRatio` is the ratio between the two. Anything that only depends on the
aspect ratio, like
[`calculateVisibleWorldSize`](/Forge/docs/api/functions/calculateVisibleWorldSize)
or the camera's projection, gives the same result with either pair.
`pixelRatio` is the ratio between the two. A camera's view (see
[What a camera sees](#what-a-camera-sees)) measures its viewport and its
`pixelsPerUnit` in CSS pixels, so it pairs with pointer positions directly.

Rendering cost grows with the square of the pixel ratio, so a 3x phone
display draws nine times as many pixels as a 1x one. A fill-rate-heavy game
Expand All @@ -160,60 +155,109 @@ const { renderContext } = createGame('game-container', {
Pass `maxPixelRatio: 1` to always render at CSS resolution, the engine's
behavior before it supported high-DPI displays.

## Sizing and positioning things relative to what's visible
## What a camera sees

Game logic that needs to know how much world is on screen right now, to
keep a background covering the full view, spawn things across the visible
width, or clamp movement to the screen's edges, should use
[`calculateVisibleWorldSize`](/Forge/docs/api/functions/calculateVisibleWorldSize)
instead of reading `RenderContext.width`/`height` (raw pixels) directly:
[`getCameraView`](/Forge/docs/api/functions/getCameraView) returns a camera
entity's [`CameraView`](/Forge/docs/api/interfaces/CameraView): the world
area it shows (`bounds` and `size`), its `pixelsPerUnit`, and conversions
between world positions and viewport positions. A viewport position is in
CSS pixels from the canvas's top-left corner, Y-down, the same space
`MouseInputSource.position` and other DOM measurements use.

The view is `verticalWorldUnits / zoom` world units tall, as wide as the
canvas's aspect ratio makes it, and centered on the camera's
`position.world`. A camera that renders into a `RenderTarget` gets the same
view, since the target is presented over the whole canvas.

```ts
import { calculateVisibleWorldSize } from '@forge-game-engine/forge/rendering';
import {
createCamera,
getCameraView,
} from '@forge-game-engine/forge/rendering';

const visibleSize = calculateVisibleWorldSize(
renderContext.width,
renderContext.height,
camera.verticalWorldUnits,
);
const camera = createCamera(world, { verticalWorldUnits: 20 });

const halfVisibleWidth = visibleSize.x / 2;
const view = getCameraView(world, camera, renderContext);
```

`visibleSize.y` always equals `verticalWorldUnits`; `visibleSize.x` follows
the destination's current aspect ratio, so a spawn range or background quad
sized from it always exactly matches the edges of the screen, on any
resolution or aspect ratio, without needing to re-derive the calculation by
hand each time.
The view is computed when you ask for it, from the camera's components at
that moment, so it's never stale and there's nothing to keep in sync. It
does reflect the order systems run in: a system registered before
`createTransformEcsSystem` sees the camera where it was last frame, and a
UI canvas's camera has its `verticalWorldUnits` written by
`createUiLayoutEcsSystem`. A system that already holds the camera's
components can call
[`computeCameraView`](/Forge/docs/api/functions/computeCameraView) with
them instead of looking the entity up.

### Sizing and positioning things relative to what's visible

Game logic that needs to know how much world is on screen, to keep a
background covering the full view, spawn things across the visible width,
or clamp movement to the screen's edges, should read the view rather than
`RenderContext.width`/`height` (raw pixels):

```ts
const { bounds, size } = getCameraView(world, camera, renderContext);

// Spawn just above the top edge, anywhere across the visible width.
const spawnPosition = {
x: bounds.min.x + Math.random() * size.x,
y: bounds.max.y + 1,
};
```

## Converting screen and world positions manually
`bounds` already accounts for where the camera is and how far it's zoomed,
so it stays right for a camera that moves or zooms. Compute it where you
use it rather than once at startup: the canvas's aspect ratio changes with
the window, and a value saved at startup goes stale.

Mouse input and other screen-space coordinates need to go through the same
PPU as whatever's on screen, or they'll be off by the camera's scale factor.
[`screenToWorldSpace`](/Forge/docs/api/functions/screenToWorldSpace) and
[`worldToScreenSpace`](/Forge/docs/api/functions/worldToScreenSpace) both
take an optional trailing `pixelsPerUnit` argument for this; pass the same
value the camera used to render, or omit it only if that camera's
`verticalWorldUnits` genuinely produces a PPU of `1` for your current canvas
size.
### Converting between the viewport and the world

Keep every size in the call in the same unit as the position. A mouse
position is in CSS pixels, so convert it against the canvas's CSS size, not
its drawing-buffer size, which is `pixelRatio` times larger on a high-DPI
display (see [High-DPI displays](#high-dpi-displays)):
Pointer input arrives as a viewport position. Convert it with
`viewportToWorld`, and place DOM elements or check whether something is on
screen with `worldToViewport`:

```ts
const pixelsPerUnit = calculatePixelsPerUnit(
renderContext.cssHeight,
camera.verticalWorldUnits,
);
const view = getCameraView(world, camera, renderContext);

const pointerWorldPosition = view.viewportToWorld(mouseInputSource.position);
const enemyOnCanvas = view.worldToViewport(enemyPosition.world);
```

Both return new vectors, so they're safe to call on an entity's live
position.

### Converting between cameras

Two cameras that draw onto the same canvas, such as a game camera and a HUD
camera with its own units, share the viewport. To put a HUD element over a
world entity, go through it:

```ts
const gameView = getCameraView(world, gameCamera, renderContext);
const hudView = getCameraView(world, hudCamera, renderContext);

const worldPosition = screenToWorldSpace(
mouseInputSource.position,
cameraPosition.world,
camera.zoom,
renderContext.cssWidth,
renderContext.cssHeight,
pixelsPerUnit,
const hudPosition = hudView.viewportToWorld(
gameView.worldToViewport(shipPosition.world),
);
```

This stays right whichever camera moves or zooms. Deriving a fixed
"HUD units per world unit" constant from the two cameras' settings breaks as
soon as either one does.

## Off-screen sprites aren't drawn

The render system skips every sprite, nine-slice region and text glyph whose
quad lies entirely outside a camera's view, before uploading anything to
the GPU. A game with a large world doesn't need to disable sprites while
they're off screen to save rendering time; leave them enabled and let the
camera skip them.

The test uses the quad the sprite shader draws: the sprite's
`width`/`height` around its `pivot`, scaled, flipped and rotated by the
entity's world transform. A custom vertex shader that moves vertices outside
that quad can be skipped while part of it would still be on screen. Text
outlines and shadows are drawn inside their glyph quads, so they never are.
Terrain meshes are always drawn.
Loading
Loading