pcunningham.works

Stars: A Radial Warp Field in RG6

A 64-star radial warp field in RG6: 256x192 square pixels, one bit per pixel, 6144 bytes at $0400, 60 fps on a stock 32K CoCo, and in colour. Stars stream outward from the centre along straight rays, and speed is proportional to how far out they started, which is what sells it as depth.

Update, 12 September: this demo has since been rebuilt with real perspective and up to 200 stars. See Stars, Again: Real Perspective in RG6. The 60 fps figure on this page was never measured: a cycle-exact trace later put this build at 10.3 fps. The page is left as it was, for comparison.

Ten seconds captured from XRoar, emulating a Tandy Color Computer with the original MC6847.

Colour for free, from artifacts

The mode is set with CSS=1, which is SCREEN 1,1 in BASIC rather than SCREEN 1,0. Two things change and both matter. The background becomes black, where at CSS=0 an RG mode draws green on dark green, which is why an earlier version looked washed out. And because RG6 has GM0 set, CSS=1 also suppresses the colour burst, which is what turns on NTSC artifacting.

A lit pixel then takes its colour from its position within a pixel pair. Light the even pixel and you get blue, the odd one and you get orange, both and you get white. Three colours fall out with no colour data stored anywhere. It is still one bit per pixel.

Colour is decided at birth and never changes

Artifact colour depends on position, and two things follow from that.

A star drawn at its exact x changes colour every time it crosses a pixel, flickering blue to orange and back. So each star is born with one of the three patterns, and the draw snaps to the containing pair before applying it. Motion quantises to two pixels horizontally, which is invisible at this scale, and the colour holds for the star's whole life.

Growth also has to be vertical. The obvious way to draw a near star bigger is to widen it, but widening into the pixel pair lights both halves, which artifacts to white, so a growing blue star would turn white as it approached. Stars therefore grow downward only, at fixed x, and keep their colour.

This is genuinely NTSC-only. On PAL or an RGB monitor the same binary gives white stars on black, which still looks fine. It just loses the colour.

Straight rays for free

A star respawns just off the centre and takes a velocity proportional to that offset. Because velocity is parallel to the offset, the trajectory is exactly the ray from the centre through the spawn point. No trigonometry, no normalising, no division.

Velocity then compounds by about three percent a frame, so every star leaves the middle slowly and is streaking by the time it reaches the edge. That acceleration is not decoration. Without it, speed is fixed by spawn offset, so fast stars start far out, and within a few seconds everything has drifted outward and the centre is simply empty. A warp field has to keep producing stars at the vanishing point, and only acceleration does that.

Three ways the motion went wrong

Each of these produced a distinct, recognisable failure, which is the useful kind of bug.

With no acceleration, a hole in the middle, for the reason above. With a shift underflow, everything frozen in a clump: the acceleration is a shift right by five, which is zero for any velocity under 32, so a star spawned slow never accelerated at all and sat near the centre forever. That is why the spawn velocity multiplies the offset by 32 rather than 16, putting the smallest non-zero spawn at exactly the value whose shift is 1. And with a forced minimum magnitude on both axes, an X: every direction clustered around the diagonals and the corners went empty. Offsets are now free to be zero on one axis, rejecting only the both-zero case, and a zero component never accelerates, which is exactly right, because that ray is pure horizontal or vertical.

Erase before you move

Each star erases the pixel it drew last frame, advances, then draws the new one, all at the top of the frame. Writing moving markers late in the frame puts them into video RAM after the beam has already scanned past, so they never appear. That one was learned the hard way on an earlier demo.

Steering the vanishing point

The centre is not a constant. It is refilled from the left joystick once per frame, before the stars are stepped, and each axis is clamped because the spawn adds a small jitter on top: without the clamp, full deflection spawns stars past the edge, they are culled on their first frame, and the field goes empty.

Only new stars use the moved centre. A star's velocity is fixed at birth, so the ones already in flight keep flying along the ray they were launched on. That is not a limitation to work around, it is what a real course change looks like: the old stream continues past you while the new one opens up where you are now pointed. Recomputing every star's heading from the live centre would swing the whole field at once, which reads as the screen rotating rather than the ship turning.

The joystick read costs about 250 cycles for both axes against a roughly 10,200 cycle frame. It is free at this scale.

The source

421 lines. Worth reading for the sign-extend-before-you-shift comments, which mark the exact places where a signed byte silently becomes the wrong number, and for the joystick read that moves the vanishing point.

The .bin is the built binary and runs as it is: LOADM"STARS" from Disk BASIC, or xroar -run stars.bin. The .fs is 6809 Forth for the cross-compiler this demo was built with.

