Skip to content

Commit eb4b8c7

Browse files
Add support for animated layer effects and a keyframe-based timeline (#1417)
* Start working on animatable layer effects * Interpolate colors & animated effects work on apply * Add nodes to change tween properties * Use smaller texts for the tween property option buttons * Fix crash when enabling animation, disabling and enabling it again * Remove keyframes * Load and save animated parameters in pxo files * Format * Start working on a keyframe based timeline * Create keyframes in the UI * Start working on keyframe value changing * Remove animated layer effect code from the layer FX settings * Refactor animated_params dictionary * Probably fix pxo loading At least old pxo files seem to be working properly * Update timeline when adding/removing keyframes * Use a tree for the layer elements Need to implement folding logic for the keyframes themselves though * Add a timeline cursor * Improve cursor moving logic * More timeline cursor improvements * Some UI improvements * Make timeline a bit prettier * Fix animated params pxo loading * Some small improvements * Fix scrolling * Don't create keyframe if there already is one * Fix frame container not resizing when clicking on keyframes * Added Constant transition * Add undo/redo when adding and deleting keyframes * Fix keyframe unselection * Start working on multiple selected keyframes * Change name of a script * Change properties of all selected keyframes With undo/redo support * [WIP] Move keyframes While it technically works, the UI side is not being updated. Also, I just realized that the way we're handling keyframe creation and deletion is wrong, because the nodes get freed when switching to a different layer, or a different project. So we most likely should just re-create the keyframe nodes when we add, delete or move them. Which means I also need to change the selected_keyframes array to store param_name and frame_index, instead of nodes. * Add IDs to keyframes for easier handling * Don't allow keyframes to go below frame 0 Mostly so they can't be lost. The proper solution would be to support negative frames, like Godot's animation player editor does, but this should work for now. * Zoom in the timeline * Support booleans and colors, scroll timeline when changing cel or layer * Implement the keyframe timeline inside the main timeline panel I feel like the UI does need some improvements still * Make properties grid container and delete keyframe button persistent nodes * Undo/redo refreshes the keyframe property UI * Hide tags when keyframe timeline is visible * Fix property nodes not having the correct name * Use a custom node to draw frames instead of using labels * Fix group layers passthrough mode not having its effect be animated * Don't create tracks for properties that currently cannot be animated Such as textures * Better documentation and future-proofing
1 parent b243e24 commit eb4b8c7

22 files changed

+1136
-23
lines changed
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[remap]
2+
3+
importer="texture"
4+
type="CompressedTexture2D"
5+
uid="uid://yhha3l44svgs"
6+
path="res://.godot/imported/keyframe.svg-35dec774d84fcacd926a7d0e2d227dd7.ctex"
7+
metadata={
8+
"vram_texture": false
9+
}
10+
11+
[deps]
12+
13+
source_file="res://assets/graphics/timeline/keyframe.svg"
14+
dest_files=["res://.godot/imported/keyframe.svg-35dec774d84fcacd926a7d0e2d227dd7.ctex"]
15+
16+
[params]
17+
18+
compress/mode=0
19+
compress/high_quality=false
20+
compress/lossy_quality=0.7
21+
compress/uastc_level=0
22+
compress/rdo_quality_loss=0.0
23+
compress/hdr_compression=1
24+
compress/normal_map=0
25+
compress/channel_pack=0
26+
mipmaps/generate=false
27+
mipmaps/limit=-1
28+
roughness/mode=0
29+
roughness/src_normal=""
30+
process/channel_remap/red=0
31+
process/channel_remap/green=1
32+
process/channel_remap/blue=2
33+
process/channel_remap/alpha=3
34+
process/fix_alpha_border=true
35+
process/premult_alpha=false
36+
process/normal_map_invert_y=false
37+
process/hdr_as_srgb=false
38+
process/hdr_clamp_exposure=false
39+
process/size_limit=0
40+
detect_3d/compress_to=1
41+
svg/scale=1.0
42+
editor/scale_with_editor_scale=false
43+
editor/convert_colors_with_editor_theme=false
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[remap]
2+
3+
importer="texture"
4+
type="CompressedTexture2D"
5+
uid="uid://dtx6hygsgoifb"
6+
path="res://.godot/imported/keyframe_selected.svg-2f24f41724ac5cd07c9e855421d962fe.ctex"
7+
metadata={
8+
"vram_texture": false
9+
}
10+
11+
[deps]
12+
13+
source_file="res://assets/graphics/timeline/keyframe_selected.svg"
14+
dest_files=["res://.godot/imported/keyframe_selected.svg-2f24f41724ac5cd07c9e855421d962fe.ctex"]
15+
16+
[params]
17+
18+
compress/mode=0
19+
compress/high_quality=false
20+
compress/lossy_quality=0.7
21+
compress/uastc_level=0
22+
compress/rdo_quality_loss=0.0
23+
compress/hdr_compression=1
24+
compress/normal_map=0
25+
compress/channel_pack=0
26+
mipmaps/generate=false
27+
mipmaps/limit=-1
28+
roughness/mode=0
29+
roughness/src_normal=""
30+
process/channel_remap/red=0
31+
process/channel_remap/green=1
32+
process/channel_remap/blue=2
33+
process/channel_remap/alpha=3
34+
process/fix_alpha_border=true
35+
process/premult_alpha=false
36+
process/normal_map_invert_y=false
37+
process/hdr_as_srgb=false
38+
process/hdr_clamp_exposure=false
39+
process/size_limit=0
40+
detect_3d/compress_to=1
41+
svg/scale=1.0
42+
editor/scale_with_editor_scale=false
43+
editor/convert_colors_with_editor_theme=false

src/Classes/LayerEffect.gd

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,39 @@
11
class_name LayerEffect
22
extends RefCounted
33

4+
signal keyframe_set
5+
46
var name := ""
57
var shader: Shader
8+
var layer: BaseLayer
69
var category := ""
7-
var params := {}
10+
var params: Dictionary[String, Variant] = {}
11+
## A Dictionary containing another Dictionary that
12+
## maps the frame indices (int) to another Dictionary of value, trans, ease,
13+
## and a layer-scope unique ID.
14+
## Example:
15+
## [codeblock]
16+
##{
17+
## "offset":
18+
## {
19+
## 0: {id: 0, "value": Vector2(0, 0), "trans": 0, "ease": 2},
20+
## 10: {id: 1, "value": Vector2(64, 64), "trans": 1, "ease": 3},
21+
## },
22+
## "wrap_around":
23+
## {
24+
## 1: {id: 2, "value": false, "trans": 0, "ease": 0},
25+
## 3: {id: 3, "value": true, "trans": 0, "ease": 0},
26+
## 10: {id: 4, "value": false, "trans": 0, "ease": 0},
27+
## },
28+
##}
29+
## [/codeblock]
30+
var animated_params: Dictionary[String, Dictionary] = {}
831
var enabled := true
932

1033

11-
func _init(_name := "", _shader: Shader = null, _category := "", _params := {}) -> void:
34+
func _init(
35+
_name := "", _shader: Shader = null, _category := "", _params: Dictionary[String, Variant] = {}
36+
) -> void:
1237
name = _name
1338
shader = _shader
1439
category = _category
@@ -19,11 +44,92 @@ func duplicate() -> LayerEffect:
1944
return LayerEffect.new(name, shader, category, params.duplicate())
2045

2146

47+
func get_params(frame_index: int) -> Dictionary:
48+
var to_return := params.duplicate()
49+
for param in animated_params:
50+
if param.begins_with("PXO_"):
51+
continue
52+
var animated_properties := animated_params[param] # Dictionary[int, Dictionary]
53+
if animated_properties.has(frame_index):
54+
# If the frame index exists in the properties, get that.
55+
to_return[param] = animated_properties[frame_index].get("value", to_return[param])
56+
else:
57+
if animated_properties.size() == 0:
58+
continue
59+
# If it doesn't exist, interpolate.
60+
var frame_edges := find_frame_edges(frame_index, animated_properties)
61+
var min_params: Dictionary = animated_properties[frame_edges[0]]
62+
var max_params: Dictionary = animated_properties[frame_edges[1]]
63+
var min_value = min_params.get("value", to_return[param])
64+
var max_value = max_params.get("value", to_return[param])
65+
if not is_interpolatable_type(min_value):
66+
to_return[param] = max_value
67+
continue
68+
var elapsed := frame_index - frame_edges[0]
69+
var delta = max_value - min_value
70+
var duration := frame_edges[1] - frame_edges[0]
71+
var trans_type: int = min_params.get("trans", Tween.TRANS_LINEAR)
72+
if trans_type == Tween.TRANS_SPRING + 1:
73+
to_return[param] = min_value
74+
continue
75+
var ease_type: Tween.EaseType = min_params.get("ease", Tween.EASE_IN)
76+
to_return[param] = Tween.interpolate_value(
77+
min_value, delta, elapsed, duration, trans_type, ease_type
78+
)
79+
return to_return
80+
81+
82+
func set_keyframe(
83+
param_name: String,
84+
frame_index: int,
85+
value: Variant = params[param_name],
86+
trans := Tween.TRANS_LINEAR,
87+
ease_type := Tween.EASE_IN
88+
) -> void:
89+
if not animated_params.has(param_name):
90+
animated_params[param_name] = {}
91+
var id := layer.next_keyframe_id
92+
animated_params[param_name][frame_index] = {
93+
"id": id, "value": value, "trans": trans, "ease": ease_type
94+
}
95+
layer.next_keyframe_id += 1
96+
keyframe_set.emit()
97+
98+
99+
func is_interpolatable_type(value: Variant) -> bool:
100+
var type := typeof(value)
101+
match type:
102+
TYPE_INT, TYPE_FLOAT, TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I:
103+
return true
104+
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_COLOR, TYPE_QUATERNION:
105+
return true
106+
_:
107+
return false
108+
109+
110+
func find_frame_edges(frame_index: int, animated_properties: Dictionary) -> Array[int]:
111+
var param_keys := animated_properties.keys()
112+
if param_keys.size() == 1:
113+
return [param_keys[0], param_keys[0]]
114+
param_keys.sort()
115+
var minimum: int = param_keys[0]
116+
var maximum: int = param_keys[-1]
117+
for key in param_keys:
118+
if key > minimum and key <= frame_index:
119+
minimum = key
120+
if key < maximum and key >= frame_index:
121+
maximum = key
122+
return [minimum, maximum]
123+
124+
22125
func serialize() -> Dictionary:
23-
var p_str := {}
24-
for param in params:
25-
p_str[param] = var_to_str(params[param])
26-
return {"name": name, "shader_path": shader.resource_path, "params": p_str, "enabled": enabled}
126+
return {
127+
"name": name,
128+
"shader_path": shader.resource_path,
129+
"enabled": enabled,
130+
"params": var_to_str(params),
131+
"animated_params": var_to_str(animated_params),
132+
}
27133

28134

29135
func deserialize(dict: Dictionary) -> void:
@@ -34,12 +140,14 @@ func deserialize(dict: Dictionary) -> void:
34140
var shader_to_load := load(path)
35141
if is_instance_valid(shader_to_load) and shader_to_load is Shader:
36142
shader = shader_to_load
143+
if dict.has("enabled"):
144+
enabled = dict["enabled"]
37145
if dict.has("params"):
38146
if typeof(dict["params"]) == TYPE_DICTIONARY:
39147
for param in dict["params"]:
40148
if typeof(dict["params"][param]) == TYPE_STRING:
41149
params[param] = str_to_var(dict["params"][param])
42150
else:
43151
params = str_to_var(dict["params"])
44-
if dict.has("enabled"):
45-
enabled = dict["enabled"]
152+
if dict.has("animated_params"):
153+
animated_params = str_to_var(dict["animated_params"])

src/Classes/Layers/BaseLayer.gd

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ var ui_color := Color(0, 0, 0, 0):
6969
set(value):
7070
ui_color = value
7171
ui_color_changed.emit()
72+
var next_keyframe_id := 0
7273
var text_server := TextServerManager.get_primary_interface()
7374

7475

@@ -255,13 +256,14 @@ func display_effects(cel: BaseCel, image_override: Image = null) -> Image:
255256
if not effects_enabled:
256257
return image
257258
var image_size := image.get_size()
259+
var frame := cel.get_frame(project)
260+
var frame_index := project.frames.find(frame)
258261
for effect in effects:
259262
if not effect.enabled or not is_instance_valid(effect.shader):
260263
continue
261-
var params := effect.params
262-
var frame := cel.get_frame(project)
264+
var params := effect.get_params(frame_index)
263265
params["PXO_time"] = frame.position_in_seconds(project)
264-
params["PXO_frame_index"] = project.frames.find(frame)
266+
params["PXO_frame_index"] = frame_index
265267
params["PXO_layer_index"] = index
266268
var shader_image_effect := ShaderImageEffect.new()
267269
shader_image_effect.generate_image(image, effect.shader, params, image_size)
@@ -274,8 +276,12 @@ func display_effects(cel: BaseCel, image_override: Image = null) -> Image:
274276
for effect in ancestor.effects:
275277
if not effect.enabled:
276278
continue
279+
var params := effect.get_params(frame_index)
280+
params["PXO_time"] = frame.position_in_seconds(project)
281+
params["PXO_frame_index"] = frame_index
282+
params["PXO_layer_index"] = index
277283
var shader_image_effect := ShaderImageEffect.new()
278-
shader_image_effect.generate_image(image, effect.shader, effect.params, image_size)
284+
shader_image_effect.generate_image(image, effect.shader, params, image_size)
279285
return image
280286

281287

@@ -312,7 +318,8 @@ func serialize() -> Dictionary:
312318
"opacity": opacity,
313319
"ui_color": ui_color,
314320
"parent": parent.index if is_instance_valid(parent) else -1,
315-
"effects": effect_data
321+
"effects": effect_data,
322+
"next_keyframe_id": next_keyframe_id,
316323
}
317324
if not user_data.is_empty():
318325
dict["user_data"] = user_data
@@ -337,6 +344,7 @@ func deserialize(dict: Dictionary) -> void:
337344
clipping_mask = dict.get("clipping_mask", false)
338345
opacity = dict.get("opacity", 1.0)
339346
user_data = dict.get("user_data", user_data)
347+
next_keyframe_id = dict.get("next_keyframe_id", 0)
340348
if dict.has("ui_color"):
341349
var tmp_ui_color = dict.ui_color
342350
if typeof(tmp_ui_color) == TYPE_STRING:
@@ -363,6 +371,7 @@ func deserialize(dict: Dictionary) -> void:
363371
print("Loading effect failed, not a dictionary.")
364372
continue
365373
var effect := LayerEffect.new()
374+
effect.layer = self
366375
effect.deserialize(effect_dict)
367376
effects.append(effect)
368377

