How to calculate needed orthographic film size when toggling between ortho and perspective lenses?

This is probably a 3d engine-agnostic question, but I didn’t manage to guess what to search for online to get an answer.

In 3d editors, when you switch between orthographic and perspective 3d view camera modes, the sizes of the models remain the same. I’m not sure how they manage this.
I’ll use screenshots from Blender as examples.
Note that we are talking about orbiting camera (3rd person camera) controls.

Here’s a model loaded in Blender, in perspectve mode:

If I switch it to orthographic, the size of the model relative to the window remains consistent:

Now, let’s zoom closer to this model and switch back to perspective mode:

Now switching to orthographic mode:

Again, the size of the model relative to the window remains more or less the same in orthographic mode as it was in perspective mode.

It seems like Blender is calculating the orthographic film size based on the distance of the camera from the orbit origin, so the size of the model remains more or less the same as the user switches between orthographic and perspective modes.
I tried doing the same, but I can’t manage to determine what the formula needed is, so that the model size between perspective and orthographic modes remain consistent both when the camera is very close and when it is very far from its orbit origin and the model.

Any ideas?

Not attaching sample code as the camera manager I have is a very large Python module. But test can be done with good old base.cam.set_y() .

Set the lens’ desired film size. Then move the camera back. For each unit that you move the camera back, increase the lens’ focal distance by the same value.

lens = base.cam.node().get_lens()
lens.film_size = (10 * base.get_aspect_ratio(), 10)

# Then, in some interval or task:
lens.focal_length += 1
base.cam.set_y(base.cam, -1)

You probably need to scale it exponentially and also linearly interpolate the near/far distances of the lens.

An orthographic camera shouldn’t have a focal length, right?

Sorry, I didn’t read your question, and I thought your question was about animating a perspective lens smoothly towards an ortho lens.

Panda determines lens settings based on a triad of three properties: FOV, focal length, and film size. Specify any two and Panda will calculate the third for you, based on the order in which you specified them.

An orthographic camera does have a film size, so you want Panda to calculate that given a known focal length (the distance to your subject), so you do:

plens.fov = plens.fov
plens.focal_length = # distance to subject center

olens = OrthographicLens()
olens.film_size = plens.film_size

olens.set_near_far(plens.near, plens.far)

When I modify Roaming Ralph and bind this method to a key:

    def change_lens(self):
        lens = base.cam.node().get_lens()
        lens.fov = lens.fov
        lens.focal_length = (base.cam.get_pos(render) - self.ralph.get_pos(render)).length()

        lens2 = OrthographicLens()
        lens2.film_size = lens.film_size
        base.cam.node().set_lens(lens2)

I see that Ralph remains almost perfectly the same size.

1 Like

Sorry, I’m trying to implement this for my camera manager which also allows to adjust the camera distance from center with mouse wheel or right click and drag, and I can’t get it to work.
What I’m guessing is happening is that setting the focal-length moves the camera object?
If you are not sure what is going on, I’ll try to simplify the camera manager code and post a snippet.

Setting the focal length doesn’t move the camera object. But if it’s changing the field-of-view, then you probably missed the crucial lens.fov = lens.fov line before the focal_length assignment line.

Sorry if I’m being very slow here, but what is lens.fov = lens.fov meant to do? Aren’t you assigning a reference to itself, which changes nothing?

Did you by any chance mean to say:

fov = lens.fov
lens.focal_length = (base.cam.get_pos(render) - self.ralph.get_pos(render)).length()
calculated_film_value_for_ortho_lens = lens.film_size
lens.fov = fov # change FOV back to original value

?

Here is the simplest code snippet we can use to see if what you suggest works or not. This is everything else, for setting camera lens type and zooming in/out, before adding your solution:

import panda3d.core
from direct.showbase.ShowBase import ShowBase

base = ShowBase()

model = base.loader.load_model("panda")
model.reparent_to(base.render)


orbit_center = base.render.attach_new_node("orbit_center")
orbit_center.set_hpr(45,-20,0)
base.camera.reparent_to(orbit_center)
base.camera.set_y(-30)

orthographic_lens = panda3d.core.OrthographicLens()
perspective_lens = base.cam.node().get_lens()

def mouse_wheel_zoom_in():
	base.camera.set_y(orbit_center, base.camera.get_y() + 2)
	
	# set a zooming range
	if base.camera.get_y(orbit_center) > -5: base.camera.set_y(orbit_center, -5)
		
