Skip to content

Instantly share code, notes, and snippets.

@lyuma
Last active May 25, 2025 02:36
Show Gist options
  • Save lyuma/0bb0d9306812905053c03ebf31f47bcf to your computer and use it in GitHub Desktop.
Save lyuma/0bb0d9306812905053c03ebf31f47bcf to your computer and use it in GitHub Desktop.
Records position and rotation changes to a node tree.
# node_animation_recorder.gd
# Copyright (c) 2025- Lyuma and V-Sekai developers
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
extends Node
class_name NodeAnimationRecorder
@export var initial_anim: Animation:
set(value):
initial_anim = value
if is_node_ready():
initialize_anim()
@export var anim_root: NodePath = ^".."
@export var initial_timestamp: float = -1
@export_custom(PROPERTY_HINT_GLOBAL_SAVE_FILE, "*.anim") var animation_path: String
@export var save_interval: float = -1
@export var node_paths: Array[NodePath]
@export var extra_properties: PackedStringArray
var anim: Animation
var root_node: Node
var node_path_cache: Dictionary[Node, NodePath]
var node_3d_cache: Dictionary[Node3D, NodePath]
var node_pos_eq_timestamp: Dictionary[Node3D, float]
var node_rot_eq_timestamp: Dictionary[Node3D, float]
var node_pos_track_cache: Dictionary[NodePath, int]
var node_rot_track_cache: Dictionary[NodePath, int]
var node_position_cache: Dictionary[Node3D, Vector3]
var node_rotation_cache: Dictionary[Node3D, Quaternion]
var node_extra_eq_timestamp: Dictionary[Node3D, PackedFloat64Array]
var node_extra_track_cache: Dictionary[NodePath, PackedInt64Array]
var node_extra_value_cache: Dictionary[Node3D, Array]
var timestamp: float
var last_save: float
func _child_node_added(node: Node):
add_node(node, root_node.get_path_to(node))
func add_node(node: Node, node_path: NodePath):
if node_path_cache.has(node):
return
node.child_entered_tree.connect(_child_node_added)
var node_3d := node as Node3D
if node_3d != null:
node_3d_cache[node_3d] = node_path
if not node_pos_track_cache.has(node_path):
var pos_track_idx := anim.add_track(Animation.TYPE_POSITION_3D)
node_pos_track_cache[node_path] = pos_track_idx
anim.track_set_path(pos_track_idx, node_path)
var pos := node_3d.position
anim.position_track_insert_key(pos_track_idx, timestamp, pos)
node_position_cache[node_3d] = pos
if not node_rot_track_cache.has(node_path):
var rot_track_idx := anim.add_track(Animation.TYPE_ROTATION_3D)
node_rot_track_cache[node_path] = rot_track_idx
anim.track_set_path(rot_track_idx, node_path)
var rot := node_3d.quaternion
anim.rotation_track_insert_key(rot_track_idx, timestamp, rot)
node_rotation_cache[node_3d] = rot
if not node_extra_track_cache.has(node_path):
var extra_track_cache: PackedInt64Array
var extra_value_cache: Array
var extra_eq_timestamp: PackedFloat64Array
extra_track_cache.resize(len(extra_properties))
extra_value_cache.resize(len(extra_properties))
extra_eq_timestamp.resize(len(extra_properties))
var has_extra: bool = false
for i in range(len(extra_properties)):
extra_track_cache[i] = -1
extra_eq_timestamp[i] = -1
var prop := extra_properties[i]
if typeof(node.get(prop)) != TYPE_NIL:
var extra_track_idx := anim.add_track(Animation.TYPE_VALUE)
extra_track_cache[i] = extra_track_idx
anim.track_set_path(extra_track_idx, str(node_path) + ":" + prop)
var val: Variant = node.get(prop)
anim.track_insert_key(extra_track_idx, timestamp, val)
extra_value_cache[i] = val
has_extra = true
if has_extra:
node_extra_eq_timestamp[node_3d] = extra_eq_timestamp
node_extra_track_cache[node_path] = extra_track_cache
node_extra_value_cache[node_3d] = extra_value_cache
else:
node_extra_track_cache[node_path] = PackedInt64Array()
for child_node in node.get_children():
add_node(child_node, NodePath(str(node_path) + "/" + str(child_node.name)))
func _ready():
initialize_anim()
func initialize_anim():
node_path_cache.clear()
node_pos_track_cache.clear()
node_rot_track_cache.clear()
if initial_anim == null:
anim = Animation.new()
else:
anim = initial_anim.duplicate() as Animation
if initial_timestamp == -1:
timestamp = anim.length
else:
timestamp = initial_timestamp
last_save = timestamp
root_node = get_node(anim_root)
# Cache existing animation tracks
for track_idx in range(anim.get_track_count()):
var typ := anim.track_get_type(track_idx)
var node_path := anim.track_get_path(track_idx)
if typ == Animation.TYPE_POSITION_3D:
node_pos_track_cache[node_path] = track_idx
if typ == Animation.TYPE_ROTATION_3D:
node_rot_track_cache[node_path] = track_idx
# Now add existing nodes from the scene.
for node_path in node_paths:
var node: Node = get_node(node_path)
add_node(node, root_node.get_path_to(node))
func _process(delta: float):
timestamp += delta
anim.length = timestamp
for node_3d in node_3d_cache:
var node_path := node_3d_cache[node_3d]
var pos_idx := node_pos_track_cache[node_path]
var rot_idx := node_rot_track_cache[node_path]
var pos: Vector3 = node_3d.position
if node_3d not in node_position_cache or not node_position_cache[node_3d].is_equal_approx(pos):
if node_pos_eq_timestamp.has(node_3d) and node_pos_eq_timestamp[node_3d] != -1:
# print("POS " + str(pos_idx) + ": " + str(node_pos_eq_timestamp[node_3d]) + " flat" + " count=" + str(anim.track_get_key_count(pos_idx)))
anim.position_track_insert_key(pos_idx, node_pos_eq_timestamp[node_3d], node_position_cache[node_3d])
node_position_cache[node_3d] = pos
# print("POS " + str(pos_idx) + ": " + str(timestamp) + " count=" + str(anim.track_get_key_count(pos_idx)))
anim.position_track_insert_key(pos_idx, timestamp, pos)
node_pos_eq_timestamp[node_3d] = -1
else:
node_pos_eq_timestamp[node_3d] = timestamp
var rot: Quaternion = node_3d.quaternion
if node_3d not in node_rotation_cache or not node_rotation_cache[node_3d].is_equal_approx(rot):
if node_rot_eq_timestamp.has(node_3d) and node_rot_eq_timestamp[node_3d] != -1:
# print("rot " + str(rot_idx) + ": " + str(node_rot_eq_timestamp[node_3d]) + " flat" + " count=" + str(anim.track_get_key_count(rot_idx)))
anim.rotation_track_insert_key(rot_idx, node_rot_eq_timestamp[node_3d], node_rotation_cache[node_3d])
node_rotation_cache[node_3d] = rot
# print("rot " + str(rot_idx) + ": " + str(timestamp) + " count=" + str(anim.track_get_key_count(rot_idx)))
anim.rotation_track_insert_key(rot_idx, timestamp, rot)
node_rot_eq_timestamp[node_3d] = -1
else:
node_rot_eq_timestamp[node_3d] = timestamp
if node_extra_track_cache.get(node_path):
var extra_track_cache: PackedInt64Array = node_extra_track_cache[node_path]
var extra_value_cache: Array = node_extra_value_cache[node_3d]
var extra_eq_timestamp: PackedFloat64Array = node_extra_eq_timestamp[node_3d]
for i in range(len(extra_track_cache)):
var track_idx := extra_track_cache[i]
var val: Variant = node_3d.get(extra_properties[i])
var equal: bool = val == extra_value_cache[i]
match typeof(val):
TYPE_VECTOR2, TYPE_VECTOR3, TYPE_VECTOR4, TYPE_BASIS, \
TYPE_TRANSFORM2D, TYPE_TRANSFORM3D, TYPE_AABB, TYPE_COLOR, \
TYPE_RECT2, TYPE_QUATERNION, TYPE_PROJECTION, TYPE_PLANE:
equal = val.is_equal_approx(extra_value_cache[i])
TYPE_FLOAT:
equal = is_equal_approx(val as float, extra_value_cache[i])
if not equal:
if extra_eq_timestamp[i] != -1:
anim.track_insert_key(track_idx, extra_eq_timestamp[i], extra_value_cache[i])
extra_value_cache[i] = val
anim.track_insert_key(track_idx, timestamp, val)
extra_eq_timestamp[i] = -1
else:
extra_eq_timestamp[i] = timestamp
if save_interval >= 0 and last_save + save_interval < timestamp:
ResourceSaver.save(anim, animation_path + "tmp.anim", ResourceSaver.FLAG_COMPRESS)
DirAccess.open("res://").rename_absolute(animation_path + "tmp.anim", animation_path)
last_save = timestamp
func _exit_tree() -> void:
OS.delay_msec(100)
pass # ResourceSaver.save(anim, animation_path, ResourceSaver.FLAG_COMPRESS)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment