2
\$\begingroup\$

I have a scene that consists of a CSGBox and a Label as a child of it.

scene tree

In the script for the CSGBox, I get the label and center it on the box so that whenever the player is looking at it, they can see the label above the box.

extends CSGBox

# The box's label.
onready var label: Label = get_node("Label")
# The player's camera.
onready var camera: Camera = get_viewport().get_camera()

# Aligns the position of the label to always face the camera.
#
# `point` - The position to align the label to.
func position_label(point: Vector3):
    var offset = Vector2(label.get_size().x / 2, 0)
    label.rect_position = camera.unproject_position(point) - offset
    

# Every frame we check to see if the label is behind the camera or the label is
# out of the render distance and if so we return early. Otherwise, we position
# the label on the cube so that it's always facing the camera.
#
# `_delta` - The difference in time between the last frame and the current frame.
func _process(_delta):
    var pos: Vector3 = global_transform.origin
    
    # If the point is behind the camera we hide the label and return early.
    if camera.is_position_behind(pos): return
    
    position_label(pos)

This works pretty well, however it seems that the label gets smaller when moving closer to the box and larger when moving away from it. I know that it's not actually increasing in size because I can log the size of the text in the label and it's always the same but I'm not sure what could be causing this effect or how it can be "countered" to make the text seem the same no matter the distance.

close up far away

\$\endgroup\$
4
  • 1
    \$\begingroup\$ It looks like your text size is measured in screenspace dimensions, not world space. So as the worldspace cube recedes into the distance and gets smaller, the label does not shrink along with it - it still occupies the same number of on-screen pixels, making it look like it's growing relative to the cube. Have you tried resizing your label inversely proportionate to its distance in front of the camera? \$\endgroup\$
    – DMGregory
    Commented Jun 2, 2021 at 18:54
  • \$\begingroup\$ @DMGregory I kinda have tried to but it's not very easy to compute the distance. I asked a question previously about this and used it to get the distance between the camera and the label but I'm not sure how to actually utilize it since the distance will change when the user looks in any other direction other than straight at the label. \$\endgroup\$ Commented Jun 2, 2021 at 19:05
  • 1
    \$\begingroup\$ You want the distance to the cube, not the distance to the label. The label is not getting further away, and that's the problem you're trying to solve by using the distance to the cube. \$\endgroup\$
    – DMGregory
    Commented Jun 2, 2021 at 19:11
  • \$\begingroup\$ @DMGregory That actually works great, however the issue is now a matter of animating font size which as I just saw on a GitHub issue now, it seems it's not feasible due to having to use integer sizes which is choppy looking. If you want to write up your comment as answer I would gladly accept it otherwise I can do it. \$\endgroup\$ Commented Jun 2, 2021 at 20:33

1 Answer 1

3
\$\begingroup\$

The text on the label is the same size always, period.

The label does not exist in 3D space, it has no concept of depth.

You already know how to convert a point from 3D space to 2D.

I present a few ways to go about this.

By the way, I remind you that the current camera can change.


Scale by the camera projection

Here is an idea: convert another point slightly offset vertically.

Let us start with your code:

    var offset = Vector2(label.get_size().x / 2, 0)
    label.rect_position = camera.unproject_position(point) - offset

We need another point just offset on y axis:

    var offset = Vector2(label.get_size().x / 2, 0)
    label.rect_position = camera.unproject_position(point) - offset

    var another_point = Vector3(point.x, point.y + v_size, point.z)

Where v_size is, I don't know, 1? You can make it the vertical size of the label in 3D.

And we "unproject" that too:

    var offset = Vector2(label.get_size().x / 2, 0)
    label.rect_position = camera.unproject_position(point) - offset

    var another_point = Vector3(point.x, point.y + v_size, point.z)
    var another_point_2d = camera.unproject_position(another_point_2d) - offset

Then we take the distance, and use that as scale factor:

    var offset = Vector2(label.get_size().x / 2, 0)
    label.rect_position = camera.unproject_position(point) - offset

    var another_point = Vector3(point.x, point.y + v_size, point.z)
    var another_point_2d = camera.unproject_position(another_point_2d) - offset
    label.rect_scale = base_scale * label.rect_position.distance_to(another_point_2d)

Where base_scale is some Vector2 that has the size normal scale of the label. If v_size is the height you want the label to have in 3D space, then base_scale is simply Vector2(1, 1). However, if you let v_size be 1, you would want to tweak base_scale to something you like. You can either tweak base_scale or v_size or both.


