Graphics programming in 2025 can be confusing and exhausting. Let’s travel back to a simpler time. Imagine it’s 2000 again and we’re anticipating what will turn out to be the most successful game console of all time. In our reverie, we have acquired a virtual development kit from the future to get ahead of the curve.
Like many others, we must do our taxes and start with Hello Triangle. However, this Hello Triangle will likely be the strangest Hello Triangle yet.
Like any graphics chip – even to this day – it chews a sequence of commands and spits out pixels. The GS chip itself is based around the idea of writing to various hardware registers to trigger work. Everything from drawing triangles to copying image data around is all done by poking the right registers in the right order. To automate this process of peeking and poking hardware registers, the front-end is responsible for reading a command stream and tickle the registers.
To get graphics on the screen, our goal will be to prepare a packet of data that a hypothetical GS can process.
Where we’re going we need no pesky API.
First, we need to program some HW registers:
The GIFTag tells the hardware how to interpret the packet, which is followed by 3 Address + Data packets that tickle the hardware register of our choosing.
.tag = { // Loops once to program 3 registers .NLOOP = 1, // End of packet .EOP = 1, // 128-bit form .FLG = GIFTagBits::PACKED, // Three registers per loop .NREG = 3, // Up to 16 x 4 bits to program 16 // different registers in one go. // A_D is a general "poke" interface // that can access any HW register. // 0x111 splats the bits. .REGS = int(GIFAddr::A_D) * 0x111, },PRMODE: Programs global settings like texture on/off, fogging on/off, blending on/off, etc. We just need to turn Gouraud shading on, i.e., color is interpolated across the triangle.
.prmode = { // Gouraud shading .data = Reg64<PRIMBits>({ .IIP = 1 }).bits, .ADDR = uint8_t(RegisterAddr::PRMODE), },FRAME: Program where the frame buffer is in VRAM. There is no height. That’s what scissor is for.
.frame = { // Programs the frame buffer // with 32-bit color. .data = Reg64<FRAMEBits>({ .FBP = fb_address / 8192, .FBW = fb_width / 64, .PSM = PSMCT32 }).bits, .ADDR = uint8_t(RegisterAddr::FRAME_1), },SCISSOR: Set the scissor rect.
.scissor = { .data = Reg64<SCISSORBits>({ .SCAX0 = 0, .SCAX1 = fb_width - 1, .SCAY0 = 0, .SCAY1 = fb_height - 1 }).bits, .ADDR = uint8_t(RegisterAddr::SCISSOR_1), },This now forms a packet and we can write that out to file.
Time for a new packet. We need to clear the frame buffer to some aesthetically pleasing color. SPRITE primitive to the rescue.
Unlike those silly modern GPUs we have a straight forward quad primitive here. It takes two points – meaning we cannot freely rotate sprites 90 or 270 degrees this way – but we have triangles for those edge cases.
The GIFTag programs primitive list of SPRITE and sets it up so that we interpret 3 registers as RGBA color followed by XYZ. Writing to XYZ “kicks” the vertex. Sounds familiar? glVertex3f in hardware? Yup, yup!
Then the final packet for our triangle:
Now we just need to program the hardware to read RGBA + XYZ in a loop 3 times and we can draw a triangle:
Now the triangle is in memory and we need to display its lovely pixels on screen. To this this we must program the CRTC. This is mostly boilerplate.
Flush out all this to disk, load it up in parallel-gs-stream and presto:
Compile-able source code can be found here for reference:
https://gist.github.com/HansKristian-Work/b88066eb8f14be21277c550a6f775956
Bonus hackery
parallel-gs-stream can read from a mkfifo file, so you could technically open a file as a FIFO and animate a triangle by writing the SPRITE + TRIANGLE packets followed by vsync packet in a loop. No need to complicate things.
Future entries
Stay tuned for simple texture mapping with perspective correction.
.png)

