From 22365a9b7fd8740bb4eccb0526736e05c9b2011d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:19:04 +0000 Subject: [PATCH 1/2] feat(rendering)!: add camera views and cull sprites outside them A camera's view (the world area it shows, its pixels per unit and conversions to and from the canvas in CSS pixels) is now computed in one place, computeCameraView/getCameraView, instead of being repeated by the render, terrain and UI systems and recomputed from pieces by game code. - The render and terrain systems project each camera from its view; createProjectionMatrix takes the world-space rect to show. - UI layout, safe area and pointer resolution use the canvas camera's view, so 'screenPixels' sizes follow the camera's zoom and a screen-space canvas fills its camera's view. - The render system skips sprites, nine-slice regions and glyphs whose quads are outside a camera's view. - calculateVisibleWorldSize, calculatePixelsPerUnit, screenToWorldSpace, worldToScreenSpace, canvasToWorldSpace and the unused CameraEcsComponent.scissorRect are removed; demos, docs, e2e scenes and /demo use the view. A render-target camera keeps the canvas's aspect, since the present pass stretches every target over the canvas; giving targets their own aspect belongs with render-target sizing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GCQ9KLWEmo3mFAR7W2gMS7 --- CHANGELOG.md | 15 + demo/src/game.ts | 14 +- documentation-site/docs/docs/ecs/game.md | 7 +- .../docs/docs/physics/forces.md | 35 +-- .../docs/rendering/world-units-and-cameras.md | 162 +++++++---- .../demos/brick-breaker/_create-background.ts | 11 +- .../pages/demos/brick-breaker/_create-game.ts | 9 +- .../easing-functions/_create-easing-rows.ts | 15 +- .../demos/easing-functions/_create-game.ts | 4 +- .../pages/demos/layout-groups/_create-game.ts | 17 +- .../linear-spring-damper/_create-game.ts | 9 +- .../_create-suspensions.ts | 15 +- .../moving-platform/_create-boundaries.ts | 15 +- .../demos/moving-platform/_create-game.ts | 46 +-- .../src/pages/demos/particles/_create-game.ts | 26 +- .../pages/demos/physics/_create-boundaries.ts | 15 +- .../src/pages/demos/physics/_create-game.ts | 35 +-- .../src/pages/demos/physics/_spawn-shapes.ts | 15 +- .../demos/prismatic-joint/_create-game.ts | 4 +- .../demos/prismatic-joint/_create-sliders.ts | 15 +- .../pages/demos/raycasting/_create-game.ts | 39 +-- .../pages/demos/raycasting/_create-targets.ts | 11 +- .../demos/revolute-joint/_create-game.ts | 4 +- .../demos/revolute-joint/_create-hinges.ts | 15 +- .../demos/space-shooter/_background.system.ts | 19 +- .../demos/space-shooter/_create-asteroids.ts | 16 +- .../demos/space-shooter/_create-background.ts | 10 +- .../pages/demos/space-shooter/_create-game.ts | 27 +- .../demos/space-shooter/_create-player.ts | 13 +- .../pages/demos/stress-test/_create-game.ts | 9 +- .../stress-test/_create-sprite-spawner.ts | 15 +- .../src/pages/demos/text/_create-game.ts | 14 +- .../src/pages/demos/torque/_create-game.ts | 14 +- .../pages/demos/ui-anchors/_create-game.ts | 13 +- .../src/pages/demos/ui-button/_create-game.ts | 17 +- .../demos/ui-canvas-group/_create-game.ts | 17 +- .../pages/demos/ui-dropdown/_create-game.ts | 17 +- .../pages/demos/ui-main-menu/_create-game.ts | 17 +- .../demos/ui-progress-bar/_create-game.ts | 17 +- .../src/pages/demos/ui-slider/_create-game.ts | 1 - .../demos/ui-stress-test/_create-game.ts | 17 +- .../src/pages/demos/ui-toggle/_create-game.ts | 17 +- .../ui-world-space-canvas/_create-game.ts | 15 +- e2e/fixtures/scenes/camera-pan-zoom.ts | 22 ++ e2e/fixtures/scenes/high-dpi-canvas.ts | 28 +- e2e/specs/camera-pan-zoom.spec.ts | 64 ++++- src/rendering/camera-view.test.ts | 250 ++++++++++++++++ src/rendering/camera-view.ts | 143 ++++++++++ src/rendering/components/camera-component.ts | 9 +- src/rendering/index.ts | 2 +- .../utils/create-projection-matrix.test.ts | 248 +++------------- .../shaders/utils/create-projection-matrix.ts | 43 +-- src/rendering/systems/render-system.test.ts | 269 ++++++++++++++++-- src/rendering/systems/render-system.ts | 57 +++- .../create-terrain-render-ecs-system.test.ts | 30 +- .../create-terrain-render-ecs-system.ts | 18 +- .../transforms/canvas-to-world-space.test.ts | 54 ---- .../transforms/canvas-to-world-space.ts | 13 - src/rendering/transforms/index.ts | 3 - .../transforms/screen-to-world-space.test.ts | 173 ----------- .../transforms/screen-to-world-space.ts | 43 --- .../transforms/world-to-screen-space.test.ts | 142 --------- .../transforms/world-to-screen-space.ts | 34 --- .../calculate-pixels-per-unit.test.ts | 36 --- .../utilities/calculate-pixels-per-unit.ts | 21 -- .../calculate-visible-world-size.test.ts | 42 --- .../utilities/calculate-visible-world-size.ts | 30 -- src/rendering/utilities/import-texture.ts | 2 +- src/rendering/utilities/index.ts | 2 - .../sprite-instance-data-segment.test.ts | 152 +++++++++- .../utilities/sprite-instance-data-segment.ts | 54 ++++ src/ui/systems/ui-layout-system.test.ts | 53 ++++ src/ui/systems/ui-layout-system.ts | 71 +++-- src/ui/systems/ui-safe-area-system.ts | 46 +-- src/ui/types/ui-axis.ts | 4 +- .../resolve-canvas-pointer-position.ts | 23 +- src/ui/utilities/resolve-rect.ts | 2 +- 77 files changed, 1619 insertions(+), 1402 deletions(-) create mode 100644 src/rendering/camera-view.test.ts create mode 100644 src/rendering/camera-view.ts delete mode 100644 src/rendering/transforms/canvas-to-world-space.test.ts delete mode 100644 src/rendering/transforms/canvas-to-world-space.ts delete mode 100644 src/rendering/transforms/index.ts delete mode 100644 src/rendering/transforms/screen-to-world-space.test.ts delete mode 100644 src/rendering/transforms/screen-to-world-space.ts delete mode 100644 src/rendering/transforms/world-to-screen-space.test.ts delete mode 100644 src/rendering/transforms/world-to-screen-space.ts delete mode 100644 src/rendering/utilities/calculate-pixels-per-unit.test.ts delete mode 100644 src/rendering/utilities/calculate-pixels-per-unit.ts delete mode 100644 src/rendering/utilities/calculate-visible-world-size.test.ts delete mode 100644 src/rendering/utilities/calculate-visible-world-size.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2f7e2ce..c5e7a66a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,21 @@ 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 + +- **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 #### Fixed 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..1f812b1cf 100644 --- a/src/ui/systems/ui-layout-system.test.ts +++ b/src/ui/systems/ui-layout-system.test.ts @@ -420,6 +420,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.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. From c314c5430f71d14127f775bb087d69301cf162a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 17:28:52 +0000 Subject: [PATCH 2/2] test(ui): cover canvas cameras with no position to take a view from Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GCQ9KLWEmo3mFAR7W2gMS7 --- src/ui/systems/ui-layout-system.test.ts | 23 ++++++++++++ src/ui/systems/ui-safe-area-system.test.ts | 41 ++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/ui/systems/ui-layout-system.test.ts b/src/ui/systems/ui-layout-system.test.ts index 1f812b1cf..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 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); + }); });