All posts

Smooth Curves on Vectrex: Bézier Curves in VPy

How VPy adds smooth Bézier curves to Vectrex games — a new visual editor tool, compile-time baking for 6809, and runtime precision for PiTrex. Plus the math behind de Casteljau subdivision on retro hardware.

vectrexvpybezierpitrexgraphicstutorial

The Vectrex draws everything with straight lines — the hardware only knows how to move the beam from one point to another in a straight shot. But curves are everywhere in game art: scrolls, S-shapes, swooping logos, organic silhouettes. Until now, the only option was to approximate them by hand: pick enough points, hope the polygon looks round enough, live with the visible corners.

This release adds proper Bézier curves to VPy, both as a drawing primitive and as a first-class format in .vec vector assets. You draw smooth curves in the editor by dragging handles, and the compiler takes care of turning them into something the hardware can actually render.


What is a Bézier Curve?

A cubic Bézier curve is defined by four points: two anchors (start and end) and two control points that pull the curve toward them without the curve actually passing through them.

P0 ──── CP0         CP1 ──── P1
  \                        /
   \     (curve)          /

The math — called de Casteljau subdivision — is elegant: lerp between P0→CP0 and CP0→CP1, lerp between those results, lerp one more time. Three rounds of linear interpolation produce a single point on the curve at parameter t. Repeat for many values of t and you get a smooth approximation.

# De Casteljau at parameter t = i/n
q0 = lerp(P0, CP0, i, n)
q1 = lerp(CP0, CP1, i, n)
q2 = lerp(CP1, P1, i, n)
r0 = lerp(q0, q1, i, n)
r1 = lerp(q1, q2, i, n)
point = lerp(r0, r1, i, n)

The key question for retro hardware is: who runs this math, and when?


Two ways to draw curves in VPy

1. DRAW_BEZIER — direct call, runtime subdivision

# Cubic: P0, CP0, CP1, P1, steps, brightness
DRAW_BEZIER(55, 0,  55, 30,  30, 55,  0, 55,  16, 100)

This calls the curve drawing function directly from your game loop. Every frame it evaluates the curve and draws it as a series of line segments. You can animate the control points in real time — changing CP0 and CP1 each frame gives a morphing, twisting curve.

2. Bezier paths in .vec assets — editor-friendly, compile-time friendly

The .vec vector format now supports a bezier path type. You draw it in the visual editor with handles; the control points are saved alongside the anchor points in the JSON file. The compiler decides how to render it based on the target platform.

# Draw a .vec that contains bezier paths — same call as always
DRAW_VECTOR("logo", 0, 0)

Drawing Bézier Curves in the Vector Editor

The editor has a new ∿ Bezier tool. Click to place an anchor, then drag to pull out symmetric handles — the outgoing handle goes where you drag, the incoming handle mirrors it around the anchor point, giving a smooth tangent at every joint.

Control points appear as cyan squares on the selected path. Dashed lines connect each anchor to its handles so you can see exactly how the curve will be pulled.

The path is stored in .vec as interleaved anchor and control points:

A0, CP0_out, CP1_in, A1, CP1_out, CP2_in, A2, ...

This is the standard cubic spline convention: every three consecutive points form one cubic segment, with the end anchor of one segment being the start anchor of the next. The editor enforces symmetric handles at every anchor when you drag, keeping tangents continuous across joints.


How it Renders: Platform by Platform

The interesting engineering question is what happens when the compiler processes a .vec with bezier paths. The answer depends entirely on the target.

Vectrex / MC6809 — compile-time baking

The real Vectrex hardware has no floating-point unit, no divide instruction, and precious few cycles per frame. Running de Casteljau at 60 Hz is not realistic.

The solution: bake the curve at compile time. The Rust codegen runs de Casteljau during the build and emits the result as a list of FCB line-segment bytes — the standard Vectrex draw-list format. The hardware never knows there was a curve; it just draws a tight polyline.

@ generated compile-time baked bezier (32 steps)
_LOGO_PATH0:
    .byte  127                   @ intensity
    .byte  $29, $E0, $00, $00    @ start y=41 x=-32
    .byte  $FF, $00, $02         @ line dy=0 dx=2
    .byte  $FF, $01, $01         @ line dy=1 dx=1
    ...  (28 more segments)
    .byte  $02                   @ end

VPy uses 32 subdivision steps per cubic segment. That gives enough density that the individual line segments are shorter than a pixel at Vectrex scale, making the approximation invisible. The total data cost is about 3 bytes per step (flag + dy + dx), so a typical 4-segment logo path costs around 384 bytes of ROM — comparable to a high-resolution polygon.

The baking runs entirely at compile time; there is no runtime cost.

PiTrex — runtime via v_drawBezierCubic

