From 95b86f661167a3fee7e93ea33dbca7e3d7e76b3b Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Sat, 24 Oct 2026 23:32:42 +0100 Subject: [PATCH 1/2] feat: add maxPixelRatio prop to Application Add a `maxPixelRatio` prop to `Application` (and `ApplicationWithoutCanvas`) that sets `app.graphicsDevice.maxPixelRatio`. The engine caps the ratio at 1 by default, so apps render at CSS resolution and look soft on high-DPI phones. Raising it from a component meant mutating `useApp().graphicsDevice` in an effect, which the `react-hooks/immutability` lint rule rejects. Like `fillMode` and `resolutionMode`, the prop is applied in its own effect once the app exists, and re-applied when it changes. That effect runs before the fill and resolution one, so the canvas resize it triggers already uses the new ratio. When the prop is omitted the engine default is left alone, and removing the prop restores it. Values must be finite numbers greater than 0; anything else warns and keeps the default. Once this ships, the `web-lens` starter in create-playcanvas can drop its `useEffect` workaround (behind `eslint-disable-next-line react-hooks/immutability`) and use the prop instead, for example ``. Co-Authored-By: Claude Opus 5.5 --- .changeset/application-max-pixel-ratio.md | 5 ++ packages/lib/src/Application.test.tsx | 92 ++++++++++++++++++++++- packages/lib/src/Application.tsx | 38 ++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 .changeset/application-max-pixel-ratio.md diff --git a/.changeset/application-max-pixel-ratio.md b/.changeset/application-max-pixel-ratio.md new file mode 100644 index 00000000..431d6398 --- /dev/null +++ b/.changeset/application-max-pixel-ratio.md @@ -0,0 +1,5 @@ +--- +"@playcanvas/react": minor +--- + +Add a `maxPixelRatio` prop to `Application` and `ApplicationWithoutCanvas`, which sets `app.graphicsDevice.maxPixelRatio` so apps can render sharply on high-DPI screens, for example ``. The prop is re-applied when it changes, and when it is omitted the engine's default is kept. diff --git a/packages/lib/src/Application.test.tsx b/packages/lib/src/Application.test.tsx index 2e354657..2945037c 100644 --- a/packages/lib/src/Application.test.tsx +++ b/packages/lib/src/Application.test.tsx @@ -1,10 +1,21 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { Application as PlayCanvasApplication } from 'playcanvas'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import '@testing-library/jest-dom'; // eslint-disable-next-line import-x/order -- preserve the existing side-effect import order -import React from 'react'; +import React, { useEffect } from 'react'; import { Application, ApplicationWithoutCanvas } from './Application.tsx'; +import { useApp } from './hooks/use-app.tsx'; + +// Hands the PlayCanvas app to the test once the Application has created it +const AppProbe = ({ onApp }: { onApp: (app: PlayCanvasApplication) => void }) => { + const app = useApp(); + useEffect(() => { + onApp(app); + }, [app, onApp]); + return null; +}; describe('Application', () => { beforeEach(() => { @@ -53,6 +64,83 @@ describe('Application', () => { } }); + describe('maxPixelRatio', () => { + // The Null graphics device used in tests defaults to a ratio of 1 + const DEFAULT_RATIO = 1; + + // Renders an Application and waits for its app to exist. Resolves with the app's graphics + // device and a function that re-renders the same Application with a new maxPixelRatio. + const renderApplication = async (maxPixelRatio?: number) => { + const onApp = vi.fn(); + const tree = (ratio?: number) => ( + + + + ); + + const { rerender } = render(tree(maxPixelRatio)); + await waitFor(() => expect(onApp).toHaveBeenCalled()); + + const app: PlayCanvasApplication = onApp.mock.calls[0][0]; + return { + device: app.graphicsDevice, + setMaxPixelRatio: (ratio?: number) => rerender(tree(ratio)) + }; + }; + + it('sets the graphics device maxPixelRatio', async () => { + const { device } = await renderApplication(2); + + expect(device.maxPixelRatio).toBe(2); + }); + + it('updates the graphics device maxPixelRatio when the prop changes', async () => { + const { device, setMaxPixelRatio } = await renderApplication(2); + + setMaxPixelRatio(1.5); + + expect(device.maxPixelRatio).toBe(1.5); + }); + + it('keeps the graphics device default when maxPixelRatio is omitted', async () => { + const { device } = await renderApplication(); + + expect(device.maxPixelRatio).toBe(DEFAULT_RATIO); + }); + + it('restores the graphics device default when maxPixelRatio is removed', async () => { + const { device, setMaxPixelRatio } = await renderApplication(2); + + setMaxPixelRatio(undefined); + + expect(device.maxPixelRatio).toBe(DEFAULT_RATIO); + }); + + it.each([0, -1, NaN, Infinity])( + 'warns and keeps the graphics device default when maxPixelRatio is %s', + async (ratio) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const { device } = await renderApplication(ratio); + + await vi.waitFor(() => + expect(warnSpy).toHaveBeenCalledWith( + '%c[PlayCanvas React]:', + expect.any(String), // the CSS string + expect.stringContaining( + `maxPixelRatio must be a finite number greater than 0. Received: ${ratio}` + ) + ) + ); + expect(device.maxPixelRatio).toBe(DEFAULT_RATIO); + } finally { + warnSpy.mockRestore(); + } + } + ); + }); + it.skip('The Application component applies canvasRef prop correctly', () => { const canvasRef = { current: document.createElement('canvas') }; canvasRef.current.setAttribute('aria-label', 'test-canvas'); diff --git a/packages/lib/src/Application.tsx b/packages/lib/src/Application.tsx index a499c732..1f5bc789 100644 --- a/packages/lib/src/Application.tsx +++ b/packages/lib/src/Application.tsx @@ -102,6 +102,7 @@ export const ApplicationWithoutCanvas: FC = (prop canvasRef, fillMode = FILLMODE_NONE, resolutionMode = RESOLUTION_AUTO, + maxPixelRatio, usePhysics = false, graphicsDeviceOptions, deviceTypes = [DEVICETYPE_WEBGL2], @@ -182,6 +183,21 @@ export const ApplicationWithoutCanvas: FC = (prop }; }, [graphicsOptsKey, deviceTypeKey]); // ← stable deps + /* eslint-disable react-hooks/immutability, react-compiler/react-compiler -- this effect intentionally mutates the engine's graphics device */ + // Applied before the fill and resolution modes below, so the canvas resize they trigger uses this ratio + useEffect(() => { + if (!app || maxPixelRatio === undefined) return; + const device = app.graphicsDevice; + const defaultRatio = device.maxPixelRatio; + device.maxPixelRatio = maxPixelRatio; + + // Hand back the device's own ratio, so removing the prop restores the engine default + return () => { + device.maxPixelRatio = defaultRatio; + }; + }, [app, maxPixelRatio]); + /* eslint-enable react-hooks/immutability, react-compiler/react-compiler */ + // Separate useEffect for these props to avoid re-rendering useEffect(() => { if (!app) return; @@ -242,6 +258,23 @@ interface ApplicationProps */ resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED; + /** + * The maximum pixel ratio to render at, which sets `app.graphicsDevice.maxPixelRatio`. The canvas + * renders at the smaller of this value and `window.devicePixelRatio`, so a value above 1 keeps the + * scene sharp on high-DPI screens, at the cost of rendering more pixels. Keep it at or below + * `window.devicePixelRatio`, as in the example: the engine also reads it back as the pixel ratio in + * effect, such as when mapping pointer positions for UI element drags. + * + * When omitted, the graphics device keeps its own default, which caps rendering at CSS resolution + * and can look soft on high-DPI screens. + * + * @example + * + * + * This renders at the screen's full resolution, up to twice the CSS resolution. + */ + maxPixelRatio?: number; + /** * When `true`, the PlayCanvas Physics system will be enabled. * @default false @@ -339,6 +372,11 @@ componentDefinition.schema = { errorMsg: () => `"resolutionMode" must be one of: ${RESOLUTION_AUTO}, ${RESOLUTION_FIXED}`, default: RESOLUTION_AUTO }, + maxPixelRatio: { + validate: (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value > 0, + errorMsg: (value: unknown) => `maxPixelRatio must be a finite number greater than 0. Received: ${value}`, + default: undefined + }, graphicsDeviceOptions: { validate: (value: unknown) => value === undefined || (typeof value === 'object' && value !== null), errorMsg: (value: unknown) => `graphicsDeviceOptions must be an object. Received: ${value}`, From a50757a0e924ed86a7ddaecc78cdd8d7b5b47fe5 Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Tue, 27 Oct 2026 15:19:46 +0000 Subject: [PATCH 2/2] feat: clamp maxPixelRatio to the display and keep it applied Address review on #361: - Hand the graphics device the ratio in effect, the smaller of the prop and `window.devicePixelRatio`, rather than the raw cap. The picker divides by `maxPixelRatio`, so a cap above the display's ratio offset picks. With the clamp, `Infinity` is accepted and renders at the display's full resolution. - Apply the ratio again on `devicerestored`, since WebGPU resets it to 1 when it restores a lost device. WebGPU fires that event from engine 2.19. - Follow changes to the display's ratio with a `matchMedia` resolution query, as `` does, so moving the window to another monitor updates it. - Make the doc example SSR-safe: `maxPixelRatio={2}`. - Move the device logic into `utils/max-pixel-ratio.ts`. The effect no longer needs an eslint-disable. - Test the canvas size, the clamp, the restore and the display change. Co-Authored-By: Claude Opus 5.5 --- .changeset/application-max-pixel-ratio.md | 2 +- packages/lib/src/Application.test.tsx | 122 ++++++++++++++++++---- packages/lib/src/Application.tsx | 31 +++--- packages/lib/src/utils/max-pixel-ratio.ts | 44 ++++++++ 4 files changed, 156 insertions(+), 43 deletions(-) create mode 100644 packages/lib/src/utils/max-pixel-ratio.ts diff --git a/.changeset/application-max-pixel-ratio.md b/.changeset/application-max-pixel-ratio.md index 431d6398..7dfc8228 100644 --- a/.changeset/application-max-pixel-ratio.md +++ b/.changeset/application-max-pixel-ratio.md @@ -2,4 +2,4 @@ "@playcanvas/react": minor --- -Add a `maxPixelRatio` prop to `Application` and `ApplicationWithoutCanvas`, which sets `app.graphicsDevice.maxPixelRatio` so apps can render sharply on high-DPI screens, for example ``. The prop is re-applied when it changes, and when it is omitted the engine's default is kept. +Add a `maxPixelRatio` prop to `Application` and `ApplicationWithoutCanvas`, so apps can render sharply on high-DPI screens, for example ``. The canvas renders at the smaller of this value and the display's pixel ratio, which is followed as it changes. When the prop is omitted, the engine's default is kept. diff --git a/packages/lib/src/Application.test.tsx b/packages/lib/src/Application.test.tsx index 2945037c..084c000f 100644 --- a/packages/lib/src/Application.test.tsx +++ b/packages/lib/src/Application.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import type { Application as PlayCanvasApplication } from 'playcanvas'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import '@testing-library/jest-dom'; // eslint-disable-next-line import-x/order -- preserve the existing side-effect import order import React, { useEffect } from 'react'; @@ -68,6 +68,30 @@ describe('Application', () => { // The Null graphics device used in tests defaults to a ratio of 1 const DEFAULT_RATIO = 1; + beforeEach(() => { + // Render on a 3x display + vi.stubGlobal('devicePixelRatio', 3); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // Stands in for matchMedia, and keeps each query's change listeners so a test can fire them + const stubMatchMedia = () => { + const queries: { media: string; listeners: Set<() => void> }[] = []; + vi.stubGlobal('matchMedia', (media: string) => { + const listeners = new Set<() => void>(); + queries.push({ media, listeners }); + return { + media, + addEventListener: (_type: string, listener: () => void) => listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener) + }; + }); + return queries; + }; + // Renders an Application and waits for its app to exist. Resolves with the app's graphics // device and a function that re-renders the same Application with a new maxPixelRatio. const renderApplication = async (maxPixelRatio?: number) => { @@ -94,6 +118,29 @@ describe('Application', () => { expect(device.maxPixelRatio).toBe(2); }); + it.each([4, Infinity])('caps maxPixelRatio %s at the display pixel ratio', async (ratio) => { + const { device } = await renderApplication(ratio); + + expect(device.maxPixelRatio).toBe(3); + }); + + it.each([ + [2, 400, 200], + [4, 600, 300] + ])('sizes the canvas for maxPixelRatio %s', async (ratio, width, height) => { + const clientWidth = vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(200); + const clientHeight = vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(100); + + try { + const { device } = await renderApplication(ratio); + + expect([device.canvas.width, device.canvas.height]).toEqual([width, height]); + } finally { + clientWidth.mockRestore(); + clientHeight.mockRestore(); + } + }); + it('updates the graphics device maxPixelRatio when the prop changes', async () => { const { device, setMaxPixelRatio } = await renderApplication(2); @@ -116,29 +163,58 @@ describe('Application', () => { expect(device.maxPixelRatio).toBe(DEFAULT_RATIO); }); - it.each([0, -1, NaN, Infinity])( - 'warns and keeps the graphics device default when maxPixelRatio is %s', - async (ratio) => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - - try { - const { device } = await renderApplication(ratio); - - await vi.waitFor(() => - expect(warnSpy).toHaveBeenCalledWith( - '%c[PlayCanvas React]:', - expect.any(String), // the CSS string - expect.stringContaining( - `maxPixelRatio must be a finite number greater than 0. Received: ${ratio}` - ) - ) - ); - expect(device.maxPixelRatio).toBe(DEFAULT_RATIO); - } finally { - warnSpy.mockRestore(); - } + it('applies maxPixelRatio again after the graphics device is restored', async () => { + const { device, setMaxPixelRatio } = await renderApplication(2); + + // WebGPU resets the ratio when it restores a lost device + device.maxPixelRatio = 1; + device.fire('devicerestored'); + + expect(device.maxPixelRatio).toBe(2); + + // Once the prop is removed, a restore leaves the default alone + setMaxPixelRatio(undefined); + device.fire('devicerestored'); + + expect(device.maxPixelRatio).toBe(DEFAULT_RATIO); + }); + + it('follows changes to the display pixel ratio', async () => { + const queries = stubMatchMedia(); + const { device, setMaxPixelRatio } = await renderApplication(4); + expect(device.maxPixelRatio).toBe(3); + + // The window moves to a 2x display + vi.stubGlobal('devicePixelRatio', 2); + queries.at(-1)?.listeners.forEach((listener) => listener()); + + expect(device.maxPixelRatio).toBe(2); + expect(queries.at(-1)?.media).toBe('(resolution: 2dppx)'); + + // Removing the prop stops following the display + setMaxPixelRatio(undefined); + + expect(queries.every(({ listeners }) => listeners.size === 0)).toBe(true); + }); + + it.each([0, -1, NaN])('warns and keeps the graphics device default when maxPixelRatio is %s', async (ratio) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const { device } = await renderApplication(ratio); + + await vi.waitFor(() => + expect(warnSpy).toHaveBeenCalledWith( + '%c[PlayCanvas React]:', + expect.any(String), // the CSS string + expect.stringContaining(`maxPixelRatio must be a number greater than 0. Received: ${ratio}`) + ) + ); + expect(device.maxPixelRatio).toBe(DEFAULT_RATIO); + } finally { + warnSpy.mockRestore(); } - ); + }); }); it.skip('The Application component applies canvasRef prop correctly', () => { diff --git a/packages/lib/src/Application.tsx b/packages/lib/src/Application.tsx index 1f5bc789..7b5903c3 100644 --- a/packages/lib/src/Application.tsx +++ b/packages/lib/src/Application.tsx @@ -23,6 +23,7 @@ import { defaultGraphicsDeviceOptions } from './types/graphics-device-options.ts import type { DeviceType } from './utils/create-graphics-device.ts'; import { internalCreateGraphicsDevice } from './utils/create-graphics-device.ts'; import { env } from './utils/env.ts'; +import { applyMaxPixelRatio } from './utils/max-pixel-ratio.ts'; import { usePicker } from './utils/picker.tsx'; import type { PublicProps } from './utils/types-utils.ts'; import { @@ -183,20 +184,12 @@ export const ApplicationWithoutCanvas: FC = (prop }; }, [graphicsOptsKey, deviceTypeKey]); // ← stable deps - /* eslint-disable react-hooks/immutability, react-compiler/react-compiler -- this effect intentionally mutates the engine's graphics device */ - // Applied before the fill and resolution modes below, so the canvas resize they trigger uses this ratio + // Applied before the fill and resolution modes below, so the canvas resize they trigger uses this ratio. + // The cleanup hands back the device's own ratio, so removing the prop restores the engine default. useEffect(() => { if (!app || maxPixelRatio === undefined) return; - const device = app.graphicsDevice; - const defaultRatio = device.maxPixelRatio; - device.maxPixelRatio = maxPixelRatio; - - // Hand back the device's own ratio, so removing the prop restores the engine default - return () => { - device.maxPixelRatio = defaultRatio; - }; + return applyMaxPixelRatio(app.graphicsDevice, maxPixelRatio); }, [app, maxPixelRatio]); - /* eslint-enable react-hooks/immutability, react-compiler/react-compiler */ // Separate useEffect for these props to avoid re-rendering useEffect(() => { @@ -259,17 +252,17 @@ interface ApplicationProps resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED; /** - * The maximum pixel ratio to render at, which sets `app.graphicsDevice.maxPixelRatio`. The canvas - * renders at the smaller of this value and `window.devicePixelRatio`, so a value above 1 keeps the - * scene sharp on high-DPI screens, at the cost of rendering more pixels. Keep it at or below - * `window.devicePixelRatio`, as in the example: the engine also reads it back as the pixel ratio in - * effect, such as when mapping pointer positions for UI element drags. + * The maximum pixel ratio to render at. The canvas renders at the smaller of this value and the + * display's own pixel ratio (`window.devicePixelRatio`), which is followed as it changes, for + * example when the window moves to another monitor. A value above 1 keeps the scene sharp on + * high-DPI screens, at the cost of rendering more pixels, and `Infinity` always renders at the + * display's full resolution. The ratio in effect is set as `app.graphicsDevice.maxPixelRatio`. * * When omitted, the graphics device keeps its own default, which caps rendering at CSS resolution * and can look soft on high-DPI screens. * * @example - * + * * * This renders at the screen's full resolution, up to twice the CSS resolution. */ @@ -373,8 +366,8 @@ componentDefinition.schema = { default: RESOLUTION_AUTO }, maxPixelRatio: { - validate: (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value > 0, - errorMsg: (value: unknown) => `maxPixelRatio must be a finite number greater than 0. Received: ${value}`, + validate: (value: unknown) => typeof value === 'number' && value > 0, + errorMsg: (value: unknown) => `maxPixelRatio must be a number greater than 0. Received: ${value}`, default: undefined }, graphicsDeviceOptions: { diff --git a/packages/lib/src/utils/max-pixel-ratio.ts b/packages/lib/src/utils/max-pixel-ratio.ts new file mode 100644 index 00000000..752a5c76 --- /dev/null +++ b/packages/lib/src/utils/max-pixel-ratio.ts @@ -0,0 +1,44 @@ +import type { GraphicsDevice } from 'playcanvas'; + +/** + * Caps the pixel ratio a graphics device renders at, until the returned function is called. + * + * The device is handed the ratio in effect rather than the cap: the smaller of `maxPixelRatio` and + * the display's own pixel ratio. The engine takes that minimum itself when it sizes the canvas, but + * reads `maxPixelRatio` back as the ratio in effect elsewhere, such as when the picker maps pointer + * positions to canvas pixels. The ratio is applied again when the display's pixel ratio changes (the + * window moves to another monitor, or the page is zoomed), and when the device is restored after a + * loss, which resets it on WebGPU. + * + * @param device - The graphics device. + * @param maxPixelRatio - The maximum pixel ratio to render at. + * @returns A function that stops following the display and hands back the device's own ratio. + */ +export const applyMaxPixelRatio = (device: GraphicsDevice, maxPixelRatio: number) => { + const defaultRatio = device.maxPixelRatio; + let displayQuery: MediaQueryList | null = null; + + const update = () => { + device.maxPixelRatio = Math.min(maxPixelRatio, window.devicePixelRatio); + + // A resolution query only reports the first change away from the ratio it was created with, + // so watch the display's current ratio again. matchMedia is missing in some environments, such as jsdom. + displayQuery?.removeEventListener('change', update); + displayQuery = + typeof window.matchMedia === 'function' + ? window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`) + : null; + displayQuery?.addEventListener('change', update); + }; + + update(); + + // WebGPU resets the ratio when it restores a lost device, and reports the restore from engine 2.19 + device.on('devicerestored', update); + + return () => { + device.off('devicerestored', update); + displayQuery?.removeEventListener('change', update); + device.maxPixelRatio = defaultRatio; + }; +};