def mouse_wheel_zoom_down():
	base.camera.set_y(orbit_center, base.camera.get_y() - 2)
	
	# set a zooming range
	if base.camera.get_y(orbit_center) < -60: base.camera.set_y(orbit_center, -60)

base.accept("wheel_up", mouse_wheel_zoom_in)
base.accept("wheel_down", mouse_wheel_zoom_down)

cam_lens_state = 1
def toggle_cam_lens_type():
	global cam_lens_state
	
	if cam_lens_state == 0:
		base.cam.node().set_lens(perspective_lens)
		cam_lens_state = 1
	elif cam_lens_state == 1:
		base.cam.node().set_lens(orthographic_lens)
		cam_lens_state = 0
		
base.accept("a", toggle_cam_lens_type)

def camera_task(task):
	# put the orthographic film adjustment code here?
	return task.cont

base.taskMgr.add(camera_task, "camera_task")


base.disable_mouse()
base.run()

And this is what I think you mean, with the relevant lines of code added to the above template code snippet:

import panda3d.core
from direct.showbase.ShowBase import ShowBase

base = ShowBase()

model = base.loader.load_model("panda")
model.reparent_to(base.render)


orbit_center = base.render.attach_new_node("orbit_center")
orbit_center.set_hpr(45,-20,0)
base.camera.reparent_to(orbit_center)
base.camera.set_y(-30)

orthographic_lens = panda3d.core.OrthographicLens()
perspective_lens = base.cam.node().get_lens()

def mouse_wheel_zoom_in():
	base.camera.set_y(orbit_center, base.camera.get_y() + 2)
	
	# set a zooming range
	if base.camera.get_y(orbit_center) > -5: base.camera.set_y(orbit_center, -5)
		
def mouse_wheel_zoom_down():
	base.camera.set_y(orbit_center, base.camera.get_y() - 2)
	
	# set a zooming range
	if base.camera.get_y(orbit_center) < -60: base.camera.set_y(orbit_center, -60)

base.accept("wheel_up", mouse_wheel_zoom_in)
base.accept("wheel_down", mouse_wheel_zoom_down)

cam_lens_state = 1
def toggle_cam_lens_type():
	global cam_lens_state
	
	if cam_lens_state == 0:
		base.cam.node().set_lens(perspective_lens)
		cam_lens_state = 1
	elif cam_lens_state == 1:
		base.cam.node().set_lens(orthographic_lens)
		cam_lens_state = 0
		
base.accept("a", toggle_cam_lens_type)

def camera_task(task):
	
	fov = perspective_lens.fov
	perspective_lens.focal_length = (base.camera.get_pos(render) - orbit_center.get_pos(render)).length()
	calculated_film_value_for_ortho_lens = perspective_lens.film_size
	perspective_lens.fov = fov # change FOV back to original value
	orthographic_lens.film_size = calculated_film_value_for_ortho_lens
	
	return task.cont

base.taskMgr.add(camera_task, "camera_task")


base.disable_mouse()
base.run()

No, I am quite sure I didn’t.

It’s what I said here:

So, what it’s accomplishing is that you’re telling Panda that the FOV is more important than the film size, because you’ve set the FOV more recently. So Panda will maintain the FOV property while recalculating the film size, rather than the other way around.

Your modified snippet works, right? If I press A, the Panda roughly remains the same size (well, the head of the Panda changes shape a bit, because it’s far away from the orbit origin). Is that not what you are observing?

I was stumped a little at why your modified code actually works, because it’s not supposed to, since you really ought to do the FOV assignment before querying the film_size. But then I realised why. It just so happens to work due to two reasons:

  • The .film_size accessor returns a reference to the underlying film_size, so when you change the FOV, the calculated_film_value_for_ortho_lens value you’ve already stored changes! I think this behaviour is confusing and I will change this in a future Panda version.
  • You’re doing this in the task, so even if the above weren’t true, the FOV fix for the first frame would carry over into the second frame and so the film size would only be incorrect for a single frame.

So, don’t count on this to continue working. I’d just move the perspective_lens.fov = perspective_lens.fov line to the top of the camera task:

def camera_task(task):
	
	perspective_lens.fov = perspective_lens.fov
	perspective_lens.focal_length = (base.camera.get_pos(render) - orbit_center.get_pos(render)).length()
	orthographic_lens.film_size = perspective_lens.film_size
	
	return task.cont

I don’t get this.

You’ve set the FOV value to what it already was.
But it doesn’t make sense to me how lens.fov = lens.fov does anything, because if the focal length has been altered and the FOV has been recalculated because of that already, how would lens.fov = lens.fov prevent the focal length from changing the FOV? It already has in the last frame, so lens.fov = lens.fov would be setting the already recalculated (from the new focal length) wrong FOV value, vs if you had stored the original correct FOV value and applied that as the lens.fov instead.
That said, your edit also works fine, so I’m very confused here.

