Animate via scene nodes instead of per-frame _draw redraws #10

Closed
opened 2026-08-10 21:33:58 +00:00 by metaheavens · 1 comment
Owner

Follow-up to #6. That was fixed by splitting static art onto a draw-once layer and capping animated redraws at 30Hz. Capping is a mitigation, not a cure - the frames it does draw still re-tessellate every polyline.

The idea

Stop redrawing, and let the renderer's cached geometry do the work: decompose animated art into child CanvasItems drawn ONCE, then animate their transform / modulate / visible with Tween or AnimationPlayer. Changing those does not re-run _draw.

One important nuance

"Use AnimationPlayer" on its own does not help. If _draw still derives its geometry from a time variable, animating that variable with a Tween still needs a queue_redraw to show anything - same cost, more machinery. The saving comes from the decomposition into nodes, with the animation framework as the natural driver.

This is already the house pattern

It is proven in this codebase, not speculative:

  • Ship.gd calls queue_redraw() exactly once, in _ready, and rotates purely via the rotation property. Ships visibly turn with zero redraws.
  • Asteroid.gd says so explicitly: "Deliberately no queue_redraw() here. position and rotation are node properties".

So the work is bringing the stragglers in line, not inventing an approach.

What still redraws every frame

  • Building.gd - the crafter's assembly line and the mothership's core/satellites (now capped to 30Hz, still per-frame within that)
  • Projectile.gd - _draw derives from age
  • ExplosionVFXBase.gd - all the explosion VFX

Sketch of the conversion

  • Mothership satellites: 4 child nodes drawn once, parented to a holder whose rotation is animated. Orbit becomes one transform update.
  • Reactor pulse: child node with animated scale + modulate.a instead of recomputing a hexagon each frame.
  • Crafter crane: child node, animate position.x along the rail.
  • Assembly hull parts: four child nodes, animate visible (and modulate for the attach flare) instead of redrawing the set.
  • Launch: animate the hull holder's position.y and modulate.a.
  • Projectile: bolt drawn once, animate scale/modulate over its life.

Where it genuinely does not fit

The weld sparks jitter randomly every frame - that is procedural, not a tween. Either keep a tiny redraw for that one element (a handful of short lines, not a whole yard) or express it as a few child nodes with animated rotation. Worth deciding during the work rather than forcing it.

Definition of done

  • Building.gd, Projectile.gd, ExplosionVFXBase.gd no longer call queue_redraw from _process
  • the 30Hz cap and the static-layer split from #6 are removed as no longer needed
  • animations look the same - verified by rendered frames, not by assumption
  • test_building_redraw_cost and test_animation_continuity updated to assert the new contract (draw-once) rather than the capped-redraw one
  • full suite green
Follow-up to #6. That was fixed by splitting static art onto a draw-once layer and capping animated redraws at 30Hz. Capping is a mitigation, not a cure - the frames it does draw still re-tessellate every polyline. ## The idea Stop redrawing, and let the renderer's cached geometry do the work: decompose animated art into child CanvasItems drawn ONCE, then animate their `transform` / `modulate` / `visible` with Tween or AnimationPlayer. Changing those does not re-run `_draw`. ## One important nuance "Use AnimationPlayer" on its own does **not** help. If `_draw` still derives its geometry from a time variable, animating that variable with a Tween still needs a `queue_redraw` to show anything - same cost, more machinery. The saving comes from the *decomposition into nodes*, with the animation framework as the natural driver. ## This is already the house pattern It is proven in this codebase, not speculative: - `Ship.gd` calls `queue_redraw()` exactly once, in `_ready`, and rotates purely via the `rotation` property. Ships visibly turn with zero redraws. - `Asteroid.gd` says so explicitly: *"Deliberately no queue_redraw() here. position and rotation are node properties"*. So the work is bringing the stragglers in line, not inventing an approach. ## What still redraws every frame - `Building.gd` - the crafter's assembly line and the mothership's core/satellites (now capped to 30Hz, still per-frame within that) - `Projectile.gd` - `_draw` derives from `age` - `ExplosionVFXBase.gd` - all the explosion VFX ## Sketch of the conversion - **Mothership satellites**: 4 child nodes drawn once, parented to a holder whose `rotation` is animated. Orbit becomes one transform update. - **Reactor pulse**: child node with animated `scale` + `modulate.a` instead of recomputing a hexagon each frame. - **Crafter crane**: child node, animate `position.x` along the rail. - **Assembly hull parts**: four child nodes, animate `visible` (and `modulate` for the attach flare) instead of redrawing the set. - **Launch**: animate the hull holder's `position.y` and `modulate.a`. - **Projectile**: bolt drawn once, animate `scale`/`modulate` over its life. ## Where it genuinely does not fit The weld sparks jitter randomly every frame - that is procedural, not a tween. Either keep a tiny redraw for that one element (a handful of short lines, not a whole yard) or express it as a few child nodes with animated rotation. Worth deciding during the work rather than forcing it. ## Definition of done - `Building.gd`, `Projectile.gd`, `ExplosionVFXBase.gd` no longer call `queue_redraw` from `_process` - the 30Hz cap and the static-layer split from #6 are removed as no longer needed - animations look the same - verified by rendered frames, not by assumption - `test_building_redraw_cost` and `test_animation_continuity` updated to assert the new contract (draw-once) rather than the capped-redraw one - full suite green
Author
Owner

