Toon proxy builder, part two

In this chapter, we’ll make the proxy builder apply materials and react to originals being renamed or deleted.

Setting materials and settings

The proxy has two more jobs: making sure the proxies’ settings match the export variables and applying materials on the proxies.

We start with the _set_materials function that takes a parent and a data type. This will be a recursive function that calls itself, replacing the parent by each child (which in turn is parent to another) until we can’t find any more MeshInstance to apply materials to. This is because it could be a Spatial that holds several meshes instead of a single MeshInstance. The user shouldn’t have to worry about that.

For safety, we stop the function if the parent is null.

func _set_materials(parent: Node, type: int) -> void:
    if not parent:
        return

If the parent is a MeshInstance, we apply materials. We get the number of surfaces and iterate over them in a for loop.

    if parent is MeshInstance:
        var mat_count: int = parent.get_surface_material_count()

        for mat in range(mat_count):

We check the datatype and apply the correct material. Note the ternary operator in the specular material; if we have an override, we apply it, otherwise we use the default one in ToonSceneBuilder.

        match type:
            ToonSceneBuilder.DataType.LIGHT:
                parent.set_surface_material(mat, builder.white_diffuse_material)
            ToonSceneBuilder.DataType.SPECULAR:
                parent.set_surface_material(mat, specular_material if specular_material else builder.specular_material)

Then, outside of the if block, we apply the recursion. We call the function again for each children.

    for child in parent.get_children():
        _set_materials(child, type)

We can call _set_materials in the _ready function at the end to make sure any proxies have the right materials. This should happen in both the editor and in game, so it should be outside of the Engine.editor_hint if block.

func _ready() -> void:
    #...
    if Engine.editor_hint:
        #...

    _set_materials(light_proxy, ToonSceneBuilder.DataType.LIGHT)
    _set_materials(specular_proxy, ToonSceneBuilder.DataType.SPECULAR)

We have some setter functions to populate. We start with _set_light_role. It sets the light color to red, green or blue in the light data and disables all but the key light in the specular data.

Like previous setters, we apply the value, check if we’re in the editor and yield until ready.

func _set_light_role(value: int) -> void:
    light_role = value

    if not Engine.editor_hint:
        return
    if not is_inside_tree():
        yield(self, "ready")

If the proxy is a light, we apply the appropriate settings: color for light data, energy and visibility in the specular data.

    if light_proxy is Light:
        match light_role:
            LightRole.KEY:
                light_proxy.light_color = Color.red
            LightRole.FILL:
                light_proxy.light_color = Color.green
            LightRole.KICK:
                light_proxy.light_color = Color.blue

    if specular_proxy is Light:
        match light_role:
            LightRole.KEY:
                specular_proxy.light_energy = 1
                specular_proxy.show()
            LightRole.FILL:
                specular_proxy.light_energy = 0
                specular_proxy.hide()
            LightRole.KICK:
                specular_proxy.light_energy = 0
                specular_proxy.hide()

Why not delete the fill and kick light from the specular? It’d make for a cleaner scene tree, but it’s far easier to call show() or hide() than to detect the need to add a brand new light or delete an existing one.

For _sets_emits_shadows, we toggle the shadow_enabled property on any lights we find.

func _set_emits_shadows(value: bool) -> void:
    emits_shadows = value

    if not Engine.editor_hint:
        return
    if not is_inside_tree():
        yield(self, "ready")

    if light_proxy is Light:
        light_proxy.shadow_enabled = emits_shadows
        specular_proxy.shadow_enabled = emits_shadows

We use the _set_materials functions we created to apply the specular material when it changes. If the user clears it, it becomes null and the ToonSceneBuilder’s will be used. Otherwise, it applies the new one.

func _set_specular_material(value: ShaderMaterial) -> void:
    specular_material = value
    if not is_inside_tree():
        yield(self, "ready")

    _set_materials(specular_proxy, ToonSceneBuilder.DataType.SPECULAR)

Renaming and clean-up

When the designer renames a node, the proxy should rename the original too. Names are how we find them at the start of the game or in the editor and if they don’t match, the proxy will create a brand new node instead.

