Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/application-max-pixel-ratio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@playcanvas/react": minor
---

Add a `maxPixelRatio` prop to `Application` and `ApplicationWithoutCanvas`, so apps can render sharply on high-DPI screens, for example `<Application maxPixelRatio={2} />`. 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.
170 changes: 167 additions & 3 deletions packages/lib/src/Application.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import type { Application as PlayCanvasApplication } from 'playcanvas';
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 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(() => {
Expand Down Expand Up @@ -53,6 +64,159 @@ describe('Application', () => {
}
});

describe('maxPixelRatio', () => {
// 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) => {
const onApp = vi.fn();
const tree = (ratio?: number) => (
<Application deviceTypes={['null']} maxPixelRatio={ratio}>
<AppProbe onApp={onApp} />
</Application>
);

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.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);

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('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', () => {
const canvasRef = { current: document.createElement('canvas') };
canvasRef.current.setAttribute('aria-label', 'test-canvas');
Expand Down
31 changes: 31 additions & 0 deletions packages/lib/src/Application.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -102,6 +103,7 @@ export const ApplicationWithoutCanvas: FC<ApplicationWithoutCanvasProps> = (prop
canvasRef,
fillMode = FILLMODE_NONE,
resolutionMode = RESOLUTION_AUTO,
maxPixelRatio,
usePhysics = false,
graphicsDeviceOptions,
deviceTypes = [DEVICETYPE_WEBGL2],
Expand Down Expand Up @@ -182,6 +184,13 @@ export const ApplicationWithoutCanvas: FC<ApplicationWithoutCanvasProps> = (prop
};
}, [graphicsOptsKey, deviceTypeKey]); // ← stable deps

// 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;
return applyMaxPixelRatio(app.graphicsDevice, maxPixelRatio);
}, [app, maxPixelRatio]);

// Separate useEffect for these props to avoid re-rendering
useEffect(() => {
if (!app) return;
Expand Down Expand Up @@ -242,6 +251,23 @@ interface ApplicationProps
*/
resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED;

/**
* 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
* <Application maxPixelRatio={2} />
*
* 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
Expand Down Expand Up @@ -339,6 +365,11 @@ componentDefinition.schema = {
errorMsg: () => `"resolutionMode" must be one of: ${RESOLUTION_AUTO}, ${RESOLUTION_FIXED}`,
default: RESOLUTION_AUTO
},
maxPixelRatio: {
validate: (value: unknown) => typeof value === 'number' && value > 0,
errorMsg: (value: unknown) => `maxPixelRatio must be a 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}`,
Expand Down
44 changes: 44 additions & 0 deletions packages/lib/src/utils/max-pixel-ratio.ts
Original file line number Diff line number Diff line change
@@ -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;
};
};
Loading