Skip to main content
1 of 3
Theraot
  • 27.5k
  • 4
  • 52
  • 79

First of all, I want to say, I sympathize. I have gone down the path of making a custom variation of a Node by composition and turning it into a scene and then having to rely on reaching deep into the structure to use it. And I have also implemented property delegates for ease of use.

The current Godot response is Editable Children. You can instance a scene with editable children, and this will allow you to overwrite the properties of the children on the editor.

There have also been some proposals. For example: Automate delegation of exported properties to internal nodes to improve GDScript abstractions.


but that feels clunky and prone to breaking changes when new methods are added to the Camera class.

I submit to you that the engine will not change behind your back.

If you are making a game, you decide if and when you upgrade to a new version of the engine, at which point you can update your class.

If you are making an addon or library, it is expected that you would release a new version if there are significant changes to the engine.

And you might even have a tool generate the code for your custom class.

However, I understand this is not ideal.


On the issue with what you want…

You could talk directly to VisualServer to create a low level camera (which is what the Camera node does behind the scenes). Or you could simply create the Camera node from code. But the node that does it would not be a Camera. So when you get it with get_node, what you get is not a Camera.

That leads you to either delegate the properties of a Camera… Or extend Camera except its Transform will not longer be the position of the camera, instead the position would be based on that Transform plus some other Transform that describe its motion around a point. That is, you want to break Liskov substitution principle.

The correct approach, is not to make a Camera, but a thing that has a Camera, and there is a perfectly reasonable to do that in engine: A Spatial with a Camera. And there is a perfectly reasonable way to reuse it: a scene. We are not going to change that.

So, we need a workaround get_node to get a Camera, and we need a solution to delegate the properties.


Before we go into that, I want to suggest an alternative approach. Consider that sometimes you might want to orbit something around something else… And have that first something not be a Camera. It is possible to create a custom Node that extends Spatial, that has the logic to orbit around something else (perhaps taking advantage of a NodePath). You can conceptualize it as a Joint, or you can conceptualize it a RemoteTransform.

In fact, it might be useful to have different controls for the Camera (e.g. first person, third person orbiting around the player, looking from a fixed position in a room, etc) and interpolate between them with an InterpolatedCamera. In fact, you don't necessarily need custom Cameras for those cases, you just need Spatials to serve as targets for the InterpolatedCamera.


Not convinced? Ok...

All problems in computer science can be solved by another level of indirection.

-- Attributed to Butler Lampson

So, we have Cameras, and we have Camera-like things. Let us call them CameraHolder. We are going to make a helper that gives you a Camera regardless if you give it a Camera or a CameraHolder. Easy:

class_name CameraGetter extends Object

static func from(node:Node) -> Camera:
    var camera_holder:CameraHolder = node as CameraHolder
    var camera:Camera

    if is_instance_valid(camera_holder)
        camera = camera_holder.get_camera()
    else:
        camera = node as Camera

    if is_instance_valid(camera)
        return camera

    return null

And now you can say CameraGetter.from(get_node("CameraPivot")) and it will give you a Camera.

We of course need to write the class CameraHolder. It must have a get_camera method. And we want to be able to use as if it were a Camera, in particular we want to appear to have all the properties of a Camera.

And that last part is tricky. This is the outline of what we will use:

tool class_name CameraHolder extends Spatial

func get_camera() -> Camera:
    return null

func _get_property_list() -> Array:
    return []

func _get(property: String):
    return null

func _set(property: String, value) -> bool:
    return false

First thing to notice is that there is a method get_camera, which in this case returns null. We will make everything else will refer to that get_camera method, so that a class that extends this one can simply override it.

Second, we are using _get_property_list. Godot will call get_property_list (which will call _get_property_list behind the scenes) to figure out what properties the Node exposes. In particular _get_property_list must return what additional properties the Node exposes. With _get_property_list you can do what you would do with export and much more.

The idea is to report all the properties of a Camera, and we can get one of those from get_camera, so we will use that to get its properties. We don't want to tell the editor to persist these properties, because we will delegate them to a Camera node that can be persisted instead. I will come back to that.

Then we have _get and _set, which will handle the case when we set properties that the Node does not have. However, you need to know that Godot will call them first, and only when they report that the property does not exist, then it will go to an actual property.

This is a problem, because we cannot rely on whether or not the Camera has a property, because our Node is also a Spatial and we don't want to delegate the its properties.

In fact, one of the properties is the script, and I spent a lot of time figuring out why the script was disappearing. It was because I was delegating the script property to a Camera that didn't have a script.

