6min read
threejs
creative webapp
hidpi
September 20, 2025
As a creative developer, I’ve built plenty of 3D interactive web applications, and one recurring headache has been the unpredictable GPU load on HiDPI monitors.
Most of the work that I do revolves around ThreeJS so I’m going to cite it a lot in this article, but all the reasoning should be valid also for other canvas-based JS libraries such as BabylonJS, PlayCanvas, Pixi.
Let’s use the official ThreeJS car example scene as a reference. The scene uses a mix of MeshStandardMaterial and MeshPhysicalMaterial with transmission, so it’s a pretty demanding setup for ThreeJS and it makes a good test case.
Here’s a screenshot showing it on an NVIDIA GTX 1650 Super GPU that outputs a 4K signal (yeah I know it’s a low-tier GPU, but it helps with understanding the core issue):
The scene runs smoothly and everything looks fine, right?
Well, no.
There’s a single OS setting that can tank performance: let’s change the UI scale of the operating system (or change the browser zoom, but let’s leave this case out for the sake of simplicity).

I usually pick 150% UI scale for my 32" 4K monitor. In the current screenshots I’m using Linux Mint Cinnamon running with proprietary Nvidia Drivers v550.163.01. You can do an equivalent operation on other operating systems (but, spoiler: Windows is not affected by the issue I’m going to discuss).
Here’s what happens:
Only 35 stuttery FPS. Not great.
Let’s find out what’s happening behind the scenes.
In the official ThreeJS examples, this bit of code is often used to set the rendering resolution:
const renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
// assume a full-window canvas
renderer.setSize(window.innerWidth, window.innerHeight);
window.addEventListener("resize", () => {
renderer.setSize(window.innerWidth, window.innerHeight);
});
Internally, ThreeJS’s setSize() and setPixelRatio() do something like this:
// Code simplified for easier reading, feel free to check the
// original source at https://github.com/mrdoob/three.js/blob/r180/src/renderers/common/Renderer.js#L1742-L1764)
setPixelRatio(value = 1) {
this._pixelRatio = value;
this.setSize(this._width, this._height);
}
setSize(width, height, updateStyle = true) {
this.domElement.width = width * this._pixelRatio;
this.domElement.height = height * this._pixelRatio;
// ...
}
ThreeJS sets its rendering size by multiplying the given window width and height by the window.devicePixelRatio. Keep this in mind as it will be useful later in the article.
There’s a small flaw, as we saw in the initial example: this logic doesn’t work well with some UI scaling settings. Some combinations tank ThreeJS performance, resulting in a huge performance load on the GPU.
We can gather more insights on this topic by inspecting the canvas element of the previous car example running at 150% UI scale.
The width and height properties are actually set by Three’s setSize()
method that we saw earlier.
In this example the rendering resolution is 4400x2526, which is a stupidly huge drawing buffer, about 30% more pixels than a standard 4K image (3840x2160).
To help me investigate the issue, I created a simple web page to easily visualize relevant variables and validate my assumptions:

Then I collected data in this spreadsheet with all the values that affect the ThreeJS renderer size.
I did all the tests with the browser window maximised (not full screen): the OS bottom bar and the browser UI were taking away vertical space from the canvas.
Notably the tests with 100% and 200% UI scale are fine, while fractional scaling values (125%, 150%, 175%) are causing issues.
Let’s look at the 150% UI scale values when placed in the renderer resize code:
renderer.setPixelRatio(window.devicePixelRatio); // 2.0
renderer.setSize(window.innerWidth, window.innerHeight);// 2560, 1263
setSize(width, height, updateStyle = true) {
this.domElement.width =
width * this._pixelRatio;
// 2560 * 2.0 = 5120
this.domElement.height =
height * this._pixelRatio;
// 1263 * 2.0 = 2526
}
ThreeJS renders to a buffer of 5120×2526 pixels (~= 12.93 MegaPixels) even though the monitor’s physical resolution is 3840×2160.
As you probably noticed window.devicePixelRatio doesn’t seem to assume a fractional value like 1.50, why is that?
Some operating systems (notably macOS and most Linux Desktop Environments) handle UI scaling with an integer upscale approach; they use an internal resolution that is an integer multiple of the target scaled resolution and then downscale to the target physical resolution.
This behaviour is used to produce a crisp image with the downside of requiring a lot of resources, as macOS itself warns you about when selecting some UI scale options.
What I found out during my tests is that:
| Operating System | Scaling approach |
|---|---|
| macOS 15.6 | integer upscaling, then downscaling |
| Linux Mint Cinnamon 22 | integer upscaling, then downscaling |
| Windows 10 | fractional scaling |
| Windows 11 | not tested (but I guess it’s fractional scaling) |
| Android | not tested |
| iOS | not tested |
Windows, due to its fractional scaling approach, doesn’t affect the default resize logic and allows for appropriately sized output buffer, so ThreeJS apps render at the correct resolution without workarounds.
For this article I didn’t investigate much further on scaling methods as my main concern was about having consistent performance. If you know some interesting resources about this topic, please leave a comment.
The approach that I take to avoid huge load spikes is to
define a maxPixelCount variable and then use it in my custom
resizeRenderer function.
/**
* This resize function takes `window.devicePixelRatio` into account internally.
* To make it work as expected, please call `renderer.setPixelRatio(1)` on your
* renderer when the app starts.
*/
function resizeRenderer(renderer, camera, maxPixelCount = 3840*2160) {
const canvas = renderer.domElement;
const pixelRatio = window.devicePixelRatio;
const width = Math.round(canvas.clientWidth * pixelRatio);
const height = Math.round(canvas.clientHeight * pixelRatio);
const pixelCount = width * height;
// assume perspective camera
camera.aspect = width / height;
camera.updateProjectionMatrix();
const renderScale = pixelCount > maxPixelCount ?
Math.sqrt(maxPixelCount / pixelCount)
: 1;
renderer.setSize(
Math.round(width * renderScale),
Math.round(height * renderScale),
false);
}
This has saved me a lot of headaches during development, as I had a way to have consistent rendering costs during development.
You can test this behaviour on https://three-hidpi-rendering-resolution.netlify.app/ by clicking on any of the rendering presets.
Canvas-based web applications may set an inappropriate rendering resolution when run on a HiDPI monitor with fractional UI scaling.
A simple workaround to avoid these problems is to limit the pixel count from the renderer resize logic, in order to deliver consistent performance and normalize the experience across devices and operating systems.
Little side note: I mentioned ThreeJS a lot, but all the reasoning applies as well to other canvas-based JS libraries: BabylonJS, PlayCanvas, Phaser, Pixi.
I hope this blog post helped you in demystifying one of the causes of high GPU usage in creative web applications.
I’m curious to know if anyone has experienced the same issues. If yes, how did you approach the problem?
Thanks for reading and happy coding! Don’t let AI take away the fun and the skills.
Bye!