The PiTrex is a Raspberry Pi Zero (or Pi 3) driving the Vectrex analog hardware directly. It has a 1 GHz ARM CPU, which changes the tradeoffs entirely.

For .vec bezier assets, the codegen no longer bakes. Instead it emits the raw control points using a new 0xFE opcode in the draw-list format:

_LOGO_PATH0:
    .byte  127                              @ intensity
    .byte  $29, $E0, $00, $00              @ start y=41 x=-32
    .byte  0xFE, $E0,$29, $E0,$29, $F9,$3D, $0F,$34   @ bezier segment
    .byte  0xFE, $0F,$34, $25,$2A, $20,$FF, $11,$08   @ bezier segment
    ...
    .byte  0x02                             @ end

At runtime, when pitrex_draw_vector reads the 0xFE marker, it calls v_drawBezierCubic from libpitrex. This runs de Casteljau on the ARM CPU with full 32-bit integer precision and delivers the resulting line segments directly to the PiTrex rendering pipeline — the same path as calling DRAW_BEZIER explicitly.

The result: .vec bezier curves on PiTrex look identical to DRAW_BEZIER calls. No baking artifacts, no integer quantization — just the mathematical curve at whatever smoothness the library uses (16 steps per segment by default).

ARM / RP2350 — not yet implemented

The RP2350 target is still in progress. DRAW_BEZIER is parsed but not yet code-generated for ARM. Bezier paths in .vec assets will fall back to straight polylines on this target until bezier support is added.


Is DRAW_BEZIER available on Vectrex 6809?

Not as a runtime call. The MC6809 runs at ~1.5 MHz, shares cycles between game logic, drawing, and audio, and needs to complete an entire frame in under ~6700 microseconds. Running de Casteljau 4 × 16 times per curve per frame would consume a significant fraction of that budget just for a single logo element.

That said, curves are fully usable on Vectrex through the compile-time baking path. The workflow is:

  1. Draw your curve in the vector editor
  2. The compiler bakes it to a polygon at build time
  3. The Vectrex ROM contains only straight-line data — no runtime math

For static art (logos, backgrounds, character sprites), this is zero cost. For animated curves where the shape changes each frame, you would need to pre-calculate a sequence of baked frames — which is exactly what the .vanim animation format is for.

If you need curves that deform at runtime on 6809, the practical approach is to use a higher-resolution DRAW_CIRCLE/DRAW_POLYGON approximation, or to pre-compute a small table of polygon vertices for each animation keyframe.


Smooth Circles Without Bezier

For the specific case of circles and arcs, DRAW_CIRCLE is the right tool. VPy already draws circles as 16-segment polygons — 16 line segments is enough that the circle looks round at any reasonable size on the Vectrex screen.

DRAW_CIRCLE(0, 0, 55)   # center, radius — 16-segment polygon

For an ellipse or partial arc, DRAW_ARC and DRAW_ELLIPSE use the same polygon approximation at compile time.


Spiral and Circle Examples

The smooth_curves test project in examples/individual_tests/ demonstrates both DRAW_BEZIER (spiral, 8 cubic segments) and DRAW_VECTOR (a bezier .vec file). Try it in the PiTrex emulator to compare the two rendering paths side by side.

# Archimedean spiral: 8 cubic segments, 2 turns, radius 8→56
# Turn 1, Seg 0: (8,0) → (0,14)  [0°→90°, r 8→14]
DRAW_BEZIER(8, 0,  8, 4,  8, 14,  0, 14,  12, 80)
 
# Turn 1, Seg 1: (0,14) → (−20,0) [90°→180°, r 14→20]
DRAW_BEZIER(0, 14,  -8, 14,  -20, 11,  -20, 0,  12, 80)
 
# ... and so on for 6 more segments

The circle approximation uses four cubic Bézier segments, each covering 90°. The magic constant k ≈ 0.5523 (more precisely, 4(√2−1)/3) gives the control point offset that minimizes the deviation from a true circle:

# k = round(r * 0.5523)
# Q1: (r,0) → (0,r)
DRAW_BEZIER(r, 0,  r, k,  k, r,  0, r,   16, 100)
# Q2: (0,r) → (−r,0)
DRAW_BEZIER(0, r,  -k, r,  -r, k,  -r, 0,  16, 100)
# Q3, Q4: same pattern

Summary

FeatureVectrex / 6809PiTrexARM / RP2350
DRAW_BEZIER (runtime)✗ not availablev_drawBezierCubic✗ planned
Bezier .vec assets✅ baked at compile time✅ runtime, full precision✗ falls back to polyline
Bezier editor tool✅ editable, baked on build✅ editable, rendered live✅ editable, baked on build
Smooth circles (DRAW_CIRCLE)✅ 16-segment polygon✅ 16-segment polygon✅ 16-segment polygon

The bezier editor is available regardless of your target. You always draw the same curve — what changes is how the compiler delivers it to the hardware.