2
\$\begingroup\$

I want to use a TextureProgress which reflects the state of a Timer.

I have tried to emit signals:

  • FROM: the Node2D._process() which owns the Timer with emit_signal("time_left_changed", timer.time_left)
  • TO: the UI

but it seems to make huge impact on performance.

\$\endgroup\$
5
\$\begingroup\$

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 TextureRects 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.

\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.