I've been trying to figure out in what ways this can be a performance problem.
I have been unable to replicate the problem. Yet, let us see what I can do.
First a couple notes on what happens internally:
- Godot timer will internally use the equivalent of
_process
or _physics_process
depending on process_mode
and will update time_left
using delta
. Which means that, sadly, it does not leverage any OS timers.
TextureProgress
inherits from Range
, and when you set its value
, you just set its value
. It is when Godot has to draw it, that it computes how to place the textures.
First, can we improve upon TextureProgress
?
Perhaps using a shader to create a progress bar would be better, depending on the hardware. And my guess is that on hardware where that could be an improvement, is also hardware where it does not have a real impact on performance.
We could also consider using TextureRect
s to mimic the TextureProgress
. However, TextureRect
works on a similar fashion than TextureProgress
(in fact, its logic can be more complex given the different options for stretching textures). Thus, I do not think this is an improvement either.
We could try using _draw
, but we would end up with the same design that TextureProgress
uses, and if we are doing it in GDScript, it won't perform better.
I don't think using an AnimatedTexture
would be worth it either.
Next, can we improve upon Timer
?
Yes, we can. First stop is Tween
. For example, you can do something like this:
var tween = Tween.new()
tween.interpolate_method(self, "push_time", 0, 100, 100)
add_child(tween)
tween.connect("tween_all_completed", tween, "queue_free")
tween.start()
Where push_time
emits the signal. You could also use interpolate_property
and change the value
of the TextureProgress
directly.
The problem is that this is still using _process
, and you will see little to no performance improvement.
Let us do better. Instead of using the time_left
of a Timer
, you can use the timeout
. That is, don't set the wait_time
to the whole duration you want, instead set it to how much time must pass for one step of the TextureProgress
. In fact, you can connect the timeout
directly instead of using a custom signal.
You could either use OS.get_ticks_usec
(or OS.get_ticks_msec
) to keep track of the start time and compute the elapsed time. Or increment the value
by one step.
Doing this you will see that the time expend on GDScript will be greatly reduced.
Can we do better?
As I said at the start, the Timer
is updating time_left
with delta
, either on _process
or _physics_process
. If we are running those anyway, we don't need all the Timer
logic. We only need to use OS.get_ticks_usec
(or OS.get_ticks_msec
) so we keep track of the start time and compute the elapsed time, and use that to set the value
.
This is somewhat close to the logic you have to begin with, since you are using time_left
. Except Godot is updating time_left
all the time. Instead this compute it all the time.
I'm not sure this is really more performant or not. We need to get out of the clutches of _process
and _physics_process
.
I tried something else, which is not better… But we will improve upon this:
class_name Stopwatch extends Node
func get_class(): return "Stopwatch"
func is_class(type:String): return type == get_class() or .is_class(type)
const seconds_per_usec:float = 0.000001
enum STATE {STOPPED, PAUSED, RUNNING}
signal tick
signal state_changed
var _start_time:int # trash when not RUNNING
var _pause_time:int # trash when not PAUSED
var _accum_time:int
export(bool)var auto_start:bool setget set_auto_start
func set_auto_start(new_value) -> void:
auto_start = new_value
if new_value and !is_inside_tree():
yield(self, "ready")
set_state(STATE.RUNNING)
var elapsed_usec:int setget _noset, get_elapsed_usec
func get_elapsed_usec() -> int:
var now:int = OS.get_ticks_usec()
if state == STATE.RUNNING:
return now - _start_time + _accum_time
return _accum_time
var elapsed_seconds:float setget _noset, get_elapsed_seconds
func get_elapsed_seconds() -> float:
return seconds_per_usec * get_elapsed_usec()
var elapsed_ticks:int setget _noset, get_elapsed_ticks
func get_elapsed_ticks() -> int:
return int(ticks_per_second * seconds_per_usec * get_elapsed_usec())
func _noset(_new_value) -> void:
push_error("readonly")
var state:int setget set_state
func set_state(new_value: int) -> void:
if state == new_value:
return
var now:int = OS.get_ticks_usec()
if state == STATE.RUNNING:
_accum_time += now - _start_time
elif state == STATE.STOPPED:
_accum_time = 0
state = new_value
if state == STATE.RUNNING:
_start_time = now
elif state == STATE.PAUSED:
_pause_time = now
_update_process()
emit_signal("state_changed")
export(float)var ticks_per_second:float setget set_ticks_per_second
func set_ticks_per_second(new_value) -> void:
if ticks_per_second == new_value:
return
ticks_per_second = new_value
_update_process()
func start():
set_state(STATE.RUNNING)
func start_ticking(new_ticks_per_second:float):
set_ticks_per_second(new_ticks_per_second)
set_state(STATE.RUNNING)
func stop():
set_state(STATE.STOPPED)
func pause():
set_state(STATE.PAUSED)
func is_stopped() -> bool:
return state == STATE.STOPPED
func is_paused() -> bool:
return state == STATE.PAUSED
func is_running() -> bool:
return state == STATE.RUNNING
var _seconds_to_next_tick:float
func compute_seconds_to_next_tick() -> float:
assert(ticks_per_second > 0)
assert(state == STATE.RUNNING)
var now:int = OS.get_ticks_usec()
var _elapsed_seconds:float = seconds_per_usec * (now - _start_time + _accum_time)
return ((int(_elapsed_seconds * ticks_per_second) + 1) / ticks_per_second) - _elapsed_seconds
func _ready() -> void:
_update_process()
func _update_process() -> void:
var should_proccess = ticks_per_second > 0 and state == STATE.RUNNING
if should_proccess:
_seconds_to_next_tick = compute_seconds_to_next_tick()
set_process(should_proccess)
func _process(delta:float) -> void:
_seconds_to_next_tick -= delta
if _seconds_to_next_tick < 0:
emit_signal("tick")
_seconds_to_next_tick = compute_seconds_to_next_tick()
That is an "Stopwatch" node. You will see a boolean auto_start
and a float ticks_per_second
in the inspector panel, which I hope are self descriptive.
The node also has a state
property that can be 0 = stopped, 1 = paused, 2 = running
, plus some convenient method start
, start_ticking
(which is like start
but lets you set ticks_per_second
as a parameter), pause
and stop
, which manipulate state
(you can also set state
directly).
Two signals: tick
and state_changed
. The tick
signal will happen according to ticks_per_second
(set ticks_per_second
to 0
or negative to disable the signal). The signal state_changed
, as its name suggest, will happen when state
changes. These signals have no parameters.
There are also is_paused
, is_stopped
and is_running
methods, that simply query state
.
Finally there are elapsed_usec
, elapsed_seconds
, and elapsed_ticks
which tell you how much time in microseconds, seconds and ticks (according to the current value of ticks_per_second
) has elapsed running (time paused does not count), respectively. Note: they are not erased when the state changes to stopped, instead they are erased when going from stopped to running. So you can stop and then read the values. It is a stopwatch!
As I said, I don't think this is better. However, it is here because can improve upon this. With a Thread
:
class_name Stopwatch extends Node
func get_class(): return "Stopwatch"
func is_class(type:String): return type == "Stopwatch" or .is_class(type)
const seconds_per_usec:float = 0.000001
const usec_per_second:int = 1000000
enum STATE {STOPPED, PAUSED, RUNNING}
signal tick
signal state_changed
var _start_time:int # trash when not RUNNING
var _pause_time:int # trash when not PAUSED
var _accum_time:int
var _thread:Thread
export(bool)var auto_start:bool
var elapsed_usec:int setget _noset, get_elapsed_usec
func get_elapsed_usec() -> int:
var now:int = OS.get_ticks_usec()
if state == STATE.RUNNING:
return now - _start_time + _accum_time
return _accum_time
var elapsed_seconds:float setget _noset, get_elapsed_seconds
func get_elapsed_seconds() -> float:
return seconds_per_usec * get_elapsed_usec()
var elapsed_ticks:int setget _noset, get_elapsed_ticks
func get_elapsed_ticks() -> int:
return int(ticks_per_second * seconds_per_usec * get_elapsed_usec())
func _noset(_new_value) -> void:
push_error("readonly")
var state:int setget set_state
func set_state(new_value: int) -> void:
if state == new_value:
return
var now:int = OS.get_ticks_usec()
if state == STATE.RUNNING:
_accum_time += now - _start_time
elif state == STATE.STOPPED:
_accum_time = 0
state = new_value
if state == STATE.RUNNING:
_start_time = now
elif state == STATE.PAUSED:
_pause_time = now
_update_process()
emit_signal("state_changed")
export(float)var ticks_per_second:float setget set_ticks_per_second
func set_ticks_per_second(new_value) -> void:
if ticks_per_second == new_value:
return
ticks_per_second = new_value
_update_process()
func start():
set_state(STATE.RUNNING)
func start_ticking(new_ticks_per_second:float):
set_ticks_per_second(new_ticks_per_second)
set_state(STATE.RUNNING)
func stop():
set_state(STATE.STOPPED)
func pause():
set_state(STATE.PAUSED)
func is_stopped() -> bool:
return state == STATE.STOPPED
func is_paused() -> bool:
return state == STATE.PAUSED
func is_running() -> bool:
return state == STATE.RUNNING
func _update_process() -> void:
var should_proccess = ticks_per_second > 0 and state == STATE.RUNNING
if should_proccess:
if _thread == null:
# there is no thread, make a new one
_thread = Thread.new()
elif _thread.is_active():
# found a thread running, nothing to do
return
# we either created a new thread, or we are restarting an exitted one
var err = _thread.start(self, "_thread_loop")
if err != OK:
push_error("Failed to start Thread.")
func _thread_loop(_userdata) -> void:
while true:
var now:int = OS.get_ticks_usec()
var _elapsed_usecs:int = now - _start_time + _accum_time
var target_usecs = int((usec_per_second * (int(seconds_per_usec * _elapsed_usecs * ticks_per_second) + 1) / ticks_per_second)) + _start_time - _accum_time
while true:
var delay = target_usecs - OS.get_ticks_usec()
if delay < 0:
break
OS.delay_usec(delay)
if ticks_per_second <= 0 or state != STATE.RUNNING:
break
emit_signal("tick")
# this should solve a race condition
# when _update_process found the thread running while it was exiting
_thread.call_deferred("wait_to_finish") # so is_active is false
call_deferred("_update_process")
func _enter_tree() -> void:
# the stopwatch may start when entering the tree
if auto_start:
state = STATE.RUNNING
# the thread may start when entering the tree
# note that children might not be ready
_update_process()
func _exit_tree():
# the stopwatch will stop when exiting the tree
state = STATE.STOPPED
if _thread == null:
return
The advantage here is that it will actually sleep. Unlike Timer
and other solutions that will be running _process
all the time, this code tries to have the Thread
sleep as much as possible. There will be some high value for ticks_per_second
where the computation of how much to sleep actually takes too much time. Remember that the point of this is have the code run less often (and thus save CPU time), not more.