In the Engine.editor_hint if block inside of _ready, we connect to the parent node’s renamed signal. We also connect to the tree_exiting signal to keep track of the user deleting an original.

But when the scene exits or changes, tree_exiting also happens, so we need a mechanism to detect that case. We connect to the scene root’s tree_exiting, too. If tree_exiting happens on both, then the scene is transitioning and we don’t delete the proxy.

    if Engine.editor_hint:
        _build_missing_proxies(parent)

        parent.connect("renamed", self, "_on_parent_renamed")
        parent.connect("tree_exiting", self, "_on_parent_tree_exiting")
        scene_root.connect("tree_exiting", self, "_on_root_tree_exiting")

When the user renames the node, we have to apply the new name to the proxy and update the RemoteTransform’s path to the new name.

func _on_parent_renamed() -> void:
    if light_proxy:
        light_proxy.name = get_parent().name
        light_remote.remote_path = (
            "../%s"
            % get_parent().get_path_to(light_proxy)
        )

    if specular_proxy:
        specular_proxy.name = get_parent().name
        specular_remote.remote_path = (
            "../%s"
            % get_parent().get_path_to(specular_proxy)
        )

When the user deletes the parent, we set an abort_deletion class variable to false, and connect to the scene tree’s idle_frame signal with a function. Waiting for a frame lets us see if the scene root also exits. If it does, then we abort. Otherwise, then we can clean up the proxies from the scene tree.

Why not use a yield? A yield state of a deleted object cannot resume. Godot throws an error and fails to delete the proxies. The signal, meanwhile, lives in the scene tree object and persists.

Note the use of the CONNECT_ONESHOT flag. If the scene root does not get deleted, we don’t want it to keep triggering every frame on an invalid object.

func _on_parent_tree_exiting() -> void:
    abort_deletion = false
    Engine.get_main_loop().connect("idle_frame", self, "_on_SceneTree_idle_frame", [], CONNECT_ONESHOT)


func _on_SceneTree_idle_frame() -> void:
    if abort_deletion:
        return

    if light_proxy:
        light_proxy.queue_free()

    if specular_proxy:
        specular_proxy.queue_free()


func _on_root_tree_exiting() -> void:
    abort_deletion = true

In conclusion

Save your work and reload the scene. The proxy builder automatically populates the viewports with a proxy and ensures they have the correct materials. It keeps their name in sync and cleans up when the parent cleans up.

The ToonProxyBuilder code

tool
class_name ToonProxyBuilder
extends Node

enum LightRole { KEY, FILL, KICK }

export (LightRole) var light_role := 0 setget _set_light_role
export var emits_shadows := false setget _set_emits_shadows
export var specular_material: ShaderMaterial setget _set_specular_material

var light_proxy: Node
var specular_proxy: Node

var light_remote: RemoteTransform
var specular_remote: RemoteTransform

var abort_deletion := false

onready var scene_root: Node = (
    get_tree().edited_scene_root
    if Engine.editor_hint
    else get_tree().root
)
onready var builder: ToonSceneBuilder = scene_root.find_node(
    "ToonSceneBuilder", true, false
)


func _ready():
    if not builder:
        return

    var parent := get_parent()

    light_proxy = builder.light_data.find_node(parent.name, true, false)
    specular_proxy = builder.specular_data.find_node(parent.name, true, false)

    if Engine.editor_hint:
        _build_missing_proxies(parent)

        parent.connect("renamed", self, "_on_parent_renamed")
        parent.connect("tree_exiting", self, "_on_parent_tree_exiting")
        scene_root.connect("tree_exiting", self, "_on_root_tree_exiting")

    _set_materials(light_proxy, ToonSceneBuilder.DataType.LIGHT)
    _set_materials(specular_proxy, ToonSceneBuilder.DataType.SPECULAR)


func _build_missing_proxies(parent: Node) -> void:
    var light_missing: bool = light_proxy == null
    var specular_missing: bool = specular_proxy == null

    yield(get_tree(), "idle_frame")

    if light_missing or specular_missing:
        parent.remove_child(self)

    if light_missing:
        var result := _build_remote_duplicates(
            parent, ToonSceneBuilder.DataType.LIGHT
        )

        light_proxy = result.proxy
        light_remote = result.proxy_remote
    else:
        light_remote = parent.find_node("LightRemote", true, false)

    if specular_missing:
        var result := _build_remote_duplicates(
            parent, ToonSceneBuilder.DataType.SPECULAR
        )

        specular_proxy = result.proxy
        specular_remote = result.proxy_remote
    else:
        specular_remote = parent.find_node("SpecularRemote", true, false)

    self.light_role = light_role
    self.emits_shadows = emits_shadows

    if light_missing or specular_missing:
        parent.add_child(self)
        owner = scene_root


func _build_remote_duplicates(parent: Node, type: int) -> Dictionary:
    var proxy: Node = parent.duplicate()
    var proxy_remote := RemoteTransform.new()

    parent.add_child(proxy_remote)
    proxy_remote.owner = scene_root

    match type:
        ToonSceneBuilder.DataType.LIGHT:
            builder.light_data.add_child(proxy)
            if proxy is Light:
                proxy.light_color = Color.red
            proxy_remote.name = "LightRemote"
        ToonSceneBuilder.DataType.SPECULAR:
            builder.specular_data.add_child(proxy)
            proxy_remote.name = "SpecularRemote"

    proxy.owner = scene_root

    proxy_remote.remote_path = "../%s" % parent.get_path_to(proxy)

    return {"proxy": proxy, "proxy_remote": proxy_remote}


func _set_materials(parent: Node, type: int) -> void:
    if not parent:
        return
    if parent is MeshInstance:
        var mat_count: int = parent.get_surface_material_count()

        for mat in range(mat_count):
            match type:
                ToonSceneBuilder.DataType.LIGHT:
                    parent.set_surface_material(mat, builder.white_diffuse_material)
                ToonSceneBuilder.DataType.SPECULAR:
                    parent.set_surface_material(mat, specular_material if specular_material else builder.specular_material)

    for child in parent.get_children():
        _set_materials(child, type)


func _set_light_role(value: int) -> void:
    light_role = value

    if not Engine.editor_hint:
        return
    if not is_inside_tree():
        yield(self, "ready")

    if light_proxy is Light:
        match light_role:
            LightRole.KEY:
                light_proxy.light_color = Color.red
            LightRole.FILL:
                light_proxy.light_color = Color.green
            LightRole.KICK:
                light_proxy.light_color = Color.blue

    if specular_proxy is Light:
        match light_role:
            LightRole.KEY:
                specular_proxy.light_energy = 1
                specular_proxy.show()
            LightRole.FILL:
                specular_proxy.light_energy = 0
                specular_proxy.hide()
            LightRole.KICK:
                specular_proxy.light_energy = 0
                specular_proxy.hide()


func _set_emits_shadows(value: bool) -> void:
    emits_shadows = value

    if not Engine.editor_hint:
        return
    if not is_inside_tree():
        yield(self, "ready")

    if light_proxy is Light:
        light_proxy.shadow_enabled = emits_shadows
        specular_proxy.shadow_enabled = emits_shadows


func _set_specular_material(value: ShaderMaterial) -> void:
    specular_material = value
    if not is_inside_tree():
        yield(self, "ready")

    _set_materials(specular_proxy, ToonSceneBuilder.DataType.SPECULAR)


func _on_parent_renamed() -> void:
    if light_proxy:
        light_proxy.name = get_parent().name
        light_remote.remote_path = (
            "../%s"
            % get_parent().get_path_to(light_proxy)
        )

    if specular_proxy:
        specular_proxy.name = get_parent().name
        specular_remote.remote_path = (
            "../%s"
            % get_parent().get_path_to(specular_proxy)
        )


func _on_parent_tree_exiting() -> void:
    abort_deletion = false
    Engine.get_main_loop().connect("idle_frame", self, "_on_SceneTree_idle_frame", [], CONNECT_ONESHOT)


func _on_SceneTree_idle_frame() -> void:
    if abort_deletion:
        return

    if light_proxy:
        light_proxy.queue_free()

    if specular_proxy:
        specular_proxy.queue_free()


func _on_root_tree_exiting() -> void:
    abort_deletion = true