stars.fs pulls in four shared library files, which are here too:

Keep them in a lib/ directory next to the source and the INCLUDE paths resolve. The listing is here to read and the binary is here to run. Rebuilding one from the other also needs the Forth kernel and the cross-compiler, both in ugufru/coco.

Show stars.fs (421 lines)
\ src/stars/stars.fs — Radial warp starfield in RG6
\
\ 64 stars stream outward from the centre along straight rays.  Speed
\ is proportional to distance from centre, which is what makes it read
\ as depth: a star that spawns near the middle crawls, one that spawns
\ further out is already moving, and both accelerate away from you as
\ perspective demands.
\
\ RG6: 256x192 SQUARE pixels, 1 bit per pixel, 6144 bytes at $0400.
\ Single buffer, erase-then-draw per star.
\
\ ── Why RG6 and not SG4 ───────────────────────────────────────────
\ This demo was attempted once before (38f4c6e) on a 64x32 SG4 grid
\ and reverted (0778b95) for "visible oscillation and zigzag".  64x32
\ is simply too coarse for a starfield: a slow star sits in one cell
\ for a dozen frames and then jumps, and there is no room for the
\ near/far spread that sells the effect.  256x192 gives 48 times the
\ addressable points, square pixels, and a black background.
\
\ Mono costs nothing here.  Stars are points; depth is carried by
\ SPEED and by SIZE (near stars are drawn as a 2x2 block, far ones as
\ a single pixel), which works without a single colour bit.
\
\ The LEFT joystick steers the vanishing point.  Only new stars are
\ launched from the moved centre; the ones already in flight keep
\ their old ray, which is exactly how a real course change looks.
\
\ Press any key to start.  Press any key again to exit to BASIC.

\ ── Libraries ─────────────────────────────────────────────────────

INCLUDE ../../../coco/lib/screen.fs
INCLUDE ../../../coco/lib/rng.fs
INCLUDE ../../../coco/lib/vdg.fs
INCLUDE ../../../coco/lib/dac.fs

\ ── Memory map ────────────────────────────────────────────────────
\ $0400-$1BFF  RG6 VRAM, 6144 bytes, 32 per scanline, SAM V=6, F=2
\ $4000-$43FF  64 stars x 16 bytes
\ $4400-$4407  scratch
\ $4408       centre x, set from the left joystick each frame
\ $4409       centre y
\
\ Star record:
\ +0,1  sx  8.8, high byte is screen x 0..255
\ +2,3  sy  8.8, high byte is screen y 0..191
\ +4,5  vx  8.8 signed
\ +6,7  vy  8.8 signed
\ +8    ox  last drawn x        +10  size: 0 = point, 1 = 2x2 block
\ +9    oy  last drawn y        +11  drawn flag
\ +12   col fixed colour: 0 blue, 1 orange, 2 white (both pixels)
\
\ RG6 pixel: addr = $0400 + y*32 + (x>>3), bit = $80 >> (x and 7).

\ ── Variables ─────────────────────────────────────────────────────

VARIABLE sctr

\ ── The frame (CODE) ──────────────────────────────────────────────
\ Erase and redraw happen at the TOP of the frame, before the beam has
\ scanned far.  Writing moving markers late in the frame puts them into
\ VRAM after the beam has already passed, so they never appear.

CODE tick-stars  ( -- )
        PSHS    X
        LDY     #$4000
        LDA     #64
        PSHS    A

@loop   ; ---- erase where we drew last frame ----
        LDA     11,Y
        BEQ     @noerase
        LDA     8,Y
        STA     $4400
        LDA     9,Y
        STA     $4401
        LDA     10,Y
        STA     $4402
        LDA     12,Y
        STA     $4406
        CLR     $4403           ; 0 = clear the pixels
        LBSR    @blot
@noerase

        ; ---- accelerate, then advance along the ray ----
        ; v += v >> 5 each frame, about 3% compounding.  This is what
        ; makes it a WARP field rather than a drift: every star leaves
        ; the centre slowly and is streaking by the time it reaches the
        ; edge, so the vanishing point keeps producing new ones instead
        ; of emptying out.  ASRA/RORB, not LSR, so the sign survives.
        LDD     4,Y
        PSHS    D
        ASRA
        RORB
        ASRA
        RORB
        ASRA
        RORB
        ASRA
        RORB
        ASRA
        RORB
        ADDD    ,S++
        STD     4,Y

        LDD     6,Y
        PSHS    D
        ASRA
        RORB
        ASRA
        RORB
        ASRA
        RORB
        ASRA
        RORB
        ASRA
        RORB
        ADDD    ,S++
        STD     6,Y

        LDD     ,Y
        ADDD    4,Y
        STD     ,Y
        LDD     2,Y
        ADDD    6,Y
        STD     2,Y

        ; ---- off screen?  (y is 0..191, x wraps at 256 naturally) ----
        LDA     2,Y
        CMPA    #192
        LBHS    @respawn
        LDA     ,Y
        LDB     4,Y             ; heading left and wrapped past 0?
        BPL     @xr
        CMPA    #248
        LBHS    @respawn
        BRA     @xok
