Skip to content

webgl: fix terminal drawn at the wrong scale when the device pixel ratio changes - #6115

Open
seanhaufler wants to merge 1 commit into
xtermjs:masterfrom
seanhaufler:fix/webgl-dpr-change-from-observer
Open

webgl: fix terminal drawn at the wrong scale when the device pixel ratio changes#6115
seanhaufler wants to merge 1 commit into
xtermjs:masterfrom
seanhaufler:fix/webgl-dpr-change-from-observer

Conversation

@seanhaufler

@seanhaufler seanhaufler commented Aug 18, 2026

Copy link
Copy Markdown

What's wrong

WebglRenderer._setCanvasDevicePixelDimensions resizes the WebGL drawing buffer whenever DevicePixelObserver reports a new device pixel content box, then only asks for a redraw:

this._canvas.width = width;
this._canvas.height = height;
this._onRequestRedraw.fire({ start: 0, end: this._terminal.rows - 1, sync: true });

The GL viewport and the resolution the shaders divide every vertex by are set in one place only, GlyphRenderer.handleResize, and from dimensions.device.canvas rather than from the canvas:

gl.viewport(0, 0, this._dimensions.device.canvas.width, this._dimensions.device.canvas.height);
gl.uniform2f(this._resolutionLocation, this._dimensions.device.canvas.width, this._dimensions.device.canvas.height);

That's fine for what the observer is there for, "correcting rounding errors when converting between CSS pixels and device pixels", where the difference is sub-pixel. But the same observer also reports a change in the device pixel ratio itself, and there the difference is a whole factor. The buffer doubles while the viewport still covers the old rectangle and the shaders still map the old resolution, so the terminal gets drawn at half scale into a corner of the buffer, which the browser then scales down into the element. Same rows, same columns, all the text present, just drawn small with blank space beside and below it.

ScreenDprMonitor is supposed to notice the ratio change first and drive handleDevicePixelRatioChange, which recomputes the dimensions at the new ratio and resizes the renderer properly. It can't be relied on to get there. Its triggers are the window's resize event and a (resolution: <dpr>dppx) media query, and a terminal in a frame whose CSS size is fixed gets no resize when the ratio under it changes: the frame's CSS dimensions don't move, only the ratio underneath them. Nothing re-checks afterwards, so once it's missed the terminal stays mis-scaled until something else resizes it, which in practice means until the page is reloaded.

Where I ran into it

I embed terminals in fixed size iframes in an internal dashboard, several kept mounted at once with only the selected one on screen, on Chrome and macOS. The usual trigger is closing the laptop and opening it again. On wake the displays are re-established and the ratio under those frames can change, but the frames themselves never resize, so the media query is the only detector left and a pane that was hidden at the time can miss it. Some proportion of the time a pane then comes back drawn at roughly half size in the corner of its box, and stays that way until the frame is reloaded. The pty reports the same rows and columns throughout and none of the text is missing, only the scale is wrong, which is what pointed at the viewport rather than at sizing.

I haven't caught it in the act with a debugger attached, so the wake from sleep part of that is inference from the symptom rather than something I watched happen. What I can show directly is the mechanism, below: put the renderer into the state a missed ratio change leaves it in and the mis-scaled terminal appears every time, in this repo's own demo.

The fix

Treat a ratio change arriving at the observer as a ratio change. When _devicePixelRatio no longer matches _coreBrowserService.dpr, hand off to handleDevicePixelRatioChange() instead of resizing the buffer on its own. It costs one comparison in the normal case, and the two paths agree whichever arrives first, since the second one to run finds the ratio already adopted and returns without doing anything. handleResize sets the canvas from dimensions.device.canvas rather than from the size observed here, the observer fires again for that, and the rounding correction then applies with the ratios in agreement.

Why not just re-apply the viewport

Calling GlyphRenderer.handleResize() after the buffer resize would put the viewport and the buffer back in step, but it wouldn't fix the rendering. The vertex coordinates are still built from dimensions.device, computed at the old ratio, so the terminal would come out consistently half size instead of half size in a corner. The dimensions have to be recomputed at the new ratio, which is what handleDevicePixelRatioChange does by way of handleResize.

Testing

npm run build, npm run esbuild, npm run lint-changes and npm run test-unit are all clean.

In the demo with the WebGL renderer active, this stands in for a display density change that ScreenDprMonitor didn't see. It puts the renderer in the state it would have been in before the display got denser, then calls the observer's callback with the box the browser would report afterwards:

const t = window.term, r = t._core._renderService._renderer.value;
r._devicePixelRatio = window.devicePixelRatio / 2;
r.handleResize(t.cols, t.rows);
r._setCanvasDevicePixelDimensions(r._canvas.width * 2, r._canvas.height * 2);

On master the terminal ends up drawn small in the corner, with _canvas at twice dimensions.device.canvas and _devicePixelRatio still stale, and it stays there. With this change both are back in agreement immediately and the terminal renders normally.

Before, on master:

The demo terminal on master after the simulated ratio change. The six lines of text are drawn at roughly half size and squashed into the lower left of the terminal, with the rest of the area blank.

After, with this change:

The demo terminal with this change after the same simulated ratio change. The same six lines are drawn at their normal size and fill the terminal as expected.

I couldn't find a way to write an automated test for it. The trigger is a real device pixel ratio change: Playwright's deviceScaleFactor is fixed for the lifetime of a browser context, and the DevTools metrics override changes window.devicePixelRatio without producing a device-pixel-content-box change in headless Chromium, so neither reaches this code path. Happy to add one if there's a hook I've missed.

Related

#4728 and #4731 are the same surface with a different cause. There the box the observer reports disagrees with the ratio under DevTools device emulation, rather than the ratio having changed without ScreenDprMonitor noticing. This change doesn't address those.

_setCanvasDevicePixelDimensions resizes the drawing buffer whenever
DevicePixelObserver reports a new device pixel content box, but the
viewport and the resolution the shaders divide by are only ever set in
handleResize, from dimensions.device.canvas. That is fine for the
sub-pixel rounding differences the observer exists to correct, but a
device pixel ratio change is reported through the same callback, and
there the buffer changes by a whole factor while the viewport still
covers the old rectangle. The terminal is then drawn at the wrong scale
into a corner of the buffer, which the browser scales down into the
element.

ScreenDprMonitor is meant to see the ratio change first and drive
handleDevicePixelRatioChange, but it only listens for a window resize
and a resolution media query, and a terminal in a frame whose CSS size
is fixed can get neither. Nothing re-checks afterwards, so the terminal
stays mis-scaled until something else resizes it.

Handle the ratio change here as well. Whichever path arrives first, the
second finds the ratio already adopted and does nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant