Property forwarder (show/edit select nested node properties in a target node)

Hi forum. I thought this little util script might be worth sharing. (Not technically a plugin, but this may be the most suitable place.)

UPDATE: Please see my follow-up reply below because I’ve since found out that this approach may not be reliable in some operating systems or potentially on slower hardware. So I can no longer recommend using it, unfortunately.

As probably everyone here, I sometimes create scenes with a sub-node structure that I then add as instances to my main scene. But oftentimes, I want to be able to control properties of sub-nodes in that sub-scene structure. It’s easy enough to write a getter and setter in the scene’s root node, but since I needed a lot of such “forwarded properties”, I wrote a PropertyForwarder util class.

Here’s a random example.
I have a scene for a light which consists of:

Light (scene root node)
└─ LightSource
. . . . └─ StencilLight

So in my main scene, I only have the Light scene. I don’t want to use “editable children”; all should be in an easily accessible interface in the Light inspector.

So, below on the right are some properties from nested children that I forward using the PropertyForwarder so that they can be controlled from the root node.

And in the same way, I can access and modify these properties in my main scene, all neatly packed in collapsible property groups named by the child nodes:

I also added an optional feature so that select properties emit a forwarded_property_changed signal. I had a need for this because when I set my LightSource colour, by default, the nested StencilLight min and max colours should be applied based on that value. I’m handling that in the root Light node.

Unfortunately, this util class still requires some basic setup in the node that the properties are forwarded to (the Light node in this example). I don’t think there is a way to overwrite _get_property_list, _set and _get from the outside, but that step is required for this util to work. Also, the node must have the @tool keyword for the properties to show in the inspector. And, naturally, if you want to observe the changes you make to the child nodes in the sub-scene, these also need to be @tools.

Here’s a basic sample setup of the “receiving node” (the Light root node in the example above):

@tool # required for the PropertyForwarder to work
extends Node

# optional: declare the signal for property change notifications (only needed if you defined any emit_changed_signal properties)
signal forwarded_property_changed(node_path: String, property: StringName, new_value: Variant, old_value: Variant)

var property_forwarder: PropertyForwarder

func _init() -> void:
	property_forwarder = PropertyForwarder.new(self, {
		"LightSource": {
			"group_name": "Light Source", # optional custom name, defaults to the node name
			"properties": ["color", "render", "offset", "float_range", "float_frequency", "random_delay", "unique"], # the forwarded properties
			"emit_changed_signal": ["color"] # optional: properties that emit forwarded_property_changed signals
		},
		"LightSource/StencilLight": {
			"group_name": "Stencil Light", # optional custom name, defaults to the node name
			"properties": ["radius", "max_color", "min_color", "color_balance", "color_falloff", "depth_range", "flicker_strength", "flicker_speed", "flicker_offset", "radius_to_color_strength", "edge_smoothness", "mesh_radius", "unique"], # the forwarded properties
		}
	})

func _ready() -> void:
	forwarded_property_changed.connect(_on_forwarded_property_changed) # (only needed if you defined any emit_changed_signal properties)

func _get_property_list() -> Array:
	return property_forwarder.get_forwarded_property_list()

func _set(property: StringName, value: Variant) -> bool:
	return property_forwarder.set_property(property, value)

func _get(property: StringName) -> Variant:
	return property_forwarder.get_property(property)

# (only needed if you defined any emit_changed_signal properties)
func _on_forwarded_property_changed(node_path: String, property: StringName, new_value: Variant, old_value: Variant) -> void:
	print("Forwarded property changed: ", node_path, ".", property, ", new value: ", new_value, ", old value: ", old_value)

And the actual PropertyForwarder util class:

## A util class to forward properties from nodes to a target node.
## This allows grouping related properties in the inspector under a single node. This is especially useful when nested
## tscn sub scenes are used in a main scene, so that we can change select child node properties directly from the sub
## scene root node in the inspector.
## This can be done manually by creating getters and setters for each property in the parent node, but this class
## automates that process.
## Note that the the `target_node` needs to have the `@tool` keyword for this to work. Also, nodes whose properties are
## forwarded need to have the `@tool` keyword to ensure that property changes are reflected in the editor.

class_name PropertyForwarder
extends RefCounted

var target_node: Node
var forwarded_nodes: Dictionary = {}
var _cached_values: Dictionary = {}

