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.
- ModeRG6, CSS=1
- Grid256 x 192
- Colours3, artifact
- Video RAM6,144 bytes
- Stars64
- App size1,321 bytes
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