@xr     CMPA    #248
        LBHS    @respawn
@xok

        ; ---- a star that is moving fast is close, so draw it bigger ----
        LDA     4,Y             ; integer part of vx, pixels per frame
        BPL     @szp
        NEGA
@szp    CMPA    #1
        BLO     @szsm
        LDA     #1
        BRA     @szst
@szsm   CLRA
@szst   STA     10,Y

        ; ---- draw at the new position ----
        ; An artifact colour comes from the pixel's X PARITY, so a star
        ; drawn at its exact x flips blue/red every time it crosses a
        ; pixel.  Snap to the containing pair and re-apply the parity
        ; this star was born with: horizontal motion quantises to two
        ; pixels, and the colour is then stable for the star's whole
        ; life.  Near stars (size 1) light BOTH pixels of the pair,
        ; which artifacts to white - so brightest reads as nearest.
        LDA     ,Y
        ANDA    #$FE            ; pair base; @blot applies the colour
        STA     $4400
        STA     8,Y
        LDA     2,Y
        STA     $4401
        STA     9,Y
        LDA     10,Y
        STA     $4402
        LDA     12,Y
        STA     $4406
        LDA     #1
        STA     $4403           ; 1 = set the pixels
        STA     11,Y
        LBSR    @blot

@next   LEAY    16,Y
        DEC     ,S
        LBNE    @loop
        LEAS    1,S
        PULS    X
        ;NEXT

; ---- respawn on a short random offset from the centre ----
; Velocity is proportional to that offset, so the trajectory is a
; straight ray outward and speed scales with how far out it starts.
@respawn
        ; Magnitude 0..7 per axis with a random sign.  Letting an axis be
        ; ZERO matters: forcing both to a minimum makes every direction
        ; cluster around the diagonals, and the field comes out as an X
        ; with empty corners.  Allowing zero gives the horizontal and
        ; vertical rays too.  A zero component simply never accelerates,
        ; which is correct - that ray is pure horizontal or vertical.
        LBSR    @rng
        PSHS    A
        ANDA    #15
        LDB     ,S
        BITB    #$80
        BEQ     @oxp
        NEGA
@oxp    LEAS    1,S
        STA     $4404

        LBSR    @rng
        PSHS    A
        ANDA    #15
        LDB     ,S
        BITB    #$40
        BEQ     @oyp
        NEGA
@oyp    LEAS    1,S
        STA     $4405

        LDA     $4404           ; but not both zero, or it never moves
        BNE     @onz
        LDA     $4405
        BNE     @onz
        LDA     #3
        STA     $4404
@onz

        ; Spawn position is jittered INDEPENDENTLY of the heading.  Tying
        ; it to the offset means every star with ox=0 starts on the same
        ; column and travels the same line, which shows up as a hard
        ; spoke through the centre.
        LBSR    @rng
        ANDA    #7
        ADDA    $4408           ; sx = centre x + 0..7
        STA     ,Y
        LBSR    @rng
        STA     1,Y

        LBSR    @rng
        ANDA    #7
        ADDA    $4409           ; sy = centre y + 0..7
        STA     2,Y
        LBSR    @rng
        STA     3,Y

        LDB     $4404           ; vx = ox * 32, sign-extended FIRST
        SEX                     ; so the shift cannot corrupt the sign
        ASLB
        ROLA
        ASLB
        ROLA
        ASLB
        ROLA
        ASLB
        ROLA
        ASLB
        ROLA
        STD     4,Y

        LDB     $4405           ; vy = oy * 32
        SEX
        ASLB
        ROLA
        ASLB
        ROLA
        ASLB
        ROLA
        ASLB
        ROLA
        ASLB
        ROLA
        STD     6,Y

        CLR     10,Y            ; starts far and small; grows on the way out
        LBSR    @rng            ; colour for this life: blue, orange or white
        ANDA    #7
        CMPA    #3
        BLO     @cb
        CMPA    #6
        BLO     @co
        LDA     #2
        BRA     @cst
@cb     CLRA
        BRA     @cst
