Keeping an object scaled based on the bounds of the canvas (really battling to explain this one)

So, you know how changing the height of the canvas will scale in the content (scale the camera?) and keep the object in the vertical bounds, however this isn’t the case when you change the width of the canvas. Eventually you’ll be able to crop the scene. I get that this is natural, what I’m trying to do is create a canvas that will always keep the object within the bounds. This is what I mean

I’m not really asking how to do it, but probably more so if anyone has any ideas on how to approach this?

Things I can think of

  • A function that will draw the camera back at a ratio to the width of the canvas, tricky when taking into account the current height of the canvas too

  • Having an array of ‘responsive breakpoints’ for the camera distance, again tricky with the height of the canvas changing this

  • Ummm, maybe keeping the canvas height exactly proportional to the fluid width? So the camera and canvas has a fixed aspect ratio

Does that make sense?

Actually I think my brain worked it out, what you really want to do is have CSS responsive breakpoints that adjust the height of the canvas itself, no need to mess with the scene

is this similar to background-size: cover in css? if yes, you can calculate it like this:

  const v = ... // viewport
  const aspect = size.width / size.height 

  const adaptedHeight = height * (aspect > width / height ? v.width / width : v.height / height)
  const adaptedWidth = width * (aspect > width / height ? v.width / width : v.height / height)
  return [adaptedWidth * factor, adaptedHeight * factor, 1]

the viewport is calculated like so:

  const getCurrentViewport = (camera, target, size) => {
      const { width, height } = size
      const distance = camera.position.distanceTo(target)
      if (isOrthographicCamera(camera)) {
        return { width: width / camera.zoom, height: height / camera.zoom, factor: 1, distance }
      } else {
        const fov = (camera.fov * Math.PI) / 180 // convert vertical fov to radians
        const h = 2 * Math.tan(fov / 2) * distance // visible height
        const w = h * (width / height)
        return { width: w, height: h, factor: width / w, distance }
      }
    }

this is how it looks, it scales the object (a plane in this case) based on the canvas viewport: https://codesandbox.io/s/r3f-floating-diamonds-dwkjr

Thanks mate, thats really helpful, and you’ve highlighted the ease of relating it to a background property. The property I’m actually trying to replicate is contain

Imagining that the square image in that pen is an object in your scene. Notice how a scene will automatically follow this behaviour in ThreeJS when adjusting the height, I’m just wondering if anyone has ideas about how to follow the behaviour of background-size: contain with both width and height. At the moment what I may need to do is force the height to be a predefined aspect ratio to a fluid width.

Is it related to this ? In this topic there is solutions to move the camera back and forth to fit an object in screen space.

1 Like

Thanks @felixmariotto, I did try @looeee’s script provided, it did a great a great job at centering toward the object, however it didn’t dynamically zoom out to keep the object in the view (without cropping) at smaller screen sizes, but I didn’t read the entire post so I may dig deeper into it

1 Like

@kylewetton did you check my updated pen here?

I totally get what you want. I want the same.

Actually, as far as “3D at the service of the web” is concerned (and not “web at the service of 3D”), it’s a crucial feature. Because we need our 3D scenes to be responsive, and some objects should remain in the viewport whatever its size.

I’m also building a scene where I need the main content of my 3D model (a set of 8 TV screens) to always be fully visible in the viewport. And I want it to take as much place as needed.

Here’s an image of what I mean:

In this scene, I have my 8 TV screen that should always be visible, with a small gap on sides:

Now you’ve seen the whole scene, here’s how it should look in the browser at different viewport sizes:

So it’s kinda like a background-size: contain applied on an invisible plane (the red zone I’ve drawn).
It should take as much width and height as it can, while keeping its aspect ratio, but should never exceed the width nor the height of the viewport.

I know it’s related to fov and a lot of maths, but I’m not a wizard at maths… sigh…

At least I can explain this problem with images.

Ok, I start to have an idea.

Let’s say that our canvas takes the whole viewport.

We’ll call the red zone the “plane”, and we’ll assume its aspect-ratio to be 4 / 3 (I know it’s not, but shush…).

We have two scenarios:

  1. The window.innerWidth is too large or the window.innerHeight is too small ;
  2. The window.innerWidth is too small or the window.innerHeight is too large ;

1. window too large or not tall enough

The first situation happens when the viewport’s aspect-ratio is bigger than the plane’s aspect-ratio.

If the viewport has an aspect-ratio of 16 / 9 = 1.778, it’s bigger than the plane’s one (4 / 3 = 1.333).

In this case, we can use the default camera behavior provided in the threejs docs:

camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();

Because the camera.fov only controls one axis (I think the vertical one).

So as we want our plane to take full screen height, this does the job.

2. window too tall or not large enough

image

The second situation happens when the viewport’s aspect-ratio is smaller than the plane’s aspect-ratio.

If the viewport has an aspect-ratio of 9 / 16 = 0.5625, it’s smaller than the plane’s one (4 / 3 = 1.333).

In this case, we want the plane to take full screen width.

We can’t rely on the default fov of threejs, because we need the camera to see up and down but not left or right (horizontal fov?).

I’ve found this: https://stackoverflow.com/a/26665260/4936168
This formula should allow us to change camera’s horizontal fov, but I’ve not tested it yet.
Not even sure if this is the correct approach.

More on this tomorrow…

Yup! Managed it!

Actually I just reversed a snippet that I used for this:

In this case, I wanted to have a background-size: cover effect on my scene.

Here’s the snippet I used:

import { MathUtils } from 'three';

const fov = 50;
const planeAspectRatio = 16 / 9;

window.addEventListener('resize', () => {	
	camera.aspect = window.innerWidth / window.innerHeight;
	
	if (camera.aspect > planeAspectRatio) {
		// window too large
		const cameraHeight = Math.tan(MathUtils.degToRad(fov / 2));
		const ratio = camera.aspect / planeAspectRatio;
		const newCameraHeight = cameraHeight / ratio;
		camera.fov = MathUtils.radToDeg(Math.atan(newCameraHeight)) * 2;
	} else {
		// window too narrow
		camera.fov = fov;
	}
})

To get a background-size: contain effect, you just need to reverse the conditions:

	if (camera.aspect > planeAspectRatio) {
		// window too large
		camera.fov = fov;
	} else {
		// window too narrow
		const cameraHeight = Math.tan(MathUtils.degToRad(fov / 2));
		const ratio = camera.aspect / planeAspectRatio;
		const newCameraHeight = cameraHeight / ratio;
		camera.fov = MathUtils.radToDeg(Math.atan(newCameraHeight)) * 2;
	}

And voilà!

Explanation

For anyone who wants to understand this snippet, I actually do exactly what I explain in the previous answer: if the window is too narrow (or too tall), I change the camera fov on the x axis (horizontally) instead of changing it on the y axis (the default behaviour).

I’ve used the formula given here: https://stackoverflow.com/a/26665260/4936168

hFOV = 2 * Math.atan( Math.tan( camera.fov * Math.PI / 180 / 2 ) * camera.aspect ) * 180 / Math.PI; // degrees

It’s just split into multiple lines for better clearness (but that’s the same formula):

const cameraHeight = Math.tan(MathUtils.degToRad(fov / 2));
const ratio = camera.aspect / planeAspectRatio;
const newCameraHeight = cameraHeight / ratio;
camera.fov = MathUtils.radToDeg(Math.atan(newCameraHeight)) * 2;
4 Likes

@nagman
Cool! This is just like overscan, really cool, this should be a core functionality "THREE. perspectiveCamera({ overscan: true});