3d Graphics on the Commodore 64, Part One

Published on 2019-04-12

This is something that came from an exercise I’ve given myself to improve my assembly language skills, which in turn is to improve improve the ISA design for my homebrew vale8 computer.

I’m interested in demoscene culture in general, and there’s a lot of demoscene-related documentation for the C64 machine. After doing a custom font and basic scroller effects on the C64, I thought a more challenging project in 6502 might be to animate wireframe geometry (the seminal “spinning cube” effect).

Here’s the result, a spinning pyramid.

In this multi-part post series, we’ll make this effect using C and 6502 assembly. The line drawing routines are in 6502 assembly, whereas the model’s vertices are pre-calculated using a C program.

Prerequisites

  • Understanding of C.

  • Understanding of 6502 assembly language.

  • Ability to assemble programs and run them in an emulator, or on a C64.

Further References

Disclaimer

I’m a novice 6502 programmer. Demoscene veterans do this sort of thing with much better performance. I’m just showing the way that I worked out for myself. I know there are opportunities for optimization, and I’m looking forward to learning more of them.

Procedure

We can divide the procedure of animating 3d geometry into a few steps. The program must follow each step for each frame of animation.

Draw the Model’s Faces

For each face, draw a line from one vertex of the face to the next, in a counterclockwise direction.

Transform the 3d Model in the World

In our case, change its rotation about the y axis each frame.

Transform Model Vertices to Display Coordinates

Convert each vertex from its 3d coordinate space to a 2d pixel coordinate on the C64’s 320x200 bitmap display.

Prerequisites to Drawing Lines

Modern video cards support graphics APIs that are high enough in level as to provide either immediate- or retained-mode functions to draw primitives like lines and polygons. The C64 provides no such interface for drawing primitives in its bitmap modes. Instead, we set the color of each pixel individually. Since we’ll implement line drawing routines that will tell us which pixels to set, we need to also make some routines to actually set those pixels.

We’ll understand how to use the C64’s standard high-resolution bitmap mode and then create some subroutines to make it convenient to use with cartesian coordinates.

Standard High-resolution Bitmap Mode

The C64 provides two bitmap modes. The one we will use is the standard high-resolution bitmap mode. This mode gives us 320x200 pixels, where each pixel may be one of 2 colors chosen from the 16-color palette. I’ll refer to it as “bitmap mode” going forward.

Enabling Bitmap Mode

To enter bitmap mode, set bit 5 of register $d011 to 1, as follows:

    lda $d011
    ora #$20
    sta $d011

Setting Bitmap Memory Location

The location of the start of bitmap memory can be either $0000 or $2000 (8192). This number is in bytes relative to the start of the current VIC bank. The default VIC bank starts at memory location $0000. If we leave it there, we must set the start of bitmap memory to $2000 since there are some lower memory values we cannot modify.

To set the start of bitmap memory to $2000, set bit 3 of register $d018 to 1:

    lda $d018
    ora #$08
    sta $d018

Bitmap Memory Arrangement

The bitmap memory is an 8192-byte array of memory. Each bit of each memory address is used to set the color of a corresponding pixel on the display.

From the perspective of a human accustomed to using the cartesian coordinate system, the VIC uses bitmap memory in an inconvenient way. We might have expected the memory to be mapped to the display something like this.

../_images/bitmap_mem_ideal.svg

Instead, we have this.

../_images/bitmap_mem_real.svg

One way to think of the bitmap memory is a 40x25 grid of cells, each of which is 8 pixels wide and 8 pixels high. This gives us 320 columns and 200 rows of pixels.

The grid of cells starts in the top left corner of the display. The bitmap memory address corresponds to a row of 8 pixels in a cell. When we increment the bitmap memory address, we move down to the next row of pixels in the current cell. When we move past the bottom row of pixels in the cell, we move to the top row of pixels in the next cell to the right. When we move past the rightmost cell in the row of cells, we move down to the leftmost cell in the next row of cells.