I don’t follow your explanation on why my code works. This is why I thought it works:

  1. You store the perspective lens FOV value in a variable. So if the perspective lens FOV gets recalculated by Panda after you change its focal length, it doesn’t matter: you have the original value stored, which you can reapply to the perspective lens later.
  2. You set the perspective lens focal length, which causes Panda to recalculate the perspective lens FOV and film size.
  3. You store the value of the recalculated film size in another variable, to apply to the orthographic lens. You don’t really need to do this, you can apply the value to the orthographic lens film size immediately, and it still seems to work. See the slightly modified code snippet which does the latter below.
  4. Since the perspective lens FOV has also been recalculated by Panda, you reapply the original stored FOV value to the perspective lens
  5. If you haven’t already in step 3, you apply the stored perspective lens film size to the orthographic lens.
    So the perspective lens gets it’s original FOV value back, and the orthographic camera gets the perspective lens film size which was calculated by Panda based on our inputted perspective lebs focal length (since orthographic cameras don’t have a focal length).

The .film_size accessor returns a reference to the underlying film_size, so when you change the FOV, the calculated_film_value_for_ortho_lens value you’ve already stored changes!

I don’t follow this explanation. It shouldn’t be returning a reference but providing a value I store in a variable, in which case the above steps should happen. But even if it did, then it wouldn’t work, because if I had set the original FOV and it had recalculated the film size based on that, it would be the old film_size value not calculated by Panda from our inputted focal length, and it wouldn’t be the value we needed (otherwise why not just access perspective lens film_size to begin with and assign it to the orthographic lens, and not do all this? )

The slightly modified snipped as mentioned above:

import panda3d.core
from direct.showbase.ShowBase import ShowBase

base = ShowBase()

model = base.loader.load_model("panda")
model.reparent_to(base.render)


orbit_center = base.render.attach_new_node("orbit_center")
orbit_center.set_hpr(45,-20,0)
base.camera.reparent_to(orbit_center)
base.camera.set_y(-30)

orthographic_lens = panda3d.core.OrthographicLens()
perspective_lens = base.cam.node().get_lens()

def mouse_wheel_zoom_in():
	base.camera.set_y(orbit_center, base.camera.get_y() + 2)
	
	# set a zooming range
	if base.camera.get_y(orbit_center) > -5: base.camera.set_y(orbit_center, -5)
		
def mouse_wheel_zoom_down():
	base.camera.set_y(orbit_center, base.camera.get_y() - 2)
	
	# set a zooming range
	if base.camera.get_y(orbit_center) < -60: base.camera.set_y(orbit_center, -60)

base.accept("wheel_up", mouse_wheel_zoom_in)
base.accept("wheel_down", mouse_wheel_zoom_down)

cam_lens_state = 1
def toggle_cam_lens_type():
	global cam_lens_state
	
	if cam_lens_state == 0:
		base.cam.node().set_lens(perspective_lens)
		cam_lens_state = 1
	elif cam_lens_state == 1:
		base.cam.node().set_lens(orthographic_lens)
		cam_lens_state = 0
		
base.accept("a", toggle_cam_lens_type)

def camera_task(task):
	
	fov = perspective_lens.fov
	perspective_lens.focal_length = (base.camera.get_pos(render) - orbit_center.get_pos(render)).length()
	orthographic_lens.film_size = perspective_lens.film_size
	perspective_lens.fov = fov # change FOV back to original value
	
	return task.cont

base.taskMgr.add(camera_task, "camera_task")


base.disable_mouse()
base.run()

It’s only in the first frame that you get the wrong result, subsequent frames will still work right. See below for a line-by-line explanation of what happens. I only recommend that you do the lens.fov = lens.fov assignment first because 1. you won’t see a single frame quickly flicker with the wrong result, and 2. if ever some other part of your codebase changes some other lens settings in the future, you’ll know for a fact that this code continues to work.

It’s a formula involving three variables. So, Panda has to recalculate only one of the properties to match the two others. If you set the focal length, Panda has to guess whether you want it to recalculate the FOV or the film size to keep the relationship intact. Panda keeps track of which parameters you assigned more recently. If you set the FOV and focal length most recently, Panda assumes you meant for it to recalculate the film size.