Done. No animated node re-runs _draw() any more.

What changed

New NeonPiece - one movable piece of neon art, rasterised once, thereafter animated only through position / rotation / scale / modulate / visible. Buildings are now assembled from these.

Mothership: the four satellites are child pieces inside a holder; the orbit is the holder's rotation, so eight hexagons a frame became one transform. The breathing core is drawn at a reference radius and scaled.

Ship crafter: hull parts are four children whose attachment is a visible flip and whose weld flare is modulate.a; the launch is the hull holder's position and alpha; the crane rides the rail on position.x with the hoist stretched by scale.y; the completion ring is drawn once and scaled outwards; beacons and reactor cell fade via modulate.a.

Projectile: the bolt is now a fixed shape with no age dependency, drawn once.

Explosions: each particle is its own child drawn once and moved; the whole effect fades by setting modulate.a on the parent, which CanvasItem inherits down the tree - so one assignment fades every particle.

The 30Hz cap and the BuildingStaticLayer from #6 are both gone, no longer needed.

On the sparks

The weld sparks were the one element flagged as maybe-unconvertible. They are now four spokes on a holder that spins and scales, which reproduces the flicker without re-tessellating anything. No redraw needed after all.

Note on approach

I did not reach for AnimationPlayer resources. The crafter's cycle is conditional (which part is attached, whether it is welding, flashing or launching), and that logic stays clearer and testable as code; the properties are assigned from _process, which costs nothing because assigning a transform does not trigger a redraw. The saving that mattered was the node decomposition, exactly as the ticket described. Happy to move to AnimationPlayer tracks if you would rather have the animation authorable outside code.

Verified

  • test_building_redraw_cost rewritten for the new contract: after 2.5s of animation the crafter, mothership AND command centre each report draw_count == 1, while the crane has travelled and the satellites have orbited - so it cannot pass by freezing the animation.
  • Rendered the buildings and compared against the pre-refactor frame: identical.
  • Rendered projectiles and all four explosion types in a new _vfx_preview scene and looked at them. This mattered: bolts once went invisible after a similar optimisation and headless tests did not catch it, because --headless has no GPU. Six bolts and four explosion types all visible.
  • Full suite 38/38.

One bug caught during the work: the hull was initially drawn at the building's origin instead of in the cradle, because the holder was offset by BAY_CENTRE when its child pieces were already local to it. Caught by looking at a render, not by a test.

Done. No animated node re-runs `_draw()` any more. ## What changed New `NeonPiece` - one movable piece of neon art, rasterised once, thereafter animated only through `position` / `rotation` / `scale` / `modulate` / `visible`. Buildings are now assembled from these. **Mothership**: the four satellites are child pieces inside a holder; the orbit is the holder's `rotation`, so eight hexagons a frame became one transform. The breathing core is drawn at a reference radius and `scale`d. **Ship crafter**: hull parts are four children whose attachment is a `visible` flip and whose weld flare is `modulate.a`; the launch is the hull holder's `position` and alpha; the crane rides the rail on `position.x` with the hoist stretched by `scale.y`; the completion ring is drawn once and scaled outwards; beacons and reactor cell fade via `modulate.a`. **Projectile**: the bolt is now a fixed shape with no `age` dependency, drawn once. **Explosions**: each particle is its own child drawn once and moved; the whole effect fades by setting `modulate.a` on the parent, which CanvasItem inherits down the tree - so one assignment fades every particle. The 30Hz cap and the `BuildingStaticLayer` from #6 are both gone, no longer needed. ## On the sparks The weld sparks were the one element flagged as maybe-unconvertible. They are now four spokes on a holder that spins and scales, which reproduces the flicker without re-tessellating anything. No redraw needed after all. ## Note on approach I did not reach for AnimationPlayer resources. The crafter's cycle is conditional (which part is attached, whether it is welding, flashing or launching), and that logic stays clearer and testable as code; the properties are assigned from `_process`, which costs nothing because assigning a transform does not trigger a redraw. The saving that mattered was the node decomposition, exactly as the ticket described. Happy to move to AnimationPlayer tracks if you would rather have the animation authorable outside code. ## Verified - `test_building_redraw_cost` rewritten for the new contract: after 2.5s of animation the crafter, mothership AND command centre each report **draw_count == 1**, while the crane has travelled and the satellites have orbited - so it cannot pass by freezing the animation. - Rendered the buildings and compared against the pre-refactor frame: identical. - Rendered projectiles and all four explosion types in a new `_vfx_preview` scene and looked at them. This mattered: bolts once went invisible after a similar optimisation and headless tests did not catch it, because `--headless` has no GPU. Six bolts and four explosion types all visible. - Full suite 38/38. One bug caught during the work: the hull was initially drawn at the building's origin instead of in the cradle, because the holder was offset by BAY_CENTRE when its child pieces were already local to it. Caught by looking at a render, not by a test.
metaheavens referenced this issue from a commit 2026-08-11 18:31:47 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
metaheavens/spaceship#10
No description provided.