Once again, the value of each bitmap memory address controls the colors of one row of 8 pixels in a cell. Bit 7 (the highest bit) of the value controls the leftmost pixel, and bit 0 (the lowest bit) controls the rightmost pixel. Since each bit can be set to either 0 or 1, there are two possible colors for each pixel in any given cell.

Bitmap Colors

In bitmap mode, each byte of display memory $0400-$07e7 controls the color of all pixels in a corresponding 8x8 cell of the bitmap. The upper 4 bits of the byte determine the color of a pixel whose bit is 1 in bitmap memory, and the lower 4 bits of the byte determine the color of a pixel whose bit is 0 in bitmap memory. The display memory maps to bitmap cells left to right, top to bottom. The following example sets the second cell in the first row of cells (pixel rows 0-7 and columns 8-15) to an alternating cyan-and-black pattern, as shown in the previous images.

lda #$aa
sta $2008
lda #$30
sta $0401

Here is a test program demonstrating how bitmap and color memory work.

../_images/test_bitmap.png

This is everything we need to know about bitmap mode before we implement our drawing routines.

Pixel Placement Routines

Our line drawing algorithms will give us the results in cartesian coordinates. This is typical, so we want to address the bitmap in cartesian terms, if possible. So let’s define some drawing routines that take cartesian coordinates as input and modify the bitmap memory appropriately behind the scenes.

We’ll define several routines that allow us to control a “pen” on the display. With these routines, we can move the pen to (almost) any (x, y) coordinate on the display, move the pen relative distances in four directions, and set the color of the pixel where the pen is located. Here is the commented source code for all the functions.

;; pen_init: Initialize the pen. Call this routine before
;; each invocation of pen_move_to.
pen_init:
    ;; Set pen address to beginning of
    ;; bitmap memory.
    lda #$20
    sta pen_addr_hi
    lda #$00
    sta pen_addr_lo

    ;; First pixel row in the cell.
    lda #$07
    sta pen_row_loc

    ;; First pixel column in the cell.
    lda #$80
    sta pen_pixel_bit

    rts
;; pen_move_to: Move the drawing pen to an (x, y) pixel
;; coordinate position on the display.
;; args
;;   y: y coordinate of the position
;;   x: x coordinate of the position
pen_move_to:
    pha
    tsx

    ;; Divide y by 8 and look up the address of the cell in
    ;; bitmap memory. The result is within 7 bytes of the
    ;; right address for the y coordinate. Store the result
    ;; to pen position.
    lda arg0
    lsr
    lsr
    lsr
    tax
    lda .cell_addr_hi,x
    sta pen_addr_hi
    lda .cell_addr_lo,x
    sta pen_addr_lo

    ;; Take y % 8, which is the number of bytes to add to
    ;; get the pixel row. Add it to the current pen
    ;; position and store new pen position.
    ; y % 8 == a & (8 - 1)
    lda arg0
    and #$07
    sta $0101,x

    clc
    adc pen_addr_lo
    sta pen_addr_lo

    ;; pen_row_loc == 7 - pixel row
    lda #$07
    sec
    sbc $0101,x
    sta pen_row_loc
    pla

    ;; Begin with pen_pixel_bit == #$80, which
    ;; represents the leftmost pixel column of the current
    ;; cell. Shift right x number of times. If we shift all
    ;; the way to the right and out of the register, add 8
    ;; to disp_pen_addr, which moves to the next cell.
+++ ldx arg1
    beq ++
-   lda pen_pixel_bit
    lsr
    sta pen_pixel_bit
    bne +
    lda #$80
    sta pen_pixel_bit
    lda #$08
    clc
    adc pen_addr_lo
    sta pen_addr_lo
    bcc +
    inc pen_addr_hi
+   dex
    bne -
++  rts
;; pen_move_up: Move the pen one pixel row up on the
;; display.
pen_move_up:
    ;; Move one pixel row up in the current cell.
    inc pen_row_loc
    lda #$08
    sec
    sbc pen_row_loc
    bne ++
    ;; If we leave the current cell, move to the bottommost
    ;; pixel row one cell up; effectively subtract 313 from
    ;; the pen address.
    lda #$00
    sta pen_row_loc
    lda #$01
    clc
    adc pen_addr_lo
    sta pen_addr_lo
    bcs +
    dec pen_addr_hi
+   lda #$c6
    clc
    adc pen_addr_lo
    sta pen_addr_lo
    bcs +
    dec pen_addr_hi
+   rts
;; pen_move_down: Move the pen one pixel row down on the
;; display.
pen_move_down:
    ;; Move one pixel row down in the current cell.
    dec pen_row_loc
    bpl ++
    ;; If we leave the current cell, move to the topmost
    ;; pixel row one cell down; add 313 to the pen address.
    lda #$07
    sta pen_row_loc
    lda #$ff
    clc
    adc pen_addr_lo
    sta pen_addr_lo
    bcc +
    inc pen_addr_hi
+   lda #$3a
    clc
    adc pen_addr_lo
    sta pen_addr_lo
    bcc +
    inc pen_addr_hi
+   rts
;; pen_move_left: Move the pen one pixel row left on the
;; display.
pen_move_left:
    ;; Shift the pixel column bit left and store.
    lda pen_pixel_bit
    clc
    asl
    sta pen_pixel_bit
    bcc +
    ;; If we shifted out of the left, put the bit back into
    ;; the right side of the value, and move to the same
    ;; pixel row in the previous cell; effectively subtract
    ;; 8 from the pen address.
    lda #$01
    sta pen_pixel_bit
    lda #$f8
    clc
    adc pen_addr_lo
    sta pen_addr_lo
    bcs +
    dec pen_addr_hi
+   rts
;; pen_move_right: Move the pen one pixel row right on the
;; display.
pen_move_right:
    ;; Shift the pixel column bit right and store.
    lda pen_pixel_bit
    lsr
    sta pen_pixel_bit
    bne +
    ;; If we shifted out of the right, put the bit back into
    ;; the left side of the value, and move to the same
    ;; pixel row in the next cell; add 8 to the pen address.
    lda #$80
    sta pen_pixel_bit
    lda #$08
    clc
    adc pen_addr_lo
    sta pen_addr_lo
    bcc +
    inc pen_addr_hi
+   rts
;; pen_px_set: Set the bitmap memory bit representing the
;; current pixel position to 1.
pen_px_set:
    lda pen_pixel_bit
    ldx #$00
    ora (pen_addr_lo,x)
    sta (pen_addr_lo,x)
    rts

Here is the complete pen source file.

We can draw any shape we want from our assembly language programs using these pen routines.

Here is a test program that uses the pen to draw straight lines from the right side of the display to the left, then left to right, then top to bottom, then bottom to top.

../_images/test_pen.png

You might have noticed the line from right to left does not originate from the far right side of the display. Examining our subroutine declaration again:

;; pen_move_to: Move the drawing pen to an (x, y) pixel
;; coordinate position on the display.
;; args
;;   y: y coordinate of the position
;;   x: x coordinate of the position

We take two arguments, y and x. Because x is supplied in an 8-bit memory location, the maximum value of x is 255. However, the rightmost pixel column on the display is 319. Because the size of the memory location that stores x is smaller than 319, pen_move_to can’t reach the right side of the display. Since our geometry will not be drawn onto the far right of the display, this is not a problem for our use case. If we want pen_move_to to reach past column 255, we can do that by taking a third argument holding one extra bit for x. When the bit is set, the column counter first counts to 255, then resets to zero, then counts again until reaching the value of the “x” argument.

Wrapping Up

Here is a zip file containing the test_bitmap and test_pen programs, as well as all the files needed to build and run them.

In the next part of this series, we implement routines to draw lines directly from any pixel on the display to another, automatically calculating all the pixels in between. Please contact me on the fediverse or Twitter with any questions or comments.