A canvas twice the size of its container
· 2 min · three.js · debugging
A 3D viewer I had just finished looked correct in every screenshot I took. Then someone opened it on their own monitor and the model was jammed into the bottom-right corner of its frame, clipped by the edges.
The setup code looked innocent:
renderer.setSize(width, height, false);
That third argument is updateStyle. Passing false tells three.js to size the drawing buffer but leave the canvas element's CSS alone. It exists for cases where you control the canvas's layout size yourself.
I was not controlling it. The canvas had no CSS width or height at all.
What that actually does
A <canvas> with no CSS size lays out at its attribute size. And setSize multiplies the attribute size by the device pixel ratio, because that is how you get a sharp render on a high-density display:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
So on a display at devicePixelRatio 2, the canvas element became twice its container's width and height. Its parent had overflow: hidden, which meant the visible region was the top-left quarter of a render that was four times too large.
The model was centred the whole time. I was looking at one quadrant of it, and the centre of the picture had been pushed to the bottom-right of what I could see.
setSize(w, h, false) sets the first and skips the second, so a canvas with no CSS size inherits the wrong one.Why every test passed
This is the part worth remembering. My whole screenshot harness drove a headless browser with an explicit device scale factor of 1, because that produces smaller, faster, more diffable images.
At devicePixelRatio 1, buffer size and CSS size are identical. The bug is not merely hard to see; it does not exist. I had built a test environment that was constitutionally incapable of reproducing the class of bug I had just written.
The fix was two lines:
renderer.domElement.style.width = "100%";
renderer.domElement.style.height = "100%";
The lesson was not about three.js. It was that "verified on my setup" quietly means "verified under my defaults", and device pixel ratio is a default that most rendering bugs care about and most test harnesses ignore. My capture script now takes the ratio as a parameter, and I check at 2.
The follow-on
While I was in there, I made the framing resolution-independent as well. Rather than a hardcoded camera distance, it derives one from the model's bounding sphere against whichever of the two fields of view is narrower:
const vFov = THREE.MathUtils.degToRad(camera.fov);
const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
const distance = radius / Math.sin(Math.min(vFov, hFov) / 2);
A hardcoded distance is really an assumption about aspect ratio. It survives exactly as long as nobody resizes the window.