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.
Understanding of C.
Understanding of 6502 assembly language.
Ability to assemble programs and run them in an emulator, or on a C64.
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.
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.
Instead, we have this.
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.
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.
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.
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.
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.