From 11202784c14bc508ace3af472d3c5a1d267e4609 Mon Sep 17 00:00:00 2001
From: Exoridus <1218727+Exoridus@users.noreply.github.com>
Date: Thu, 22 Oct 2026 11:43:11 +0200
Subject: [PATCH 1/2] fix(rendering): map pointer coordinates over the unzoomed
design space in screenToWorld
---
.../coordinates-and-views/split-views.ts | 5 +-
site/src/content/api/application.json | 2 +-
site/src/content/api/pointer.json | 4 +-
site/src/content/api/view.json | 10 +-
.../content/guide/input/mouse-and-pointer.mdx | 4 +-
.../guide/runtime/coordinates-and-views.mdx | 4 +-
src/core/Application.ts | 6 +-
src/input/InteractionSystem.ts | 8 +-
src/input/Pointer.ts | 2 +-
src/rendering/RenderingContext.ts | 9 ++
src/rendering/View.ts | 79 ++++++-----
test/core/application-lifecycle.test.ts | 35 ++++-
test/input/interaction.test.ts | 49 +++++++
test/rendering/rendering-context.test.ts | 19 +++
test/rendering/view.test.ts | 128 ++++++++++++++++++
15 files changed, 311 insertions(+), 53 deletions(-)
diff --git a/examples/guides/coordinates-and-views/split-views.ts b/examples/guides/coordinates-and-views/split-views.ts
index c6c60c000..04bda5b42 100644
--- a/examples/guides/coordinates-and-views/split-views.ts
+++ b/examples/guides/coordinates-and-views/split-views.ts
@@ -13,7 +13,8 @@ export const drawSplitWorld = (context: RenderingContext, world: RenderNode, lef
// #endregion guide:split-views
// #region guide:pointer-world
-import type { PointLike } from '@codexo/exojs';
+import type { Application, PointLike } from '@codexo/exojs';
-export const pointerInWorld = (view: View, pointer: PointLike): PointLike => view.screenToWorld(pointer.x, pointer.y);
+export const pointerInWorld = (app: Application, view: View, pointer: PointLike): PointLike =>
+ view.screenToWorld(pointer.x, pointer.y, app.width, app.height);
// #endregion guide:pointer-world
diff --git a/site/src/content/api/application.json b/site/src/content/api/application.json
index e41d8d566..68844f9ed 100644
--- a/site/src/content/api/application.json
+++ b/site/src/content/api/application.json
@@ -356,7 +356,7 @@
}
],
"returnType": "PointLike",
- "description": "Convert a logical/design-space pixel coordinate - the space of Pointer.x/Pointer.y and node positions, e.g. 0..app.width - to a world position using the active camera. At the default centered camera this is the identity; with a panned/zoomed/rotated camera it undoes the transform. Equivalent to app.rendering.view.screenToWorld(x, y)."
+ "description": "Convert a logical/design-space pixel coordinate - the space of Pointer.x/Pointer.y and node positions, e.g. 0..app.width - to a world position using the active camera. At the default centered camera this is the identity; with a panned/zoomed/rotated camera it undoes the transform, and it honours a camera whose viewport covers only part of the canvas. Equivalent to app.rendering.view.screenToWorld(x, y, app.width, app.height)."
},
{
"name": "setCursor",
diff --git a/site/src/content/api/pointer.json b/site/src/content/api/pointer.json
index 85e4eff49..1de9167bb 100644
--- a/site/src/content/api/pointer.json
+++ b/site/src/content/api/pointer.json
@@ -1,6 +1,6 @@
{
"title": "Pointer",
- "description": "Unified mouse / touch / pen pointer. Wraps a single browser `PointerEvent.pointerId` and writes its state (position, buttons, pressure, tilt, etc.) into the engine's shared channels buffer so it can be polled by Input bindings or read directly by interaction-aware scene nodes. Coordinates are stored in the application's logical space - `app.width` / `app.height` units (`0..app.width` × `0..app.height`), matching node positions, the 2-argument View.screenToWorld, and the active camera. The CSS-pixel event coordinates are mapped through the canvas's display rect, so the value is independent of Application.pixelRatio, of the render resolution, and of however the canvas is scaled for display: a click at its right edge always reads `app.width`, whichever sizing policy is in play. A point outside the canvas maps outside `0..app.width` accordingly. The channel writes are normalized to 0..1 (position, size, twist, tilt) for backend-agnostic sampling. Pointers are owned by the InputSystem, which assigns them a slot index in 0..15 (see maxPointers) and exposes their per-slot channel offsets via the Pointer class namespace constants.",
+ "description": "Unified mouse / touch / pen pointer. Wraps a single browser `PointerEvent.pointerId` and writes its state (position, buttons, pressure, tilt, etc.) into the engine's shared channels buffer so it can be polled by Input bindings or read directly by interaction-aware scene nodes. Coordinates are stored in the application's logical space - `app.width` / `app.height` units (`0..app.width` × `0..app.height`), matching node positions and Application.screenToWorld. The CSS-pixel event coordinates are mapped through the canvas's display rect, so the value is independent of Application.pixelRatio, of the render resolution, and of however the canvas is scaled for display: a click at its right edge always reads `app.width`, whichever sizing policy is in play. A point outside the canvas maps outside `0..app.width` accordingly. The channel writes are normalized to 0..1 (position, size, twist, tilt) for backend-agnostic sampling. Pointers are owned by the InputSystem, which assigns them a slot index in 0..15 (see maxPointers) and exposes their per-slot channel offsets via the Pointer class namespace constants.",
"symbol": "Pointer",
"kind": "class",
"subsystem": "input",
@@ -20,7 +20,7 @@
"members": [],
"paragraphs": [
"Unified mouse / touch / pen pointer. Wraps a single browser `PointerEvent.pointerId` and writes its state (position, buttons, pressure, tilt, etc.) into the engine's shared channels buffer so it can be polled by Input bindings or read directly by interaction-aware scene nodes.",
- "Coordinates are stored in the application's logical space - `app.width` / `app.height` units (`0..app.width` × `0..app.height`), matching node positions, the 2-argument View.screenToWorld, and the active camera. The CSS-pixel event coordinates are mapped through the canvas's display rect, so the value is independent of Application.pixelRatio, of the render resolution, and of however the canvas is scaled for display: a click at its right edge always reads `app.width`, whichever sizing policy is in play. A point outside the canvas maps outside `0..app.width` accordingly. The channel writes are normalized to 0..1 (position, size, twist, tilt) for backend-agnostic sampling.",
+ "Coordinates are stored in the application's logical space - `app.width` / `app.height` units (`0..app.width` × `0..app.height`), matching node positions and Application.screenToWorld. The CSS-pixel event coordinates are mapped through the canvas's display rect, so the value is independent of Application.pixelRatio, of the render resolution, and of however the canvas is scaled for display: a click at its right edge always reads `app.width`, whichever sizing policy is in play. A point outside the canvas maps outside `0..app.width` accordingly. The channel writes are normalized to 0..1 (position, size, twist, tilt) for backend-agnostic sampling.",
"Pointers are owned by the InputSystem, which assigns them a slot index in 0..15 (see maxPointers) and exposes their per-slot channel offsets via the Pointer class namespace constants."
],
"importLine": "import { Pointer } from '@codexo/exojs'",
diff --git a/site/src/content/api/view.json b/site/src/content/api/view.json
index 30098c8ff..832ef4d52 100644
--- a/site/src/content/api/view.json
+++ b/site/src/content/api/view.json
@@ -800,7 +800,7 @@
}
],
"returnType": "PointLike",
- "description": "Convert a logical/design-space pixel coordinate to a world-space position. The 2-argument form treats (x, y) as coordinates in the view's own design space (0..view.width × 0..view.height) - the same space as Pointer.x/Pointer.y and node positions - and applies only the inverse camera transform. The viewport rectangle is intentionally ignored, because design-space input already lives inside the camera's projection - the mapping from the surface to logical pixels happened upstream. At the default centered camera this is the identity."
+ "description": "Convert a logical/design-space pixel coordinate to a world-space position. The 2-argument form treats (x, y) as coordinates in the view's design space: the unzoomed size given to the constructor, resize or reset. Zooming, or writing size directly, changes how much world the view shows but not this space, so a full-surface view sized to the canvas keeps accepting Pointer.x/Pointer.y unchanged at every zoom level. The viewport rectangle is ignored; for a view that covers only part of the surface (split screen) or whose design size differs from the canvas, use the 4-argument form with the surface size. At the default centered camera this is the identity."
},
{
"name": "screenToWorld",
@@ -910,7 +910,7 @@
}
],
"returnType": "PointLike",
- "description": "Convert a raw canvas backing-store pixel coordinate to a world-space position. Accounts for the view's viewport rectangle, so it works correctly in multi-view / split-screen and letterboxed setups."
+ "description": "Convert a coordinate on the render surface to a world-space position. Accounts for the view's viewport rectangle, so it works correctly in multi-view / split-screen and letterboxed setups. The coordinate and the surface size only need to share a unit: pass app.width/app.height for pointer coordinates, or app.canvas.width/app.canvas.height for backing-store pixels."
},
{
"name": "setBounds",
@@ -1499,7 +1499,7 @@
}
],
"returnType": "PointLike",
- "description": "Convert a world-space position to logical/design-space pixel coordinates (0..view.width × 0..view.height). Inverse of the 2-argument screenToWorld; the viewport is ignored. At the default centered camera this is the identity."
+ "description": "Convert a world-space position to design-space pixel coordinates. Inverse of the 2-argument screenToWorld, with the same design space; the viewport is ignored. At the default centered camera this is the identity."
},
{
"name": "worldToScreen",
@@ -1609,7 +1609,7 @@
}
],
"returnType": "PointLike",
- "description": "Convert a world-space position to raw canvas backing-store pixel coordinates. Accounts for the view's viewport rectangle."
+ "description": "Convert a world-space position to a coordinate on the render surface. Inverse of the 4-argument screenToWorld; accounts for the view's viewport rectangle, and the result is in the unit of the surface size."
},
{
"name": "zoom",
@@ -1656,7 +1656,7 @@
}
],
"returnType": "this",
- "description": ""
+ "description": "Multiply the visible area by factor relative to the current zoom. A factor of 0.5 halves the visible area, like doubling zoomLevel."
},
{
"name": "zoomIn",
diff --git a/site/src/content/guide/input/mouse-and-pointer.mdx b/site/src/content/guide/input/mouse-and-pointer.mdx
index 786d0bac2..61402df76 100644
--- a/site/src/content/guide/input/mouse-and-pointer.mdx
+++ b/site/src/content/guide/input/mouse-and-pointer.mdx
@@ -130,9 +130,9 @@ const world = view.screenToWorld(pointer.x, pointer.y);
// world.x, world.y are now in scene world space
```
-The inverse is `worldToScreen(worldX, worldY)`, and the two round-trip. If the default centered camera is active (no pan/zoom), design space already equals world space, so `screenToWorld` is the identity and no mapping is needed.
+The inverse is `worldToScreen(worldX, worldY)`, and the two round-trip. The two-argument form maps over the view's design space — the size it was created or resized with — so zooming changes how much world is visible but not which pointer coordinates are accepted. If the default centered camera is active (no pan/zoom), design space already equals world space, so `screenToWorld` is the identity and no mapping is needed. `app.screenToWorld(x, y)` maps through the active camera.
-For raw canvas backing-store pixels — e.g. an off-screen render target at a different pixel ratio — the four-argument form `screenToWorld(x, y, canvasWidth, canvasHeight)` additionally accounts for the viewport rectangle and physical pixel size.
+For a view that covers only part of the canvas (split screen, minimap) or whose size differs from the canvas, use the four-argument form `screenToWorld(x, y, app.width, app.height)`: it accounts for the viewport rectangle. Backing-store pixels work the same way with `app.canvas.width` and `app.canvas.height`.
## Interactive sprites
diff --git a/site/src/content/guide/runtime/coordinates-and-views.mdx b/site/src/content/guide/runtime/coordinates-and-views.mdx
index 3f1222f85..e0b2fb2ba 100644
--- a/site/src/content/guide/runtime/coordinates-and-views.mdx
+++ b/site/src/content/guide/runtime/coordinates-and-views.mdx
@@ -35,11 +35,11 @@ For smoothing, keep a separate world-space camera target and move it toward the
## Convert input using the same view
-Pointer coordinates arrive in logical canvas units. Convert them through the view that rendered the world object:
+Pointer coordinates arrive in logical canvas units. Convert them through the view that rendered the world object, over the logical canvas size so the view's viewport is taken into account:
-In split-screen, first choose the viewport under the pointer, then use that viewport's view. Converting with the other player's camera can yield a plausible point in the wrong part of the world. If hit testing needs a child's local coordinates, apply the appropriate world-to-local conversion afterward.
+For a camera that covers the whole canvas, the two-argument `view.screenToWorld(x, y)` gives the same result at every zoom level; `app.screenToWorld(x, y)` does this for the active camera. In split-screen, first choose the viewport under the pointer, then use that viewport's view with the canvas size as above. Converting with the other player's camera can yield a plausible point in the wrong part of the world. If hit testing needs a child's local coordinates, apply the appropriate world-to-local conversion afterward.
Do not multiply pointer coordinates by DPR to repair a camera mismatch. The input system already supplies logical canvas coordinates; backing pixels are a rendering detail.
diff --git a/src/core/Application.ts b/src/core/Application.ts
index 31f855ccb..1e8828cfb 100644
--- a/src/core/Application.ts
+++ b/src/core/Application.ts
@@ -1122,10 +1122,12 @@ export class Application = {}> {
* {@link Pointer.x}/{@link Pointer.y} and node positions, e.g. `0..app.width`
* - to a world position using the active camera. At the default centered
* camera this is the identity; with a panned/zoomed/rotated camera it undoes
- * the transform. Equivalent to `app.rendering.view.screenToWorld(x, y)`.
+ * the transform, and it honours a camera whose viewport covers only part of
+ * the canvas. Equivalent to
+ * `app.rendering.view.screenToWorld(x, y, app.width, app.height)`.
*/
public screenToWorld(x: number, y: number): PointLike {
- return this._rendering.view.screenToWorld(x, y);
+ return this._rendering.view.screenToWorld(x, y, this.width, this.height);
}
/**
diff --git a/src/input/InteractionSystem.ts b/src/input/InteractionSystem.ts
index 8eab448bc..f9ebd7cfb 100644
--- a/src/input/InteractionSystem.ts
+++ b/src/input/InteractionSystem.ts
@@ -1633,7 +1633,7 @@ export class InteractionSystem {
const uiRoot = this._app.scenes.currentScene?._peekUI() ?? null;
if (uiRoot !== null) {
- const ui = this._app.rendering.screenView.screenToWorld(x, y);
+ const ui = this._designToLayerSpace(x, y, true);
const uiHit = this._hitTestNode(uiRoot, ui.x, ui.y);
if (uiHit !== null) {
@@ -1641,7 +1641,7 @@ export class InteractionSystem {
}
}
- const world = this._app.rendering.view.screenToWorld(x, y);
+ const world = this._designToLayerSpace(x, y, false);
return { node: this._hitTest(world.x, world.y), x: world.x, y: world.y };
}
@@ -1665,7 +1665,9 @@ export class InteractionSystem {
private _designToLayerSpace(x: number, y: number, ui: boolean): PointLike {
const view = ui ? this._app.rendering.screenView : this._app.rendering.view;
- return view.screenToWorld(x, y);
+ // Map over the logical surface rather than the view's own design space:
+ // the active camera may cover only part of the canvas (split screen).
+ return view.screenToWorld(x, y, this._app.width, this._app.height);
}
/** Whether `node` lives inside the active scene's UI layer. */
diff --git a/src/input/Pointer.ts b/src/input/Pointer.ts
index 6d1072721..d8441f605 100644
--- a/src/input/Pointer.ts
+++ b/src/input/Pointer.ts
@@ -115,7 +115,7 @@ export enum PointerState {
*
* Coordinates are stored in the application's logical space - `app.width` /
* `app.height` units (`0..app.width` × `0..app.height`), matching node
- * positions, the 2-argument {@link View.screenToWorld}, and the active camera.
+ * positions and {@link Application.screenToWorld}.
* The CSS-pixel event coordinates are mapped through the canvas's display rect,
* so the value is independent of {@link Application.pixelRatio}, of the render
* resolution, and of however the canvas is scaled for display: a click at its
diff --git a/src/rendering/RenderingContext.ts b/src/rendering/RenderingContext.ts
index 661297669..556a7ab1e 100644
--- a/src/rendering/RenderingContext.ts
+++ b/src/rendering/RenderingContext.ts
@@ -247,7 +247,16 @@ export class RenderingContext implements DrawContext {
* Preserves the camera's center and zoom; only the visible area size changes.
*/
public resize(width: number, height: number): void {
+ const zoom = this._view.zoomLevel;
+
+ // `View.resize` resets zoom along with the design space; reapply it so a
+ // zoomed camera keeps its apparent scale across canvas resizes.
this._view.resize(width, height);
+
+ if (zoom !== 1) {
+ this._view.setZoom(zoom);
+ }
+
this._screenView.resize(width, height);
this._screenView.setCenter(width / 2, height / 2);
}
diff --git a/src/rendering/View.ts b/src/rendering/View.ts
index ab200cd8e..a33664759 100644
--- a/src/rendering/View.ts
+++ b/src/rendering/View.ts
@@ -278,10 +278,12 @@ export class View {
return this;
}
+ /**
+ * Multiply the visible area by `factor` relative to the current zoom. A
+ * factor of `0.5` halves the visible area, like doubling {@link zoomLevel}.
+ */
public zoom(factor: number): this {
- this.resize(this._size.width * factor, this._size.height * factor);
-
- return this;
+ return this.setZoom(this._zoomLevel / factor);
}
/**
@@ -492,27 +494,32 @@ export class View {
/**
* Convert a logical/design-space pixel coordinate to a world-space position.
*
- * The 2-argument form treats `(x, y)` as coordinates in the view's own
- * design space (`0..view.width` × `0..view.height`) - the same space as
- * {@link Pointer.x}/{@link Pointer.y} and node positions - and applies only
- * the inverse camera transform. The viewport rectangle is intentionally
- * ignored, because design-space input already lives inside the camera's
- * projection - the mapping from the surface to logical pixels happened
- * upstream. At the default centered camera this is the identity.
+ * The 2-argument form treats `(x, y)` as coordinates in the view's design
+ * space: the unzoomed size given to the constructor, {@link resize} or
+ * {@link reset}. Zooming, or writing {@link size} directly, changes how much
+ * world the view shows but not this space, so a full-surface view sized to
+ * the canvas keeps accepting {@link Pointer.x}/{@link Pointer.y} unchanged
+ * at every zoom level. The viewport rectangle is ignored; for a view that
+ * covers only part of the surface (split screen) or whose design size
+ * differs from the canvas, use the 4-argument form with the surface size.
+ * At the default centered camera this is the identity.
*
- * @param x - X coordinate in design/logical pixels (`0..view.width`).
- * @param y - Y coordinate in design/logical pixels (`0..view.height`).
+ * @param x - X coordinate in design pixels.
+ * @param y - Y coordinate in design pixels.
*/
public screenToWorld(x: number, y: number): PointLike;
/**
- * Convert a raw canvas backing-store pixel coordinate to a world-space
- * position. Accounts for the view's viewport rectangle, so it works
- * correctly in multi-view / split-screen and letterboxed setups.
+ * Convert a coordinate on the render surface to a world-space position.
+ * Accounts for the view's viewport rectangle, so it works correctly in
+ * multi-view / split-screen and letterboxed setups. The coordinate and the
+ * surface size only need to share a unit: pass `app.width`/`app.height` for
+ * pointer coordinates, or `app.canvas.width`/`app.canvas.height` for
+ * backing-store pixels.
*
- * @param screenX - X pixel relative to the canvas top-left corner.
- * @param screenY - Y pixel relative to the canvas top-left corner.
- * @param canvasWidth - Canvas backing-store width in pixels (`app.canvas.width`).
- * @param canvasHeight - Canvas backing-store height in pixels (`app.canvas.height`).
+ * @param screenX - X coordinate relative to the surface top-left corner.
+ * @param screenY - Y coordinate relative to the surface top-left corner.
+ * @param canvasWidth - Surface width in the unit of `screenX`.
+ * @param canvasHeight - Surface height in the unit of `screenY`.
*/
public screenToWorld(screenX: number, screenY: number, canvasWidth: number, canvasHeight: number): PointLike;
public screenToWorld(screenX: number, screenY: number, canvasWidth?: number, canvasHeight?: number): PointLike {
@@ -520,8 +527,8 @@ export class View {
let clipY: number;
if (canvasWidth === undefined || canvasHeight === undefined) {
- const w = this.width || 1;
- const h = this.height || 1;
+ const w = this._designWidth();
+ const h = this._designHeight();
clipX = (screenX / w) * 2 - 1;
clipY = 1 - (screenY / h) * 2;
@@ -555,23 +562,23 @@ export class View {
}
/**
- * Convert a world-space position to logical/design-space pixel coordinates
- * (`0..view.width` × `0..view.height`). Inverse of the 2-argument
- * {@link screenToWorld}; the viewport is ignored. At the default centered
- * camera this is the identity.
+ * Convert a world-space position to design-space pixel coordinates. Inverse
+ * of the 2-argument {@link screenToWorld}, with the same design space; the
+ * viewport is ignored. At the default centered camera this is the identity.
*
* @param worldX - World X coordinate.
* @param worldY - World Y coordinate.
*/
public worldToScreen(worldX: number, worldY: number): PointLike;
/**
- * Convert a world-space position to raw canvas backing-store pixel
- * coordinates. Accounts for the view's viewport rectangle.
+ * Convert a world-space position to a coordinate on the render surface.
+ * Inverse of the 4-argument {@link screenToWorld}; accounts for the view's
+ * viewport rectangle, and the result is in the unit of the surface size.
*
* @param worldX - World X coordinate.
* @param worldY - World Y coordinate.
- * @param canvasWidth - Canvas backing-store width in pixels (`app.canvas.width`).
- * @param canvasHeight - Canvas backing-store height in pixels (`app.canvas.height`).
+ * @param canvasWidth - Surface width, e.g. `app.width` or `app.canvas.width`.
+ * @param canvasHeight - Surface height, e.g. `app.height` or `app.canvas.height`.
*/
public worldToScreen(worldX: number, worldY: number, canvasWidth: number, canvasHeight: number): PointLike;
public worldToScreen(worldX: number, worldY: number, canvasWidth?: number, canvasHeight?: number): PointLike {
@@ -581,8 +588,8 @@ export class View {
if (canvasWidth === undefined || canvasHeight === undefined) {
return {
- x: ((clipX + 1) / 2) * this.width,
- y: ((1 - clipY) / 2) * this.height,
+ x: ((clipX + 1) / 2) * this._designWidth(),
+ y: ((1 - clipY) / 2) * this._designHeight(),
};
}
@@ -679,6 +686,16 @@ export class View {
this._flags.destroy();
}
+ // A view constructed with a zero size and sized later only through `size`
+ // has no design space of its own; its visible area is the closest stand-in.
+ private _designWidth(): number {
+ return this._zoomBaseWidth || this.width || 1;
+ }
+
+ private _designHeight(): number {
+ return this._zoomBaseHeight || this.height || 1;
+ }
+
private _setDirty(): void {
this._flags.addMask(ViewFlags.TransformInverse | ViewFlags.BoundingBox);
this._updateId++;
diff --git a/test/core/application-lifecycle.test.ts b/test/core/application-lifecycle.test.ts
index 798b73258..c7642fd86 100644
--- a/test/core/application-lifecycle.test.ts
+++ b/test/core/application-lifecycle.test.ts
@@ -1256,7 +1256,7 @@ describe('Application lifecycle / getters / sizing', () => {
// -------------------------------------------------------------------------
describe('screenToWorld()', () => {
- test('delegates to rendering.view.screenToWorld with the same args and return value', async () => {
+ test('delegates to rendering.view.screenToWorld over the logical surface', async () => {
const { Application } = await loadHarness();
const app = new Application({ backend: { type: 'webgl2' } });
const fakePoint = { x: 42, y: 24 };
@@ -1264,9 +1264,40 @@ describe('Application lifecycle / getters / sizing', () => {
const result = app.screenToWorld(10, 20);
- expect(spy).toHaveBeenCalledWith(10, 20);
+ expect(spy).toHaveBeenCalledWith(10, 20, app.width, app.height);
expect(result).toBe(fakePoint);
});
+
+ test('maps a design point through a zoomed active camera', async () => {
+ const { Application } = await loadHarness();
+ const { View } = await import('#rendering/View');
+ const app = new Application({ backend: { type: 'webgl2' } });
+ const view = new View(300, 200, app.width, app.height).setZoom(2);
+
+ app.rendering.view = view;
+
+ const centre = app.screenToWorld(app.width / 2, app.height / 2);
+ const quarter = app.screenToWorld(app.width / 4, app.height / 4);
+
+ expect(centre.x).toBeCloseTo(view.center.x, 2);
+ expect(centre.y).toBeCloseTo(view.center.y, 2);
+ expect(quarter.x).toBeCloseTo(view.center.x - app.width / 8, 2);
+ expect(quarter.y).toBeCloseTo(view.center.y - app.height / 8, 2);
+ });
+
+ test('maps a design point through a split-screen active camera', async () => {
+ const { Application } = await loadHarness();
+ const { View } = await import('#rendering/View');
+ const app = new Application({ backend: { type: 'webgl2' } });
+ const view = new View(1000, 0, app.width / 2, app.height).setViewport(0.5, 0, 0.5, 1);
+
+ app.rendering.view = view;
+
+ const centre = app.screenToWorld(app.width * 0.75, app.height / 2);
+
+ expect(centre.x).toBeCloseTo(1000, 2);
+ expect(centre.y).toBeCloseTo(0, 2);
+ });
});
// -------------------------------------------------------------------------
diff --git a/test/input/interaction.test.ts b/test/input/interaction.test.ts
index 677a5ffb4..122982143 100644
--- a/test/input/interaction.test.ts
+++ b/test/input/interaction.test.ts
@@ -14,6 +14,7 @@ import { BrowserPlatform } from '#platform/BrowserPlatform';
import { Container } from '#rendering/Container';
import { Drawable } from '#rendering/Drawable';
import type { Geometry } from '#rendering/geometry/Geometry';
+import { View } from '#rendering/View';
import { frameDelta } from '../support/frame-delta';
@@ -316,6 +317,54 @@ describe('InteractionSystem — hit-test basics', () => {
// 2. Z-order - top child wins
// ---------------------------------------------------------------------------
+describe('InteractionSystem — active camera mapping', () => {
+ const hitsAt = (
+ app: Application,
+ scene: Scene,
+ signals: MockSignals,
+ bounds: [number, number, number, number],
+ pointer: { x: number; y: number },
+ ): boolean => {
+ const im = new InteractionSystem(app);
+ im.attachRoot(scene.root);
+ const sprite = new TestSprite().setBounds(...bounds);
+
+ sprite.interactive = true;
+ scene.addChild(sprite);
+
+ const handler = vi.fn();
+
+ sprite.onPointerDown.add(handler);
+ dispatchPointer(signals.onPointerDown, pointer);
+ flushInteractions(im);
+
+ im.destroy();
+ sprite.destroy();
+
+ return handler.mock.calls.length === 1;
+ };
+
+ test('hits a world node under the pointer through a zoomed camera', () => {
+ const { app, scene, signals } = createApp();
+ const view = new View(400, 300, 800, 600).setZoom(2);
+
+ (app.rendering as { view: View }).view = view;
+
+ // Design (200, 150) shows world (300, 225) at zoom 2.
+ expect(hitsAt(app, scene, signals, [290, 215, 20, 20], { x: 200, y: 150 })).toBe(true);
+ });
+
+ test('hits a world node through a split-screen active camera viewport', () => {
+ const { app, scene, signals } = createApp();
+ const view = new View(1000, 0, 400, 600).setViewport(0.5, 0, 0.5, 1).setZoom(2);
+
+ (app.rendering as { view: View }).view = view;
+
+ // The centre of the right half shows the camera centre (1000, 0).
+ expect(hitsAt(app, scene, signals, [990, -10, 20, 20], { x: 600, y: 300 })).toBe(true);
+ });
+});
+
describe('InteractionSystem — z-order', () => {
test('top child (added last) receives hit over bottom child at same position', () => {
const { app, scene, signals } = createApp();
diff --git a/test/rendering/rendering-context.test.ts b/test/rendering/rendering-context.test.ts
index 5f0881253..b86600b12 100644
--- a/test/rendering/rendering-context.test.ts
+++ b/test/rendering/rendering-context.test.ts
@@ -183,6 +183,25 @@ describe('RenderingContext', () => {
expect(drawEvents[1]).toBe(green);
});
+ test('resize keeps the camera zoom and maps the new design space', () => {
+ const { backend } = createMockBackend();
+ const context = new RenderingContext(backend);
+ const view = new View(500, 400, 320, 200).setZoom(2);
+
+ context.view = view;
+ context.resize(640, 400);
+
+ expect(view.zoomLevel).toBe(2);
+ expect(view.width).toBeCloseTo(320);
+ expect(view.height).toBeCloseTo(200);
+ expect(view.center.x).toBe(500);
+
+ const corner = view.screenToWorld(0, 0);
+
+ expect(corner.x).toBeCloseTo(340, 2);
+ expect(corner.y).toBeCloseTo(300, 2);
+ });
+
test('stats exposes backend stats', () => {
const { backend } = createMockBackend();
const context = new RenderingContext(backend);
diff --git a/test/rendering/view.test.ts b/test/rendering/view.test.ts
index dc16de7a3..96a674abc 100644
--- a/test/rendering/view.test.ts
+++ b/test/rendering/view.test.ts
@@ -254,6 +254,134 @@ describe('View — coordinate conversion', () => {
});
});
+describe('View — coordinate conversion under zoom', () => {
+ // Expected values are derived from the camera geometry, never from the
+ // inverse function: a zoom of `z` shows `referenceSize / z` world units
+ // centred on the camera, stretched across the whole design space.
+
+ const expectPoint = (actual: { x: number; y: number }, x: number, y: number): void => {
+ expect(actual.x).toBeCloseTo(x, 2);
+ expect(actual.y).toBeCloseTo(y, 2);
+ };
+
+ test('2-arg screenToWorld maps the design space across the zoomed visible area', () => {
+ const view = new View(400, 300, 800, 600);
+
+ view.setZoom(2);
+
+ expectPoint(view.screenToWorld(400, 300), 400, 300);
+ expectPoint(view.screenToWorld(200, 150), 300, 225);
+ expectPoint(view.screenToWorld(0, 0), 200, 150);
+ expectPoint(view.screenToWorld(800, 600), 600, 450);
+ });
+
+ test('2-arg screenToWorld zooms out below 1', () => {
+ const view = new View(400, 300, 800, 600);
+
+ view.setZoom(0.5);
+
+ expectPoint(view.screenToWorld(400, 300), 400, 300);
+ expectPoint(view.screenToWorld(200, 150), 0, 0);
+ expectPoint(view.screenToWorld(0, 0), -400, -300);
+ });
+
+ test('2-arg screenToWorld combines zoom with a panned, non-square camera', () => {
+ const view = new View(1000, 500, 1280, 720);
+
+ view.setZoom(4);
+
+ // Visible area 320×180 centred on (1000, 500).
+ expectPoint(view.screenToWorld(0, 0), 840, 410);
+ expectPoint(view.screenToWorld(1280, 720), 1160, 590);
+ expectPoint(view.screenToWorld(640, 360), 1000, 500);
+ });
+
+ test('2-arg screenToWorld scales distances by 1 / zoom under rotation', () => {
+ const view = new View(50, -20, 1280, 720);
+
+ view.setZoom(4);
+ view.setRotation(90);
+
+ const world = view.screenToWorld(640 + 100, 360);
+
+ expect(Math.hypot(world.x - 50, world.y + 20)).toBeCloseTo(25, 2);
+ });
+
+ test('2-arg worldToScreen places world points where the projection renders them', () => {
+ const view = new View(300, 200, 800, 600);
+
+ view.setZoom(2.5);
+ view.setRotation(30);
+
+ const m = view.getTransform();
+
+ for (const [x, y] of [
+ [300, 200],
+ [250, 180],
+ [410, 260],
+ ]) {
+ // The backend maps clip space onto the full 800×600 design surface.
+ const clipX = m.a * x + m.b * y + m.x;
+ const clipY = m.c * x + m.d * y + m.y;
+ const pixelX = ((clipX + 1) / 2) * 800;
+ const pixelY = ((1 - clipY) / 2) * 600;
+
+ expectPoint(view.worldToScreen(x, y), pixelX, pixelY);
+ expectPoint(view.screenToWorld(pixelX, pixelY), x, y);
+ }
+ });
+
+ test('zoom() keeps the design space and tracks the zoom level', () => {
+ const view = new View(400, 300, 800, 600);
+
+ view.zoom(0.5);
+
+ expect(view.width).toBeCloseTo(400);
+ expect(view.zoomLevel).toBeCloseTo(2);
+ expectPoint(view.screenToWorld(200, 150), 300, 225);
+ });
+
+ test('writing the size directly narrows the visible area without moving the design space', () => {
+ const view = new View(400, 300, 800, 600);
+
+ view.size.set(400, 300);
+
+ expectPoint(view.screenToWorld(200, 150), 300, 225);
+ expectPoint(view.worldToScreen(300, 225), 200, 150);
+ });
+
+ test('resize() replaces the design space and resets zoom', () => {
+ const view = new View(400, 300, 800, 600);
+
+ view.setZoom(2);
+ view.resize(1600, 1200);
+
+ expect(view.zoomLevel).toBe(1);
+ expectPoint(view.screenToWorld(0, 0), -400, -300);
+ expectPoint(view.screenToWorld(1600, 1200), 1200, 900);
+ });
+
+ test('4-arg screenToWorld agrees with the 2-arg form for a full-surface view', () => {
+ const view = new View(400, 300, 800, 600);
+
+ view.setZoom(2);
+
+ expectPoint(view.screenToWorld(200, 150, 800, 600), 300, 225);
+ expectPoint(view.screenToWorld(400, 300, 1600, 1200), 300, 225);
+ });
+
+ test('4-arg screenToWorld resolves a pointer inside a split-screen viewport', () => {
+ const right = new View(1000, 0, 400, 600).setViewport(0.5, 0, 0.5, 1);
+
+ right.setZoom(2);
+
+ // The right half of an 800×600 surface shows 200×300 world units around (1000, 0).
+ expectPoint(right.screenToWorld(600, 300, 800, 600), 1000, 0);
+ expectPoint(right.screenToWorld(400, 0, 800, 600), 900, -150);
+ expectPoint(right.worldToScreen(900, -150, 800, 600), 400, 0);
+ });
+});
+
describe('View.destroy', () => {
test('reports destroyed only after destroy has run', () => {
const view = new View(0, 0, 100, 100);
From 36e5d1e1ab28393c3324a944bca81aba1a32b145 Mon Sep 17 00:00:00 2001
From: Exoridus <1218727+Exoridus@users.noreply.github.com>
Date: Thu, 22 Oct 2026 12:11:02 +0200
Subject: [PATCH 2/2] fix(rendering): keep zoom() relative to the current size
and guard a zero-extent viewport
---
src/rendering/View.ts | 9 ++++-
.../view-rotated-cull-bounds.test.ts | 15 +++++----
test/rendering/view-rotation-pivot.test.ts | 4 +--
test/rendering/view.test.ts | 33 +++++++++++++++++++
4 files changed, 51 insertions(+), 10 deletions(-)
diff --git a/src/rendering/View.ts b/src/rendering/View.ts
index a33664759..5cef4dcaa 100644
--- a/src/rendering/View.ts
+++ b/src/rendering/View.ts
@@ -283,7 +283,10 @@ export class View {
* factor of `0.5` halves the visible area, like doubling {@link zoomLevel}.
*/
public zoom(factor: number): this {
- return this.setZoom(this._zoomLevel / factor);
+ this._zoomLevel = Math.max(0.0001, this._zoomLevel / factor);
+ this._size.set(this._size.width * factor, this._size.height * factor);
+
+ return this;
}
/**
@@ -536,6 +539,10 @@ export class View {
const vw = this._viewport.width * canvasWidth;
const vh = this._viewport.height * canvasHeight;
+ if (vw === 0 || vh === 0) {
+ return { x: 0, y: 0 };
+ }
+
clipX = ((screenX - this._viewport.x * canvasWidth) / vw) * 2 - 1;
clipY = 1 - ((screenY - this._viewport.y * canvasHeight) / vh) * 2;
}
diff --git a/test/rendering/view-rotated-cull-bounds.test.ts b/test/rendering/view-rotated-cull-bounds.test.ts
index d6d579e49..0e3fa02e3 100644
--- a/test/rendering/view-rotated-cull-bounds.test.ts
+++ b/test/rendering/view-rotated-cull-bounds.test.ts
@@ -2,16 +2,17 @@ import { View } from '#rendering/View';
/**
* The world-space AABB of the four viewport corners, mapped through the view's
- * own `screenToWorld`. This is by definition the area the camera renders, so
- * the cull bounds must cover exactly it - whatever rotation, zoom or centre is
- * in play, and independent of how the projection composes them.
+ * own `screenToWorld` over a unit surface. This is by definition the area the
+ * camera renders, so the cull bounds must cover exactly it - whatever rotation,
+ * zoom or centre is in play, and independent of how the projection composes
+ * them.
*/
const visibleAreaFromCorners = (view: View): { left: number; top: number; right: number; bottom: number } => {
const corners = [
- view.screenToWorld(0, 0),
- view.screenToWorld(view.width, 0),
- view.screenToWorld(view.width, view.height),
- view.screenToWorld(0, view.height),
+ view.screenToWorld(0, 0, 1, 1),
+ view.screenToWorld(1, 0, 1, 1),
+ view.screenToWorld(1, 1, 1, 1),
+ view.screenToWorld(0, 1, 1, 1),
];
const xs = corners.map(corner => corner.x);
const ys = corners.map(corner => corner.y);
diff --git a/test/rendering/view-rotation-pivot.test.ts b/test/rendering/view-rotation-pivot.test.ts
index d3c184f21..596bb6a64 100644
--- a/test/rendering/view-rotation-pivot.test.ts
+++ b/test/rendering/view-rotation-pivot.test.ts
@@ -62,8 +62,8 @@ describe('View rotation pivots around the camera center', () => {
view.setRotation(37);
view.setZoom(2.5);
- // Zoom shrinks the design space, so the middle of the viewport moves with it.
- const world = view.screenToWorld(view.width / 2, view.height / 2);
+ // Zoom changes the visible area, not the design space the pointer lives in.
+ const world = view.screenToWorld(WIDTH / 2, HEIGHT / 2);
expect(world.x).toBeCloseTo(-250, 6);
expect(world.y).toBeCloseTo(480, 6);
diff --git a/test/rendering/view.test.ts b/test/rendering/view.test.ts
index 96a674abc..f1b1841c4 100644
--- a/test/rendering/view.test.ts
+++ b/test/rendering/view.test.ts
@@ -350,6 +350,39 @@ describe('View — coordinate conversion under zoom', () => {
expectPoint(view.worldToScreen(300, 225), 200, 150);
});
+ test('zoom() scales a directly written size and keeps the design space', () => {
+ const view = new View(400, 300, 800, 600);
+
+ view.size.set(400, 300);
+ view.zoom(0.5);
+
+ expect(view.width).toBeCloseTo(200);
+ expectPoint(view.screenToWorld(0, 0), 300, 225);
+ });
+
+ test('View.from maps the design space of its size at the requested zoom', () => {
+ const view = View.from({ center: { x: 400, y: 300 }, size: { width: 800, height: 600 }, zoom: 2 });
+
+ expectPoint(view.screenToWorld(200, 150), 300, 225);
+ });
+
+ test('a view constructed without a size maps over the size written later', () => {
+ const view = new View(400, 300, 0, 0);
+
+ view.size.set(800, 600);
+
+ expectPoint(view.screenToWorld(0, 0), 0, 0);
+ expectPoint(view.worldToScreen(800, 600), 800, 600);
+ });
+
+ test('4-arg screenToWorld returns a finite point for a zero-extent viewport', () => {
+ const view = new View(400, 300, 800, 600).setViewport(0, 0, 0, 0);
+ const world = view.screenToWorld(10, 10, 800, 600);
+
+ expect(Number.isFinite(world.x)).toBe(true);
+ expect(Number.isFinite(world.y)).toBe(true);
+ });
+
test('resize() replaces the design space and resets zoom', () => {
const view = new View(400, 300, 800, 600);