From 752b40670c94255339eefd736fd424d7abbbe248 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:01:33 -0400 Subject: [PATCH] feat-add-pixel-perfect-camera --- .claude/skills/jgengine-gameplay/api.md | 8 ++++ CHANGELOG.md | 1 + packages/core/src/game/cameraConfig.ts | 9 ++++ packages/core/src/game/pixelPerfect.test.ts | 26 ++++++++++ packages/core/src/game/pixelPerfect.ts | 53 +++++++++++++++++++++ packages/shell/src/Shell3dPresentation.tsx | 24 ++++++++++ packages/shell/src/camera/cameraRigs.tsx | 7 +++ scripts/export-manifest.json | 1 + 8 files changed, 129 insertions(+) create mode 100644 packages/core/src/game/pixelPerfect.test.ts create mode 100644 packages/core/src/game/pixelPerfect.ts diff --git a/.claude/skills/jgengine-gameplay/api.md b/.claude/skills/jgengine-gameplay/api.md index fa66395ba..234b142da 100644 --- a/.claude/skills/jgengine-gameplay/api.md +++ b/.claude/skills/jgengine-gameplay/api.md @@ -323,6 +323,7 @@ - `InspectionZoomAnchor` (type): type InspectionZoomAnchor = "target" | "cursor" | "center" — How scroll-zoom re-anchors the view for the inspection rig (#207.7): - `target` — dolly toward the orbit target (classic OrbitControls behavior). - `cursor` — dolly toward the point under the pointer. - `center` — dolly toward the viewport center; equivalent to `target` for an OrbitControls-driven rig, since the camera always faces `target` and that point already projects to the exact center of the viewport. - `LockOnCameraConfig` (interface): interface LockOnCameraConfig — Lock-on / strafe rig (#26) — yaw bound to player→target, move axis becomes strafe. - `ObserverCameraConfig` (interface): interface ObserverCameraConfig — Detached spectator/photo cam (#120) — binds to any entity or fixed point, never reads player input. +- `PixelPerfectCameraConfig` (interface): interface PixelPerfectCameraConfig — Pixel-art orthographic camera tuning. `pixelsPerUnit` is the authored pixel density. - `PlayerFovConfig` (interface): interface PlayerFovConfig — Player-facing FOV preference applied across every perspective camera rig. Orthographic projections ignore it. - `RtsCameraConfig` (interface): interface RtsCameraConfig extends TopDownCameraConfig — Free-pan / edge-scroll RTS rig (#24) — pan/rotate/zoom independent of any avatar. - `ShoulderCameraConfig` (interface): interface ShoulderCameraConfig — Over-the-shoulder combat rig (#25) — offset, ADS, shoulder swap, decoupled reticle. @@ -680,6 +681,13 @@ - `classifyPing` (function): function classifyPing(hit: PointerHit, deps: PingClassifyDeps = {}, options: PingClassifyOptions = {}): PingCategory — Classify what a pointer/aim ray hit into a ping category. Entity hits resolve by catalog role (hostile → enemy, else location); object hits by an optional catalog category tag; open ground is a location ping. - `createPingSystem` (function): function createPingSystem(deps: PingSystemDeps): PingSystem — Contextual ping/marker communication between teammates, classified by what was pinged. +## @jgengine/core/game/pixelPerfect + +- `PixelPerfectFrustum` (interface): interface PixelPerfectFrustum — The orthographic bounds and scale derived from a pixel-perfect camera config. +- `PixelPerfectViewport` (interface): interface PixelPerfectViewport — Viewport dimensions in device-independent canvas pixels. +- `pixelPerfectFrustum` (function): function pixelPerfectFrustum(viewport: PixelPerfectViewport, pixelsPerUnit: number, integerScale = false): PixelPerfectFrustum — Computes an orthographic frustum whose world units map to the requested pixel density. Integer scaling chooses the largest whole-number density multiplier available in the viewport. +- `snapPixelPerfectPosition` (function): function snapPixelPerfectPosition(position: T, pixelsPerUnit: number): T — Snaps a camera position to the authored pixel grid without changing its orientation. + ## @jgengine/core/game/playableGame - `AmbientLightingConfig` (interface): interface AmbientLightingConfig — ⚠ undocumented diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a241f18..d449f686d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ between (`--json` for structured output). - WS sessions now issue resume tickets and retain disconnected memberships for a 15-second grace window, allowing reconnects to rejoin without replacing player state. - Shell entity sprites can play atlas-backed sprite clips while preserving raw texture sprites. - Tilemap layers can render atlas-backed textured tiles as one instanced quad mesh with parallax. +- Core pixel-perfect orthographic frustum and pixel-grid camera snapping are available to shell cameras. - Core sprite atlas adapters and deterministic 2D sprite clip playback primitives. - `src/art-direction.md` scaffold and `check-art-direction` gate for created games. - Host-authoritative shell sessions now expose each accepted world frame to the local prediction buffer, reconciling and snapping the possessed pose only when drift exceeds its threshold. diff --git a/packages/core/src/game/cameraConfig.ts b/packages/core/src/game/cameraConfig.ts index efeed9e70..ed265c043 100644 --- a/packages/core/src/game/cameraConfig.ts +++ b/packages/core/src/game/cameraConfig.ts @@ -53,6 +53,13 @@ export type CameraRigKind = /** Canvas camera projection. "orthographic" renders a flat 2D-style view (side-scrollers, falling-block puzzles) — pair with `rig: "sideScroll"`; default "perspective". */ export type CameraProjection = "perspective" | "orthographic"; +/** Pixel-art orthographic camera tuning. `pixelsPerUnit` is the authored pixel density. */ +export interface PixelPerfectCameraConfig { + pixelsPerUnit: number; + /** Increase density in whole-number steps to use the largest scale that fits the viewport. */ + integerScale?: boolean; +} + /** Fixed lateral 2.5D follow (side-on platformer cam): the camera sits perpendicular to the travel axis, tracks the followed entity, and never reads player look input. */ export interface SideScrollCameraConfig { /** World axis the action travels along; the camera watches from the perpendicular side. Default "x". */ @@ -313,6 +320,8 @@ export interface GameCameraConfig { rig?: CameraRigKind; /** Canvas camera projection. "orthographic" renders a flat 2D-style view (side-scrollers, falling-block puzzles) — pair with `rig: "sideScroll"`; default perspective. */ projection?: CameraProjection; + /** Pixel-perfect orthographic rendering; ignored for perspective cameras. */ + pixelPerfect?: PixelPerfectCameraConfig; /** Render frustum overrides applied to the canvas camera. `far` defaults to 300 — raise it for worlds whose content spans more than a few hundred units, or distant scenery silently clips. `zoom` is the orthographic zoom in canvas pixels per world unit, read only when `projection` is "orthographic"; default 50. */ frustum?: { fov?: number; near?: number; far?: number; zoom?: number }; /** Universal player FOV preference (slider + persistence) for perspective rigs. Ignored when `projection` is `"orthographic"`. */ diff --git a/packages/core/src/game/pixelPerfect.test.ts b/packages/core/src/game/pixelPerfect.test.ts new file mode 100644 index 000000000..88f4c451d --- /dev/null +++ b/packages/core/src/game/pixelPerfect.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { pixelPerfectFrustum, snapPixelPerfectPosition } from "./pixelPerfect"; + +describe("pixelPerfectFrustum", () => { + test("maps viewport pixels to world units", () => { + expect(pixelPerfectFrustum({ width: 320, height: 180 }, 16)).toEqual({ + left: -10, + right: 10, + top: 5.625, + bottom: -5.625, + zoom: 16, + scale: 1, + }); + }); + + test("uses the largest integer density multiplier", () => { + const result = pixelPerfectFrustum({ width: 640, height: 360 }, 16, true); + expect(result.scale).toBe(22); + expect(result.zoom).toBe(352); + expect(result.right - result.left).toBeCloseTo(640 / 352); + }); +}); + +test("snapPixelPerfectPosition rounds every camera axis to the pixel grid", () => { + expect(snapPixelPerfectPosition({ x: 0.11, y: 1.24, z: -2.17 }, 10)).toEqual({ x: 0.1, y: 1.2, z: -2.2 }); +}); diff --git a/packages/core/src/game/pixelPerfect.ts b/packages/core/src/game/pixelPerfect.ts new file mode 100644 index 000000000..3217065d5 --- /dev/null +++ b/packages/core/src/game/pixelPerfect.ts @@ -0,0 +1,53 @@ +/** Viewport dimensions in device-independent canvas pixels. */ +export interface PixelPerfectViewport { + width: number; + height: number; +} + +/** The orthographic bounds and scale derived from a pixel-perfect camera config. */ +export interface PixelPerfectFrustum { + left: number; + right: number; + top: number; + bottom: number; + zoom: number; + scale: number; +} + +/** + * Computes an orthographic frustum whose world units map to the requested pixel density. + * Integer scaling chooses the largest whole-number density multiplier available in the viewport. + */ +export function pixelPerfectFrustum( + viewport: PixelPerfectViewport, + pixelsPerUnit: number, + integerScale = false, +): PixelPerfectFrustum { + const width = Math.max(1, viewport.width); + const height = Math.max(1, viewport.height); + const density = Math.max(1e-6, pixelsPerUnit); + const scale = integerScale ? Math.max(1, Math.floor(Math.min(width, height) / density)) : 1; + const zoom = density * scale; + return { + left: -width / (2 * zoom), + right: width / (2 * zoom), + top: height / (2 * zoom), + bottom: -height / (2 * zoom), + zoom, + scale, + }; +} + +/** Snaps a camera position to the authored pixel grid without changing its orientation. */ +export function snapPixelPerfectPosition( + position: T, + pixelsPerUnit: number, +): T { + const density = Math.max(1e-6, pixelsPerUnit); + return { + ...position, + x: Math.round(position.x * density) / density, + y: Math.round(position.y * density) / density, + z: Math.round(position.z * density) / density, + }; +} diff --git a/packages/shell/src/Shell3dPresentation.tsx b/packages/shell/src/Shell3dPresentation.tsx index aa07c5130..ea2d56537 100644 --- a/packages/shell/src/Shell3dPresentation.tsx +++ b/packages/shell/src/Shell3dPresentation.tsx @@ -11,6 +11,8 @@ import { type RefObject, type SetStateAction, } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import type { OrthographicCamera } from "three"; import { contextVerbInput, @@ -33,6 +35,7 @@ import type { SceneEntity } from "@jgengine/core/scene/entityStore"; import { DEFAULT_PICKUP_RADIUS } from "@jgengine/core/game/worldItem"; import type { PointerConfig } from "@jgengine/core/game/playableGame"; import { CAMERA_FRUSTUM_DEFAULTS } from "@jgengine/core/game/playableGame"; +import { pixelPerfectFrustum, snapPixelPerfectPosition } from "@jgengine/core/game/pixelPerfect"; import type { GameSettingsConfig } from "@jgengine/core/settings/settingsModel"; import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile"; import { @@ -100,6 +103,24 @@ import { const PRIMARY_CLICK_MOVE_THRESHOLD_PX = 6; const DEFAULT_BACKGROUND_COLOR = "#14161b"; +function PixelPerfectCamera({ pixelsPerUnit, integerScale }: { pixelsPerUnit: number; integerScale?: boolean }) { + const camera = useThree((state) => state.camera) as OrthographicCamera; + const size = useThree((state) => state.size); + useFrame(() => { + if (camera.isOrthographicCamera !== true) return; + const frustum = pixelPerfectFrustum(size, pixelsPerUnit, integerScale === true); + camera.left = frustum.left; + camera.right = frustum.right; + camera.top = frustum.top; + camera.bottom = frustum.bottom; + camera.zoom = 1; + const snapped = snapPixelPerfectPosition(camera.position, pixelsPerUnit); + camera.position.set(snapped.x, snapped.y, snapped.z); + camera.updateProjectionMatrix(); + }, 1); + return null; +} + /** 3D play surface: canvas, world overlays, and shared chrome. @internal */ export function Shell3dPresentation({ playable, @@ -498,6 +519,9 @@ export function Shell3dPresentation({ gl={{ preserveDrawingBuffer: true }} style={{ touchAction: "none" }} > + {orthographic && playable.camera?.pixelPerfect !== undefined ? ( + + ) : null} {backgroundColor !== undefined ? : null} {cinematicLook ? (