Coding an editorScript to control cameras

We’ve seen how to code tool scripts that create and populate viewport nodes for us. Now we can code a plugin that finds the editor’s camera and controls the light and specular viewport cameras.

The following code happens inside of plugin.gd.

Data

There’s some data we need to work: the two viewports and their cameras and the editor’s viewport. The editor’s viewport is not a type that GDScript exposes, but it does inherit from the GUI nodes, so we can settle for a Control base class.

As a bonus, we also throw in a CheckBox for the Preview checkbox when selecting a camera.

var light_camera: Camera
var specular_camera: Camera

var light_viewport: Viewport
var specular_viewport: Viewport

var editor_viewport: Control
var preview_checkbox: CheckBox

When the plugin should run

Whenever the editor opens a Node or Resource, Godot calls the handles function that takes the opened object in question. We check what that object is and tell Godot whether it should run or not. If so, it runs editor specific functions.

In our case, the plugin should run if the object that the user opened is the current scene and there’s a ToonSceneBuilder.

func handles(object: Object) -> bool:
    if object == get_tree().edited_scene_root:
        var builder: ToonSceneBuilder = _find_by_type_name(object, "ToonSceneBuilder")
        if builder:

The _find_by_type_name function goes through the scene hierarchy recursively until it finds the right node or runs out of children.

func _find_by_type_name(parent: Node, type_name: String) -> Node:
    for child in parent.get_children():
        if child.get_class() == type_name:
            return child
        else:
            var result: Node = _find_by_type_name(child, type_name)
            if result:
                return result
    return null

The object must have a get_class function that returns the class name, so add one to ToonSceneBuilder.gd.

func get_class() -> String:
    return "ToonSceneBuilder"

If both of the conditions are true, then we fire a function to find the cameras so we can control them later. If either of them is false, then we return false.

func handles(object: Object) -> bool:
    if object == get_tree().edited_scene_root:
        var builder: ToonSceneBuilder = _find_toon_scene_builder(object)
        if builder:
            return _initialize_camera_control(object, builder, get_editor_interface())

    return false

Finding cameras

The ToonSceneBuilder has references to the viewports we can use. If they’re missing, we skip doing anything and return false. But if we find either, we tell Godot to warn us when the editor camera moves, update the viewport sizes to the editor size and return true. We use the _find_by_type_name function again to find the editor viewport for later use.

func _initialize_camera_control(object: Object, toon_builder: ToonSceneBuilder) -> bool:
    var editor_root := interface.get_editor_viewport()
    editor_viewport = _find_by_type_name(editor_root, "SpatialEditorViewport")

    light_viewport = toon_builder.light_data
    if light_viewport:
        light_viewport.size = editor_viewport.rect_size
        light_camera = light_viewport.get_camera()

    specular_viewport = toon_builder.specular_data
    if specular_viewport:
        specular_viewport.size = editor_viewport.rect_size
        specular_camera = specular_viewport.get_camera()

    if light_camera or specular_camera:
        set_input_event_forwarding_always_enabled()

        return true
    else:
        return false

Updating camera positions

The set_input_event_forwarding_always_enabled is a function that tells Godot to send updates whenever the editor camera moves. Those updates happen inside of the forward_spatial_gui_input function. If the function returns true, it consumes the event and the rest of the editor doesn’t receive it. In our case, we’re piggybacking on the signal so we return false to prevent consuming the event.

func forward_spatial_gui_input(camera: Camera, event: InputEvent) -> bool:
    _set_camera_and_viewports(camera.global_transform)
    return false

In the _set_camera_and_viewports function, we update the transform of the cameras and set their viewports to the editor viewport’s size.

func _set_camera_and_viewports(transform: Transform) -> void:
    if light_camera:
        light_camera.global_transform = transform
        light_viewport.size = editor_viewport.rect_size
    if specular_camera:
        specular_camera.global_transform = transform
        specular_viewport.size = editor_viewport.rect_size

ToonCamera and ToonViewportContainer

In theory, the transform of the viewport cameras changes whenever the editor gets an input event and the viewport sizes to the editor’s. But the viewport containers stretch viewports to their sizes and the main scene’s camera has remote transforms to control the viewport cameras. That’s fine when in game, but while in the editor, we want to turn that behavior off.

We create two new scripts: one extends ViewportContainer and the other extends Camera. They’re both tool scripts and have a class name.

ToonCamera

tool
class_name ToonCamera
extends Camera


func _ready() -> void:
    toggle_remotes()


# Sets the remote transform's activation on or off depending on if project is in Editor
func toggle_remotes() -> void:
    var is_game := not Engine.editor_hint

    var light_remote: RemoteTransform = find_node("LightRemote")
    if light_remote:
        light_remote.update_position = is_game
        light_remote.update_rotation = is_game
        light_remote.update_scale = is_game

    var specular_remote: RemoteTransform = find_node("SpecularRemote")
    if specular_remote:
        specular_remote.update_position = is_game
        specular_remote.update_rotation = is_game
        specular_remote.update_scale = is_game

