ThreeJS: fractional scaling fatigue on HiDPI monitors

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):

threejs car example at 4k, 200% UI scale on Linux Mint

Scene rendering at 60 FPS. Display set to 4K at 200% UI scale.

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:

Scene rendering at 35fps. UI scale set to 150%.

Only 35 stuttery FPS. Not great.

Let’s find out what’s happening behind the scenes.

At what resolution does ThreeJS render?

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

Why are we getting this crazy-high pixel count?

To help me investigate the issue, I created a simple web page to easily visualize relevant variables and validate my assumptions:

screenshot of the webpage showing the debug widget

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.

As you can see, some cells have a red background. These configurations are the ones rendering more pixels than needed, creating confusion and inconsistencies between tests.

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.

The culprit: devicePixelRatio not assuming fractional values

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.

Do all Operating Systems approach UI scaling with the integer upscaling technique?

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.

How to render the correct number of pixels?

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.

Takeaways and conclusions

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!

Additional resources

comments powered by Disqus