In your latest modified snippet code, what happens exactly line-by-line is this. You can verify that this is exactly true by printing out every variable after every line.

  1. You store a reference (not a copy) to the FOV stored in the perspective lens. In Python, the assignment operator just creates a new reference to the same value. Python does not have value assignment, like C or C++.
  2. You change the focal length. Panda decides whether to recalculate the FOV or the film size. In the first frame, I believe it decides to recalculate the FOV, in subsequent frames it calculates the film size. The reference to the FOV that you stored is also updated.
  3. You copy the film size (which is entirely unchanged from its default value, since you didn’t “lock” the FOV) to the orthographic lens.
  4. You assign what is still a reference to the current FOV to the current FOV. This does not change any of the lens parameters, least of which the FOV, but this is the key piece that makes the code work in the next frame.

During the next frame, it works the same way, except in 2, Panda recalculates the film size, because you set the FOV most recently.

What you probably meant to happen was to create a copy of the FOV. But it would still have the wrong behaviour.

Okay thank you. The part about .film_size being a reference is understandable. My understanding was that the Python wrapper took a C++ get_film_size() method and allowed you to access it with .film_size . I may be remembering this wrong, but I think that’s how the Blender Python API works.

Yes, that is how it works, but get_film_size() is also returning a reference, so that’s not

The confusion probably comes from the fact that it’s a Point2 object, which has mutable x and y members. Because the C++ code stores the object by-value rather than by-reference (for efficiency), when Lens recalculates it, rather than replacing it with a whole new Point2 object with a new address it just updates the x and y members.

I realise how this can be unintuitive, similar to how MouseWatcherNode.get_mouse() was confusing. Perhaps I will also change these methods to return a copy instead.

My programming experience is limited to only a handful of of languages and APIs. And the fact that I haven’t touched programming for many years also doesn’t help.
But even considering all this, I can’t recall any get() returning reference of a mutable object before.

There are many such examples. For example, the get_lens() call we have made returns a mutable reference to a PerspectiveLens object, which we’ve used to make changes to the existing lens (and not to a new copy of the lens).

Another example: if you call get_child(0) on a PandaNode, you get a mutable reference to the child PandaNode, of which you can change the colour or transformation or other attributes.

But those return references to Panda3D objects. Not numbers or lists of numbers. You don’t want the latter to change its value randomly and unexpectedly, so you’ll want a copy.

Right, but a Point2 is also a Panda3D object. Our binding generator doesn’t see it as different.

I do recognise that people expect it to be different, because it feels more like a primitive type, like a floating-point number or a string. So, I will probably change the Panda API to make a copy in 1.11.

Right, it is, but only because the wrapper couldn’t do better, and some Panda3D methods accept a list/tuple of numbers, while others require a Panda3D specific objects like Vec/Point but which do not contain any more data than just a list of numbers, right?

How could the wrapper “do it better”? By constructing a new tuple with the same numbers instead of a vector object?

Note that a list of numbers would still have the same problem. You can replicate the same problem in pure Python with a list of numbers, since it is mutable.

By better I just mean the wrapper makes sure a copy is created. When using getters to get numbers or list of numbers, I don’t recall an instance where I had to keep in mind that the data acquired was a reference which could have its value updated at any time by anyone but myself. It makes sense for more advanced objects as those objects are specifically assumed to have a constantly updating data, but when you use a getter to get a number or list of numbers, I don’t think that’s typically your assumption.
For example, if I want to get a resolution of a window and I use instance.get_res(), I don’t expect that number to be updated as the window is updated, I assume the number to remain what it was when I got it, and I know its expected of my to do get_res() again when I need an updated number. In contrary, if I get an object such as “WindowProperty” with the getter, then I assume I’m getting a library object and I can access it’s data when I want, and it will provide me the up-to-date values when I do that.

Am I wrong? I thought this was the common practice and I thought you agreed previously.

I will add one point against the use of returning a copy of a vector: vectors, being classes, incur a cost of construction. If the getter is used sufficiently frequently, then it seems to me that this cost may add up, potentially having performance impacts.

Returning the original, non-copy vector doesn’t incur this construction-cost, and thus should the getter in question be used frequently should, I think, provide better performance.

Now, there is the question of whether it’s likely that these getters will be called so frequently–I imagine that it would take a fair few calls per frame to produce a noticeable difference.

I do recall that I did once find on profiling that vector-construction was a performance issue for me–but that was, I think, in my own code, and not in such a getter.

It’s also possible that I’m mistaken in where the cost would lie: I’m assuming that, even if the underlying vector is produced in C++, a Python-object would still have to be constructed when it’s requested from the Python side. If that’s not the case, then the issue may be negligible.