src/UI/Timeline/AnimationTimeline.gd

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ var global_layer_expand := true
6969
@onready var tag_container: Control = %TagContainer
7070
@onready var layer_frame_h_split := %LayerFrameHSplit as HSplitContainer
7171
@onready var layer_frame_header_h_split := %LayerFrameHeaderHSplit as HSplitContainer
72+
@onready var keyframe_timeline := %KeyframeTimeline as KeyframeTimeline
7273
@onready var delete_frame := %DeleteFrame as Button
7374
@onready var move_frame_left := %MoveFrameLeft as Button
7475
@onready var move_frame_right := %MoveFrameRight as Button
@@ -271,7 +272,7 @@ func reset_settings() -> void:
271272
func _get_minimum_size() -> Vector2:
272273
# X targets enough to see layers, 1 frame, vertical scrollbar, and padding
273274
# Y targets enough to see 1 layer
274-
if not is_instance_valid(layer_vbox):
275+
if not is_instance_valid(layer_vbox) or not cel_vbox.is_visible_in_tree():
275276
return Vector2.ZERO
276277
return Vector2(layer_vbox.size.x + cel_size + 26, cel_size + 105)
277278

@@ -1798,3 +1799,10 @@ func _on_layer_frame_h_split_dragged(offset: int) -> void:
17981799
layer_frame_header_h_split.split_offset = offset
17991800
if layer_frame_h_split.split_offset != offset:
18001801
layer_frame_h_split.split_offset = offset
1802+
1803+
1804+
func _on_keyframe_timeline_check_button_toggled(toggled_on: bool) -> void:
1805+
keyframe_timeline.visible = toggled_on
1806+
tag_scroll_container.visible = not toggled_on
1807+
layer_frame_header_h_split.get_parent().visible = not toggled_on
1808+
frame_scroll_container.get_parent().visible = not toggled_on

0 commit comments

Comments
 (0)