Rendering 3D Objects at Real Size

Published: Nov 25, 2023

Here’s a cool idea. What if this render of an M8 screw was real size?

Namely that its thread diameter was 8mm, its thread length was 20mm, and its thread pitch was 1mm.

Well we’re in a web browser and it turns out CSS has absolute units so here’s a div of width “1in”:

div {
    height: 1in;
    width: 1in;
    background-color: #ffbb3e;
    margin: auto;
}

Using a ruler on my computer monitor, the above div is pretty close… 25mm or about 0.98 inches. I can grab the physical pixel size using Firefox Screenshot and selecting the element. I get a 120x120 image. I have a monitor that is roughly 122 ppi, so again, 👍. However, when viewing the div on a phone, I measure 16mm or 0.62in and when viewing CSS docs you’ll find that 1in == 96px, so what’s up? Well, “px” here is a actually a “CSS pixel” or “reference pixel” which is a unit used internally for CSS renderers. Running this bit of code to get the width and height of the above div will give you 96:

const elem = document.getElementById('1in');
console.log(elem.offsetWidth, elem.offsetHeight);

See the CSS spec concerning absolute units for more details. For fun, here’s a 1px div:

div {
    height: 1px;
    width: 1px;
    background-color: #ffbb3e;
    margin: auto;
}

The resulting size of the div is CSS pixels multiplied by window.devicePixelRatio which is defined at the OS/compositor level. And this value doesn’t align with the actual pixel per inch (ppi) of the connected monitor, which is what we really need to render anything to a specific physical size.

Unfortunately, for privacy reasons, there is no way to determine a monitor’s ppi within the browser, so we’ll have to ask the user for it in this case.

devicePixelRatio: undefined
Physical Pixels: NaN
    
      div {
        height: NaNpx;
        width: NaNpx;
        background-color: #ffbb3e;
        margin: auto;
      }
    
  

The above div should stay about the same size regardless of zoom level, which is honestly kind of a trippy experience (unless you’re on mobile). In Firefox, you can use Ctrl+ScrollWheel to modify zoom and Ctrl+0 to reset to default.

From here, we’re able to generate HTML elements of fixed and known size. To render 3d objects of real size, it is a matter of creating a canvas of known size and tweaking the camera’s viewport in relation to said size.

If using an orthographic camera, it is simple to “fit an object” in the viewport. An orthographic camera’s frustum (viewing region) is defined by a rectangular prism: a rectangle + a near/far render distance. Define the camera’s viewport to be a square whose sides are equal to the width of the object We’ll be maintaining a square aspect ratio in our renders.

Here’s a render of a cube which fills 50% of the viewport. This is embedded in a canvas that is always fixed to a size of 60mm. You can control the rotation in this canvas.

The scaling canvas causes this cube to always be 30mm in size. And because the canvas scales there’s no need to modify the camera viewport. However it should be equally possible to have a fixed canvas size and a camera whose viewport responds to monitor pixel density.

It should be noted that I’m working with STLs so my objects don’t have units, so realistically my renders could be off by an order of magnitude or even a system of measurement. But for most other 3d object file formats this information is available, so you can compute a canvas size from the model rather than going the other way around as I’ve done here.

Doing the same process with a perspective camera is slightly more complex, but not much more. A perspective camera’s frustum is defined by a field of view (FOV) angle and a near/far render distance . And an aspect ratio, but we’re assuming a square ratio here.

Calculating these values should be a bit of trigonometry away.

For a FOV of 15° and our 30mm cube, the distance from the cube face to the camera is 15 / tan(7.5°) so somewhere around 114mm. Since our cube is centered, we’ll also need to offset the camera by half the cube width or 15mm. Here’s the same 30mm cube rendered with a perspective camera:

Like before, we’ve doubled our theoretical cube width so that the cube takes 50% of the screen rather than the full viewport.

To render our M8 screw, we preprocess our model to determine a canvas size. This is a matter of creating a bounding box and finding it’s largest edge. After knowing the size of the screw, it’s simply a matter of applying this value to the previous work we’ve done.

Here’s an M8 screw with a perspective camera (the first M8 screw in this post used an orthographic camera):

Also apologies for the render artifacts; Personally I blame FreeCAD regardless of the source of the issue.

I’m finding my phone to be grossly incorrect in rendering everything in this page. It also seems like there is an alternative zooming functionality that doesn’t respect devicePixelRatio, so that may be the culprit. The issue is not the fractional inaccuracy of the ppi input; That’s only causing an inaccuracy of 1-2mm and for my phone would behave in the other direction — it would make the resulting render larger not smaller.

Don’t know! Maybe I’ll explore it some more later.