diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9bf5988..c0532e4e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/demo/src/game.ts b/demo/src/game.ts index 44f2999c2..7db5349d4 100644 --- a/demo/src/game.ts +++ b/demo/src/game.ts @@ -2,7 +2,6 @@ import { addPositionComponent, addRotationComponent, addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createGame, @@ -12,6 +11,7 @@ import { degreesToRadians, EcsSystem, EcsWorld, + getCameraView, PositionEcsComponent, positionId, Random, @@ -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; @@ -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; diff --git a/documentation-site/docs/docs/ecs/game.md b/documentation-site/docs/docs/ecs/game.md index 5068de28a..6caa59ef5 100644 --- a/documentation-site/docs/docs/ecs/game.md +++ b/documentation-site/docs/docs/ecs/game.md @@ -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. diff --git a/documentation-site/docs/docs/physics/forces.md b/documentation-site/docs/docs/physics/forces.md index 298eb0a5c..68b066cae 100644 --- a/documentation-site/docs/docs/physics/forces.md +++ b/documentation-site/docs/docs/physics/forces.md @@ -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); }); diff --git a/documentation-site/docs/docs/rendering/world-units-and-cameras.md b/documentation-site/docs/docs/rendering/world-units-and-cameras.md index 63cdadb04..83922c746 100644 --- a/documentation-site/docs/docs/rendering/world-units-and-cameras.md +++ b/documentation-site/docs/docs/rendering/world-units-and-cameras.md @@ -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 @@ -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 @@ -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. diff --git a/documentation-site/src/pages/demos/brick-breaker/_create-background.ts b/documentation-site/src/pages/demos/brick-breaker/_create-background.ts index 12789a81a..3fd3e8404 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_create-background.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_create-background.ts @@ -1,9 +1,9 @@ import { addSpriteComponent, - calculateVisibleWorldSize, combineInstanceDataSegments, createQuadGeometry, ForgeShaderSource, + getCameraView, Material, Renderable, RenderContext, @@ -11,7 +11,6 @@ import { } from '@forge-game-engine/forge/rendering'; import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { addPositionComponent } from '@forge-game-engine/forge/common'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { backgroundShader } from './_background.shader'; import { backgroundId } from './_background.component'; @@ -19,11 +18,13 @@ import { backgroundId } from './_background.component'; * Creates a full-screen, shader-driven gradient sprite that sits behind the * play area, so the backdrop doesn't need a static image asset. * @param world - The ECS world to add the background entity to. + * @param camera - The camera entity whose visible area the background fills. * @param renderContext - The render context used to build the material. * @param renderLayer - The render layer the background should be drawn on. */ export function createBackground( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): void { @@ -50,11 +51,7 @@ export function createBackground( const backgroundEntity = world.createEntity(); - const visibleWorldSize = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const visibleWorldSize = getCameraView(world, camera, renderContext).size; addSpriteComponent(world, backgroundEntity, { width: visibleWorldSize.x, diff --git a/documentation-site/src/pages/demos/brick-breaker/_create-game.ts b/documentation-site/src/pages/demos/brick-breaker/_create-game.ts index 26a0afd09..00a519a2c 100644 --- a/documentation-site/src/pages/demos/brick-breaker/_create-game.ts +++ b/documentation-site/src/pages/demos/brick-breaker/_create-game.ts @@ -52,7 +52,7 @@ export const createBrickBreakerGame = async (): Promise => { // The background sits on its own static camera, added before the // foreground camera, so it always renders first and stays behind the // gameplay sprites. - createCamera(world, { + const backgroundCamera = createCamera(world, { isStatic: true, cullingMask: renderLayers.background, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, @@ -64,7 +64,12 @@ export const createBrickBreakerGame = async (): Promise => { verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - createBackground(world, renderContext, renderLayers.background); + createBackground( + world, + backgroundCamera, + renderContext, + renderLayers.background, + ); const moveInput = new Axis1dAction( 'paddleMove', diff --git a/documentation-site/src/pages/demos/easing-functions/_create-easing-rows.ts b/documentation-site/src/pages/demos/easing-functions/_create-easing-rows.ts index 557aef996..c7c3e3aa9 100644 --- a/documentation-site/src/pages/demos/easing-functions/_create-easing-rows.ts +++ b/documentation-site/src/pages/demos/easing-functions/_create-easing-rows.ts @@ -15,13 +15,12 @@ import { } from '@forge-game-engine/forge/animations'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createImageSprite, + getCameraView, RenderContext, SpriteEcsComponent, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { easingRowId } from './_easing-row.component'; interface EasingRowConfig { @@ -100,11 +99,13 @@ function placeSprite( * sweeps back and forth across it, so all the easing curves can be compared * side by side. * @param world - The ECS world to add the row entities to. + * @param camera - The camera entity whose visible area the rows are laid out across. * @param renderContext - The render context used to load and size sprites. * @param renderLayer - The render layer the rows should be drawn on. */ export async function createEasingRows( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { @@ -117,11 +118,11 @@ export async function createEasingRows( layer: renderLayer, }); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const trackHalfWidth = width / 2 - horizontalMarginPixels; const minX = -trackHalfWidth * (1 - overshootMarginFraction); const maxX = trackHalfWidth * (1 - overshootMarginFraction); diff --git a/documentation-site/src/pages/demos/easing-functions/_create-game.ts b/documentation-site/src/pages/demos/easing-functions/_create-game.ts index 57bd6fbc6..6daff1592 100644 --- a/documentation-site/src/pages/demos/easing-functions/_create-game.ts +++ b/documentation-site/src/pages/demos/easing-functions/_create-game.ts @@ -16,13 +16,13 @@ const renderLayers = { export const createEasingFunctionsGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createEasingRows(world, renderContext, renderLayers.foreground); + await createEasingRows(world, camera, renderContext, renderLayers.foreground); world.addSystem(createCameraEcsSystem(time)); world.addSystem(createEasingRowEcsSystem(time)); diff --git a/documentation-site/src/pages/demos/layout-groups/_create-game.ts b/documentation-site/src/pages/demos/layout-groups/_create-game.ts index deda36464..218fc7a43 100644 --- a/documentation-site/src/pages/demos/layout-groups/_create-game.ts +++ b/documentation-site/src/pages/demos/layout-groups/_create-game.ts @@ -18,13 +18,13 @@ import { } from '@forge-game-engine/forge/input'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; import { @@ -51,6 +51,7 @@ const renderLayers = { async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -62,11 +63,11 @@ async function createBackdrop( }); backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; @@ -147,13 +148,13 @@ export const createLayoutGroupsGame = async ( ): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.world, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBackdrop(world, renderContext); + await createBackdrop(world, camera, renderContext); const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); const fontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); diff --git a/documentation-site/src/pages/demos/linear-spring-damper/_create-game.ts b/documentation-site/src/pages/demos/linear-spring-damper/_create-game.ts index cd9d025a2..9b0e0e073 100644 --- a/documentation-site/src/pages/demos/linear-spring-damper/_create-game.ts +++ b/documentation-site/src/pages/demos/linear-spring-damper/_create-game.ts @@ -23,13 +23,18 @@ const renderLayers = { export const createLinearSpringDamperGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createSuspensions(world, renderContext, renderLayers.foreground); + await createSuspensions( + world, + camera, + renderContext, + renderLayers.foreground, + ); // A reset (teleport) and `createSpringLineEcsSystem` both write `local` // positions, so they run before `createTransformEcsSystem`, which turns diff --git a/documentation-site/src/pages/demos/linear-spring-damper/_create-suspensions.ts b/documentation-site/src/pages/demos/linear-spring-damper/_create-suspensions.ts index d878fe81d..b1a2317a0 100644 --- a/documentation-site/src/pages/demos/linear-spring-damper/_create-suspensions.ts +++ b/documentation-site/src/pages/demos/linear-spring-damper/_create-suspensions.ts @@ -15,13 +15,12 @@ import { } from '@forge-game-engine/forge/physics'; import { addSpriteComponent, - calculateVisibleWorldSize, createImageSprite, + getCameraView, NineSliceOptions, RenderContext, SpriteEcsComponent, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; import { addResetComponent } from './_reset.component'; import { addSpringLineComponent } from './_spring-line.component'; @@ -258,20 +257,22 @@ function createSuspensionScenario( * `LinearSpringEcsComponent`, the right pairs it with a * `LinearDamperEcsComponent`. * @param world - The ECS world to add the scenarios' entities to. + * @param camera - The camera entity whose visible area the scenarios are laid out across. * @param renderContext - The render context used to load sprites. * @param renderLayer - The render layer the scenarios should be drawn on. */ export async function createSuspensions( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { const sprites = await loadSuspensionSprites(renderContext, renderLayer); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const columnWidth = width / 2; const mountY = height * 0.25; const stiffness = 30_000; diff --git a/documentation-site/src/pages/demos/moving-platform/_create-boundaries.ts b/documentation-site/src/pages/demos/moving-platform/_create-boundaries.ts index 81f71e801..01b91cf0b 100644 --- a/documentation-site/src/pages/demos/moving-platform/_create-boundaries.ts +++ b/documentation-site/src/pages/demos/moving-platform/_create-boundaries.ts @@ -11,11 +11,10 @@ import { } from '@forge-game-engine/forge/physics'; import { addSpriteComponent, - calculateVisibleWorldSize, createImageSprite, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; export const wallThickness = 40; @@ -37,11 +36,13 @@ function rectangleVertices(width: number, height: number): Vector2[] { * safety net that catches any crate that slides off the platform's edge, * and keeps everything within the visible area. * @param world - The ECS world to add the boundary entities to. + * @param camera - The camera entity whose visible area the boundaries enclose. * @param renderContext - The render context used to load the wall sprite. * @param renderLayer - The render layer the boundaries should be drawn on. */ export async function createBoundaries( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { @@ -53,11 +54,11 @@ export async function createBoundaries( layer: renderLayer, }); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const halfWidth = width / 2; const halfHeight = height / 2; diff --git a/documentation-site/src/pages/demos/moving-platform/_create-game.ts b/documentation-site/src/pages/demos/moving-platform/_create-game.ts index 3a7002197..b750c0bf0 100644 --- a/documentation-site/src/pages/demos/moving-platform/_create-game.ts +++ b/documentation-site/src/pages/demos/moving-platform/_create-game.ts @@ -1,10 +1,8 @@ import { - calculatePixelsPerUnit, - calculateVisibleWorldSize, createCamera, createCameraEcsSystem, createRenderEcsSystem, - screenToWorldSpace, + getCameraView, } from '@forge-game-engine/forge/rendering'; import { createGame, Game } from '@forge-game-engine/forge/utilities'; import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; @@ -18,7 +16,6 @@ import { createGravityEcsSystem, createNarrowPhaseEcsSystem, } from '@forge-game-engine/forge/physics'; -import { Vec2 } from '@forge-game-engine/forge/math'; import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { createBoundaries } from './_create-boundaries'; import { createPlatform, platformHeight } from './_create-platform'; @@ -32,22 +29,22 @@ const renderLayers = { export const createMovingPlatformGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const platformY = -height * 0.15; const leftX = -width * 0.25; const rightX = width * 0.25; - await createBoundaries(world, renderContext, renderLayers.foreground); + await createBoundaries(world, camera, renderContext, renderLayers.foreground); await createPlatform( world, renderContext, @@ -102,10 +99,7 @@ export const createMovingPlatformGame = async (): Promise => { world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); - // Click anywhere to drop another crate at that position - the camera is - // static at the world origin with a zoom of 1 (see `createCamera` above), - // so screen coordinates can be converted to world coordinates directly, - // once scaled by the camera's pixels-per-unit. + // Click anywhere to drop another crate at that position. renderContext.canvas.addEventListener('mousedown', (event: MouseEvent) => { const canvasBounds = renderContext.canvas.getBoundingClientRect(); @@ -114,23 +108,11 @@ export const createMovingPlatformGame = async (): Promise => { 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, - DEMO_VERTICAL_WORLD_UNITS, - ); - - const worldPosition = screenToWorldSpace( - screenPosition, - Vec2.zero, - 1, - renderContext.cssWidth, - renderContext.cssHeight, - pixelsPerUnit, - ); + const worldPosition = getCameraView( + world, + camera, + renderContext, + ).viewportToWorld(screenPosition); spawnCrate(world, crateSprite, worldPosition); }); diff --git a/documentation-site/src/pages/demos/particles/_create-game.ts b/documentation-site/src/pages/demos/particles/_create-game.ts index e777120d3..8ed35bd13 100644 --- a/documentation-site/src/pages/demos/particles/_create-game.ts +++ b/documentation-site/src/pages/demos/particles/_create-game.ts @@ -1,9 +1,8 @@ import { - calculatePixelsPerUnit, createCamera, createCameraEcsSystem, createRenderEcsSystem, - screenToWorldSpace, + getCameraView, } from '@forge-game-engine/forge/rendering'; import { createGame, Game } from '@forge-game-engine/forge/utilities'; import { @@ -14,7 +13,7 @@ import { createLifetimeTrackingEcsSystem, createRemoveFromWorldEcsSystem, } from '@forge-game-engine/forge/lifecycle'; -import { Random, Vec2, Vector2 } from '@forge-game-engine/forge/math'; +import { Random, Vector2 } from '@forge-game-engine/forge/math'; import { createParticleEcsSystem, createParticleOpacityEcsSystem, @@ -36,7 +35,7 @@ const fountainHeightFromBottom = DEMO_VERTICAL_WORLD_UNITS * 0.12; export const createParticlesGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, @@ -75,9 +74,6 @@ export const createParticlesGame = async (): Promise => { world.addSystem(createTransformEcsSystem()); world.addSystem(createRenderEcsSystem(renderContext)); - // The camera is static at the world origin with a zoom of 1 (see - // `createCamera` above), so screen coordinates can be converted to world - // coordinates directly, once scaled by the camera's pixels-per-unit. const toWorldPosition = (event: MouseEvent): Vector2 => { const canvasBounds = renderContext.canvas.getBoundingClientRect(); @@ -86,22 +82,8 @@ export const createParticlesGame = async (): Promise => { 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, - DEMO_VERTICAL_WORLD_UNITS, - ); - - return screenToWorldSpace( + return getCameraView(world, camera, renderContext).viewportToWorld( screenPosition, - Vec2.zero, - 1, - renderContext.cssWidth, - renderContext.cssHeight, - pixelsPerUnit, ); }; diff --git a/documentation-site/src/pages/demos/physics/_create-boundaries.ts b/documentation-site/src/pages/demos/physics/_create-boundaries.ts index 5bb7ebba9..9ebdd1ef1 100644 --- a/documentation-site/src/pages/demos/physics/_create-boundaries.ts +++ b/documentation-site/src/pages/demos/physics/_create-boundaries.ts @@ -11,11 +11,10 @@ import { } from '@forge-game-engine/forge/physics'; import { addSpriteComponent, - calculateVisibleWorldSize, createImageSprite, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; export const wallThickness = 40; @@ -36,11 +35,13 @@ function rectangleVertices(width: number, height: number): Vector2[] { * Creates static, non-rigid-body entities for the floor and side walls, * bounding the area in which shapes can fall and collide. * @param world - The ECS world to add the boundary entities to. + * @param camera - The camera entity whose visible area the boundaries enclose. * @param renderContext - The render context used to load the wall sprite. * @param renderLayer - The render layer the boundaries should be drawn on. */ export async function createBoundaries( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { @@ -52,11 +53,11 @@ export async function createBoundaries( layer: renderLayer, }); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const halfWidth = width / 2; const halfHeight = height / 2; diff --git a/documentation-site/src/pages/demos/physics/_create-game.ts b/documentation-site/src/pages/demos/physics/_create-game.ts index 16e788575..820086b19 100644 --- a/documentation-site/src/pages/demos/physics/_create-game.ts +++ b/documentation-site/src/pages/demos/physics/_create-game.ts @@ -1,9 +1,8 @@ import { - calculatePixelsPerUnit, createCamera, createCameraEcsSystem, createRenderEcsSystem, - screenToWorldSpace, + getCameraView, } from '@forge-game-engine/forge/rendering'; import { createGame, Game } from '@forge-game-engine/forge/utilities'; import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; @@ -18,7 +17,6 @@ import { createGravityEcsSystem, createNarrowPhaseEcsSystem, } from '@forge-game-engine/forge/physics'; -import { Vec2 } from '@forge-game-engine/forge/math'; import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { createBoundaries } from './_create-boundaries'; import { spawnShapes } from './_spawn-shapes'; @@ -39,14 +37,14 @@ const explosionRadius = 600; export const createPhysicsGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBoundaries(world, renderContext, renderLayers.foreground); - await spawnShapes(world, renderContext, renderLayers.foreground); + await createBoundaries(world, camera, renderContext, renderLayers.foreground); + await spawnShapes(world, camera, renderContext, renderLayers.foreground); const collisionPairs: CollisionPair[] = []; const collisionManifolds: CollisionManifold[] = []; @@ -73,9 +71,6 @@ export const createPhysicsGame = async (): Promise => { world.addSystem(createRenderEcsSystem(renderContext)); world.addSystem(createEulerIntegrationEcsSystem(time)); - // The camera is static at the world origin with a zoom of 1 (see - // `createCamera` above), so screen coordinates can be converted to world - // coordinates directly, once scaled by the camera's pixels-per-unit. renderContext.canvas.addEventListener('mousedown', (event: MouseEvent) => { const canvasBounds = renderContext.canvas.getBoundingClientRect(); @@ -84,23 +79,11 @@ export const createPhysicsGame = async (): Promise => { 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, - DEMO_VERTICAL_WORLD_UNITS, - ); - - const worldPosition = screenToWorldSpace( - screenPosition, - Vec2.zero, - 1, - renderContext.cssWidth, - renderContext.cssHeight, - pixelsPerUnit, - ); + const worldPosition = getCameraView( + world, + camera, + renderContext, + ).viewportToWorld(screenPosition); applyExplosiveForce(world, worldPosition, explosionForce, explosionRadius); }); diff --git a/documentation-site/src/pages/demos/physics/_spawn-shapes.ts b/documentation-site/src/pages/demos/physics/_spawn-shapes.ts index 04ed68e7c..719b04324 100644 --- a/documentation-site/src/pages/demos/physics/_spawn-shapes.ts +++ b/documentation-site/src/pages/demos/physics/_spawn-shapes.ts @@ -16,12 +16,11 @@ import { } from '@forge-game-engine/forge/physics'; import { addSpriteComponent, - calculateVisibleWorldSize, createImageSprite, + getCameraView, RenderContext, SpriteEcsComponent, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; import { wallThickness } from './_create-boundaries'; @@ -74,11 +73,13 @@ function createTriangleCollider(width: number, height: number): Collider { * Spawns a pile of dynamic circle, square, triangle and plank bodies above * the visible area, which then fall, bounce and rest under gravity. * @param world - The ECS world to add the shape entities to. + * @param camera - The camera entity whose visible area shapes are spawned within. * @param renderContext - The render context used to load the shape sprites. * @param renderLayer - The render layer the shapes should be drawn on. */ export async function spawnShapes( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { @@ -134,11 +135,11 @@ export async function spawnShapes( const random = new Random(); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const halfWidth = width / 2; const halfHeight = height / 2; diff --git a/documentation-site/src/pages/demos/prismatic-joint/_create-game.ts b/documentation-site/src/pages/demos/prismatic-joint/_create-game.ts index 58ec7f230..e337d7810 100644 --- a/documentation-site/src/pages/demos/prismatic-joint/_create-game.ts +++ b/documentation-site/src/pages/demos/prismatic-joint/_create-game.ts @@ -27,13 +27,13 @@ const renderLayers = { export const createPrismaticJointGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createSliders(world, renderContext, renderLayers.foreground); + await createSliders(world, camera, renderContext, renderLayers.foreground); const collisionPairs: CollisionPair[] = []; const collisionManifolds: CollisionManifold[] = []; diff --git a/documentation-site/src/pages/demos/prismatic-joint/_create-sliders.ts b/documentation-site/src/pages/demos/prismatic-joint/_create-sliders.ts index 4685f0515..8c3c3ec4b 100644 --- a/documentation-site/src/pages/demos/prismatic-joint/_create-sliders.ts +++ b/documentation-site/src/pages/demos/prismatic-joint/_create-sliders.ts @@ -16,13 +16,12 @@ import { } from '@forge-game-engine/forge/physics'; import { addSpriteComponent, - calculateVisibleWorldSize, createImageSprite, + getCameraView, NineSliceOptions, RenderContext, SpriteEcsComponent, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; import { addPumpComponent } from './_pump.component'; @@ -271,20 +270,22 @@ function createSliderScenario( * down a vertical rail after each upward pump, and a ball that gravity pulls * down a diagonal incline after each pump back up it. * @param world - The ECS world to add the scenarios' entities to. + * @param camera - The camera entity whose visible area the scenarios are laid out across. * @param renderContext - The render context used to load sprites. * @param renderLayer - The render layer the scenarios should be drawn on. */ export async function createSliders( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { const sprites = await loadSliderSprites(renderContext, renderLayer); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const columnWidth = width / 3; const columnLeft = -width / 2 + columnWidth / 2; diff --git a/documentation-site/src/pages/demos/raycasting/_create-game.ts b/documentation-site/src/pages/demos/raycasting/_create-game.ts index 97b02e9a8..bf93a5ca9 100644 --- a/documentation-site/src/pages/demos/raycasting/_create-game.ts +++ b/documentation-site/src/pages/demos/raycasting/_create-game.ts @@ -1,10 +1,8 @@ import { - calculatePixelsPerUnit, - calculateVisibleWorldSize, createCamera, createCameraEcsSystem, createRenderEcsSystem, - screenToWorldSpace, + getCameraView, } from '@forge-game-engine/forge/rendering'; import { createGame, Game } from '@forge-game-engine/forge/utilities'; import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; @@ -29,19 +27,15 @@ const rayMaxLength = 2000; export const createRaycastingGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createTargets(world, renderContext, renderLayers.foreground); + await createTargets(world, camera, renderContext, renderLayers.foreground); - const { x: width } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width } = getCameraView(world, camera, renderContext).size; const rayOrigin = { x: -width / 2 + 40, y: 0 }; const rayVisual = await createRayVisual( @@ -72,9 +66,6 @@ export const createRaycastingGame = async (): Promise => { world.addSystem(createCameraEcsSystem(time)); world.addSystem(createRenderEcsSystem(renderContext)); - // The camera is static at the world origin with a zoom of 1 (see - // `createCamera` above), so screen coordinates can be converted to world - // coordinates directly, once scaled by the camera's pixels-per-unit. renderContext.canvas.addEventListener('mousemove', (event: MouseEvent) => { const canvasBounds = renderContext.canvas.getBoundingClientRect(); @@ -83,23 +74,11 @@ export const createRaycastingGame = async (): Promise => { 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, - DEMO_VERTICAL_WORLD_UNITS, - ); - - const mouseWorldPosition = screenToWorldSpace( - screenPosition, - Vec2.zero, - 1, - renderContext.cssWidth, - renderContext.cssHeight, - pixelsPerUnit, - ); + const mouseWorldPosition = getCameraView( + world, + camera, + renderContext, + ).viewportToWorld(screenPosition); // Clone before subtracting: `mouseWorldPosition` is a fresh point every // event, `rayOrigin` is reused every event. diff --git a/documentation-site/src/pages/demos/raycasting/_create-targets.ts b/documentation-site/src/pages/demos/raycasting/_create-targets.ts index e110f61bf..bf9fb0983 100644 --- a/documentation-site/src/pages/demos/raycasting/_create-targets.ts +++ b/documentation-site/src/pages/demos/raycasting/_create-targets.ts @@ -13,11 +13,10 @@ import { } from '@forge-game-engine/forge/physics'; import { addSpriteComponent, - calculateVisibleWorldSize, createImageSprite, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; interface TargetSpec { @@ -83,11 +82,13 @@ function buildTargets(width: number): TargetSpec[] { * so a plain `ColliderEcsComponent` plus `AabbEcsComponent` is all `raycast` * needs to see them. * @param world - The ECS world to add the target entities to. + * @param camera - The camera entity whose visible width the targets are spread across. * @param renderContext - The render context used to load target sprites. * @param renderLayer - The render layer targets should be drawn on. */ export async function createTargets( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { @@ -107,11 +108,7 @@ export async function createTargets( layer: renderLayer, }); - const { x: width } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width } = getCameraView(world, camera, renderContext).size; for (const target of buildTargets(width)) { const entity = world.createEntity(); diff --git a/documentation-site/src/pages/demos/revolute-joint/_create-game.ts b/documentation-site/src/pages/demos/revolute-joint/_create-game.ts index 43b96d8aa..f50d4097b 100644 --- a/documentation-site/src/pages/demos/revolute-joint/_create-game.ts +++ b/documentation-site/src/pages/demos/revolute-joint/_create-game.ts @@ -27,13 +27,13 @@ const renderLayers = { export const createRevoluteJointGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createHinges(world, renderContext, renderLayers.foreground); + await createHinges(world, camera, renderContext, renderLayers.foreground); const collisionPairs: CollisionPair[] = []; const collisionManifolds: CollisionManifold[] = []; diff --git a/documentation-site/src/pages/demos/revolute-joint/_create-hinges.ts b/documentation-site/src/pages/demos/revolute-joint/_create-hinges.ts index 54fa5f00c..f45257259 100644 --- a/documentation-site/src/pages/demos/revolute-joint/_create-hinges.ts +++ b/documentation-site/src/pages/demos/revolute-joint/_create-hinges.ts @@ -15,13 +15,12 @@ import { } from '@forge-game-engine/forge/physics'; import { addSpriteComponent, - calculateVisibleWorldSize, createImageSprite, + getCameraView, NineSliceOptions, RenderContext, SpriteEcsComponent, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; import { addPushComponent } from './_push.component'; @@ -339,20 +338,22 @@ function createWheelScenario( * pendulum released to swing freely under gravity, and a wheel spun up once * and left to rotate indefinitely. * @param world - The ECS world to add the scenarios' entities to. + * @param camera - The camera entity whose visible area the scenarios are laid out across. * @param renderContext - The render context used to load sprites. * @param renderLayer - The render layer the scenarios should be drawn on. */ export async function createHinges( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { const sprites = await loadHingeSprites(renderContext, renderLayer); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const columnWidth = width / 3; const columnLeft = -width / 2 + columnWidth / 2; diff --git a/documentation-site/src/pages/demos/space-shooter/_background.system.ts b/documentation-site/src/pages/demos/space-shooter/_background.system.ts index e89f3d2cc..86b1b31bc 100644 --- a/documentation-site/src/pages/demos/space-shooter/_background.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_background.system.ts @@ -1,18 +1,18 @@ import { EcsSystem } from '@forge-game-engine/forge/ecs'; import { Time } from '@forge-game-engine/forge/common'; import { - calculateVisibleWorldSize, + getCameraView, RenderContext, SpriteEcsComponent, spriteId, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { backgroundId } from './_background.component'; /** - * Re-derives the background sprite's world size and `u_resolution` uniform - * every frame from `renderContext`'s current dimensions, instead of the - * fixed values `createBackground` computed once at startup - otherwise the + * Re-derives the background sprite's world size from `camera`'s view, and + * its `u_resolution` uniform from `renderContext`'s current dimensions, + * whenever the canvas is resized, instead of keeping the fixed values + * `createBackground` computed once at startup - otherwise the * background quad (and the noise pattern its shader draws, which assumes * `u_resolution` matches the canvas) stays sized for whatever aspect ratio * the container had when the game started, leaving bare canvas past its @@ -20,6 +20,7 @@ import { backgroundId } from './_background.component'; */ export const createBackgroundEcsSystem = ( time: Time, + camera: number, renderContext: RenderContext, ): EcsSystem<[SpriteEcsComponent]> => { let lastWidth = -1; @@ -28,7 +29,7 @@ export const createBackgroundEcsSystem = ( return { query: [spriteId], tags: [backgroundId], - update: (_world, { components: [spriteComponents] }) => { + update: (world, { components: [spriteComponents] }) => { const resized = renderContext.width !== lastWidth || renderContext.height !== lastHeight; @@ -39,11 +40,7 @@ export const createBackgroundEcsSystem = ( } const visibleWorldSize = resized - ? calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ) + ? getCameraView(world, camera, renderContext).size : null; for (const spriteComponent of spriteComponents) { diff --git a/documentation-site/src/pages/demos/space-shooter/_create-asteroids.ts b/documentation-site/src/pages/demos/space-shooter/_create-asteroids.ts index c52caa4e1..f502425c3 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-asteroids.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-asteroids.ts @@ -1,11 +1,10 @@ import { getAssetUrl } from '@site/src/utils/get-asset-url'; import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { - calculateVisibleWorldSize, createImageSprite, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { AsteroidSpawnerEcsComponent, asteroidSpawnerId, @@ -21,6 +20,7 @@ const asteroidImagePaths = [ export async function createAsteroidSpawner( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { @@ -36,19 +36,15 @@ export async function createAsteroidSpawner( const spawnerEntity = world.createEntity(); - const visibleWorldWidth = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ).x; + const visibleWorldSize = getCameraView(world, camera, renderContext).size; const spawnerComponent: AsteroidSpawnerEcsComponent = { asteroidSprites, timeBetweenSpawns: 0.2, nextSpawnTime: 0, - minX: -visibleWorldWidth / 2, - maxX: visibleWorldWidth / 2, - spawnY: DEMO_VERTICAL_WORLD_UNITS / 2 + 100, + minX: -visibleWorldSize.x / 2, + maxX: visibleWorldSize.x / 2, + spawnY: visibleWorldSize.y / 2 + 100, minSpeed: 70, maxSpeed: 130, rotationSpeed: Math.PI / 6, diff --git a/documentation-site/src/pages/demos/space-shooter/_create-background.ts b/documentation-site/src/pages/demos/space-shooter/_create-background.ts index 91c437aba..abff6c65a 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-background.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-background.ts @@ -1,11 +1,11 @@ import { addSpriteComponent, - calculateVisibleWorldSize, Color, combineInstanceDataSegments, createQuadGeometry, createTextureFromImage, ForgeShaderSource, + getCameraView, Material, Renderable, RenderContext, @@ -13,13 +13,13 @@ import { } from '@forge-game-engine/forge/rendering'; import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { addPositionComponent } from '@forge-game-engine/forge/common'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { backgroundShader } from './_background.shader'; import { backgroundId } from './_background.component'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; export async function createBackground( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { @@ -68,11 +68,7 @@ export async function createBackground( const backgroundEntity = world.createEntity(); - const visibleWorldSize = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const visibleWorldSize = getCameraView(world, camera, renderContext).size; addSpriteComponent(world, backgroundEntity, { width: visibleWorldSize.x, diff --git a/documentation-site/src/pages/demos/space-shooter/_create-game.ts b/documentation-site/src/pages/demos/space-shooter/_create-game.ts index 604194acd..41f2b224e 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-game.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-game.ts @@ -167,13 +167,24 @@ export const createSpaceShooterGame = async ( game, ); - await createBackground(world, renderContext, renderLayers.background); + await createBackground( + world, + backgroundCameraEntity, + renderContext, + renderLayers.background, + ); const playerSprites = await createPlayer( renderContext, world, + foregroundCameraEntity, + renderLayers.foreground, + ); + await createAsteroidSpawner( + world, + foregroundCameraEntity, + renderContext, renderLayers.foreground, ); - await createAsteroidSpawner(world, renderContext, renderLayers.foreground); const explosionSpawner = await createExplosionSpawner( renderContext, renderLayers.foreground, @@ -207,7 +218,13 @@ export const createSpaceShooterGame = async ( }); const respawnPlayer = (): void => { - spawnPlayer(renderContext, world, renderLayers.foreground, playerSprites); + spawnPlayer( + renderContext, + world, + foregroundCameraEntity, + renderLayers.foreground, + playerSprites, + ); }; const onPlayerDeath = (): void => { @@ -229,7 +246,9 @@ export const createSpaceShooterGame = async ( world.addSystem(createCameraEcsSystem(time)); world.addSystem(createCameraShakeEcsSystem(time, random)); world.addSystem(createMovementEcsSystem(moveInput, time)); - world.addSystem(createBackgroundEcsSystem(time, renderContext)); + world.addSystem( + createBackgroundEcsSystem(time, backgroundCameraEntity, renderContext), + ); world.addSystem(createAudioEcsSystem()); world.addSystem(createLifetimeTrackingEcsSystem(time)); world.addSystem(createRemoveFromWorldEcsSystem()); diff --git a/documentation-site/src/pages/demos/space-shooter/_create-player.ts b/documentation-site/src/pages/demos/space-shooter/_create-player.ts index dc6c0099b..33e7390dc 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-player.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-player.ts @@ -2,9 +2,9 @@ import { getAssetUrl } from '@site/src/utils/get-asset-url'; import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createImageSprite, + getCameraView, RenderContext, SpriteEcsComponent, } from '@forge-game-engine/forge/rendering'; @@ -19,7 +19,6 @@ import { addColliderComponent, CircleCollider, } from '@forge-game-engine/forge/physics'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { PlayerId } from './_player.component'; import { gunId } from './_gun.component'; @@ -82,6 +81,7 @@ export async function loadPlayerSprites( export function spawnPlayer( renderContext: RenderContext, world: EcsWorld, + camera: number, renderLayer: number, playerSprites: PlayerSprites, ): void { @@ -95,11 +95,7 @@ export function spawnPlayer( const playerRadius = (playerSprite.width * playerScale + playerSprite.height * playerScale) / 4; - const visibleWorldSize = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const visibleWorldSize = getCameraView(world, camera, renderContext).size; const halfVisibleWidth = visibleWorldSize.x / 2; const halfVisibleHeight = visibleWorldSize.y / 2; @@ -139,11 +135,12 @@ export function spawnPlayer( export async function createPlayer( renderContext: RenderContext, world: EcsWorld, + camera: number, renderLayer: number, ): Promise { const playerSprites = await loadPlayerSprites(renderContext, renderLayer); - spawnPlayer(renderContext, world, renderLayer, playerSprites); + spawnPlayer(renderContext, world, camera, renderLayer, playerSprites); return playerSprites; } diff --git a/documentation-site/src/pages/demos/stress-test/_create-game.ts b/documentation-site/src/pages/demos/stress-test/_create-game.ts index e555ca1aa..589b71e11 100644 --- a/documentation-site/src/pages/demos/stress-test/_create-game.ts +++ b/documentation-site/src/pages/demos/stress-test/_create-game.ts @@ -18,13 +18,18 @@ const renderLayers = { export const createStressTestGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createSpriteSpawner(world, renderContext, renderLayers.foreground); + await createSpriteSpawner( + world, + camera, + renderContext, + renderLayers.foreground, + ); const random = new Random(); diff --git a/documentation-site/src/pages/demos/stress-test/_create-sprite-spawner.ts b/documentation-site/src/pages/demos/stress-test/_create-sprite-spawner.ts index d2494b2b6..12ab2d754 100644 --- a/documentation-site/src/pages/demos/stress-test/_create-sprite-spawner.ts +++ b/documentation-site/src/pages/demos/stress-test/_create-sprite-spawner.ts @@ -1,10 +1,9 @@ import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { - calculateVisibleWorldSize, createImageSprite, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; import { SpriteSpawnerEcsComponent, @@ -18,11 +17,13 @@ const spriteScale = 0.25; /** * Creates the entity that drives the stress test's sprite batch spawner. * @param world - The ECS world to add the spawner entity to. + * @param camera - The camera entity whose visible area sprites are spawned within. * @param renderContext - The render context used to load the sprite. * @param renderLayer - The render layer spawned sprites should be drawn on. */ export async function createSpriteSpawner( world: EcsWorld, + camera: number, renderContext: RenderContext, renderLayer: number, ): Promise { @@ -35,11 +36,11 @@ export async function createSpriteSpawner( layer: renderLayer, }); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const halfWidth = width / 2; const halfHeight = height / 2; diff --git a/documentation-site/src/pages/demos/text/_create-game.ts b/documentation-site/src/pages/demos/text/_create-game.ts index 4ceeb4ad6..02ff95701 100644 --- a/documentation-site/src/pages/demos/text/_create-game.ts +++ b/documentation-site/src/pages/demos/text/_create-game.ts @@ -1,10 +1,10 @@ import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; import { - calculateVisibleWorldSize, createCamera, createCameraEcsSystem, createImageSprite, createRenderEcsSystem, + getCameraView, } from '@forge-game-engine/forge/rendering'; import { createTextShapingEcsSystem, @@ -58,7 +58,7 @@ export const createTextGame = async ( ): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, @@ -75,11 +75,11 @@ export const createTextGame = async ( layer: renderLayers.foreground, }); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const usableWidth = width - margin * 2; const left = -usableWidth / 2; let y = height / 2 - margin; diff --git a/documentation-site/src/pages/demos/torque/_create-game.ts b/documentation-site/src/pages/demos/torque/_create-game.ts index 70f9ab8bf..8cb37e26f 100644 --- a/documentation-site/src/pages/demos/torque/_create-game.ts +++ b/documentation-site/src/pages/demos/torque/_create-game.ts @@ -1,8 +1,8 @@ import { - calculateVisibleWorldSize, createCamera, createCameraEcsSystem, createRenderEcsSystem, + getCameraView, } from '@forge-game-engine/forge/rendering'; import { createGame, Game } from '@forge-game-engine/forge/utilities'; import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; @@ -33,7 +33,7 @@ const renderLayers = { export const createTorqueGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.foreground, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, @@ -51,11 +51,11 @@ export const createTorqueGame = async (): Promise => { new KeyboardHoldBinding(thrustInput, keyCodes.space), ); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; const columnWidth = width / 2; await createThrusterScenario( diff --git a/documentation-site/src/pages/demos/ui-anchors/_create-game.ts b/documentation-site/src/pages/demos/ui-anchors/_create-game.ts index 197fa136c..69fd5441f 100644 --- a/documentation-site/src/pages/demos/ui-anchors/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-anchors/_create-game.ts @@ -5,13 +5,13 @@ import { import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, spriteId, } from '@forge-game-engine/forge/rendering'; @@ -53,6 +53,7 @@ const textColor = new Color(0.12, 0.12, 0.16, 1); async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -64,11 +65,11 @@ async function createBackdrop( }); backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; diff --git a/documentation-site/src/pages/demos/ui-button/_create-game.ts b/documentation-site/src/pages/demos/ui-button/_create-game.ts index 8dc5b89e1..3b4f529c0 100644 --- a/documentation-site/src/pages/demos/ui-button/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-button/_create-game.ts @@ -18,13 +18,13 @@ import { } from '@forge-game-engine/forge/input'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; import { @@ -56,6 +56,7 @@ const textColor = new Color(0.12, 0.12, 0.16, 1); async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -67,11 +68,11 @@ async function createBackdrop( }); backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; @@ -145,13 +146,13 @@ function createUiInputs( export const createButtonGame = async (fontAtlasUrl: string): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.world, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBackdrop(world, renderContext); + await createBackdrop(world, camera, renderContext); const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); diff --git a/documentation-site/src/pages/demos/ui-canvas-group/_create-game.ts b/documentation-site/src/pages/demos/ui-canvas-group/_create-game.ts index eef963267..4abd6fda1 100644 --- a/documentation-site/src/pages/demos/ui-canvas-group/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-canvas-group/_create-game.ts @@ -10,13 +10,13 @@ import { } from '@forge-game-engine/forge/input'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; import { @@ -46,6 +46,7 @@ const renderLayers = { async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -57,11 +58,11 @@ async function createBackdrop( }); backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; @@ -101,13 +102,13 @@ export const createCanvasGroupGame = async ( ): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.world, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBackdrop(world, renderContext); + await createBackdrop(world, camera, renderContext); const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); diff --git a/documentation-site/src/pages/demos/ui-dropdown/_create-game.ts b/documentation-site/src/pages/demos/ui-dropdown/_create-game.ts index e59a1ef43..a1a8f60e7 100644 --- a/documentation-site/src/pages/demos/ui-dropdown/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-dropdown/_create-game.ts @@ -10,13 +10,13 @@ import { } from '@forge-game-engine/forge/input'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; import { @@ -43,6 +43,7 @@ const textColor = new Color(0.12, 0.12, 0.16, 1); async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -54,11 +55,11 @@ async function createBackdrop( }); backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; @@ -92,13 +93,13 @@ export const createDropdownGame = async ( ): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.world, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBackdrop(world, renderContext); + await createBackdrop(world, camera, renderContext); const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); diff --git a/documentation-site/src/pages/demos/ui-main-menu/_create-game.ts b/documentation-site/src/pages/demos/ui-main-menu/_create-game.ts index 86037fa45..3d4c5a606 100644 --- a/documentation-site/src/pages/demos/ui-main-menu/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-main-menu/_create-game.ts @@ -18,12 +18,12 @@ import { } from '@forge-game-engine/forge/input'; import { addSpriteComponent, - calculateVisibleWorldSize, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; import { @@ -64,6 +64,7 @@ const renderLayers = { async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -75,11 +76,11 @@ async function createBackdrop( }); backdropSprite.tintColor = fleetCommandPalette.void; - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; @@ -158,13 +159,13 @@ export const createUiMainMenuGame = async ( ): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.world, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBackdrop(world, renderContext); + await createBackdrop(world, camera, renderContext); const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); diff --git a/documentation-site/src/pages/demos/ui-progress-bar/_create-game.ts b/documentation-site/src/pages/demos/ui-progress-bar/_create-game.ts index dde4b3d40..dede71687 100644 --- a/documentation-site/src/pages/demos/ui-progress-bar/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-progress-bar/_create-game.ts @@ -5,13 +5,13 @@ import { import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; import { @@ -40,6 +40,7 @@ const renderLayers = { async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -51,11 +52,11 @@ async function createBackdrop( }); backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; @@ -80,13 +81,13 @@ export const createProgressBarGame = async ( ): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.world, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBackdrop(world, renderContext); + await createBackdrop(world, camera, renderContext); const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); diff --git a/documentation-site/src/pages/demos/ui-slider/_create-game.ts b/documentation-site/src/pages/demos/ui-slider/_create-game.ts index 8365d340a..ceab11397 100644 --- a/documentation-site/src/pages/demos/ui-slider/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-slider/_create-game.ts @@ -10,7 +10,6 @@ import { } from '@forge-game-engine/forge/input'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, diff --git a/documentation-site/src/pages/demos/ui-stress-test/_create-game.ts b/documentation-site/src/pages/demos/ui-stress-test/_create-game.ts index a2827a912..3cc7d8168 100644 --- a/documentation-site/src/pages/demos/ui-stress-test/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-stress-test/_create-game.ts @@ -5,13 +5,13 @@ import { import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; import { @@ -51,6 +51,7 @@ const cellSpacing = { x: 4, y: 4 }; async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -62,11 +63,11 @@ async function createBackdrop( }); backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; @@ -93,13 +94,13 @@ export const createUiStressTestGame = async ( ): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.world, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBackdrop(world, renderContext); + await createBackdrop(world, camera, renderContext); const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); diff --git a/documentation-site/src/pages/demos/ui-toggle/_create-game.ts b/documentation-site/src/pages/demos/ui-toggle/_create-game.ts index de0b6e8f1..9510fd66f 100644 --- a/documentation-site/src/pages/demos/ui-toggle/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-toggle/_create-game.ts @@ -10,13 +10,13 @@ import { } from '@forge-game-engine/forge/input'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; import { @@ -47,6 +47,7 @@ const textColor = new Color(0.12, 0.12, 0.16, 1); async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -58,11 +59,11 @@ async function createBackdrop( }); backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; @@ -95,13 +96,13 @@ function createPointerInput( export const createToggleGame = async (fontAtlasUrl: string): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - createCamera(world, { + const camera = createCamera(world, { isStatic: true, cullingMask: renderLayers.world, verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBackdrop(world, renderContext); + await createBackdrop(world, camera, renderContext); const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); diff --git a/documentation-site/src/pages/demos/ui-world-space-canvas/_create-game.ts b/documentation-site/src/pages/demos/ui-world-space-canvas/_create-game.ts index a3fb38a0e..dd668cb4f 100644 --- a/documentation-site/src/pages/demos/ui-world-space-canvas/_create-game.ts +++ b/documentation-site/src/pages/demos/ui-world-space-canvas/_create-game.ts @@ -10,13 +10,13 @@ import { import { EcsSystem, EcsWorld } from '@forge-game-engine/forge/ecs'; import { addSpriteComponent, - calculateVisibleWorldSize, Color, createCamera, createCameraEcsSystem, createImageSprite, createPresentEcsSystem, createRenderEcsSystem, + getCameraView, RenderContext, } from '@forge-game-engine/forge/rendering'; import { @@ -51,6 +51,7 @@ const enemyY = -40; async function createBackdrop( world: EcsWorld, + camera: number, renderContext: RenderContext, ): Promise { const whiteImage = await renderContext.imageCache.getOrLoad( @@ -70,11 +71,11 @@ async function createBackdrop( // the very back explicitly instead. backdropSprite.sortDepth = -10000; - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; backdropSprite.width = width; backdropSprite.height = height; @@ -233,7 +234,7 @@ export const createWorldSpaceCanvasGame = async ( verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, }); - await createBackdrop(world, renderContext); + await createBackdrop(world, worldCamera, renderContext); const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); diff --git a/e2e/fixtures/scenes/camera-pan-zoom.ts b/e2e/fixtures/scenes/camera-pan-zoom.ts index 711cb6ab2..9e861f596 100644 --- a/e2e/fixtures/scenes/camera-pan-zoom.ts +++ b/e2e/fixtures/scenes/camera-pan-zoom.ts @@ -15,6 +15,7 @@ import { createRenderEcsSystem, createTransformEcsSystem, EcsWorld, + getCameraView, KeyboardAxis2dBinding, KeyboardInputSource, keyCodes, @@ -81,6 +82,14 @@ export interface CameraSceneHandle extends SceneHandle { readonly zoom: number; /** The camera's current local position. */ readonly position: { x: number; y: number }; + /** + * The mouse's position as of the last `step()`, converted to world space + * from `MouseInputSource.position` through the camera's view (see + * `getCameraView`). + */ + readonly pointerWorldPosition: { x: number; y: number }; + /** The green origin square's side length, in world units. */ + readonly greenSquareWorldSize: number; /** * Scans a horizontal line through the vertical center of the canvas's @@ -210,14 +219,27 @@ export const createScene: CreateScene = async ( world.addSystem(createPresentEcsSystem(renderContext)); let clockInMilliseconds = 0; + let pointerWorldPosition = { x: 0, y: 0 }; return { step(deltaMilliseconds: number = defaultStepDeltaMilliseconds): void { clockInMilliseconds += deltaMilliseconds; time.update(clockInMilliseconds); world.update(); + + pointerWorldPosition = getCameraView( + world, + cameraEntity, + renderContext, + ).viewportToWorld(mouseInputSource.position); }, + get pointerWorldPosition(): { x: number; y: number } { + return pointerWorldPosition; + }, + + greenSquareWorldSize: cellSize, + get zoom(): number { return world.getComponent(cameraEntity, cameraId)! .zoom; diff --git a/e2e/fixtures/scenes/high-dpi-canvas.ts b/e2e/fixtures/scenes/high-dpi-canvas.ts index 1344f187d..651aef028 100644 --- a/e2e/fixtures/scenes/high-dpi-canvas.ts +++ b/e2e/fixtures/scenes/high-dpi-canvas.ts @@ -1,6 +1,5 @@ import { addPositionComponent, - calculatePixelsPerUnit, Color, createCamera, createCanvas, @@ -11,10 +10,9 @@ import { createRenderEcsSystem, createTransformEcsSystem, EcsWorld, + getCameraView, MouseInputSource, - positionId, registerInputs, - screenToWorldSpace, spriteId, Time, Vector2, @@ -69,8 +67,7 @@ export interface HighDpiCanvasSceneHandle extends SceneHandle { readonly devicePixelsPerUnit: number; /** * The mouse's position as of the last `step()`, converted to world space - * from `MouseInputSource.position` (CSS pixels) against the render - * context's CSS size. + * from `MouseInputSource.position` (CSS pixels) through the camera's view. */ readonly pointerWorldPosition: Vector2; /** @@ -145,20 +142,11 @@ export const createScene: CreateScene = async ( time.update(clockInMilliseconds); world.update(); - const cameraPosition = world.getComponent(cameraEntity, positionId); - - if (!cameraPosition) { - throw new Error('The scene camera has no position component.'); - } - - pointerWorldPosition = screenToWorldSpace( - mouseInputSource.position, - cameraPosition.world, - 1, - renderContext.cssWidth, - renderContext.cssHeight, - calculatePixelsPerUnit(renderContext.cssHeight, verticalWorldUnits), - ); + pointerWorldPosition = getCameraView( + world, + cameraEntity, + renderContext, + ).viewportToWorld(mouseInputSource.position); }, get canvasMetrics(): HighDpiCanvasMetrics { @@ -175,7 +163,7 @@ export const createScene: CreateScene = async ( squareWorldSize, get devicePixelsPerUnit(): number { - return calculatePixelsPerUnit(renderContext.height, verticalWorldUnits); + return renderContext.height / verticalWorldUnits; }, get pointerWorldPosition(): Vector2 { diff --git a/e2e/specs/camera-pan-zoom.spec.ts b/e2e/specs/camera-pan-zoom.spec.ts index 4749d92d7..e18177947 100644 --- a/e2e/specs/camera-pan-zoom.spec.ts +++ b/e2e/specs/camera-pan-zoom.spec.ts @@ -174,8 +174,8 @@ test.describe('camera pan/zoom', () => { (whileHeld.bounds!.left + whileHeld.bounds!.right) / 2; const actualShift = centerWhileHeld - centerBefore; - // Panning the camera right moves world content left on screen (see - // createProjectionMatrix's `translate(-cameraPosition.x, ...)`); world + // Panning the camera right moves world content left on screen (the + // camera's view, and so its projection, is centered on it); world // units map 1:1 to pixels at zoom 1, scaling with zoom otherwise. const expectedShift = -(whileHeld.position.x - before.position.x) * whileHeld.zoom; @@ -207,4 +207,64 @@ test.describe('camera pan/zoom', () => { expect(oneMoreStepLater.bounds).toEqual(afterRelease.bounds); }); }); + + test('converts the pointer to world space through the moved, zoomed camera', async ({ + page, + }) => { + await test.step('zoom in and pan right over several frames', async () => { + await animateFrames(page, 4, () => + page.locator('canvas').dispatchEvent('wheel', { deltaY: -100 }), + ); + await page.keyboard.down('ArrowRight'); + await animateFrames(page, 4); + await page.keyboard.up('ArrowRight'); + await animateFrames(page, 1); + }); + + const state = + await test.step('capture the camera and the rendered green square', () => + captureState(page)); + + expect(state.zoom).toBeGreaterThan(1); + expect(state.position.x).toBeGreaterThan(0); + expect(state.bounds).not.toBeNull(); + + await test.step('move the mouse onto the rendered green square', async () => { + const canvasBox = await page.locator('canvas').boundingBox(); + const drawingBufferWidth = await page.evaluate( + () => document.querySelector('canvas')!.width, + ); + + expect(canvasBox).not.toBeNull(); + + // `bounds` is measured in drawing-buffer pixels along the canvas's + // vertical center, where the origin square sits. + const squareCenterX = + (state.bounds!.left + state.bounds!.right) / 2 / drawingBufferWidth; + + await page.mouse.move( + canvasBox!.x + squareCenterX * canvasBox!.width, + canvasBox!.y + canvasBox!.height / 2, + ); + await step(page); + }); + + const pointer = await test.step('read the pointer world position', () => + page.evaluate(() => { + const scene = window.__forgeTestHooks as unknown as Hooks; + + return { + position: scene.pointerWorldPosition, + squareSize: scene.greenSquareWorldSize, + }; + })); + + await test.step('assert the pointer lands on the green square in world space', () => { + // The pixel the pointer is on shows the green square, so the view + // must map it back inside the square, which is centered on the + // origin. + expect(Math.abs(pointer.position.x)).toBeLessThan(pointer.squareSize / 2); + expect(Math.abs(pointer.position.y)).toBeLessThan(pointer.squareSize / 2); + }); + }); }); diff --git a/src/rendering/camera-view.test.ts b/src/rendering/camera-view.test.ts new file mode 100644 index 000000000..173d3b434 --- /dev/null +++ b/src/rendering/camera-view.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest'; +import { addPositionComponent, PositionEcsComponent } from '../common'; +import { EcsWorld } from '../ecs'; +import { computeCameraView, getCameraView } from './camera-view'; +import { addCameraComponent, CameraEcsComponent, cameraId } from './components'; +import { RenderContext } from './render-context'; +import { RenderTarget } from './render-target'; + +const buildRenderContext = ( + cssWidth: number, + cssHeight: number, + pixelRatio: number = 1, +): RenderContext => + ({ + width: cssWidth * pixelRatio, + height: cssHeight * pixelRatio, + cssWidth, + cssHeight, + pixelRatio, + }) as RenderContext; + +const buildCamera = ( + overrides: Partial = {}, +): CameraEcsComponent => { + const world = new EcsWorld(); + + return addCameraComponent(world, world.createEntity(), overrides); +}; + +const buildPosition = (x: number, y: number): PositionEcsComponent => ({ + local: { x, y }, + world: { x, y }, +}); + +describe('computeCameraView', () => { + it('shows verticalWorldUnits vertically and follows the canvas aspect horizontally', () => { + const view = computeCameraView( + buildCamera({ verticalWorldUnits: 10 }), + buildPosition(0, 0), + buildRenderContext(800, 400), + ); + + expect(view.size).toEqual({ x: 20, y: 10 }); + expect(view.bounds).toEqual({ + min: { x: -10, y: -5 }, + max: { x: 10, y: 5 }, + }); + expect(view.pixelsPerUnit).toBe(40); + }); + + it('shows half as much of the world at a zoom of 2', () => { + const view = computeCameraView( + buildCamera({ verticalWorldUnits: 10, zoom: 2 }), + buildPosition(0, 0), + buildRenderContext(800, 400), + ); + + expect(view.size).toEqual({ x: 10, y: 5 }); + expect(view.bounds).toEqual({ + min: { x: -5, y: -2.5 }, + max: { x: 5, y: 2.5 }, + }); + expect(view.pixelsPerUnit).toBe(80); + }); + + it("centers the view on the camera's world position", () => { + const position: PositionEcsComponent = { + local: { x: 0, y: 0 }, + world: { x: 30, y: -12 }, + }; + + const view = computeCameraView( + buildCamera({ verticalWorldUnits: 10 }), + position, + buildRenderContext(400, 400), + ); + + expect(view.bounds).toEqual({ + min: { x: 25, y: -17 }, + max: { x: 35, y: -7 }, + }); + }); + + it('follows a portrait canvas', () => { + const view = computeCameraView( + buildCamera({ verticalWorldUnits: 10 }), + buildPosition(0, 0), + buildRenderContext(300, 600), + ); + + expect(view.size).toEqual({ x: 5, y: 10 }); + }); + + it('measures pixels per unit in CSS pixels on a high-DPI canvas', () => { + const view = computeCameraView( + buildCamera({ verticalWorldUnits: 10 }), + buildPosition(0, 0), + buildRenderContext(800, 400, 2), + ); + + expect(view.size).toEqual({ x: 20, y: 10 }); + expect(view.pixelsPerUnit).toBe(40); + expect(view.worldToViewport({ x: 10, y: -5 })).toEqual({ x: 800, y: 400 }); + }); + + it("lays a render target camera's view out at the canvas's aspect, since the target is presented over the whole canvas", () => { + const renderTarget = { width: 100, height: 100 } as RenderTarget; + + const view = computeCameraView( + buildCamera({ verticalWorldUnits: 10, renderTarget }), + buildPosition(0, 0), + buildRenderContext(800, 400), + ); + + expect(view.size).toEqual({ x: 20, y: 10 }); + }); + + it("maps the view's corners and center to the canvas's, Y-down", () => { + const view = computeCameraView( + buildCamera({ verticalWorldUnits: 10, zoom: 2 }), + buildPosition(100, 50), + buildRenderContext(800, 400), + ); + + expect(view.worldToViewport({ x: 100, y: 50 })).toEqual({ + x: 400, + y: 200, + }); + expect(view.worldToViewport(view.bounds.min)).toEqual({ x: 0, y: 400 }); + expect(view.worldToViewport(view.bounds.max)).toEqual({ x: 800, y: 0 }); + expect(view.viewportToWorld({ x: 0, y: 0 })).toEqual({ + x: view.bounds.min.x, + y: view.bounds.max.y, + }); + }); + + it('round-trips a position through the viewport and back', () => { + const view = computeCameraView( + buildCamera({ verticalWorldUnits: 7, zoom: 1.5 }), + buildPosition(-3, 8), + buildRenderContext(640, 360, 2), + ); + const worldPosition = { x: 1.25, y: 6.5 }; + + const roundTripped = view.viewportToWorld( + view.worldToViewport(worldPosition), + ); + + expect(roundTripped.x).toBeCloseTo(worldPosition.x); + expect(roundTripped.y).toBeCloseTo(worldPosition.y); + }); + + it('converts a position on one camera to the matching position on another', () => { + const renderContext = buildRenderContext(800, 400); + const gameView = computeCameraView( + buildCamera({ verticalWorldUnits: 10 }), + buildPosition(5, 0), + renderContext, + ); + const hudView = computeCameraView( + buildCamera({ verticalWorldUnits: 1080 }), + buildPosition(0, 0), + renderContext, + ); + + const onHud = hudView.viewportToWorld( + gameView.worldToViewport({ x: 5, y: 5 }), + ); + + expect(onHud.x).toBeCloseTo(0); + expect(onHud.y).toBeCloseTo(540); + }); + + it('returns new vectors without mutating its input', () => { + const view = computeCameraView( + buildCamera(), + buildPosition(0, 0), + buildRenderContext(800, 400), + ); + const worldPosition = { x: 1, y: 2 }; + + const viewportPosition = view.worldToViewport(worldPosition); + + expect(viewportPosition).not.toBe(worldPosition); + expect(worldPosition).toEqual({ x: 1, y: 2 }); + }); + + it.each([ + { description: 'verticalWorldUnits', camera: { verticalWorldUnits: 0 } }, + { description: 'zoom', camera: { zoom: 0 } }, + ])('throws for a non-positive $description', ({ camera }) => { + expect(() => + computeCameraView( + buildCamera(camera), + buildPosition(0, 0), + buildRenderContext(800, 400), + ), + ).toThrow(/must be a positive number/); + }); + + it('throws for a canvas with no area', () => { + expect(() => + computeCameraView( + buildCamera(), + buildPosition(0, 0), + buildRenderContext(0, 400), + ), + ).toThrow(/must be a positive number/); + }); +}); + +describe('getCameraView', () => { + it("computes the view from the entity's camera and position", () => { + const world = new EcsWorld(); + const camera = world.createEntity(); + + addPositionComponent(world, camera, { local: { x: 4, y: 2 } }); + addCameraComponent(world, camera, { verticalWorldUnits: 10 }); + + const view = getCameraView(world, camera, buildRenderContext(400, 400)); + + expect(view.bounds).toEqual({ + min: { x: -1, y: -3 }, + max: { x: 9, y: 7 }, + }); + }); + + it('throws for an entity with no camera component', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + addPositionComponent(world, entity); + + expect(() => + getCameraView(world, entity, buildRenderContext(400, 400)), + ).toThrow(/needs both a camera and a position component/); + }); + + it('throws for a camera with no position component', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + addCameraComponent(world, entity); + + expect(world.getComponent(entity, cameraId)).not.toBeNull(); + expect(() => + getCameraView(world, entity, buildRenderContext(400, 400)), + ).toThrow(/needs both a camera and a position component/); + }); +}); diff --git a/src/rendering/camera-view.ts b/src/rendering/camera-view.ts new file mode 100644 index 000000000..3d757d482 --- /dev/null +++ b/src/rendering/camera-view.ts @@ -0,0 +1,143 @@ +import { PositionEcsComponent, positionId } from '../common/index.js'; +import { EcsWorld } from '../ecs/ecs-world.js'; +import { Rect, Vector2 } from '../math/index.js'; +import { CameraEcsComponent, cameraId } from './components/index.js'; +import { RenderContext } from './render-context.js'; + +/** + * What a camera sees this frame, and conversions between its world and the + * viewport (the canvas, in CSS pixels). + * + * Computed from the camera's components when asked for (see + * `computeCameraView` and `getCameraView`), so it's never stored and can't + * disagree with the camera. + */ +export interface CameraView { + /** The world-space area the camera shows. */ + readonly bounds: Rect; + + /** `bounds`' size, in world units. */ + readonly size: Vector2; + + /** CSS pixels per world unit on the canvas. */ + readonly pixelsPerUnit: number; + + /** + * Converts a world position to a viewport position: CSS pixels from the + * canvas's top-left corner, Y-down, like pointer positions (e.g. + * `MouseInputSource.position`). + * @param worldPosition - The position in world space. + * @returns A new vector holding the position on the canvas, in CSS pixels. + */ + worldToViewport(worldPosition: Vector2): Vector2; + + /** + * Converts a viewport position (CSS pixels from the canvas's top-left + * corner, Y-down, e.g. `MouseInputSource.position`) to a world position. + * The inverse of `worldToViewport`. + * @param viewportPosition - The position on the canvas, in CSS pixels. + * @returns A new vector holding the position in world space. + */ + viewportToWorld(viewportPosition: Vector2): Vector2; +} + +function assertPositive(name: string, value: number): void { + if (Number.isNaN(value) || value <= 0) { + throw new Error( + `Unable to compute a camera view: ${name} must be a positive number, received ${value}.`, + ); + } +} + +/** + * Computes what a camera sees, from components a system already has. + * + * The view is `verticalWorldUnits / zoom` world units tall, centered on the + * camera's `position.world`, with its width following the canvas's aspect + * ratio. A camera with a `renderTarget` gets the same view: a present pass + * stretches the target over the whole canvas, so its contents are laid out + * at the canvas's aspect ratio. Viewport conversions are against the + * canvas's CSS size, because pointer input, the DOM and safe-area insets + * all measure in CSS pixels. + * + * The view reflects the components as they are when this is called: a + * caller that runs before the transform system sees last frame's + * `position.world`. + * @param camera - The camera's `CameraEcsComponent`. + * @param position - The camera's `PositionEcsComponent`. + * @param renderContext - The render context the camera draws through. + * @returns The camera's view. + * @throws An error if the camera's `verticalWorldUnits` or `zoom`, or the + * canvas's size, isn't positive. + */ +export function computeCameraView( + camera: CameraEcsComponent, + position: PositionEcsComponent, + renderContext: RenderContext, +): CameraView { + const { verticalWorldUnits, zoom } = camera; + const { cssWidth, cssHeight } = renderContext; + + assertPositive('verticalWorldUnits', verticalWorldUnits); + assertPositive('zoom', zoom); + assertPositive('cssWidth', cssWidth); + assertPositive('cssHeight', cssHeight); + assertPositive('width', renderContext.width); + assertPositive('height', renderContext.height); + + const height = verticalWorldUnits / zoom; + // The drawing buffer's aspect ratio rather than the CSS size's: the two + // differ by the buffer's rounding to whole pixels, and the buffer is what + // GL draws the view into. + const width = (height * renderContext.width) / renderContext.height; + const { x: centerX, y: centerY } = position.world; + const minX = centerX - width / 2; + const maxY = centerY + height / 2; + + return { + bounds: { + min: { x: minX, y: centerY - height / 2 }, + max: { x: centerX + width / 2, y: maxY }, + }, + size: { x: width, y: height }, + pixelsPerUnit: cssHeight / height, + worldToViewport: (worldPosition: Vector2): Vector2 => ({ + x: ((worldPosition.x - minX) / width) * cssWidth, + y: ((maxY - worldPosition.y) / height) * cssHeight, + }), + viewportToWorld: (viewportPosition: Vector2): Vector2 => ({ + x: minX + (viewportPosition.x / cssWidth) * width, + y: maxY - (viewportPosition.y / cssHeight) * height, + }), + }; +} + +/** + * Computes what a camera entity sees. For game code that has the camera's + * entity rather than its components; see `computeCameraView`. + * @param world - The ECS world the camera entity belongs to. + * @param camera - The camera entity. + * @param renderContext - The render context the camera draws through. + * @returns The camera's view. + * @throws An error if `camera` has no `CameraEcsComponent` or + * `PositionEcsComponent`, or if `computeCameraView` does. + */ +export function getCameraView( + world: EcsWorld, + camera: number, + renderContext: RenderContext, +): CameraView { + const cameraComponent = world.getComponent( + camera, + cameraId, + ); + const position = world.getComponent(camera, positionId); + + if (!cameraComponent || !position) { + throw new Error( + `Unable to get the camera view of entity "${camera}": it needs both a camera and a position component.`, + ); + } + + return computeCameraView(cameraComponent, position, renderContext); +} diff --git a/src/rendering/components/camera-component.ts b/src/rendering/components/camera-component.ts index 69b1ce5fa..24b91a073 100644 --- a/src/rendering/components/camera-component.ts +++ b/src/rendering/components/camera-component.ts @@ -1,5 +1,4 @@ import { Axis1dAction, Axis2dAction } from '../../input/index.js'; -import { Rect } from '../../math/index.js'; import { createComponentId } from '../../ecs/ecs-component.js'; import { EcsWorld } from '../../ecs/ecs-world.js'; import { RenderTarget } from '../render-target.js'; @@ -77,18 +76,12 @@ export interface CameraDefaultedOptions { * Horizontal extent follows automatically from the destination's aspect * ratio, so the same `verticalWorldUnits` shows the same amount of the * world vertically regardless of window size/shape (see - * `calculatePixelsPerUnit`). Defaults to `10`. + * `computeCameraView`). Defaults to `10`. */ verticalWorldUnits: number; } export interface CameraEcsComponent extends CameraDefaultedOptions { - /** - * When set, restricts this camera's draw output to the given rectangular - * region instead of its full destination. - */ - scissorRect?: Rect; - /** * The 1D input action that drives `zoom` changes, if any. Ignored while * `isStatic` is `true`. diff --git a/src/rendering/index.ts b/src/rendering/index.ts index a06a5c8d8..9d87b4188 100644 --- a/src/rendering/index.ts +++ b/src/rendering/index.ts @@ -1,9 +1,9 @@ +export * from './camera-view.js'; export * from './components/index.js'; export * from './nine-slice-options.js'; export * from './sprite.js'; export * from './systems/index.js'; export * from './shaders/index.js'; -export * from './transforms/index.js'; export * from './enums/index.js'; export * from './utilities/index.js'; export * from './materials/index.js'; diff --git a/src/rendering/shaders/utils/create-projection-matrix.test.ts b/src/rendering/shaders/utils/create-projection-matrix.test.ts index 5bd123cad..3882be88b 100644 --- a/src/rendering/shaders/utils/create-projection-matrix.test.ts +++ b/src/rendering/shaders/utils/create-projection-matrix.test.ts @@ -1,18 +1,34 @@ import { describe, expect, it } from 'vitest'; import { createProjectionMatrix } from './create-projection-matrix'; -import { Matrix3x3 } from '../../../math'; +import { Matrix3x3, Rect } from '../../../math'; + +const centeredRect = (width: number, height: number): Rect => ({ + min: { x: -width / 2, y: -height / 2 }, + max: { x: width / 2, y: height / 2 }, +}); + +const project = ( + matrix: Matrix3x3, + x: number, + y: number, +): { x: number; y: number } => { + const m = matrix.matrix; + + // Sprite instance data negates world y before it reaches the shader. + return { + x: m[0] * x + m[3] * -y + m[6], + y: m[1] * x + m[4] * -y + m[7], + }; +}; describe('createProjectionMatrix', () => { it.each([ - { description: 'given width and height', width: 800, height: 600 }, + { description: 'a wide area', width: 800, height: 600 }, { description: 'a very small area', width: 1, height: 1 }, { description: 'a very large area', width: 10000, height: 10000 }, ])( - 'should create a correct projection matrix for $description', + 'should scale $description centered on the origin to clip space', ({ width, height }) => { - const cameraPosition = { x: 0, y: 0 }; - const zoom = 1; - const expectedMatrix = new Matrix3x3([ 2 / width, 0, @@ -25,220 +41,26 @@ describe('createProjectionMatrix', () => { 1, ]); - const result = createProjectionMatrix( - width, - height, - cameraPosition, - zoom, - ); + const result = createProjectionMatrix(centeredRect(width, height)); expect(result).toEqual(expectedMatrix); }, ); - it('should create a correct projection matrix for a square area', () => { - const size = 500; - const cameraPosition = { x: 0, y: 0 }; - const zoom = 1; - const expectedMatrix = new Matrix3x3([ - 2 / size, - 0, - 0, - -0, - -2 / size, - 0, - 0, - 0, - 1, - ]); - - const result = createProjectionMatrix(size, size, cameraPosition, zoom); - - expect(result).toEqual(expectedMatrix); - }); - - it('should apply zoom correctly', () => { - const width = 800; - const height = 600; - const cameraPosition = { x: 0, y: 0 }; - const zoom = 2; - - const expectedMatrix = new Matrix3x3([ - (2 / width) * zoom, - 0, - 0, - -0, - (-2 / height) * zoom, - 0, - 0, - 0, - 1, - ]); - - const result = createProjectionMatrix(width, height, cameraPosition, zoom); - - expect(result).toEqual(expectedMatrix); - }); - - it('should translate the camera position correctly', () => { - const width = 800; - const height = 600; - const cameraPosition = { x: 100, y: 50 }; - const zoom = 1; - - // The matrix should include translation by -cameraPosition.x and - // +cameraPosition.y (sprite y is pre-negated, so the camera's y must - // translate unnegated to land back on the same sprite). - const scaleX = 2 / width; - const scaleY = -2 / height; - const tx = -cameraPosition.x * scaleX; - const ty = cameraPosition.y * scaleY; - - const expectedMatrix = new Matrix3x3([ - scaleX, - 0, - 0, - -0, - scaleY, - 0, - tx, - ty, - 1, - ]); - - const result = createProjectionMatrix(width, height, cameraPosition, zoom); - - expect(result).toEqual(expectedMatrix); - }); - - it('should apply both zoom and camera translation', () => { - const width = 400; - const height = 200; - const cameraPosition = { x: 10, y: -20 }; - const zoom = 0.5; - - const scaleX = (2 / width) * zoom; - const scaleY = (-2 / height) * zoom; - const tx = -cameraPosition.x * scaleX; - const ty = cameraPosition.y * scaleY; - - const result = createProjectionMatrix(width, height, cameraPosition, zoom); - - // Use toBeCloseTo for floating-point comparisons to handle Float32Array precision - // values used in the test are of type `number` which is a double-precision floats - // but the matrix is stored as `Float32Array` which are single-precision floats - // we use single-precision floats in the implementation for performance reasons - // and because we send these matrices to the GPU which typically uses single-precision floats - expect(result.matrix[0]).toBeCloseTo(scaleX); - expect(result.matrix[1]).toBeCloseTo(0); - expect(result.matrix[2]).toBeCloseTo(0); - expect(result.matrix[3]).toBeCloseTo(0); - expect(result.matrix[4]).toBeCloseTo(scaleY); - expect(result.matrix[5]).toBeCloseTo(0); - expect(result.matrix[6]).toBeCloseTo(tx); - expect(result.matrix[7]).toBeCloseTo(ty); - expect(result.matrix[8]).toBeCloseTo(1); - }); - - it('should apply pixelsPerUnit as an additional scale factor', () => { - const width = 800; - const height = 600; - const cameraPosition = { x: 0, y: 0 }; - const zoom = 1; - const pixelsPerUnit = 100; - - const expectedMatrix = new Matrix3x3([ - (2 / width) * pixelsPerUnit, - 0, - 0, - -0, - (-2 / height) * pixelsPerUnit, - 0, - 0, - 0, - 1, - ]); - - const result = createProjectionMatrix( - width, - height, - cameraPosition, - zoom, - pixelsPerUnit, - ); - - expect(result).toEqual(expectedMatrix); - }); - - it('should combine pixelsPerUnit with zoom and camera translation', () => { - const width = 400; - const height = 200; - const cameraPosition = { x: 10, y: -20 }; - const zoom = 0.5; - const pixelsPerUnit = 20; - - const scaleX = (2 / width) * pixelsPerUnit * zoom; - const scaleY = (-2 / height) * pixelsPerUnit * zoom; - const tx = -cameraPosition.x * scaleX; - const ty = cameraPosition.y * scaleY; - - const result = createProjectionMatrix( - width, - height, - cameraPosition, - zoom, - pixelsPerUnit, - ); - - expect(result.matrix[0]).toBeCloseTo(scaleX); - expect(result.matrix[4]).toBeCloseTo(scaleY); - expect(result.matrix[6]).toBeCloseTo(tx); - expect(result.matrix[7]).toBeCloseTo(ty); - }); - - it('should default pixelsPerUnit to 1 when omitted', () => { - const width = 800; - const height = 600; - const cameraPosition = { x: 0, y: 0 }; - const zoom = 1; - - const withDefault = createProjectionMatrix( - width, - height, - cameraPosition, - zoom, - ); - const withExplicitOne = createProjectionMatrix( - width, - height, - cameraPosition, - zoom, - 1, - ); - - expect(withDefault).toEqual(withExplicitOne); - }); - - it('should handle negative zoom (flipping)', () => { - const width = 100; - const height = 100; - const cameraPosition = { x: 0, y: 0 }; - const zoom = -1; + it('should map the corners of an off-center area to the corners of clip space', () => { + const bounds: Rect = { min: { x: 10, y: -20 }, max: { x: 50, y: 0 } }; - const expectedMatrix = new Matrix3x3([ - (2 / width) * zoom, - -0, - 0, - 0, - (-2 / height) * zoom, - 0, - 0, - 0, - 1, - ]); + const result = createProjectionMatrix(bounds); - const result = createProjectionMatrix(width, height, cameraPosition, zoom); + const bottomLeft = project(result, bounds.min.x, bounds.min.y); + const topRight = project(result, bounds.max.x, bounds.max.y); + const center = project(result, 30, -10); - expect(result).toEqual(expectedMatrix); + expect(bottomLeft.x).toBeCloseTo(-1); + expect(bottomLeft.y).toBeCloseTo(-1); + expect(topRight.x).toBeCloseTo(1); + expect(topRight.y).toBeCloseTo(1); + expect(center.x).toBeCloseTo(0); + expect(center.y).toBeCloseTo(0); }); }); diff --git a/src/rendering/shaders/utils/create-projection-matrix.ts b/src/rendering/shaders/utils/create-projection-matrix.ts index 7a37884a2..07ce4a825 100644 --- a/src/rendering/shaders/utils/create-projection-matrix.ts +++ b/src/rendering/shaders/utils/create-projection-matrix.ts @@ -1,40 +1,27 @@ -import { Matrix3x3, Vector2 } from '../../../math/index.js'; +import { Matrix3x3, Rect } from '../../../math/index.js'; /** - * Creates a projection matrix for rendering with a camera. - * The projection matrix is centered on the camera position and applies zoom. + * Creates the projection matrix that maps a world-space area onto clip + * space, filling the destination it's drawn into. * - * @param width - The width of the viewport. - * @param height - The height of the viewport. - * @param cameraPosition - The position of the camera in world coordinates. - * @param zoom - The zoom level to apply to the projection. - * @param pixelsPerUnit - The number of pixels one world unit occupies (see - * `calculatePixelsPerUnit`). Defaults to `1`. + * @param bounds - The world-space area to show, such as a camera's + * `CameraView.bounds` (see `computeCameraView`). * @returns A 3x3 projection matrix that can be used for rendering. */ -export function createProjectionMatrix( - width: number, - height: number, - cameraPosition: Vector2, - zoom: number, - pixelsPerUnit: number = 1, -): Matrix3x3 { +export function createProjectionMatrix(bounds: Rect): Matrix3x3 { const projectionMatrix = Matrix3x3.identity; + const width = bounds.max.x - bounds.min.x; + const height = bounds.max.y - bounds.min.y; - // Centered projection: (0,0) is center of screen. World units are - // converted to pixels before pixels are converted to clip space. - projectionMatrix.scale( - (2 / width) * pixelsPerUnit, - (-2 / height) * pixelsPerUnit, - ); - - // Apply zoom around the center - projectionMatrix.scale(zoom, zoom); + projectionMatrix.scale(2 / width, -2 / height); - // Center cameraPos on screen. Sprite instance data negates world.y before + // Center the area on screen. Sprite instance data negates world.y before // it reaches the shader (see bindSpriteInstanceData), so unlike x, the - // camera's y must be translated unnegated to land back on the same sprite. - projectionMatrix.translate(-cameraPosition.x, cameraPosition.y); + // center's y must be translated unnegated to land back on the same sprite. + projectionMatrix.translate( + -(bounds.min.x + width / 2), + bounds.min.y + height / 2, + ); return projectionMatrix; } diff --git a/src/rendering/systems/render-system.test.ts b/src/rendering/systems/render-system.test.ts index bdc8c6515..5c993f629 100644 --- a/src/rendering/systems/render-system.test.ts +++ b/src/rendering/systems/render-system.test.ts @@ -5,6 +5,7 @@ import { EcsWorld } from '../../ecs'; import { addPositionComponent, addRotationComponent, + addScaleComponent, PositionEcsComponent, } from '../../common'; import { Vec2 } from '../../math'; @@ -23,12 +24,12 @@ import { Material } from '../materials/material'; import { ShaderCache } from '../shaders'; import { ImageCache } from '../../asset-loading'; import { createProjectionMatrix } from '../shaders'; -import { calculatePixelsPerUnit } from '../utilities/calculate-pixels-per-unit'; import { addTextComponent, TextEcsComponent, } from '../../text/components/text-component.js'; import { + GlyphQuad, TextMeshEcsComponent, textMeshId, } from '../../text/components/text-mesh-component.js'; @@ -107,6 +108,9 @@ describe('createRenderEcsSystem', () => { isStatic: true, cullingMask, renderTarget, + // Wide enough that tests about anything other than view culling + // never place a sprite outside the view. + verticalWorldUnits: 10000, }); addPositionComponent(world, entity); @@ -265,19 +269,15 @@ describe('createRenderEcsSystem', () => { world.update(); - const expectedPixelsPerUnit = calculatePixelsPerUnit(200, 10); - const expected = createProjectionMatrix( - 400, - 200, - Vec2.zero, - 1, - expectedPixelsPerUnit, - ); + const expected = createProjectionMatrix({ + min: { x: -10000, y: -5000 }, + max: { x: 10000, y: 5000 }, + }); expect(material.setUniform).toHaveBeenCalledWith('u_projection', expected); }); - it('scales the projection matrix by the camera-derived pixels-per-unit', () => { + it("projects the camera's verticalWorldUnits over the canvas's height", () => { const entity = world.createEntity(); addCameraComponent(world, entity, { @@ -295,14 +295,10 @@ describe('createRenderEcsSystem', () => { renderContext.resize(400, 200); world.update(); - const expectedPixelsPerUnit = calculatePixelsPerUnit(200, 20); - const expected = createProjectionMatrix( - 400, - 200, - Vec2.zero, - 1, - expectedPixelsPerUnit, - ); + const expected = createProjectionMatrix({ + min: { x: -20, y: -10 }, + max: { x: 20, y: 10 }, + }); expect(material.setUniform).toHaveBeenCalledWith('u_projection', expected); }); @@ -872,4 +868,241 @@ describe('createRenderEcsSystem', () => { expect(mockGl.drawArraysInstanced).toHaveBeenCalledTimes(1); }); }); + + describe('view culling', () => { + // An 800x600 canvas and a camera at the origin showing 10 world units + // vertically: the view spans x in [-6.67, 6.67] and y in [-5, 5]. + const addNarrowCamera = (): void => { + const entity = world.createEntity(); + + addCameraComponent(world, entity, { + isStatic: true, + verticalWorldUnits: 10, + }); + addPositionComponent(world, entity); + }; + + const addSpriteAt = ( + renderable: Renderable, + position: { x: number; y: number }, + overrides: Partial = {}, + ): number => { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { local: position }); + addSpriteComponent( + world, + entity, + createSprite(renderable, { pivot: { x: 0.5, y: 0.5 }, ...overrides }), + ); + + return entity; + }; + + const drawnSprites = (bindInstanceData: Mock): SpriteEcsComponent[] => + bindInstanceData.mock.calls.map( + (call) => (call[0] as { sprite: SpriteEcsComponent }).sprite, + ); + + it('skips a sprite just outside the top edge', () => { + addNarrowCamera(); + const { renderable, bindInstanceData } = createRenderable(4); + + addSpriteAt(renderable, { x: 0, y: 5.51 }); + + world.update(); + + expect(bindInstanceData).not.toHaveBeenCalled(); + expect(mockGl.drawArraysInstanced).not.toHaveBeenCalled(); + }); + + it('draws a sprite overlapping the top edge by a sliver', () => { + addNarrowCamera(); + const { renderable, bindInstanceData } = createRenderable(4); + + addSpriteAt(renderable, { x: 0, y: 5.49 }); + + world.update(); + + expect(bindInstanceData).toHaveBeenCalledTimes(1); + }); + + it('skips only the sprites outside the view, keeping the rest in draw order', () => { + addNarrowCamera(); + const { renderable, bindInstanceData } = createRenderable(4); + + addSpriteAt(renderable, { x: 0, y: 2 }, { sortDepth: 2 }); + addSpriteAt(renderable, { x: -20, y: 0 }, { sortDepth: 0 }); + addSpriteAt(renderable, { x: 0, y: -2 }, { sortDepth: 1 }); + addSpriteAt(renderable, { x: 0, y: -20 }, { sortDepth: 3 }); + + world.update(); + + expect( + drawnSprites(bindInstanceData).map((sprite) => sprite.sortDepth), + ).toEqual([1, 2]); + }); + + it("respects the sprite's rotation", () => { + addNarrowCamera(); + const { renderable, bindInstanceData } = createRenderable(4); + + // A 4x0.2 bar centered at x = 8 reaches x = 6 lying flat, inside the + // view, but only x = 7.9 standing upright. + addSpriteAt(renderable, { x: 8, y: 0 }, { width: 4, height: 0.2 }); + const upright = addSpriteAt( + renderable, + { x: 8, y: 0 }, + { width: 4, height: 0.2, sortDepth: 1 }, + ); + + addRotationComponent(world, upright, { local: Math.PI / 2 }); + + world.update(); + + expect( + drawnSprites(bindInstanceData).map((sprite) => sprite.sortDepth), + ).toEqual([undefined]); + }); + + it("respects the sprite's scale", () => { + addNarrowCamera(); + const { renderable, bindInstanceData } = createRenderable(4); + + addSpriteAt(renderable, { x: 7.5, y: 0 }); + const scaled = addSpriteAt( + renderable, + { x: 7.5, y: 0 }, + { sortDepth: 1 }, + ); + + addScaleComponent(world, scaled, { local: { x: 4, y: 4 } }); + + world.update(); + + expect( + drawnSprites(bindInstanceData).map((sprite) => sprite.sortDepth), + ).toEqual([1]); + }); + + it('culls against a moved, zoomed camera', () => { + const camera = world.createEntity(); + + addCameraComponent(world, camera, { + isStatic: true, + verticalWorldUnits: 10, + zoom: 2, + }); + addPositionComponent(world, camera, { local: { x: 100, y: 0 } }); + + const { renderable, bindInstanceData } = createRenderable(4); + + // The view spans x in [96.67, 103.33] and y in [-2.5, 2.5]. + addSpriteAt(renderable, { x: 0, y: 0 }); + addSpriteAt(renderable, { x: 100, y: 3.1 }); + addSpriteAt(renderable, { x: 103.5, y: 0 }, { sortDepth: 1 }); + + world.update(); + + expect( + drawnSprites(bindInstanceData).map((sprite) => sprite.sortDepth), + ).toEqual([1]); + }); + + it('culls each camera against its own view', () => { + addNarrowCamera(); + const farCamera = world.createEntity(); + + addCameraComponent(world, farCamera, { + isStatic: true, + verticalWorldUnits: 10, + }); + addPositionComponent(world, farCamera, { local: { x: 50, y: 0 } }); + + const { renderable, bindInstanceData } = createRenderable(4); + + addSpriteAt(renderable, { x: 0, y: 0 }); + addSpriteAt(renderable, { x: 50, y: 0 }); + + world.update(); + + expect(bindInstanceData).toHaveBeenCalledTimes(2); + expect(mockGl.drawArraysInstanced).toHaveBeenCalledTimes(2); + }); + + it('draws only the nine-slice regions inside the view', () => { + addNarrowCamera(); + const { renderable, bindInstanceData } = createRenderable(4); + + // Spans x in [6, 16]: only its 1-unit-wide left column reaches into + // the view. + addSpriteAt( + renderable, + { x: 11, y: 0 }, + { + width: 10, + height: 6, + slices: { left: 1, right: 1, top: 1, bottom: 1 }, + }, + ); + + world.update(); + + expect(bindInstanceData).toHaveBeenCalledTimes(3); + expect( + drawnSprites(bindInstanceData).every((sprite) => sprite.width === 1), + ).toBe(true); + }); + + it('draws only the glyphs of a text inside the view', () => { + addNarrowCamera(); + const { renderable, bindInstanceData } = createRenderable(4); + const glyph = (x: number): GlyphQuad => ({ + offset: { x, y: 0 }, + size: { x: 1, y: 1 }, + uvOffset: Vec2.zero, + uvScale: Vec2.one, + }); + + addTextEntity(renderable, 0, { + glyphs: [glyph(5), glyph(6.5), glyph(7.5)], + }); + + world.update(); + + expect( + bindInstanceData.mock.calls.map( + (call) => + (call[0] as { position: PositionEcsComponent }).position.world.x, + ), + ).toEqual([5, 6.5]); + }); + + it("draws an off-screen text's outline and shadow with its visible glyphs", () => { + addNarrowCamera(); + const { renderable, bindInstanceData } = createRenderable(4); + + addTextEntity( + renderable, + 0, + { + glyphs: [ + { + offset: { x: 6.9, y: 0 }, + size: { x: 1, y: 1 }, + uvOffset: Vec2.zero, + uvScale: Vec2.one, + }, + ], + }, + { outlineWidth: 2, shadowColor: new Color(0, 0, 0, 1) }, + ); + + world.update(); + + // The effects pass draws inside the same glyph quad as the fill, so + // a glyph reaching into the view keeps both. + expect(bindInstanceData).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/src/rendering/systems/render-system.ts b/src/rendering/systems/render-system.ts index 81b83d17a..ef713b7de 100644 --- a/src/rendering/systems/render-system.ts +++ b/src/rendering/systems/render-system.ts @@ -8,7 +8,7 @@ import { ScaleEcsComponent, scaleId, } from '../../common/index.js'; -import { Matrix3x3, Vec2 } from '../../math/index.js'; +import { Matrix3x3, Rect, Rects, Vec2 } from '../../math/index.js'; import { EcsSystem } from '../../ecs/ecs-system.js'; import { matchesMask } from '../../utilities/matches-mask.js'; import { @@ -26,13 +26,14 @@ import { SpriteEcsComponent, spriteId, } from '../components/index.js'; +import { computeCameraView } from '../camera-view.js'; import { RenderContext } from '../render-context.js'; import { RenderTarget } from '../render-target.js'; import { Renderable } from '../renderable.js'; import { createProjectionMatrix } from '../shaders/index.js'; import { RenderCommand } from '../render-command.js'; -import { calculatePixelsPerUnit } from '../utilities/calculate-pixels-per-unit.js'; import { computeNineSliceRegions } from '../utilities/compute-nine-slice-regions.js'; +import { computeSpriteInstanceBounds } from '../utilities/sprite-instance-data-segment.js'; const setupInstanceAttributesAndDraw = ( renderContext: RenderContext, @@ -372,6 +373,35 @@ function buildCameraCommands( } } +const commandBounds: Rect = Rects.zero; + +/** + * Removes the commands whose quads don't overlap `viewBounds`, keeping the + * rest in order, so nothing a camera can't see is uploaded or drawn. Every + * command - a sprite, a nine-slice region or a glyph - is a quad drawn from + * the same instance components, so one bounds test covers them all. Quads + * touching the view's edge are kept. + * @param commands - The camera's commands, compacted in place. + * @param viewBounds - The world-space area the camera shows. + */ +function cullCommandsOutsideView( + commands: RenderCommand[], + viewBounds: Rect, +): void { + let visibleCount = 0; + + for (const command of commands) { + computeSpriteInstanceBounds(command.components, commandBounds); + + if (Rects.intersects(commandBounds, viewBounds)) { + commands[visibleCount] = command; + visibleCount++; + } + } + + commands.length = visibleCount; +} + function flushBatches( renderContext: RenderContext, projectionMatrix: Matrix3x3, @@ -403,7 +433,10 @@ const commandBuffersByCameraIndex: RenderCommand[][] = []; const clearedDestinationsThisFrame = new Set(); /** - * Creates a render system that batches and renders sprites based on the camera view. + * Creates a render system that draws every camera's sprites and text, + * batched by renderable. Each camera is projected from its view (see + * `computeCameraView`), and sprites, nine-slice regions and glyphs whose + * quads are outside that view are skipped before anything is uploaded. * * @param renderContext The rendering context * @returns The render ECS system @@ -441,18 +474,12 @@ export const createRenderEcsSystem = ( const cameraComponent = cameras[c]; const cameraPositionComponent = cameraPositions[c]; - const pixelsPerUnit = calculatePixelsPerUnit( - renderContext.height, - cameraComponent.verticalWorldUnits, - ); - - const projectionMatrix = createProjectionMatrix( - renderContext.width, - renderContext.height, - cameraPositionComponent.world, - cameraComponent.zoom, - pixelsPerUnit, + const view = computeCameraView( + cameraComponent, + cameraPositionComponent, + renderContext, ); + const projectionMatrix = createProjectionMatrix(view.bounds); let commands = commandBuffersByCameraIndex[c]; @@ -480,6 +507,8 @@ export const createRenderEcsSystem = ( renderContext.pixelRatio, ); + cullCommandsOutsideView(commands, view.bounds); + const target = cameraComponent.renderTarget ?? null; renderContext.bindRenderTarget(target); diff --git a/src/rendering/terrain/create-terrain-render-ecs-system.test.ts b/src/rendering/terrain/create-terrain-render-ecs-system.test.ts index df7178abf..a49546197 100644 --- a/src/rendering/terrain/create-terrain-render-ecs-system.test.ts +++ b/src/rendering/terrain/create-terrain-render-ecs-system.test.ts @@ -14,8 +14,8 @@ import { RenderContext } from '../render-context.js'; import { ShaderCache } from '../shaders/index.js'; import { Geometry } from '../geometry/index.js'; import { Material } from '../materials/index.js'; -import { calculatePixelsPerUnit } from '../utilities/calculate-pixels-per-unit.js'; import { createProjectionMatrix } from '../shaders/index.js'; +import { Matrix3x3 } from '../../math/index.js'; describe('createTerrainRenderEcsSystem', () => { let world: EcsWorld; @@ -151,25 +151,27 @@ describe('createTerrainRenderEcsSystem', () => { expect(mockGl.bindFramebuffer).toHaveBeenCalledWith('FRAMEBUFFER', null); }); - it('scales the projection matrix by the camera-derived pixels-per-unit', () => { + it("projects the camera's view: verticalWorldUnits tall, the canvas's aspect wide, centered on the camera", () => { addCamera({ verticalWorldUnits: 20 }); addTerrain(); world.update(); - const expectedPixelsPerUnit = calculatePixelsPerUnit(600, 20); - const expectedMatrix = createProjectionMatrix( - 800, - 600, - { x: 10, y: 20 }, - 1, - expectedPixelsPerUnit, - ); + const halfWidth = (20 * (800 / 600)) / 2; + const expectedMatrix = createProjectionMatrix({ + min: { x: 10 - halfWidth, y: 10 }, + max: { x: 10 + halfWidth, y: 30 }, + }); - expect(material.setUniform).toHaveBeenCalledWith( - 'u_projection', - expectedMatrix, - ); + const [, projection] = (material.setUniform as Mock).mock.calls[0] as [ + string, + Matrix3x3, + ]; + + expect(projection.matrix).toHaveLength(expectedMatrix.matrix.length); + expectedMatrix.matrix.forEach((value, index) => { + expect(projection.matrix[index]).toBeCloseTo(value); + }); }); it('draws once per camera when multiple cameras are present', () => { diff --git a/src/rendering/terrain/create-terrain-render-ecs-system.ts b/src/rendering/terrain/create-terrain-render-ecs-system.ts index 8454edd05..7fe2b6a52 100644 --- a/src/rendering/terrain/create-terrain-render-ecs-system.ts +++ b/src/rendering/terrain/create-terrain-render-ecs-system.ts @@ -1,12 +1,12 @@ import { EcsSystem } from '../../ecs/index.js'; import { PositionEcsComponent, positionId } from '../../common/index.js'; import { matchesMask } from '../../utilities/matches-mask.js'; +import { computeCameraView } from '../camera-view.js'; import { CameraEcsComponent, cameraId } from '../components/index.js'; import { CLEAR_STRATEGY } from '../enums/index.js'; import { RenderContext } from '../render-context.js'; import { RenderTarget } from '../render-target.js'; import { createProjectionMatrix } from '../shaders/index.js'; -import { calculatePixelsPerUnit } from '../utilities/calculate-pixels-per-unit.js'; import { TerrainMeshEcsComponent, terrainMeshId } from './components/index.js'; function drawTerrainMeshesForCamera( @@ -31,18 +31,12 @@ function drawTerrainMeshesForCamera( clearedDestinationsThisUpdate.add(target); } - const pixelsPerUnit = calculatePixelsPerUnit( - renderContext.height, - cameraComponent.verticalWorldUnits, - ); - - const projectionMatrix = createProjectionMatrix( - renderContext.width, - renderContext.height, - cameraPositionComponent.world, - cameraComponent.zoom, - pixelsPerUnit, + const { bounds } = computeCameraView( + cameraComponent, + cameraPositionComponent, + renderContext, ); + const projectionMatrix = createProjectionMatrix(bounds); for (const terrainMeshComponent of terrainMeshComponents) { if ( diff --git a/src/rendering/transforms/canvas-to-world-space.test.ts b/src/rendering/transforms/canvas-to-world-space.test.ts deleted file mode 100644 index a5813a3b2..000000000 --- a/src/rendering/transforms/canvas-to-world-space.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { canvasToWorldSpace } from './canvas-to-world-space'; - -describe('canvasToWorldSpace', () => { - it('should convert canvas position to world position correctly', () => { - const canvasPosition = { x: 100, y: 200 }; - const worldCenter = { x: 300, y: 400 }; - const expectedWorldPosition = { x: 400, y: 600 }; - - const result = canvasToWorldSpace(canvasPosition, worldCenter); - - expect(result).toEqual(expectedWorldPosition); - }); - - it('should handle negative canvas position correctly', () => { - const canvasPosition = { x: -100, y: -200 }; - const worldCenter = { x: 300, y: 400 }; - const expectedWorldPosition = { x: 200, y: 200 }; - - const result = canvasToWorldSpace(canvasPosition, worldCenter); - - expect(result).toEqual(expectedWorldPosition); - }); - - it('should handle zero canvas position correctly', () => { - const canvasPosition = { x: 0, y: 0 }; - const worldCenter = { x: 300, y: 400 }; - const expectedWorldPosition = { x: 300, y: 400 }; - - const result = canvasToWorldSpace(canvasPosition, worldCenter); - - expect(result).toEqual(expectedWorldPosition); - }); - - it('should handle zero world center correctly', () => { - const canvasPosition = { x: 100, y: 200 }; - const worldCenter = { x: 0, y: 0 }; - const expectedWorldPosition = { x: 100, y: 200 }; - - const result = canvasToWorldSpace(canvasPosition, worldCenter); - - expect(result).toEqual(expectedWorldPosition); - }); - - it('should handle both zero canvas position and world center correctly', () => { - const canvasPosition = { x: 0, y: 0 }; - const worldCenter = { x: 0, y: 0 }; - const expectedWorldPosition = { x: 0, y: 0 }; - - const result = canvasToWorldSpace(canvasPosition, worldCenter); - - expect(result).toEqual(expectedWorldPosition); - }); -}); diff --git a/src/rendering/transforms/canvas-to-world-space.ts b/src/rendering/transforms/canvas-to-world-space.ts deleted file mode 100644 index b1ead41db..000000000 --- a/src/rendering/transforms/canvas-to-world-space.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Vec2, Vector2 } from '../../math/index.js'; - -/** - * Converts a position from canvas space to world space. - * - * @param canvasPosition - The position in canvas space. - * @param worldCenter - The center of the world space. - * @returns The position in world space. - */ -export const canvasToWorldSpace = ( - canvasPosition: Vector2, - worldCenter: Vector2, -): Vector2 => Vec2.add(Vec2.clone(canvasPosition), worldCenter); diff --git a/src/rendering/transforms/index.ts b/src/rendering/transforms/index.ts deleted file mode 100644 index 2a10fb252..000000000 --- a/src/rendering/transforms/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './canvas-to-world-space.js'; -export * from './screen-to-world-space.js'; -export * from './world-to-screen-space.js'; diff --git a/src/rendering/transforms/screen-to-world-space.test.ts b/src/rendering/transforms/screen-to-world-space.test.ts deleted file mode 100644 index c1ffa4d01..000000000 --- a/src/rendering/transforms/screen-to-world-space.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { screenToWorldSpace } from './screen-to-world-space'; - -describe('screenToWorldSpace', () => { - it.each([ - { description: 'the default camera', cameraZoom: 1 }, - { description: 'the default camera position, zoomed in', cameraZoom: 2 }, - { - description: 'the default camera position, zoomed out', - cameraZoom: 0.5, - }, - ])( - 'should convert screen position to world position correctly with $description and pointer in the center of the screen', - ({ cameraZoom }) => { - const screenPosition = { x: 400, y: 300 }; - const cameraPosition = { x: 0, y: 0 }; - const screenWidth = 800; - const screenHeight = 600; - - const expectedWorldPosition = { x: 0, y: 0 }; - - const result = screenToWorldSpace( - screenPosition, - cameraPosition, - cameraZoom, - screenWidth, - screenHeight, - ); - - expect(result).toEqual(expectedWorldPosition); - }, - ); - - it('should convert screen position to world position correctly with the default camera and pointer is close to the top-left of the screen', () => { - const screenPosition = { x: 100, y: 100 }; - const cameraPosition = { x: 0, y: 0 }; - const cameraZoom = 1; - const screenWidth = 800; - const screenHeight = 600; - - const expectedWorldPosition = { x: -300, y: 200 }; - - const result = screenToWorldSpace( - screenPosition, - cameraPosition, - cameraZoom, - screenWidth, - screenHeight, - ); - - expect(result).toEqual(expectedWorldPosition); - }); - - it('should convert screen position to world position correctly with the camera panned and pointer in the center of the screen', () => { - const screenPosition = { x: 400, y: 300 }; - const cameraPosition = { x: -100, y: -100 }; - const cameraZoom = 1; - const screenWidth = 800; - const screenHeight = 600; - - const expectedWorldPosition = { x: -100, y: -100 }; - - const result = screenToWorldSpace( - screenPosition, - cameraPosition, - cameraZoom, - screenWidth, - screenHeight, - ); - - expect(result).toEqual(expectedWorldPosition); - }); - - it('should convert screen position to world position correctly with the camera panned and pointer below center', () => { - const screenPosition = { x: 500, y: 400 }; - const cameraPosition = { x: -100, y: -100 }; - const cameraZoom = 1; - const screenWidth = 800; - const screenHeight = 600; - - const expectedWorldPosition = { x: 0, y: -200 }; - - const result = screenToWorldSpace( - screenPosition, - cameraPosition, - cameraZoom, - screenWidth, - screenHeight, - ); - - expect(result).toEqual(expectedWorldPosition); - }); - - it.each([ - { - description: 'the default camera', - cameraZoom: 1, - expected: { x: -400, y: 300 }, - }, - { - description: 'the default camera, zoomed in', - cameraZoom: 2, - expected: { x: -200, y: 150 }, - }, - { - description: 'the default camera, zoomed out', - cameraZoom: 0.5, - expected: { x: -800, y: 600 }, - }, - ])( - 'should convert screen position to world position correctly with $description and pointer exactly in the top-left', - ({ cameraZoom, expected }) => { - const screenPosition = { x: 0, y: 0 }; - const cameraPosition = { x: 0, y: 0 }; - const screenWidth = 800; - const screenHeight = 600; - - const expectedWorldPosition = { x: expected.x, y: expected.y }; - - const result = screenToWorldSpace( - screenPosition, - cameraPosition, - cameraZoom, - screenWidth, - screenHeight, - ); - - expect(result).toEqual(expectedWorldPosition); - }, - ); - - it('should convert screen position to world position correctly with the camera panned, zoomed in and pointer exactly in the top-left', () => { - const screenPosition = { x: 0, y: 0 }; - const cameraPosition = { x: 100, y: 100 }; - const cameraZoom = 2; - const screenWidth = 800; - const screenHeight = 600; - - const expectedWorldPosition = { x: -100, y: 250 }; - - const result = screenToWorldSpace( - screenPosition, - cameraPosition, - cameraZoom, - screenWidth, - screenHeight, - ); - - expect(result).toEqual(expectedWorldPosition); - }); - - it('should scale by pixelsPerUnit in addition to zoom', () => { - const screenPosition = { x: 500, y: 400 }; - const cameraPosition = { x: 0, y: 0 }; - const cameraZoom = 1; - const screenWidth = 800; - const screenHeight = 600; - const pixelsPerUnit = 10; - - const expectedWorldPosition = { x: 10, y: -10 }; - - const result = screenToWorldSpace( - screenPosition, - cameraPosition, - cameraZoom, - screenWidth, - screenHeight, - pixelsPerUnit, - ); - - expect(result).toEqual(expectedWorldPosition); - }); -}); diff --git a/src/rendering/transforms/screen-to-world-space.ts b/src/rendering/transforms/screen-to-world-space.ts deleted file mode 100644 index 8ce48bfae..000000000 --- a/src/rendering/transforms/screen-to-world-space.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Vector2 } from '../../math/index.js'; - -/** - * Converts a position from screen space to world space. - * - * Forge uses a Y-up world coordinate system (positive Y points upward), while - * the canvas uses a Y-down screen coordinate system (positive Y points downward). - * The rendering projection matrix applies a negative Y scale (`-2 / height`) to - * account for this, so this function must flip Y when converting screen → world. - * - * `screenPosition`, `screenWidth`, `screenHeight` and `pixelsPerUnit` must - * all be in the same unit. A pointer position (e.g. `MouseInputSource.position`) - * is in CSS pixels, so pair it with `RenderContext.cssWidth`/`cssHeight` and - * a `pixelsPerUnit` computed from `cssHeight` - not `width`/`height`, which - * are larger by `RenderContext.pixelRatio` on a high-DPI display. - * - * @param screenPosition - The position in screen space (e.g., mouse position relative to the viewport). - * @param cameraPosition - The position of the camera in world space. - * @param cameraZoom - The zoom level of the camera. - * @param screenWidth - The width of the screen in pixels. - * @param screenHeight - The height of the screen in pixels. - * @param pixelsPerUnit - The number of pixels one world unit occupies (see - * `calculatePixelsPerUnit`). Defaults to `1`. - * @returns The position in world space. - */ -export function screenToWorldSpace( - screenPosition: Vector2, - cameraPosition: Vector2, - cameraZoom: number, - screenWidth: number, - screenHeight: number, - pixelsPerUnit: number = 1, -): Vector2 { - // Shift origin to the center of the screen, flip Y to convert from - // Y-down screen space to Y-up world space, then scale and offset. - const scale = cameraZoom * pixelsPerUnit; - const worldX = - (screenPosition.x - screenWidth / 2) / scale + cameraPosition.x; - const worldY = - (screenHeight / 2 - screenPosition.y) / scale + cameraPosition.y; - - return { x: worldX, y: worldY }; -} diff --git a/src/rendering/transforms/world-to-screen-space.test.ts b/src/rendering/transforms/world-to-screen-space.test.ts deleted file mode 100644 index 82ed40a9f..000000000 --- a/src/rendering/transforms/world-to-screen-space.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { worldToScreenSpace } from './world-to-screen-space'; - -describe('worldToScreenSpace', () => { - it('should convert world position to screen position correctly', () => { - const worldPosition = { x: 100, y: 200 }; - const cameraPosition = { x: 50, y: 50 }; - const cameraZoom = 2; - const canvasCenter = { x: 400, y: 300 }; - const expectedScreenPosition = { x: 500, y: 600 }; - - const result = worldToScreenSpace( - worldPosition, - cameraPosition, - cameraZoom, - canvasCenter, - ); - - expect(result).toEqual(expectedScreenPosition); - }); - - it('should handle negative world position correctly', () => { - const worldPosition = { x: -100, y: -200 }; - const cameraPosition = { x: 50, y: 50 }; - const cameraZoom = 2; - const canvasCenter = { x: 400, y: 300 }; - const expectedScreenPosition = { x: 100, y: -200 }; - - const result = worldToScreenSpace( - worldPosition, - cameraPosition, - cameraZoom, - canvasCenter, - ); - - expect(result).toEqual(expectedScreenPosition); - }); - - it('should handle zero world position correctly', () => { - const worldPosition = { x: 0, y: 0 }; - const cameraPosition = { x: 50, y: 50 }; - const cameraZoom = 2; - const canvasCenter = { x: 400, y: 300 }; - const expectedScreenPosition = { x: 300, y: 200 }; - - const result = worldToScreenSpace( - worldPosition, - cameraPosition, - cameraZoom, - canvasCenter, - ); - - expect(result).toEqual(expectedScreenPosition); - }); - - it('should handle zero camera position correctly', () => { - const worldPosition = { x: 100, y: 200 }; - const cameraPosition = { x: 0, y: 0 }; - const cameraZoom = 2; - const canvasCenter = { x: 400, y: 300 }; - const expectedScreenPosition = { x: 600, y: 700 }; - - const result = worldToScreenSpace( - worldPosition, - cameraPosition, - cameraZoom, - canvasCenter, - ); - - expect(result).toEqual(expectedScreenPosition); - }); - - it('should handle zero camera zoom correctly', () => { - const worldPosition = { x: 100, y: 200 }; - const cameraPosition = { x: 50, y: 50 }; - const cameraZoom = 1; - const canvasCenter = { x: 400, y: 300 }; - const expectedScreenPosition = { x: 450, y: 450 }; - - const result = worldToScreenSpace( - worldPosition, - cameraPosition, - cameraZoom, - canvasCenter, - ); - - expect(result).toEqual(expectedScreenPosition); - }); - - it('should handle zero canvas center correctly', () => { - const worldPosition = { x: 100, y: 200 }; - const cameraPosition = { x: 50, y: 50 }; - const cameraZoom = 2; - const canvasCenter = { x: 0, y: 0 }; - const expectedScreenPosition = { x: 100, y: 300 }; - - const result = worldToScreenSpace( - worldPosition, - cameraPosition, - cameraZoom, - canvasCenter, - ); - - expect(result).toEqual(expectedScreenPosition); - }); - - it('should handle both zero world position and camera position correctly', () => { - const worldPosition = { x: 0, y: 0 }; - const cameraPosition = { x: 0, y: 0 }; - const cameraZoom = 2; - const canvasCenter = { x: 400, y: 300 }; - const expectedScreenPosition = { x: 400, y: 300 }; - - const result = worldToScreenSpace( - worldPosition, - cameraPosition, - cameraZoom, - canvasCenter, - ); - - expect(result).toEqual(expectedScreenPosition); - }); - - it('should scale by pixelsPerUnit in addition to zoom', () => { - const worldPosition = { x: 10, y: 20 }; - const cameraPosition = { x: 0, y: 0 }; - const cameraZoom = 1; - const canvasCenter = { x: 400, y: 300 }; - const pixelsPerUnit = 10; - const expectedScreenPosition = { x: 500, y: 500 }; - - const result = worldToScreenSpace( - worldPosition, - cameraPosition, - cameraZoom, - canvasCenter, - pixelsPerUnit, - ); - - expect(result).toEqual(expectedScreenPosition); - }); -}); diff --git a/src/rendering/transforms/world-to-screen-space.ts b/src/rendering/transforms/world-to-screen-space.ts deleted file mode 100644 index deed6ca5d..000000000 --- a/src/rendering/transforms/world-to-screen-space.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Vec2, Vector2 } from '../../math/index.js'; - -/** - * Converts a position from world space to screen space. - * - * @param worldPosition - The position in world space. - * @param cameraPosition - The position of the camera in world space. - * @param cameraZoom - The zoom level of the camera. - * @param canvasCenter - The center of the canvas. - * @param pixelsPerUnit - The number of pixels one world unit occupies (see - * `calculatePixelsPerUnit`). Defaults to `1`. - * @returns The position in screen space. - */ -export const worldToScreenSpace = ( - worldPosition: Vector2, - cameraPosition: Vector2, - cameraZoom: number, - canvasCenter: Vector2, - pixelsPerUnit: number = 1, -): Vector2 => { - // Clone before subtracting: `worldPosition` is a caller-supplied vector - // (e.g. an entity's live world position), so this must not mutate it. - const relativePosition = Vec2.subtract( - Vec2.clone(worldPosition), - cameraPosition, - ); - const zoomedPosition = Vec2.multiply( - relativePosition, - cameraZoom * pixelsPerUnit, - ); - const screenPosition = Vec2.add(zoomedPosition, canvasCenter); - - return screenPosition; -}; diff --git a/src/rendering/utilities/calculate-pixels-per-unit.test.ts b/src/rendering/utilities/calculate-pixels-per-unit.test.ts deleted file mode 100644 index 73482d46d..000000000 --- a/src/rendering/utilities/calculate-pixels-per-unit.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { calculatePixelsPerUnit } from './calculate-pixels-per-unit'; - -describe('calculatePixelsPerUnit', () => { - it('should compute pixels-per-unit from canvas height and vertical world units', () => { - expect(calculatePixelsPerUnit(1080, 10)).toBe(108); - }); - - it('should double when canvas height doubles', () => { - const verticalWorldUnits = 10; - - const base = calculatePixelsPerUnit(1080, verticalWorldUnits); - const doubled = calculatePixelsPerUnit(2160, verticalWorldUnits); - - expect(doubled).toBe(base * 2); - }); - - it('should halve when vertical world units double', () => { - const canvasHeight = 1080; - - const base = calculatePixelsPerUnit(canvasHeight, 10); - const doubled = calculatePixelsPerUnit(canvasHeight, 20); - - expect(doubled).toBe(base / 2); - }); - - it('should throw when canvasHeight is not positive', () => { - expect(() => calculatePixelsPerUnit(0, 10)).toThrow(); - expect(() => calculatePixelsPerUnit(-100, 10)).toThrow(); - }); - - it('should throw when verticalWorldUnits is not positive', () => { - expect(() => calculatePixelsPerUnit(1080, 0)).toThrow(); - expect(() => calculatePixelsPerUnit(1080, -10)).toThrow(); - }); -}); diff --git a/src/rendering/utilities/calculate-pixels-per-unit.ts b/src/rendering/utilities/calculate-pixels-per-unit.ts deleted file mode 100644 index ffc9252a1..000000000 --- a/src/rendering/utilities/calculate-pixels-per-unit.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Computes pixels-per-unit from a camera's `verticalWorldUnits` and the - * current canvas height, so a world unit spans a consistent number of - * screen pixels regardless of vertical resolution. - * @param canvasHeight - The render destination's height, in pixels. - * @param verticalWorldUnits - The total world-space height the camera should show. - * @returns The number of pixels one world unit occupies vertically. - * @throws An error if `canvasHeight` or `verticalWorldUnits` is not positive. - */ -export function calculatePixelsPerUnit( - canvasHeight: number, - verticalWorldUnits: number, -): number { - if (canvasHeight <= 0 || verticalWorldUnits <= 0) { - throw new Error( - `calculatePixelsPerUnit requires positive values, received canvasHeight=${canvasHeight}, verticalWorldUnits=${verticalWorldUnits}.`, - ); - } - - return canvasHeight / verticalWorldUnits; -} diff --git a/src/rendering/utilities/calculate-visible-world-size.test.ts b/src/rendering/utilities/calculate-visible-world-size.test.ts deleted file mode 100644 index 6b5db7a81..000000000 --- a/src/rendering/utilities/calculate-visible-world-size.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { calculateVisibleWorldSize } from './calculate-visible-world-size'; - -describe('calculateVisibleWorldSize', () => { - it('should return verticalWorldUnits as the height, and scale width by aspect ratio', () => { - const result = calculateVisibleWorldSize(1600, 900, 10); - - expect(result).toEqual({ x: (10 * 1600) / 900, y: 10 }); - }); - - it('should return a square size for a square destination', () => { - const result = calculateVisibleWorldSize(500, 500, 20); - - expect(result).toEqual({ x: 20, y: 20 }); - }); - - it('should keep height constant while width tracks aspect ratio changes', () => { - const verticalWorldUnits = 600; - - const wide = calculateVisibleWorldSize(1600, 600, verticalWorldUnits); - const narrow = calculateVisibleWorldSize(800, 600, verticalWorldUnits); - - expect(wide.y).toBe(verticalWorldUnits); - expect(narrow.y).toBe(verticalWorldUnits); - expect(wide.x).toBeGreaterThan(narrow.x); - }); - - it('should throw when canvasWidth is not positive', () => { - expect(() => calculateVisibleWorldSize(0, 600, 10)).toThrow(); - expect(() => calculateVisibleWorldSize(-100, 600, 10)).toThrow(); - }); - - it('should throw when canvasHeight is not positive', () => { - expect(() => calculateVisibleWorldSize(800, 0, 10)).toThrow(); - expect(() => calculateVisibleWorldSize(800, -600, 10)).toThrow(); - }); - - it('should throw when verticalWorldUnits is not positive', () => { - expect(() => calculateVisibleWorldSize(800, 600, 0)).toThrow(); - expect(() => calculateVisibleWorldSize(800, 600, -10)).toThrow(); - }); -}); diff --git a/src/rendering/utilities/calculate-visible-world-size.ts b/src/rendering/utilities/calculate-visible-world-size.ts deleted file mode 100644 index dc20072ac..000000000 --- a/src/rendering/utilities/calculate-visible-world-size.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Vector2 } from '../../math/index.js'; - -/** - * Computes the full width/height, in world units, a camera's view spans at - * the given destination dimensions: `verticalWorldUnits` tall, with the - * width following automatically from the destination's aspect ratio. Use - * this instead of reading `RenderContext.width`/`height` in pixels directly - * for world-space layout (spawn bounds, movement limits, background sizing, - * ...), so that layout stays correct across resolutions and aspect ratios. - * @param canvasWidth - The render destination's width, in pixels. - * @param canvasHeight - The render destination's height, in pixels. - * @param verticalWorldUnits - The total world-space height the camera shows (see `CameraEcsComponent.verticalWorldUnits`). - * @returns The visible width/height, in world units. - * @throws An error if any argument is not positive. - */ -export function calculateVisibleWorldSize( - canvasWidth: number, - canvasHeight: number, - verticalWorldUnits: number, -): Vector2 { - if (canvasWidth <= 0 || canvasHeight <= 0 || verticalWorldUnits <= 0) { - throw new Error( - `calculateVisibleWorldSize requires positive values, received canvasWidth=${canvasWidth}, canvasHeight=${canvasHeight}, verticalWorldUnits=${verticalWorldUnits}.`, - ); - } - - const aspectRatio = canvasWidth / canvasHeight; - - return { x: verticalWorldUnits * aspectRatio, y: verticalWorldUnits }; -} diff --git a/src/rendering/utilities/import-texture.ts b/src/rendering/utilities/import-texture.ts index b0c62f36a..7de7e9318 100644 --- a/src/rendering/utilities/import-texture.ts +++ b/src/rendering/utilities/import-texture.ts @@ -7,7 +7,7 @@ export interface ImportTextureOptions { * How many pixels of the texture span one world unit, mirroring a texture * asset's "Pixels Per Unit" import setting in engines like Unity. The * resulting `worldWidth`/`worldHeight` are `width / pixelsPerUnit` and - * `height / pixelsPerUnit`. This is independent of `calculatePixelsPerUnit`, + * `height / pixelsPerUnit`. This is independent of `CameraView.pixelsPerUnit`, * which converts world units to *screen* pixels at render time based on * the camera; `pixelsPerUnit` here converts *texture* pixels to world * units once, at import time. Defaults to `100`. diff --git a/src/rendering/utilities/index.ts b/src/rendering/utilities/index.ts index 4d95469f5..bba491535 100644 --- a/src/rendering/utilities/index.ts +++ b/src/rendering/utilities/index.ts @@ -1,5 +1,3 @@ -export * from './calculate-pixels-per-unit.js'; -export * from './calculate-visible-world-size.js'; export * from './compute-nine-slice-regions.js'; export * from './create-camera.js'; export * from './create-canvas.js'; diff --git a/src/rendering/utilities/sprite-instance-data-segment.test.ts b/src/rendering/utilities/sprite-instance-data-segment.test.ts index 17f3ce8da..5aea28e05 100644 --- a/src/rendering/utilities/sprite-instance-data-segment.test.ts +++ b/src/rendering/utilities/sprite-instance-data-segment.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { spriteInstanceDataSegment } from './sprite-instance-data-segment.js'; +import { + computeSpriteInstanceBounds, + spriteInstanceDataSegment, +} from './sprite-instance-data-segment.js'; +import { Rect, Rects } from '../../math/index.js'; import { Color } from '../color.js'; import type { InstanceComponents } from '../renderable.js'; import type { SpriteEcsComponent } from '../components/sprite-component.js'; @@ -66,3 +70,149 @@ describe('spriteInstanceDataSegment.bindInstanceData', () => { expect(buffer[TINT_COLOR_A_OFFSET]).toBeCloseTo(0.4); }); }); + +describe('computeSpriteInstanceBounds', () => { + const buildSprite = ( + overrides: Partial = {}, + ): SpriteEcsComponent => ({ + width: 4, + height: 2, + pivot: { x: 0.5, y: 0.5 }, + tintColor: new Color(1, 1, 1, 1), + uvOffset: { x: 0, y: 0 }, + uvScale: { x: 1, y: 1 }, + enabled: true, + layer: 0, + renderable: undefined as never, + ...overrides, + }); + + /** + * The bounds of the quad corners `sprite.vert` computes from the instance + * data `bindInstanceData` writes, converted back from the shader's Y-down + * space - an independent reference for what's actually drawn. + */ + const boundsDrawnByShader = (components: InstanceComponents): Rect => { + const data = new Float32Array(spriteInstanceDataSegment.floatsPerInstance); + + spriteInstanceDataSegment.bindInstanceData(components, data, 0); + + const [x, y, rotation, scaleX, scaleY, width, height, pivotX, pivotY] = + data; + const pivot = { x: (pivotX - 0.5) * 2, y: -(pivotY - 0.5) * 2 }; + const corners = [ + { x: -1, y: -1 }, + { x: 1, y: -1 }, + { x: -1, y: 1 }, + { x: 1, y: 1 }, + ].map((corner) => { + const scaledX = (corner.x - pivot.x) * width * scaleX * 0.5; + const scaledY = (corner.y - pivot.y) * height * scaleY * 0.5; + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + + return { + x: cos * scaledX - sin * scaledY + x, + y: -(sin * scaledX + cos * scaledY + y), + }; + }); + + return { + min: { + x: Math.min(...corners.map((corner) => corner.x)), + y: Math.min(...corners.map((corner) => corner.y)), + }, + max: { + x: Math.max(...corners.map((corner) => corner.x)), + y: Math.max(...corners.map((corner) => corner.y)), + }, + }; + }; + + const expectRectCloseTo = (actual: Rect, expected: Rect): void => { + expect(actual.min.x).toBeCloseTo(expected.min.x, 4); + expect(actual.min.y).toBeCloseTo(expected.min.y, 4); + expect(actual.max.x).toBeCloseTo(expected.max.x, 4); + expect(actual.max.y).toBeCloseTo(expected.max.y, 4); + }; + + it('spans width by height around a centered pivot at the world position', () => { + const components: InstanceComponents = { + ...buildComponents(buildSprite()), + position: { local: { x: 0, y: 0 }, world: { x: 10, y: 20 } }, + }; + + expect(computeSpriteInstanceBounds(components, Rects.zero)).toEqual({ + min: { x: 8, y: 19 }, + max: { x: 12, y: 21 }, + }); + }); + + it('extends up and right from a bottom-left pivot (Y-up)', () => { + const components = buildComponents(buildSprite({ pivot: { x: 0, y: 0 } })); + + expect(computeSpriteInstanceBounds(components, Rects.zero)).toEqual({ + min: { x: 0, y: 0 }, + max: { x: 4, y: 2 }, + }); + }); + + it('writes into and returns the rect it is given', () => { + const result = Rects.zero; + + expect( + computeSpriteInstanceBounds(buildComponents(buildSprite()), result), + ).toBe(result); + }); + + it.each([ + { description: 'unrotated', rotation: 0, scale: { x: 1, y: 1 } }, + { + description: 'rotated 30 degrees', + rotation: Math.PI / 6, + scale: { x: 1, y: 1 }, + }, + { + description: 'rotated -100 degrees', + rotation: -1.745, + scale: { x: 1, y: 1 }, + }, + { description: 'scaled', rotation: 0.4, scale: { x: 3, y: 0.5 } }, + { + description: 'negatively scaled', + rotation: 2.2, + scale: { x: -2, y: 1.5 }, + }, + ])( + 'matches the quad sprite.vert draws for an off-center pivot, $description', + ({ rotation, scale }) => { + const components: InstanceComponents = { + position: { local: { x: 0, y: 0 }, world: { x: -3, y: 7 } }, + rotation: { local: rotation, world: rotation }, + scale: { local: scale, world: scale }, + sprite: buildSprite({ pivot: { x: 0.2, y: 0.9 } }), + flip: null, + }; + + expectRectCloseTo( + computeSpriteInstanceBounds(components, Rects.zero), + boundsDrawnByShader(components), + ); + }, + ); + + it('matches the quad sprite.vert draws for a flipped sprite', () => { + const components: InstanceComponents = { + position: { local: { x: 0, y: 0 }, world: { x: 1, y: 1 } }, + rotation: { local: 0.7, world: 0.7 }, + scale: null, + sprite: buildSprite({ pivot: { x: 0, y: 1 } }), + flip: { flipX: true, flipY: true }, + }; + + expectRectCloseTo( + computeSpriteInstanceBounds(components, Rects.zero), + boundsDrawnByShader(components), + ); + }); +}); diff --git a/src/rendering/utilities/sprite-instance-data-segment.ts b/src/rendering/utilities/sprite-instance-data-segment.ts index 2672282c5..09929521b 100644 --- a/src/rendering/utilities/sprite-instance-data-segment.ts +++ b/src/rendering/utilities/sprite-instance-data-segment.ts @@ -1,3 +1,4 @@ +import type { Rect } from '../../math/index.js'; import type { InstanceComponents, Renderable } from '../renderable.js'; import type { InstanceDataSegment } from './instance-data-segment.js'; import { setupInstanceAttribute } from './setup-instance-attribute.js'; @@ -74,6 +75,59 @@ function bindSpriteInstanceData( sprite.tintColor.a * (sprite.opacityMultiplier ?? 1); } +/** + * Computes the world-space axis-aligned bounds of the quad `sprite.vert` + * draws for one instance: `sprite`'s `width`/`height` around its `pivot`, + * scaled (and flipped), rotated and placed at `position.world`, matching + * the instance data `spriteInstanceDataSegment` binds. The render system + * skips instances whose bounds are outside a camera's view. + * @param components - The instance's components. + * @param result - The rect to write the bounds into, so a caller testing + * many instances can reuse one. + * @returns `result`. + */ +export function computeSpriteInstanceBounds( + components: InstanceComponents, + result: Rect, +): Rect { + const { position, rotation, scale, sprite, flip } = components; + const scaleX = (scale?.world.x ?? 1) * (flip?.flipX ? -1 : 1); + const scaleY = (scale?.world.y ?? 1) * (flip?.flipY ? -1 : 1); + const width = sprite.width * scaleX; + const height = sprite.height * scaleY; + // The quad's center and half extents, relative to the pivot, before + // rotation. A negative scale flips the quad around the pivot, which + // moves its center but not its extents. + const centerX = (0.5 - sprite.pivot.x) * width; + const centerY = (0.5 - sprite.pivot.y) * height; + const halfWidth = Math.abs(width) / 2; + const halfHeight = Math.abs(height) / 2; + const radians = rotation?.world ?? 0; + + if (radians === 0) { + result.min.x = position.world.x + centerX - halfWidth; + result.min.y = position.world.y + centerY - halfHeight; + result.max.x = position.world.x + centerX + halfWidth; + result.max.y = position.world.y + centerY + halfHeight; + + return result; + } + + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const rotatedCenterX = position.world.x + centerX * cos - centerY * sin; + const rotatedCenterY = position.world.y + centerX * sin + centerY * cos; + const extentX = halfWidth * Math.abs(cos) + halfHeight * Math.abs(sin); + const extentY = halfWidth * Math.abs(sin) + halfHeight * Math.abs(cos); + + result.min.x = rotatedCenterX - extentX; + result.min.y = rotatedCenterY - extentY; + result.max.x = rotatedCenterX + extentX; + result.max.y = rotatedCenterY + extentY; + + return result; +} + function setupSpriteInstanceAttributes( gl: WebGL2RenderingContext, renderable: Renderable, diff --git a/src/ui/systems/ui-layout-system.test.ts b/src/ui/systems/ui-layout-system.test.ts index 1c4d0fb5d..55e147af9 100644 --- a/src/ui/systems/ui-layout-system.test.ts +++ b/src/ui/systems/ui-layout-system.test.ts @@ -104,6 +104,29 @@ describe('createUiLayoutEcsSystem', () => { }); }); + it("still resolves a screen-space canvas root rect centered at the origin when its camera has no position, and syncs the camera's verticalWorldUnits", () => { + const world = new EcsWorld(); + const renderContext = buildRenderContext(1920, 1080); + const camera = world.createEntity(); + + addCameraComponent(world, camera); + + const canvas = world.createEntity(); + + addPositionComponent(world, canvas); + addRectTransformComponent(world, canvas); + addCanvasComponent(world, canvas, { camera }); + + world.addSystem(createUiLayoutEcsSystem(renderContext)); + world.update(); + + expect(world.getComponent(canvas, rectTransformId)!.rect).toEqual({ + min: { x: -960, y: -540 }, + max: { x: 960, y: 540 }, + }); + expect(world.getComponent(camera, cameraId)!.verticalWorldUnits).toBe(1080); + }); + it('follows the destination aspect ratio (scaleWithScreenSize keeps height, grows width)', () => { const world = new EcsWorld(); const renderContext = buildRenderContext(1600, 800); // 2:1 aspect ratio @@ -420,6 +443,59 @@ describe('createUiLayoutEcsSystem', () => { expect(rect.max.x - rect.min.x).toBe(200); }); + it("keeps a world-space canvas's screenPixels-unit child the same size on screen as its camera zooms", () => { + const world = new EcsWorld(); + const renderContext = buildRenderContext(1920, 1080); + const { canvas, camera } = createTestCanvas(world, { + renderMode: uiCanvasRenderModes.worldSpace, + }); + const cameraComponent = world.getComponent(camera, cameraId)!; + + cameraComponent.verticalWorldUnits = 10; + addRectTransformComponent(world, canvas, UiAnchor.center({ x: 4, y: 2 })); + + const label = world.createEntity(); + + addPositionComponent(world, label); + addParentComponent(world, label, { parent: canvas }); + addRectTransformComponent(world, label, { + x: UiAxis.point(0, { pivot: 0, size: 216, sizeUnit: 'screenPixels' }), + y: UiAxis.stretch({ min: 0, max: 1 }), + }); + + world.addSystem(createUiLayoutEcsSystem(renderContext)); + world.update(); + + // 1080 CSS pixels over 10 world units: 216 screen pixels is 2 units. + let rect = world.getComponent(label, rectTransformId)!.rect; + + expect(rect.max.x - rect.min.x).toBeCloseTo(2); + + cameraComponent.zoom = 2; + world.update(); + + rect = world.getComponent(label, rectTransformId)!.rect; + + expect(rect.max.x - rect.min.x).toBeCloseTo(1); + }); + + it("fills its camera's view: a screen-space canvas root follows a moved camera", () => { + const world = new EcsWorld(); + const renderContext = buildRenderContext(1920, 1080); + const { canvas, camera } = createTestCanvas(world); + const cameraPosition = world.getComponent(camera, positionId)!; + + cameraPosition.world = { x: 100, y: -50 }; + + world.addSystem(createUiLayoutEcsSystem(renderContext)); + world.update(); + + expect(world.getComponent(canvas, rectTransformId)!.rect).toEqual({ + min: { x: -860, y: -590 }, + max: { x: 1060, y: 490 }, + }); + }); + it('resolves a stretched child that spans the full canvas width', () => { const world = new EcsWorld(); const renderContext = buildRenderContext(1920, 1080); diff --git a/src/ui/systems/ui-layout-system.ts b/src/ui/systems/ui-layout-system.ts index d62bfb04d..245c7ecb9 100644 --- a/src/ui/systems/ui-layout-system.ts +++ b/src/ui/systems/ui-layout-system.ts @@ -8,9 +8,10 @@ import { EcsSystem } from '../../ecs/ecs-system.js'; import { EcsWorld } from '../../ecs/ecs-world.js'; import { Rect, Rects, Vector2 } from '../../math/index.js'; import { - calculatePixelsPerUnit, CameraEcsComponent, cameraId, + CameraView, + computeCameraView, RenderContext, SpriteEcsComponent, spriteId, @@ -31,8 +32,8 @@ import { resolveRect } from '../utilities/resolve-rect.js'; /** * Resolves a canvas's root rect (and the world height its dedicated camera * should show) from the render destination's live size, per `scaleMode`. - * Centered at the origin, matching the world camera's own projection - * convention. + * Centered at the origin; the rect is only used as is when the canvas's + * camera can't provide a view (see `resolveEntityRect`). */ function resolveCanvasRootRect( renderContext: RenderContext, @@ -81,9 +82,9 @@ function pivotPositionOf(rect: Rect, pivot: Vector2): Vector2 { } /** - * Resolves one entity's rect - a screen-space canvas root (via - * `resolveCanvasRootRect`, also syncing its camera's `verticalWorldUnits` - * and resizing its `renderTarget` if it has one), or, for anything else + * Resolves one entity's rect - a screen-space canvas root (its camera's + * view, after syncing the camera's `verticalWorldUnits` from + * `resolveCanvasRootRect` and resizing its `renderTarget` if it has one), or, for anything else * (an ordinary element, or a world-space canvas root), against `parentRect` * via `resolveRect` - and the reference-pixel-to-screen-pixel ratio this * entity's own *children* should resolve a `'screenPixels'`-unit `UiAxis` @@ -130,33 +131,57 @@ function resolveEntityRect( ); } - return { - rect: resolved.rect, - childPixelsPerUnit: calculatePixelsPerUnit( - renderContext.cssHeight, - resolved.worldHeight, - ), - }; + const view = computeCanvasCameraView( + world, + renderContext, + canvasComponent, + camera, + ); + + if (!view) { + return { rect: resolved.rect, childPixelsPerUnit: pixelsPerUnit }; + } + + // The canvas fills what its camera shows, wherever the camera is and + // however it's zoomed. + return { rect: view.bounds, childPixelsPerUnit: view.pixelsPerUnit }; } const rect = resolveRect(parentRect, rectTransform, pixelsPerUnit); const camera = canvasComponent ? world.getComponent(canvasComponent.camera, cameraId) : null; - - if (!camera) { - return { rect, childPixelsPerUnit: pixelsPerUnit }; - } + const view = + canvasComponent && camera + ? computeCanvasCameraView(world, renderContext, canvasComponent, camera) + : null; return { rect, - childPixelsPerUnit: calculatePixelsPerUnit( - renderContext.cssHeight, - camera.verticalWorldUnits, - ), + childPixelsPerUnit: view ? view.pixelsPerUnit : pixelsPerUnit, }; } +/** + * The view of a canvas's camera, or `null` if the camera entity has no + * position to center a view on. + */ +function computeCanvasCameraView( + world: EcsWorld, + renderContext: RenderContext, + canvasComponent: CanvasEcsComponent, + camera: CameraEcsComponent, +): CameraView | null { + const cameraPosition = world.getComponent( + canvasComponent.camera, + positionId, + ); + + return cameraPosition + ? computeCameraView(camera, cameraPosition, renderContext) + : null; +} + /** * Creates a system that resolves every `RectTransformEcsComponent` against * its parent's rect, top-down, in hierarchy pre-order starting from each @@ -191,8 +216,8 @@ function resolveEntityRect( * world-space canvas typically shares the game's own world camera. * * Also computes each canvas's current reference-pixel-to-screen-pixel ratio - * (`calculatePixelsPerUnit(renderContext.cssHeight, verticalWorldUnits)`, - * re-derived from whichever camera that canvas root just resolved) and + * (its camera's `CameraView.pixelsPerUnit`, which follows the camera's + * zoom, re-derived from whichever camera that canvas root just resolved) and * threads it down through the whole subtree, so any descendant's * `'screenPixels'`-unit `UiAxis` size/margin (see `UiAxisSizeUnit`) converts * against the ratio that's actually live for the canvas it belongs to, not diff --git a/src/ui/systems/ui-safe-area-system.test.ts b/src/ui/systems/ui-safe-area-system.test.ts index a9f8460d8..c55ba8efc 100644 --- a/src/ui/systems/ui-safe-area-system.test.ts +++ b/src/ui/systems/ui-safe-area-system.test.ts @@ -241,4 +241,45 @@ describe('createUiSafeAreaEcsSystem', () => { expect(rectTransform.x).toEqual(originalX); expect(rectTransform.y).toEqual(originalY); }); + + it('leaves the rect untouched when the owning canvas camera has no position to take a view from', () => { + const world = new EcsWorld(); + const renderContext = buildRenderContext(1080); + const camera = world.createEntity(); + + addCameraComponent(world, camera, { verticalWorldUnits: 1080 }); + + const canvas = world.createEntity(); + + addPositionComponent(world, canvas); + addRectTransformComponent(world, canvas); + addCanvasComponent(world, canvas, { camera }); + + const entity = world.createEntity(); + + addPositionComponent(world, entity); + addParentComponent(world, entity, { parent: canvas }); + addRectTransformComponent(world, entity); + addUiSafeAreaComponent(world, entity); + + const { x: originalX, y: originalY } = world.getComponent( + entity, + rectTransformId, + )!; + + world.addSystem( + createUiSafeAreaEcsSystem(renderContext, () => ({ + top: 40, + right: 5, + bottom: 20, + left: 5, + })), + ); + world.update(); + + const rectTransform = world.getComponent(entity, rectTransformId)!; + + expect(rectTransform.x).toBe(originalX); + expect(rectTransform.y).toBe(originalY); + }); }); diff --git a/src/ui/systems/ui-safe-area-system.ts b/src/ui/systems/ui-safe-area-system.ts index e173b62e8..0b77f0c3e 100644 --- a/src/ui/systems/ui-safe-area-system.ts +++ b/src/ui/systems/ui-safe-area-system.ts @@ -1,9 +1,11 @@ import { EcsSystem } from '../../ecs/ecs-system.js'; import { EcsWorld } from '../../ecs/ecs-world.js'; +import { PositionEcsComponent, positionId } from '../../common/index.js'; import { - calculatePixelsPerUnit, CameraEcsComponent, cameraId, + CameraView, + computeCameraView, RenderContext, SafeAreaInsets, } from '../../rendering/index.js'; @@ -22,11 +24,12 @@ import { import { UiAxis } from '../types/ui-axis.js'; import { findOwningCanvas } from '../utilities/find-owning-canvas.js'; -/** The owning canvas's camera, resolved once per entity so the pixels-per-unit conversion matches whatever that canvas is actually showing. */ -function resolveOwningCamera( +/** The view of the owning canvas's camera, resolved once per entity so the pixels-per-unit conversion matches whatever that canvas is actually showing. */ +function resolveOwningCameraView( world: EcsWorld, entity: number, -): CameraEcsComponent | null { + renderContext: RenderContext, +): CameraView | null { const owningCanvas = findOwningCanvas(world, entity); if (owningCanvas === null) { @@ -35,17 +38,30 @@ function resolveOwningCamera( const canvas = world.getComponent(owningCanvas, canvasId); - return canvas - ? world.getComponent(canvas.camera, cameraId) + if (!canvas) { + return null; + } + + const camera = world.getComponent( + canvas.camera, + cameraId, + ); + const cameraPosition = world.getComponent( + canvas.camera, + positionId, + ); + + return camera && cameraPosition + ? computeCameraView(camera, cameraPosition, renderContext) : null; } /** * Creates a system that keeps every `UiSafeAreaEcsComponent` element's rect * clear of the live safe-area insets (see `getSafeAreaInsets`), converted - * from CSS pixels into that element's canvas's own UI world units via - * `calculatePixelsPerUnit` (the same conversion `resolveCanvasPointerPosition` - * uses) - so the inset shrinks the rect by a consistent visual amount + * from CSS pixels into that element's canvas's own UI world units via its + * camera's `CameraView.pixelsPerUnit` (the same view + * `resolveCanvasPointerPosition` converts through) - so the inset shrinks the rect by a consistent visual amount * regardless of the canvas's `scaleMode`/reference resolution. Assumes the * render destination fills the browser viewport, the same assumption * `getSafeAreaInsets` itself is only meaningful under. @@ -81,18 +97,14 @@ export const createUiSafeAreaEcsSystem = ( const safeArea = safeAreas[i]; const rectTransform = rectTransforms[i]; - const camera = resolveOwningCamera(world, entities[i]); + const view = resolveOwningCameraView(world, entities[i], renderContext); - if (!camera) { + if (!view) { continue; } - // Insets are in CSS pixels, so convert against the canvas's CSS - // height, not its pixel-ratio-scaled drawing buffer. - const pixelsPerUnit = calculatePixelsPerUnit( - renderContext.cssHeight, - camera.verticalWorldUnits, - ); + // Insets are in CSS pixels, like the view's pixels per unit. + const { pixelsPerUnit } = view; const left = safeArea.left ? insets.left / pixelsPerUnit : 0; const right = safeArea.right ? insets.right / pixelsPerUnit : 0; diff --git a/src/ui/types/ui-axis.ts b/src/ui/types/ui-axis.ts index dc0fe075a..06f29d746 100644 --- a/src/ui/types/ui-axis.ts +++ b/src/ui/types/ui-axis.ts @@ -6,8 +6,8 @@ * CSS pixel count (the same unit DOM layout uses, so it keeps the same * physical size on a HiDPI display), converted to reference pixels fresh * every frame (via - * `createUiLayoutEcsSystem`'s own `pixelsPerUnit`, the same conversion - * `calculatePixelsPerUnit` gives `createUiSafeAreaEcsSystem`) - useful for an + * the canvas camera's `CameraView.pixelsPerUnit`, the same conversion + * `createUiSafeAreaEcsSystem` uses) - useful for an * element that should keep a constant on-screen size regardless of the * canvas's `scaleMode`/reference resolution or the destination's aspect * ratio, such as a fixed-width sidebar inside an otherwise `scaleWithScreenSize`/ diff --git a/src/ui/utilities/resolve-canvas-pointer-position.ts b/src/ui/utilities/resolve-canvas-pointer-position.ts index 6a599983a..3379bafc5 100644 --- a/src/ui/utilities/resolve-canvas-pointer-position.ts +++ b/src/ui/utilities/resolve-canvas-pointer-position.ts @@ -2,11 +2,10 @@ import { positionId } from '../../common/index.js'; import { EcsWorld } from '../../ecs/ecs-world.js'; import { Vector2 } from '../../math/index.js'; import { - calculatePixelsPerUnit, CameraEcsComponent, cameraId, + computeCameraView, RenderContext, - screenToWorldSpace, } from '../../rendering/index.js'; import { CanvasEcsComponent } from '../components/canvas-component.js'; import { UiPointerSource } from '../types/ui-pointer-source.js'; @@ -43,19 +42,9 @@ export function resolveCanvasPointerPosition( return null; } - // The pointer position is in CSS pixels, so it's converted against the - // canvas's CSS size rather than its (pixel-ratio-scaled) drawing buffer. - const pixelsPerUnit = calculatePixelsPerUnit( - renderContext.cssHeight, - camera.verticalWorldUnits, - ); - - return screenToWorldSpace( - pointerSource.position, - cameraPosition.world, - camera.zoom, - renderContext.cssWidth, - renderContext.cssHeight, - pixelsPerUnit, - ); + return computeCameraView( + camera, + cameraPosition, + renderContext, + ).viewportToWorld(pointerSource.position); } diff --git a/src/ui/utilities/resolve-rect.ts b/src/ui/utilities/resolve-rect.ts index ad9347b7f..28fc689c5 100644 --- a/src/ui/utilities/resolve-rect.ts +++ b/src/ui/utilities/resolve-rect.ts @@ -55,7 +55,7 @@ function resolveAxis( * @param parentRect - The parent's already-resolved rect, in UI world space. * @param rectTransform - The rect transform to resolve. * @param pixelsPerUnit - The owning canvas's live reference-pixel-to-screen-pixel - * ratio (see `calculatePixelsPerUnit`), used to convert a `'screenPixels'`-unit + * ratio (see `CameraView.pixelsPerUnit`), used to convert a `'screenPixels'`-unit * `size`/`margin` (see {@link UiAxisSizeUnit}) into reference pixels. Defaults * to `1` - a neutral value under which `'screenPixels'` behaves exactly like * `'referencePixels'` - for callers that don't track a live ratio.