First Scene¶
Open a window and add a node.
▶ Run in browserTags: beginner tutorial
First Scene¶
Your first SimVX scene in under thirty lines. By the end you will have opened a window, drawn a pulsing circle, and seen three of the lifecycle hooks every node has access to.
Step 1: Subclass Node2D¶
Every visible thing in SimVX is a node. Node2D is the 2D-aware variant:
it knows about position, rotation, and lets you override on_draw()
to issue immediate-mode draw commands.
from simvx.core import Node2D
class FirstScene(Node2D):
pass
Step 2: Hook into the frame loop¶
Three hooks fire automatically once the node is in the scene tree:
on_ready(): called once after the node enters the tree. Initialise state here.on_update(dt): called every frame with the seconds since the last frame.on_draw(renderer): issue draw commands here. The 2D renderer is retained: it re-runson_drawonly when the node changes, so a node whose drawing animates every frame (like our pulsing circle) setsdynamic = Trueto opt into per-frame redraws.
import math
class FirstScene(Node2D):
dynamic = True # the circle pulses every frame, so redraw it every frame
def on_ready(self):
self.t = 0.0
def on_update(self, dt: float):
self.t += dt
def on_draw(self, renderer):
radius = 80 + 20 * math.sin(self.t * 2) # gentle pulse, 60..100 px
renderer.draw_circle((400, 300), radius, colour=(0.4, 0.8, 1.0, 1.0), filled=True)
renderer.draw_text("Welcome to SimVX", (400, 440), scale=2, alignment="centre", colour=(1.0, 1.0, 1.0))
alignment="centre" anchors the text’s centre on the x you pass, so there is no
width arithmetic to hand-tune.
Step 3: Launch it¶
App creates a Vulkan-backed window and runs your scene tree.
from simvx.graphics import App
App(title="First Scene", width=800, height=600).run(FirstScene())
Run it¶
# In your own copy of this directory
python main.py
# From the root of a repository checkout
uv run python examples/tutorials/first_scene/main.py
A window opens with a pulsing blue circle and a welcome message.
What’s next¶
Nodes and Signals – the next tutorial: wire several nodes together so they react to each other.
Input and Movement – drive a node from the keyboard with input actions.
Bouncing Balls –
Propertydescriptors and many children at once.
Source¶
1"""First Scene: Open a window and add a node.
2
3A two-minute tour: subclass `Node2D`, override `on_draw()` to paint a pulsing
4circle, launch with `App`, and meet the lifecycle hooks called along the way.
5
6# /// simvx
7# tags = ["beginner", "tutorial"]
8# web = { root = "FirstScene", width = 800, height = 600, responsive = true }
9# ///
10
11Run: uv run python examples/tutorials/first_scene/main.py
12Headless self-check: uv run python examples/tutorials/first_scene/main.py --test
13"""
14
15import math
16
17from simvx.core import Node2D
18from simvx.graphics import App
19
20WIDTH, HEIGHT = 800, 600
21
22
23class FirstScene(Node2D):
24 # The circle pulses every frame -> opt this node into per-frame redraw so the
25 # retained 2D renderer re-runs on_draw each frame instead of freezing the geometry.
26 dynamic = True
27
28 def on_ready(self):
29 self.t = 0.0
30
31 def on_update(self, dt: float):
32 self.t += dt
33
34 def on_draw(self, renderer):
35 cx, cy = WIDTH / 2, HEIGHT / 2
36 radius = 80 + 20 * math.sin(self.t * 2) # gentle pulse, 60..100 px
37 renderer.draw_circle((cx, cy), radius, colour=(0.4, 0.8, 1.0, 1.0), filled=True)
38 # alignment="centre" anchors the text's centre on cx, so no width maths.
39 renderer.draw_text("Welcome to SimVX", (cx, cy + 140), scale=2, alignment="centre", colour=(1.0, 1.0, 1.0))
40
41
42def _selftest() -> bool:
43 """Headless: run the scene and check the circle really pulses on screen.
44
45 The lesson is that ``on_update`` advances state and ``on_draw`` paints it, so
46 both halves are checked from the rendered frames: one captured where the pulse
47 is widest and one where it is narrowest, and the circle's own pixels counted in
48 each. A frozen ``on_draw`` would give two identical counts.
49 """
50 import numpy as np
51
52 from simvx.graphics.testing import assert_not_blank, save_png
53
54 # radius = 80 + 20*sin(2t): widest a quarter of the way through the cycle,
55 # narrowest three quarters through, at 60 frames a second.
56 WIDEST, NARROWEST = round(math.pi / 4 * 60), round(3 * math.pi / 4 * 60)
57
58 app = App(title="First Scene", width=WIDTH, height=HEIGHT, visible=False)
59 scene = FirstScene(name="FirstScene")
60 seen: dict[str, object] = {}
61
62 def on_frame(idx: int, _t: float) -> bool:
63 if idx == WIDEST:
64 seen["t_widest"] = scene.t
65 elif idx == NARROWEST:
66 seen["t_narrowest"] = scene.t
67 return True
68
69 frames = app.run_headless(scene, frames=NARROWEST + 2, on_frame=on_frame, capture_frames=[WIDEST, NARROWEST])
70 assert_not_blank(frames[0])
71 save_png(frames[0], "/tmp/first_scene_test.png")
72
73 ok = True
74
75 def check(label: str, passed: bool, detail: str) -> None:
76 nonlocal ok
77 ok = ok and passed
78 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
79
80 def circle_pixels(frame) -> int:
81 """Pixels painted in the circle's blue, which nothing else in the scene uses."""
82 r, g, b = (frame[..., i].astype(np.int16) for i in range(3))
83 return int(np.count_nonzero((b > 180) & (b > r + 60) & (g > r)))
84
85 check(
86 "on_update advances the scene clock by the frame time",
87 abs(seen["t_widest"] - WIDEST / 60.0) < 0.02 and abs(seen["t_narrowest"] - NARROWEST / 60.0) < 0.02,
88 f"{seen['t_widest']:.2f}s at frame {WIDEST}, {seen['t_narrowest']:.2f}s at frame {NARROWEST}",
89 )
90
91 wide, narrow = circle_pixels(frames[0]), circle_pixels(frames[1])
92 check(
93 "the circle is on screen and its area swings between the two radii the pulse names",
94 abs(wide - math.pi * 100**2) / (math.pi * 100**2) < 0.05
95 and abs(narrow - math.pi * 60**2) / (math.pi * 60**2) < 0.05,
96 f"{wide} px at its widest (a 100px circle is {math.pi * 100**2:.0f}), "
97 f"{narrow} px at its narrowest ({math.pi * 60**2:.0f})",
98 )
99 check(
100 "so the frame really is repainted rather than frozen after the first one",
101 wide > narrow,
102 f"{wide - narrow} pixels of difference between the two captured frames",
103 )
104
105 print("screenshot: /tmp/first_scene_test.png")
106 print("SELFTEST:", "PASS" if ok else "FAIL")
107 return ok
108
109
110if __name__ == "__main__":
111 import sys
112
113 if "--test" in sys.argv:
114 sys.exit(0 if _selftest() else 1)
115 App(title="First Scene", width=WIDTH, height=HEIGHT).run(FirstScene())