@co     LDA     #1
@cst    STA     12,Y
        CLR     11,Y            ; nothing drawn yet
        LBRA    @next

; ---- set or clear a star at ($4400,$4401), size $4402, mode $4403 ----
; Growth is DOWNWARD only.  Widening to the pixel pair would light both
; halves and artifact to white, so a growing star would change colour.
@blot   LBSR    @pcol
        LDA     $4402
        BEQ     @bdone
        INC     $4401
        LBSR    @pcol
        DEC     $4401
@bdone  RTS

; One star at ($4400 = pair base, $4401), in colour $4406.
; 0 = even pixel, 1 = odd pixel, 2 = both, which artifacts to white.
@pcol   LDA     $4406
        CMPA    #2
        BEQ     @pcw
        LDA     $4400
        PSHS    A
        ADDA    $4406
        STA     $4400
        LBSR    @pix
        PULS    A
        STA     $4400
        RTS
@pcw    LBSR    @pix
        INC     $4400
        LBSR    @pix
        DEC     $4400
        RTS

; ---- one pixel: addr = $0400 + y*32 + (x>>3), bit = $80 >> (x and 7)
@pix    LDA     $4401
        CMPA    #192
        BHS     @pxdone
        LDB     #32
        MUL
        ADDD    #$0400
        TFR     D,X
        LDB     $4400
        LSRB
        LSRB
        LSRB
        ABX

        LDB     $4400
        ANDB    #7
        LDA     #$80
@pxsh   TSTB
        BEQ     @pxgo
        LSRA
        DECB
        BRA     @pxsh

@pxgo   TST     $4403
        BEQ     @pxclr
        ORA     ,X
        STA     ,X
        RTS
@pxclr  COMA
        ANDA    ,X
        STA     ,X
@pxdone RTS

; ---- Inline LCG: seed = seed * 25173 + 13849 ----
@rng    LDA     VAR_SEED+1
        LDB     #$55
        MUL
        PSHS    D
        LDA     VAR_SEED
        LDB     #$55
        MUL
        ADDB    ,S
        STB     ,S
        LDA     VAR_SEED+1
        LDB     #$62
        MUL
        ADDB    ,S
        STB     ,S
        PULS    D
        ADDD    #$3619
        STD     VAR_SEED
        RTS
;CODE

\ ── Centre from the left joystick ─────────────────────────────────
\ Each axis samples 0..63.  X scales by 4 and Y by 3 to span the
\ 256x192 screen.  Both are clamped so that the 0..7 spawn jitter
\ added on top cannot run off the right edge or below line 191: a
\ star spawned past either would be culled on its first frame and
\ the field would go empty at full deflection.

: read-centre  ( -- )
  joy-sel-lx joy-sample 4 *  247 MIN  $4408 C!
  joy-sel-ly joy-sample 3 *  183 MIN  $4409 C! ;

\ ── Title screen ──────────────────────────────────────────────────

: vemit  ( char -- )
  $3F AND  kvar-cur @ $0400 + C!  kvar-cur @ 1 + kvar-cur ! ;

: .title  ( -- )
  12 6 AT
  83 vemit 84 vemit 65 vemit 82 vemit 70 vemit 73 vemit 69 vemit 76 vemit 68 vemit
  9 9 AT
  80 vemit 82 vemit 69 vemit 83 vemit 83 vemit 32 vemit
  65 vemit 78 vemit 89 vemit 32 vemit 75 vemit 69 vemit 89 vemit ;

\ ── Main ──────────────────────────────────────────────────────────
\ 64 stars at ~120 cycles is ~7,700 of 14,917.  Single buffer: each
\ star erases its own last pixel and draws the new one, so there is
\ nothing to clear and nothing to swap.

: stars  ( -- )
  cls-black  .title
  0 seed !
  BEGIN  vsync  seed @ 1 + seed !  KEY?  UNTIL
  BEGIN  vsync  KEY? 0=  UNTIL

  $4000 1024 0 FILL              \ every star starts undrawn and off-screen
  64 0 DO  200 $4000 I 16 * + 2 + C!  LOOP

  $0400 6144 0 FILL             \ blank first: this FILL is slow enough to
  6 set-sam-v                   \ show as garbage if the mode is set first
  2 set-sam-f
  $F8 set-pia                   \ RG6, CSS=1: black background, artifact colour

  read-centre                   \ before the first tick, or every star
                                \ respawns around (0,0) on frame one
  BEGIN
    read-centre
    tick-stars
    vsync
    KEY?
  UNTIL

  reset-text ;

stars

← Back to SepTandy