This problem is further complicated by the fact that calling get from _get will result in a stack overflow. Similarly calling get_property_list from _get_property_list is a stack overflow. And Godot will crash.

Thus, first we need to somehow sort out what properties do an Spatial have. This is the solution I came up with:

var _spatial_property_names:Array = []

func _get_spatial_property_names() -> Array:
    if _spatial_property_names.size() == 0:
        var spatial = Spatial.new()
        var spatial_properties = spatial.get_property_list()
        for property in spatial_properties:
            if property.type == TYPE_NIL:
                continue

            _spatial_property_names.append(property.name)

        spatial.free()
    
    return _spatial_property_names

That is, we are going to create a new Spatial, and ask it what properties does it have. We also skip TYPE_NIL, because these are used to communicate property categories to the editor. They are not real properties. And, of course, also memoize the result in an array for future reference. We don't want to make a node every time we need this.

Now, we can build our property list like this:

var _camera_properties:Array = []

func _get_property_list() -> Array:
    var spatial_property_names = _get_spatial_property_names()
    if _camera_properties.size() == 0:
        var camera:Camera = get_camera()
        if is_instance_valid(camera):
            var properties = camera.get_property_list()
            for property in properties:
                if !spatial_property_names.has(property.name):
                    property.usage = property.usage & ~PROPERTY_USAGE_STORAGE 
                    _camera_properties.append(property)

    return _camera_properties

As you can see, if a property is in the list of properties that Spatial has, it is not an additional property. It would be a duplicate. And we don't report it.

For those that we report, we make sure they don't have the flag PROPERTY_USAGE_STORAGE set, so the editor will not persist them. We, of course, want to let the editor persist the Spatial properties, which we skipped.

And we are also memoizing this result.

Finally, these are _get and _set:

func _get(property: String):
    var spatial_property_names = _get_spatial_property_names()
    if spatial_property_names.has(property):
        return null

    var camera:Camera = get_camera()
    if is_instance_valid(camera):
        return camera.get(property)

    return null

func _set(property: String, value) -> bool:
    var spatial_property_names = _get_spatial_property_names()
    if spatial_property_names.has(property):
        return false

    var camera:Camera = get_camera()
    if is_instance_valid(camera) and property in camera:
        get_camera().set(property, value)
        return true

    return false

As you would expect, we rely on _get_spatial_property_names to know what property to avoid delegating to the Camera.

Ah, and get_camera can keep returning null. It is ok. We are checking with is_instance_valid everywhere we use it.

Now, we can extend that class to implement CameraPivot:

tool class_name CameraPivot extends CameraHolder

func get_camera() -> Camera:
    if is_inside_tree():
        return get_node_or_null("Camera") as Camera

    return null

Here I'm being extra careful because I know Godot will call this in _get_property_list. So I do not to call get_node which can result in an error, I call get_node_or_null. And I do not call get_node_or_null if the Node is not in the scene tree.

And this is my implementation for the orbit logic:

export(float) var orbit_distance:float setget set_orbit_distance
export(float, -180, 180) var orbit_yaw:float setget set_orbit_yaw
export(float, -180, 180) var orbit_pitch:float setget set_orbit_pitch
export(float, -180, 180) var orbit_roll:float setget set_orbit_roll

func set_orbit_distance(new_value:float) -> void:
    orbit_distance = new_value
    update_transform()

func set_orbit_yaw(new_value:float) -> void:
    orbit_yaw = new_value
    update_transform()

func set_orbit_pitch(new_value:float) -> void:
    orbit_pitch = new_value
    update_transform()

func set_orbit_roll(new_value:float) -> void:
    orbit_roll = new_value
    update_transform()

func update_transform() -> void:
    var camera:Camera = get_camera()
    if !is_instance_valid(camera):
        return

    camera.transform = \
          Transform.IDENTITY.rotated(Vector3.UP, deg2rad(orbit_yaw)) \
        * Transform.IDENTITY.rotated(Vector3.RIGHT, deg2rad(orbit_pitch)) \
        * Transform.IDENTITY.rotated(Vector3.FORWARD, deg2rad(orbit_roll)) \
        * Transform.IDENTITY.translated(Vector3.BACK * orbit_distance)

You don't have to do it this way, of course. You can extend CameraHolder and implement get_camera to get you the Camera from wherever you have it, and add whatever properties and methods make sense to you.

The only thing missing is that the "Preview" toggle will not appear for out CameraHolder or our CameraPivot. I suppose this can addressed with a custom gizmo (which is a type of plugin). However, I haven't delved into that.

Theraot
  • 27.5k
  • 4
  • 52
  • 79