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
.
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
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
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
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
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.
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
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.
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.
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 ViewportTexture
s.
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