Scale by field of view

To be fair, that is not the only way to go about it. Since you know both the position of the camera and the position of the object the label is supposed to be attached to…

You can easily compute the distance:

var distance = camera.global_transform.origin.distance_to(global_transform.origin)

By the way, if you want to use the distance along the direction the camera is looking, you can project over the the z basis from its transform:

var difference = global_transform.origin - camera.global_transform.origin
var distance = difference.project(camera.global_transform.basis.z).length()

Either way, you just use some scale factor divided by the distance:

var scale = factor * get_viewport().size.y / distance
label.rect_scale = Vector2(scale, scale)

And you tweak the factor to your liking.


What about the field of view? Right.

Computing the screen size of 3D object given its real size, the field of view and distance took me some figuring out. This is how I made sense of it:

First we will find the size the screen would take on the 3D world at the given distance from the camera. Let us consider half the size. So we have a right triangle with the camera one the corner, one side is in the direction the camera is looking and its size is the distance, the angle there is half the field of view:

         3d_screen_size/2
         ┌────────────
         │          ⟋
         │        ⟋
distance │      ⟋
         │    ⟋
         │  ⟋
         │⟋ <- FOV/2
       camera

So that 3d_screen_size is distance * 2 * tan(FOV * 0.5).

We can then compute what proportion of that our object occupies:

proportion = object_size / 3d_screen_size

And scale that with the view port size:

size.y = viewport.size.y * proportion 

That is:

size.y = viewport.size.y * object_size / (distance * 2 * tan(FOV * 0.5))

Actually, let us get a scale:

scale = viewport.size.y / (distance * 2 * tan(FOV * 0.5))

So we can do this:

var scale = factor * get_viewport().size.y / (distance * 2 * tan(camera.fov * 0.5))
label.rect_scale = Vector2(scale, scale)

Again I have added a factor for you to tweak, because honestly, it is going to look huge on normal settings (considering that the size of the label in pixels is being taken as a size in 3D).

By the way, since you have some factor and - assuming no changes in the camera field of view - 2 * tan(camera.fov * 0.5) is just some constant. Then you probably can simply have some constant. And trigonometry stuff is not needed. Yeah, all that figuring out is just throw a constant factor at it.


Errata: The above method works until the field of view reaches 90º (a quarter turn). Then the tan formula will give you negatives.

Addendum: So here is yet another way to figure out the scale that does not have the problem I mentioned above:

var difference = global_transform.origin - camera.global_transform.origin
var depth = difference.project(camera.global_transform.basis.z).length()

var ref = camera.project_position(Vector2.ZERO, 1)
var scaled_ref = camera.project_position(Vector2.ZERO, depth)
var regular_size = ref - ref.project(camera_transform.basis.z)
var scaled_size = scaled_ref - scaled_ref.project(camera_transform.basis.z)
var scale = regular_size.length() / scaled_size.length()
rect_scale = scale_factor * Vector2(scale, scale)

The code projects the top left corner of the viewport to 3D space using the camera projection at two different depths: 1, and the distance to the object along the view line of the camera (depth).

That should give you points in the top left line of the viewing frustum (within rounding error), at depth 1, and at the depth of the object.

Then the code projects them into the view line of the camera, and take the difference. This gives you two vectors normal to the view line of the camera, so we an use their relative size for scaling.


Render to a texture

And those aren't the only ways to go about this.

You can render the Label to a texture by placing it inside a Viewport node with its own Camera2D. And then make a ViewportTexture from it and set it to a 3D object.

See the official 2D in 3D demo project

\$\endgroup\$
2
  • \$\begingroup\$ Wow amazing write up, lots of information and it's incredibly helpful. I have tried rendering to a viewport by following both the 2D in 3D demo project and the GUI in 3D demo project but it always results in blurry text close up, even with dynamic fonts. \$\endgroup\$ Commented Jun 2, 2021 at 21:19
  • \$\begingroup\$ @Mr.Smithyyy If only we had a signed distance field texture solution ready to use. You could get crispy outlines every time. You probably can do it with a bitmap texture made specially for it and a some shader code. I haven't tried though. By the way, I believe for VR rendering to texture is the better approach, because it will actually match the depth. \$\endgroup\$
    – Theraot
    Commented Jun 2, 2021 at 21:47

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .