Skip to content

feat: add maxPixelRatio prop to Application - #361

Merged
abstrakt8 merged 2 commits into
mainfrom
feat/application-max-pixel-ratio
Oct 27, 2026
Merged

abstrakt8 merged 2 commits into
mainfrom
feat/application-max-pixel-ratio

Conversation

@willeastcott

@willeastcott willeastcott commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a maxPixelRatio prop to Application (and ApplicationWithoutCanvas) that caps the pixel ratio the canvas renders at:

<Application maxPixelRatio={2}>

The engine caps the pixel ratio at 1 by default (Math.min(1, devicePixelRatio) on WebGL2, 1 on WebGPU), so apps render at CSS resolution and look soft on high-DPI phones. Raising the cap from a component meant mutating useApp().graphicsDevice in an effect. The react-hooks/immutability lint rule rejects that, so every app needed an eslint-disable for it. @playcanvas/web-components already exposes this setting as <pc-app max-pixel-ratio>, and this prop works the same way.

Change

  • The device is handed the ratio in effect: the smaller of the prop and window.devicePixelRatio. The engine takes that minimum itself when it sizes the canvas, but it also reads maxPixelRatio back elsewhere (the picker, UI element drags and XR). The picker divides by it, so an unclamped cap above the display's ratio would offset picks. Infinity renders at the display's full resolution.
  • The ratio follows the display's own. A matchMedia resolution query, as <pc-app> uses, applies it again when the window moves to another monitor or the page is zoomed.
  • It is also applied again on devicerestored, because WebGPU resets the ratio to 1 when it restores a lost device. WebGPU fires that event from engine 2.19, so on older engines a WebGPU restore still drops the ratio back to 1.
  • 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 just before theirs, so the canvas resize their calls trigger already uses the new ratio.
  • When the prop is omitted, the engine default is left alone. Removing the prop restores the device's own value and stops both listeners.
  • It is validated like the other props: it must be a number greater than 0. Anything else (0, negatives, NaN) logs a warning and keeps the default.
  • The device logic lives in an internal helper, utils/max-pixel-ratio.ts, so the effect needs no eslint-disable.
  • Adds a minor changeset for @playcanvas/react.

Testing

  • 13 tests in Application.test.tsx, on a stubbed 3x display, cover:
    • setting, updating, omitting and removing the prop
    • clamping 4 and Infinity to the display's ratio
    • the canvas size the ratio produces
    • applying the ratio again after devicerestored, and not once the prop is removed
    • following a display change, and removing that listener on cleanup
    • rejecting 0, -1 and NaN
  • pnpm test: 156 passed, 16 skipped (13 more passing than main).
  • pnpm run lint: no new errors or warnings.
  • pnpm run build:lib and pnpm run build:blocks pass.

Once this is released, the upcoming web-lens starter in create-playcanvas can use this prop instead of its useEffect workaround.

🤖 Generated with Claude Code

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
`<Application maxPixelRatio={Math.min(window.devicePixelRatio, 2)}>`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a50757a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@playcanvas/react Minor
@playcanvas/blocks Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@playcanvas/blocks@361
npm i https://pkg.pr.new/@playcanvas/react@361

commit: a50757a

@willeastcott willeastcott added the enhancement New feature or request label Oct 25, 2026

@abstrakt8 abstrakt8 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by Claude Opus and GPT-6 Astra.

Thanks, this looks like the right shape to me. A top-level prop fits with fillMode/resolutionMode and <pc-app max-pixel-ratio>, and graphicsDeviceOptions isn't the place for it, since those are creation options and changing them recreates the app. A few things I'd tighten before merging:

  1. Clamp to devicePixelRatio internally. The engine clamps in resizeCanvas, but the picker divides by the raw maxPixelRatio (picker.tsx). So maxPixelRatio={3} on a 2x screen would offset picks. I'd rather enforce the limit in code than in the docs.
  2. Re-apply after device restore. On WebGPU, initDeviceCaps resets the ratio to 1 when the device is restored, and the effect won't re-run because its deps don't change. A devicerestored listener, removed in cleanup, would cover that.
  3. Make the doc example SSR-safe. Math.min(window.devicePixelRatio, 2) runs during render. With an internal clamp, the example can just be maxPixelRatio={2}.
  4. Optional: follow DPR changes, for example when the window moves to another monitor, via a matchMedia resolution listener like web-components uses.
  5. Tests: it would be good to also assert the actual canvas size, and the re-apply after restore.

Separately, it might be worth an issue to discuss changing the default to match web-components (full DPR) in the next major. That's a performance trade-off, so it's out of scope here.

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 `<pc-app>` 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 <noreply@anthropic.com>
@abstrakt8 abstrakt8 self-assigned this Oct 27, 2026

@abstrakt8 abstrakt8 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by Claude Opus and GPT-6 Astra.

Thanks, a50757a addresses all the points: the clamp to the display ratio, re-applying on devicerestored, following display changes, the SSR-safe example and the tests. Approving.

@abstrakt8

Copy link
Copy Markdown
Contributor

Will run some real device testing, then merge it in

@abstrakt8

Copy link
Copy Markdown
Contributor

Verified on real devices at a50757a:

  • macOS, Chrome (DPR 2), WebGL2 and WebGPU: canvas sizing and picking accuracy (box centres and edges) at maxPixelRatio 2, Infinity and unset; toggling the prop off and on; following DPR changes via browser zoom; ratio re-applied after context loss/restore and a GPU-process crash on both backends.
  • Android, Pixel, Chrome (DPR 2.625), WebGL2: canvas sizing and touch picking at 2, Infinity and unset; rotation; context loss/restore; frame rate held at 60 fps at full DPR.

Not tested: iOS Safari, Firefox, and WebGPU on Android (the engine disables it on that GPU).

@abstrakt8
abstrakt8 merged commit a25d853 into main Oct 27, 2026
6 checks passed
@abstrakt8
abstrakt8 deleted the feat/application-max-pixel-ratio branch October 27, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants