Graphics programming like it’s 2000 – An esoteric introduction to PlayStation 2 graphics – Part 1

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.

FILE *file = fopen("dump.gs", "wb");
if (!file)
    return 1;

Where we’re going we need no pesky API.

First, we need to program some HW registers:

struct {
    GIFTagBits tag;
    PackedADBits prmode;
    PackedADBits frame;
    PackedADBits scissor;
};

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.

struct {
    GIFTagBits tag;
    PackedRGBAQBits rgba;
    PackedXYZBits xyz0;
    PackedXYZBits xyz1;
};

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!

.tag = {
    // One primitive.
    .NLOOP = 1,
    .EOP = 1,

    // Begin a new primitive sequence.
    .PRE = 1,
    .PRIM = Reg64<PRIMBits>({
       .PRIM = int(PRIMType::Sprite) }).words[0],

    .FLG = GIFTagBits::PACKED,
    .NREG = 3,
    .REGS =
       int(GIFAddr::RGBAQ) |
       (int(GIFAddr::XYZ2) * 0x110),
},
.rgba = {
    .R = 0x20,
    .G = 0x30,
    .B = 0x40,
    .A = 0xff,
},
.xyz0 = {
    // Top-left coordinate in 12.4 fixed point.
    .X = 0 << 4,
    .Y = 0 << 4,
    .Z = 0,
},
.xyz1 = {
    // Bottom-right coordinate in 12.4 fixed point.
    .X = fb_width << 4,
    .Y = fb_height << 4,
    .Z = 0,
},

Then the final packet for our triangle:

struct PackedVertex {
    PackedRGBAQBits rgba;
    PackedXYZBits xyz;
};

const struct {
    GIFTagBits tag;
    PackedVertex verts[3];
};

Now we just need to program the hardware to read RGBA + XYZ in a loop 3 times and we can draw a triangle:

.tag = {
    // Three vertices
    .NLOOP = 3,
    .EOP = 1,

    // Begin a new primitive sequence.
    .PRE = 1,
    .PRIM = Reg64<PRIMBits>({
       .PRIM = int(PRIMType::TriangleList) }).words[0],

    .FLG = GIFTagBits::PACKED,

    // Every loop writes RGBA, then kicks vertex
    .NREG = 2,
    .REGS =
       int(GIFAddr::RGBAQ) |
       (int(GIFAddr::XYZ2) * 0x10),
},
.verts = {
    {
       .rgba = { .R = 0xff },
       .xyz = { .X = 300 << 4, .Y = 100 << 4 },
    },
    {
       .rgba = { .G = 0xff },
       .xyz = { .X = 100 << 4, .Y = 400 << 4 },
    },
    {
       .rgba = { .B = 0xff },
       .xyz = { .X = 500 << 4, .Y = 400 << 4 },
    },
},

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.

// Program special registers which control the CRTC,
// aka display controller.
PrivRegisterState priv = {};

// Only enable display circuit 1.
priv.pmode.EN1 = 1;
priv.pmode.EN2 = 0;
// Just has to be 1. *shrug*
priv.pmode.CRTMD = 1;
// Normal NTSC 480i.
priv.smode1.CMOD = SMODE1Bits::CMOD_NTSC;
priv.smode1.LC = SMODE1Bits::LC_ANALOG;
priv.smode2.INT = 1;
// Effectively disables alpha blending against BG color.
priv.pmode.MMOD = PMODEBits::MMOD_ALPHA_ALP;
priv.pmode.SLBG = PMODEBits::SLBG_ALPHA_BLEND_BG;
priv.pmode.ALP = 0xff;

// Program the framebuffer pointer.
priv.dispfb1.FBP = fb_address / 8192;
priv.dispfb1.FBW = fb_width / 64;
priv.dispfb1.PSM = PSMCT32;
priv.dispfb1.DBX = 0;
priv.dispfb1.DBY = 0;

// Center the display area so it covers the full screen.
priv.display1.DX = 640; // Overscan centering.
priv.display1.DY = 50; // Overscan centering.
priv.display1.MAGH = 4 - 1; // 640 width framebuffer.
priv.display1.MAGV = 0; // No scaling vertically.
priv.display1.DW = (640 - 1) * SMODE1Bits::CLOCK_DIVIDER_COMPOSITE;
priv.display1.DH = 448 - 1;

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.