Stop redrawing what only needs moving

Reviewed against the rule issues #6 and #10 set for the buildings: geometry
is rasterised once and thereafter only moved, scaled or faded, because every
NeonDraw call expands into four antialiased polylines.

The planet visuals never got that refactor. The black hole rebuilt ten
glowing paths of twenty-five points every frame - forty polylines - to spin
five rings; each ring is a NeonPiece that rotates now. The research lab spent
nine draw calls a frame, eight of them fixed geometry, to turn one dish and
blink six lights; the dish is a child that rotates and the lights a child
whose modulate breathes. The gas giant's bands genuinely deform, so they
still draw, but its constant rim moved out to a child. NeonPiece gained
add_path, which it needed for open arcs.

And I had broken the rule myself. ScannerGuidance re-queued a redraw of its
arrow every frame for a colour that changes only with the destination - and
it runs with PROCESS_MODE_ALWAYS, so it was re-tessellating through every
dialog that stops the rest of the game. Guarded, and pinned by a test that
counts CanvasItem's own draw signal: 36 rasterisations in 0.6s before, one
after.

Two smaller things from the same pass: the station menu drew its condition
bar by hand where the ship lab already uses a ProgressBar node, so it uses
one now and its _draw is gone; and station/upkeep refreshes were wired into
_refresh_garrison(), which nothing but a test calls, so both menus showed
whatever was true when they opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Loïc Wisniewski 2026-09-08 11:35:45 +02:00
commit 7d1a584c15
11 changed files with 350 additions and 78 deletions

View file

@ -5,6 +5,15 @@ extends Node2D
# and armoured rather than a natural silhouette (#63: this is an ENEMY
# installation, not a celestial body), with a rotating dish and blinking
# warning lights to read as occupied/hostile at a glance.
#
# Built out of NeonPiece children rather than redrawn each frame. Issues #6
# and #10 established that rule for the buildings: every NeonDraw call
# expands into four antialiased polylines, so re-running _draw() to advance
# an animation re-tessellates the whole thing. This visual was doing exactly
# that - nine draw calls a frame, of which eight were fixed geometry - to
# rotate one dish and fade six lights. Both are node properties: the dish is
# a child that spins, the lights are a child whose modulate breathes, and the
# hull is rasterised once and never touched again.
var phase: float = 0.0
@ -14,42 +23,72 @@ const CORE_COLOR: Color = Color(0.9, 0.25, 0.18)
const WARNING_COLOR: Color = Color(1.0, 0.2, 0.15)
const DARK_CORE: Color = Color(0.14, 0.02, 0.02)
var _dish: NeonPiece
var _lights: NeonPiece
func _ready() -> void:
_build()
func configure(_shape_seed: int) -> void:
queue_redraw()
if _dish == null:
_build()
func extractor_anchor() -> Vector2:
return Vector2(0.0, -(RADIUS + 40.0))
func _build() -> void:
for child in get_children():
child.queue_free()
# The armoured hull and its core: fixed, so drawn once.
var shell: NeonPiece = NeonPiece.create(self, Vector2.ZERO, HULL_COLOR)
shell.core_color = DARK_CORE
shell.add_polygon(_ring(RADIUS, 0.0), 0.85)
var core: NeonPiece = NeonPiece.create(self, Vector2.ZERO, CORE_COLOR)
core.core_color = DARK_CORE
core.add_polygon(_ring(RADIUS * 0.55, PI / 6.0), 0.9)
# The dish, drawn along +X and then SPUN by rotating this node - the
# rotation is what used to cost a full re-tessellation every frame.
_dish = NeonPiece.create(self, Vector2.ZERO, CORE_COLOR)
_dish.core_color = DARK_CORE
var tip: Vector2 = Vector2(RADIUS * 1.35, 0.0)
_dish.add_line(Vector2.ZERO, tip, 0.55, 0.8)
_dish.add_polygon(_circle(tip, 11.0), 1.0)
# The warning lights blink by fading the whole piece, not by redrawing
# six circles at a new alpha.
_lights = NeonPiece.create(self, Vector2.ZERO, WARNING_COLOR)
_lights.core_color = DARK_CORE
for i in range(6):
var angle: float = TAU * float(i) / 6.0
_lights.add_polygon(_circle(Vector2(cos(angle), sin(angle)) * RADIUS, 5.0), 1.0)
func _ring(radius: float, offset: float) -> PackedVector2Array:
var points: PackedVector2Array = PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0 + offset
points.append(Vector2(cos(angle), sin(angle)) * radius)
return points
func _circle(centre: Vector2, radius: float) -> PackedVector2Array:
var points: PackedVector2Array = PackedVector2Array()
for i in range(12):
var angle: float = TAU * float(i) / 12.0
points.append(centre + Vector2(cos(angle), sin(angle)) * radius)
return points
func _process(delta: float) -> void:
phase += delta
queue_redraw()
func _draw() -> void:
var hull: PackedVector2Array = PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0
hull.append(Vector2(cos(angle), sin(angle)) * RADIUS)
NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 0.85, DARK_CORE)
var inner: PackedVector2Array = PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0 + PI / 6.0
inner.append(Vector2(cos(angle), sin(angle)) * RADIUS * 0.55)
NeonDraw.draw_glowing_polygon(self, inner, CORE_COLOR, 0.9, DARK_CORE)
# A slow-rotating dish - reads as actively transmitting, not derelict.
var dish_angle: float = phase * 0.6
var dish_tip: Vector2 = Vector2(cos(dish_angle), sin(dish_angle)) * RADIUS * 1.35
NeonDraw.draw_glowing_line(self, Vector2.ZERO, dish_tip, CORE_COLOR, 0.8, 0.55, DARK_CORE)
NeonDraw.draw_glowing_circle(self, dish_tip, 11.0, CORE_COLOR, 1.0, DARK_CORE)
# Blinking hull-corner warning lights.
var blink: float = 0.4 + 0.6 * absf(sin(phase * 4.0))
for i in range(6):
var angle: float = TAU * float(i) / 6.0
var at: Vector2 = Vector2(cos(angle), sin(angle)) * RADIUS
NeonDraw.draw_glowing_circle(self, at, 5.0, WARNING_COLOR, blink, DARK_CORE)
# Two node properties. Nothing is rasterised again.
if _dish != null:
_dish.rotation = phase * 0.6
if _lights != null:
_lights.modulate.a = 0.4 + 0.6 * absf(sin(phase * 4.0))