ToonViewportContainer

tool
class_name ToonViewportContainer
extends ViewportContainer


func _enter_tree() -> void:
    stretch = not Engine.editor_hint

We can go back to ToonSceneBuilder.gd and replace all instances of ViewportContainer with ToonViewportContainer. One is inside of _find_viewport when using find_node:

var container: ToonViewportContainer = scene_root.find_node(
        viewport_name, true, false
    )

The other is in _build_data:

var view := ToonViewportContainer.new()

Restart Godot so the plugin refreshes. Re-opening the scene sets the size of viewports to the the size of your editor viewport and the toon shader looks correct from any angle. If there’s a delay (like using the mouse wheel) you can trigger an input event by moving the mouse inside of the viewport and it will ‘snap’ back to normal.

React on toggling Camera Preview mode

As a bonus, we can connect to the Preview checkbox’s pressed signal and then re-center the cameras accordingly.

func _connect_preview_checkbox(interface: EditorInterface) -> void:
    preview_checkbox = _find_by_type_name(editor_viewport, "CheckBox")

    if not preview_checkbox.is_connected(
        "pressed", self, "_on_Preview_pressed"
    ):
        preview_checkbox.connect("pressed", self, "_on_Preview_pressed")

In the reacting signal, we get the main camera from the main viewport and use its transform to set those in the viewports.

func _on_Preview_pressed() -> void:
    var camera := get_editor_interface().get_edited_scene_root().get_viewport().get_camera()
    _set_camera_and_viewports(camera.global_transform)

We call it alongside set_input_event_forwarding_always_enabled in _initialize_camera_control.

    if light_camera or specular_camera:
    set_input_event_forwarding_always_enabled()

    _connect_preview_checkbox(interface)

    return true

The last thing to do is to clean up when the plugin leaves the editor scene tree:

func _exit_tree() -> void:
    if preview_checkbox:
        preview_checkbox.disconnect("pressed", self, "_on_Preview_pressed")

Restart Godot again to reload the plugin and enable the feature.

In conclusion

You learned to make a plugin that works more closely with the editor than regular tool scripts can. It receives input events from the editor and matches viewport cameras to the viewport of the editor to update ViewportTextures.

tool
extends EditorPlugin

var light_camera: Camera
var specular_camera: Camera

var light_viewport: Viewport
var specular_viewport: Viewport

var editor_viewport: Control
var preview_checkbox: CheckBox


func _exit_tree() -> void:
    if preview_checkbox:
        preview_checkbox.disconnect("pressed", self, "_on_Preview_pressed")


func handles(object: Object) -> bool:
    if object == get_tree().edited_scene_root:
        var builder: ToonSceneBuilder = _find_by_type_name(object, "ToonSceneBuilder")
        if builder:
            return _initialize_camera_control(object, builder, get_editor_interface())

    return false


func forward_spatial_gui_input(camera: Camera, event: InputEvent) -> bool:
    _set_camera_and_viewports(camera.global_transform)
    return false


func _on_Preview_pressed() -> void:
    var camera := get_editor_interface().get_edited_scene_root().get_viewport().get_camera()
    _set_camera_and_viewports(camera.global_transform)


func _set_camera_and_viewports(transform: Transform) -> void:
    if light_camera:
        light_camera.global_transform = transform
        light_viewport.size = editor_viewport.rect_size
    if specular_camera:
        specular_camera.global_transform = transform
        specular_viewport.size = editor_viewport.rect_size


func _initialize_camera_control(object: Object, toon_builder: ToonSceneBuilder, interface: EditorInterface) -> bool:
    var editor_root := interface.get_editor_viewport()
    editor_viewport = _find_by_type_name(editor_root, "SpatialEditorViewport")
    
    light_viewport = toon_builder.light_data
    if light_viewport:
        light_viewport.size = editor_viewport.rect_size
        light_camera = light_viewport.get_camera()

    specular_viewport = toon_builder.specular_data
    if specular_viewport:
        specular_viewport.size = editor_viewport.rect_size
        specular_camera = specular_viewport.get_camera()

    if light_camera or specular_camera:
        set_input_event_forwarding_always_enabled()
        
        _connect_preview_checkbox(interface)
        
        return true
    else:
        return false


func _connect_preview_checkbox(interface: EditorInterface) -> void:
    preview_checkbox = _find_by_type_name(editor_viewport, "CheckBox")

    if not preview_checkbox.is_connected(
        "pressed", self, "_on_Preview_pressed"
    ):
        preview_checkbox.connect("pressed", self, "_on_Preview_pressed")


func _find_by_type_name(parent: Node, type_name: String) -> Node:
    for child in parent.get_children():
        if child.get_class() == type_name:
            return child
        else:
            var result: Node = _find_by_type_name(child, type_name)
            if result:
                return result
    return null