2D drawing

A node draws in on_draw(renderer). The renderer is Draw2D (simvx.graphics.draw2d), and it draws in the node’s own space: (0, 0) is the node’s origin, and its transform and every ancestor’s apply to what it draws.

Method

Draws

rect(pos, size, *, fill=, stroke=, width=)

A rectangle

circle(centre, radius, *, fill=, stroke=, width=)

A circle

line(*points, stroke=, width=)

A polyline

polygon(points, *, fill=, stroke=)

A polygon, convex or not

text(text, pos, *, fill=, scale=, align=)

Text in the project font; fill is required

texture(source, pos, size, *, region=, tint=, rotation=)

An image: a file path, image bytes, an RGBA array or a Texture

nine_patch(source, pos, size, *, texture_size, margins=)

A 9-slice panel that stretches without distorting its corners

Drawing is retained

The 2D renderer keeps the geometry each on_draw produced and re-runs on_draw only when the node is marked dirty, so a busy scene stays cheap. Three rules cover every case:

What on_draw reads

What to do

A Property, or the node’s own transform (position, rotation, scale, including +=)

Nothing: the write marks the node dirty

Per-frame animation in plain attributes (a timer, a pulse, a scroll offset)

dynamic = True on the class: that node re-draws every frame

A plain attribute changed on an event (a score bumped in a signal handler)

self.queue_redraw() where it changes

import math

from simvx.core import Node2D, Property


class Paddle(Node2D):
    speed = Property(400.0)

    def on_update(self, dt):
        self.position.y += self.speed * dt   # moving the transform marks it dirty

    def on_draw(self, renderer):
        renderer.rect((-6, -40), (12, 80), fill=(1, 1, 1))


class Pulse(Node2D):
    dynamic = True                           # on_draw reads self.t every frame

    def on_ready(self):
        self.t = 0.0

    def on_update(self, dt):
        self.t += dt

    def on_draw(self, renderer):
        renderer.circle((0, 0), 12 + 8 * math.sin(self.t), fill=(0.4, 0.8, 1, 1))


class Scoreboard(Node2D):
    def on_ready(self):
        self.scores = [0, 0]

    def add_point(self, side):
        self.scores[side] += 1
        self.queue_redraw()                  # scores is not a Property

    def on_draw(self, renderer):
        renderer.text(f"{self.scores[0]}  {self.scores[1]}", (0, 0), fill=(1, 1, 1, 1), scale=2.0)

Prefer queue_redraw() to dynamic when the content changes rarely: re-drawing a large static picture every frame is wasted work.

A node that should have been dynamic renders once and then looks frozen. While developing, set Node.dev_warnings = True (or run under python -X dev): a freeze detector re-draws one clean node every Node.freeze_check_interval seconds (1.0 by default) and issues a DevWarning naming the node when its output changed with nothing marking it dirty. It is off by default, so a shipped game never pays for it.

Order

Within one on_draw, call order is draw order: later calls paint over earlier ones, whatever their kind.

def on_draw(self, renderer):
    renderer.texture(self.background, (0, 0), (320, 180))
    renderer.rect((0, 0), (320, 180), fill=(0, 0, 0, 0.7))        # dims the background
    renderer.text("Paused", (160, 80), fill=(1, 1, 1, 1), scale=2.0, align="centre")

Between nodes, Node2D.z_index orders siblings, then tree order applies. Adjacent calls that share a pipeline, a clip and a texture are batched into one GPU draw, so drawing many sprites from one atlas in a row is cheap.

Clipping and transforms

renderer.clip(rect) scissors everything drawn inside the block to (x, y, w, h) in the drawing code’s own space; nested clips intersect. renderer.transform(a, b, c, d, tx, ty) draws through an extra affine transform.

with renderer.clip((0, 0, 200, 120)):
    renderer.text(self.long_line, (4, 4), fill=(1, 1, 1, 1))

See Examples Gallery for the 2D feature examples, and simvx.graphics.draw2d for every method and parameter.