func _init(node: Node, forwards: Dictionary = {}) -> void:
	target_node = node
	forwarded_nodes = forwards

func get_forwarded_property_list() -> Array:
	var properties := []

	for node_path: String in forwarded_nodes:
		var config: Dictionary = forwarded_nodes[node_path]
		var node: Node = target_node.get_node_or_null(NodePath(node_path))
		var group_name: StringName = config.get("group_name", "")

		add_forwarded_properties(properties, node, config.properties, group_name)

	return properties

func set_property(property: StringName, value: Variant) -> bool:
	for node_path: String in forwarded_nodes:
		var config: Dictionary = forwarded_nodes[node_path]
		var node: Node = target_node.get_node_or_null(NodePath(node_path))

		if set_forwarded_property(property, value, node, config.properties, node_path):
			return true
	return false

func get_property(property: StringName) -> Variant:
	for node_path: String in forwarded_nodes:
		var config: Dictionary = forwarded_nodes[node_path]
		var node: Node = target_node.get_node_or_null(NodePath(node_path))

		var result: Variant = get_forwarded_property(property, node, config.properties)
		if result != null:
			return result
	return null

func add_forwarded_properties(properties: Array, node: Node, forwarded_props: Array, group_name: StringName = "") -> void:
	if not node:
		return

	var display_name: StringName = group_name if group_name != "" else node.name

	properties.append({
		"name": display_name,
		"type": TYPE_NIL,
		"usage": PROPERTY_USAGE_GROUP
	})

	var child_props := node.get_property_list()

	for prop in child_props:
		if prop.name in forwarded_props:
			var new_prop := prop.duplicate()
			new_prop.name = prop.name
			new_prop.usage = PROPERTY_USAGE_DEFAULT
			properties.append(new_prop)

func set_forwarded_property(property: StringName, value: Variant, node: Node, forwarded_props: Array, node_path: String) -> bool:
	if not node or not property in forwarded_props:
		return false

	var config: Dictionary = forwarded_nodes[node_path]
	var emit_changed_signal: Array = config.get("emit_changed_signal", [])
	var old_value: Variant = null
	var should_emit_signal := property in emit_changed_signal

	if should_emit_signal:
		if target_node.has_signal("forwarded_property_changed"):
			var cache_key := node_path + ":" + property
			old_value = _cached_values.get(cache_key)
		else:
			push_warning("Target node does not have 'forwarded_property_changed' signal declared. Signal will not be emitted.")
			should_emit_signal = false

	node.set(property, value)

	if should_emit_signal:
		var cache_key := node_path + ":" + property
		_cached_values[cache_key] = value

		if old_value != value:
			target_node.emit_signal("forwarded_property_changed", node_path, property, value, old_value)

	return true

func get_forwarded_property(property: StringName, node: Node, forwarded_props: Array) -> Variant:
	if not node or not property in forwarded_props:
		return null

	return node.get(property)

Maybe this is helpful in some setups.

3 Likes

Replying to my own post above: while this looked very promising, I have since changed this to a custom Resource-based config approach. The PropertyForwarder turns out to be unreliable. It caused issues on game start on Android, where the saved values were only partially applied (it worked fine for shader parameters, but not for e.g. Node3D.scale, etc.). That’s probably due to a race condition, and I could find no way to fix it reliably.

A custom Resource that holds config property values for nested nodes in sub scenes is closer to the intended Godot architecture, and there should be no issues that arose from the PropertyForwarder which relied on being a @tool script where properties were forwarded at runtime. Custom resources are more work to set up, but in the end, because there is a potential to break things in a certain OS (or possibly on lower spec hardware that can more likely lead to race conditions), I would no longer recommend using the PropertyForwarder.

A custom config Resource is quite project-specific, so without going too much into the details, you can create a script with a custom class_name (for example, LightSourceConfig) that extends Resource.

Each config export var should be a setter that calls emit_changed(). You can use that signal to then apply the config variable/s in the scene’s root node, which also has an export var for the actual config resource. The setup is more complex, but just as a starting point.

I may post a full “configurable node system” in this forum at some point, but right now I don’t have it set up for very common use cases.

Or you do the most common way and set up export vars in the root node of the scene for the sub-node properties you need to be accessible in the main scene, and simply handle it in that way.

Anyway, the main point here is, unfortunately, the approach in my original post is not necessarily reliable, and it’d need quite a refactor to replace it with a different system.