Introduction: Atari 8 Bit 3d Wire Frame Graphics

About: I was acceptable in the 80's

1983 called; they want their wire frame graphics back!


This Instructable covers how I created 3D wire frame graphics (in mode 8) using an Atari 8 bit computer. Speed is key to smooth animation and the 6502 chugs along at 1.79mhz (getting on for about 1000 times slower than a Raspberry Pi), so cheating is in order.

I'm using Action! and 6502 assembly language together with tricks like screen flipping, a new line drawing algorithm, and how to avoid floating point calculations.

Unfortunately the Atari can't calculate and draw lines in real time, so I've split the work in half; Action! generates the points and assembly code draws the lines.

Also due to processor limitations, I haven't considered back-culling or changing the point of view. Welcome to 1983!

Supplies

Atari 8 bit computer or emulator. I use Altirra https://www.virtualdub.org/index.html

Action! (sorry) https://atariwiki.org/wiki/Wiki.jsp?page=Action

6502 assembly language editor and complier. I use eclipse https://www.wudsn.com/index.php/ide

I've assumed knowledge of the Action! programming language, 6502 assembly code, the inner workings of the Atari 8 bit computers (diplay lists etc), matrices and trigonometry.

Step 1: The Theory

3D rotation with matrices

Sadly you can't just take a bunch of points and apply some trigonometry functions to them. It has to be done with rotational matrices.

The idea's reasonably straight forward

i. The corners of your shape (referred to as points from now on) are stored in 3 matrices; one for x, y and z co-ordinates

ii. We also need some rotational matrices which contain the sine / cosine values for whatever angle you're rotating the shape - see the basic rotations paragraph at this link https://en.wikipedia.org/wiki/Rotation_matrix#In_three_dimensions

iii. Multiply all the matrices, starting with x, then the results go into the y matrix and those results go into the z matrix.

To avoid losing precision the calculations always use the shape's original points, otherwise any rounding errors in the calculated points will be compounded.


Floating point calculations

These steps generate a whole bunch of floating point calculations which isn't quick. We can avoid this by populating the rotational matrices with integers, and then dividing them once the calculations are complete. For example

Sin(30) = 0.5, but if we use 32 to represent Sin(30) (Sin(30) x 64 = 32) and use 16 bit multlplication, we can then divide by 64 (or ROR x 6 in assembly) to get the answer

If we assume our point has the x co-ordinate 50, this looks like

32 x 50 = 1,600. Divide by 64 give 25 and not a decimal point in sight!


Line drawing

The line drawing routine in the operating system is too slow for what we want, but remember it has a ton of error checking to make it robust. I'm sure we're all grateful for this when we were starting to program.

Instead we're using a version of Bresenham's line alogorithm https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm

No idea how it works, but that doesn't stop us turning it into a short Basic routine and then into assembly code

1500 REM === DRAW a LINE
1510 REM Inputs are X1, Y1, X2, Y2: Destroys value of X1, Y1
1520 DX = ABS(X2 - X1):SX = -1:IF X1 < X2 THEN SX = 1
1530 DY = ABS(Y2 - Y1):SY = -1:IF Y1 < Y2 THEN SY = 1
1540 ER = -DY:IF DX > DY THEN ER = DX
1550 ER = INT(ER / 2)
1560 PLOT X1,Y1
1570 IF X1 = X2 AND Y1 = Y2 THEN RETURN
1580 E2 = ER
1590 IF E2 > -DX THEN ER = ER - DY:X1 = X1 + SX
1600 IF E2 < DY THEN ER = ER + DX:Y1 = Y1 + SY
1610 GOTO 1560



Screen flipping

Shapes don't instantly appear on the screen if they involve a lot of lines, and early experiments with vblank routines didn't work as well as expected.Instead the demonstration draws an image in one memory area while displaying another. The display list is then re-written to switch between memory areas. By changing 4 bytes (in mode 8) the new image appears immediately.

Step 2: Generate Points

The video shows the 3D rotation routine running code generated by Action! with page swapping and the new line drawing routine. The code does the matrix calculations but it's a bit slow. However we can use this to generate points that can be used by a 6502 assembly program.


Code walk through

Variables

Points (corners of the shape) are held in an integer array. These are the starting points of the shape and do not change. For some reason, Action! wouldn't allow me to initiate an array with negative numbers, so these points are offset by +30.

int array xstart(18) =[60 50 10 0 10 50 40 20 34 38 38 34 26 22 22 26 30 30]
int array ystart(18) =[30 35 35 30 25 25 30 30 32 32 28 28 32 32 28 28 30 30]
int array zstart(18) =[20 20 20 20 20 20 40 40 20 20 20 20 20 20 20 20 40 45]


We also have some instructions to determine where to draw the lines. The code works it's way through the array picking a point to draw from, and where to draw to. The first line will draw from point 0 (x=60, y=30, z=20) to point 1 (x=50, y=35, z=20)

int array drawfm(20) =[0 1 2 3 4 0 0 6 7 1 7 8  9 10 11 12 13 14 15 16 0]
int array drawat(20) =[1 2 3 4 5 5 6 7 3 6 2 9 10 11  8 13 14 15 12 17 0]


Sine and Cosine values are held in integer arrays. The values are 64 times larger than the actual values (sin(30) = 0.5 x 64 = 32). Then because Action! gets upset about initiating arrays with negative numbers, the values are offset by +64.

int array sintable(36) =[64 75 86 96 105 113 119 124 127 128 127 124 119 113 105 96 86 75 64 53 42 32 23 15 9 4 1 0 1 4 9 15 23 32 42 53 64]
int array costable(36) =[128 127 124 119 113 105 96 86 75 64 53 42 32 23 15 9 4 1 0 1 4 9 15 23 32 42 53 64 75 86 96 105 113 119 124 127 128]


Rotational matricies for x, y and z co-ordinates are also held in an integer array

int array xmatrix(9) =[64 0 0 0 64 0 0 0 64]
int array ymatrix(9) =[64 0 0 0 64 0 0 0 64]
int array zmatrix(9) =[64 0 0 0 64 0 0 0 64]


Scale factor determines the size of the shape; bigger numbers correspond to a small shape. Another integer array

int array factor(13) =[100 95 90 85 80 75 70 60 50 40 20 10 5]


To move the centre of the shape around the screen I use these two matricies which correspond to screen coordinates.

int array xmids(13) = [40 42 44 46 50 54 58 62 64 66 70 75 75]
int array ymids(13) = [40 42 44 46 50 54 58 62 64 66 70 75 75]


Finally I have various matricies to store finished points and the results of temporary calculations.


Setting up.

The first step is to correct the matricies for Action!'s weirdness. As mentioned above, the Sine and Cosine tables are offset by +64, so a simple loop fixes that.

 for i=0 to 36
 do
  sintable(i) = sintable(i) -64
  costable(i) = costable(i) -64
 od


The starting points are offset by +30, so another loop takes care of that

 for i=0 to 17
  do
   xstart(i) = xstart(i) -30
   ystart(i) = ystart(i) -30
   zstart(i) = zstart(i) -30
  od


I'm also using page 6 to store finished values, and need to clear some memory before starting. This could also be done with Zero() or SetBlock()

 moveblock(x1temp, x1, 40)
 moveblock(y1temp, y1, 40)
 moveblock(x2temp, x2, 40)
 moveblock(y2temp, y2, 40)


The other thing I do is import the line drawing assembly code which will be explained in the Draw Shapes section


Calculating the points

The steps are

1) Get the center of the shape from the xmids and ymids arrays, and the size of the shape

2) Decide on the x, y and z angles the shape will be rotated by

3) From now on, the steps are in the calculate procedure

4) set up the rotation matricies by getting values from the sine and cosine tables (depending on the x, y and z angles)

;set up matrices
 xmatrix(4) = costable(x)
 xmatrix(5) = sintable(x)
 xmatrix(7) = -sintable(x)
 xmatrix(8) = costable(x)
 ymatrix(0) = costable(y)
 ymatrix(2) = -sintable(y)
 ymatrix(6) = sintable(y)
 ymatrix(8) = costable(y)
 zmatrix(0) = costable(z)
 zmatrix(1) = sintable(z)
 zmatrix(3) = -sintable(z)
 zmatrix(4) = costable(z)

5) Perform the x rotation by multiplying the x, y and z points by the x rotation matrix. The results are store in a temporary matrix and divided by 64.

; start with x
 for i =0 to 17
  do
  xpoints(i) = xstart(i) * xmatrix(0)
  xpoints(i) = xpoints(i) + (ystart(i) * xmatrix(3)) 
  xpoints(i) = xpoints(i) + (zstart(i) * xmatrix(6))
  ypoints(i) = xstart(i) * xmatrix(1)
  ypoints(i) = ypoints(i) + (ystart(i) * xmatrix(4))
  ypoints(i) = ypoints(i) + (zstart(i) * xmatrix(7))
  zpoints(i) = xstart(i) * xmatrix(2)
  zpoints(i) = zpoints(i) + (ystart(i) * xmatrix(5))
  zpoints(i) = zpoints(i) + (zstart(i) * xmatrix(8))
  xtemp(i) = xpoints(i) /64
  ytemp(i) = ypoints(i) /64
  ztemp(i) = zpoints(i) /64
 od

6) Perform the y rotation by multiplying the x, y and z points by the y rotation matrix. The results are store in a temporary matrix and divided by 64.

; y rotation next
 for i =0 to 17
  do
   xpoints(i) = xtemp(i) * ymatrix(0)
   xpoints(i) = xpoints(i) + (ytemp(i) * ymatrix(3))
   xpoints(i) = xpoints(i) + (ztemp(i) * ymatrix(6))
   ypoints(i) = xtemp(i) * ymatrix(1)
   ypoints(i) = ypoints(i) + (ytemp(i) * ymatrix(4))
   ypoints(i) = ypoints(i) + (ztemp(i) * ymatrix(7))
   zpoints(i) = (xtemp(i) * ymatrix(2))
   zpoints(i) = zpoints(i) + (ytemp(i) * ymatrix(5))
   zpoints(i) = zpoints(i) + (ztemp(i) * ymatrix(8))
   xtemp(i) = xpoints(i) /64
   ytemp(i) = ypoints(i) /64
   ztemp(i) = zpoints(i) /64
  od


7) Perform the z rotation by multiplying the x, y and z points by the z rotation matrix. The results are store in a temporary matrix and divided by 64.

; z rotation last
 for i =0 to 17
  do
   xpoints(i) = (xtemp(i) * zmatrix(0))
   xpoints(i) = xpoints(i) + (ytemp(i) * zmatrix(3))
   xpoints(i) = xpoints(i) + (ztemp(i) * zmatrix(6))
   xpoints(i) = xpoints(i)/64
   ypoints(i) = (xtemp(i) * zmatrix(1))
   ypoints(i) = ypoints(i) + (ytemp(i) * zmatrix(4))
   ypoints(i) = ypoints(i) + (ztemp(i) * zmatrix(7))
   ypoints(i) = ypoints(i)/64
   zpoints(i) = (xtemp(i) * zmatrix(2)) 
   zpoints(i) = zpoints(i) + (ytemp(i) * zmatrix(5))
   zpoints(i) = zpoints(i) + (ztemp(i) * zmatrix(8))
   zpoints(i) = zpoints(i)/64
  od

8) Calculate the 2D representation of the 3D points. There's a simplification here to allow for the 6502's lack of speed, which divides the z points by 2. The output from this step is some screen co-ordinates in page 6 which are used by the line drawing assembly code to draw the shape.


The co-ordinates from step 8 can be output to a text file and are used in the assembly routine which draws the shape in section 3.


Drawing the shape

The shape is drawn in an area of memory that isn't currently displayed. When it's complete the display list is re-directed to that area of memory, and the next frame is drawn in a different area of memory. The process is repeated.

We tell the assembly process where to draw the shape by poking $58 and $59 with the memory location. The memory is cleared, and the drawshape procedure is called. Drawshape() takes the calculated points stored in page 6 and draws the shape. When it's finished, the display list is re-directed to the memory

proc drawScreen0()
  pokec($58, screen0)
  zero(screen0,$1000) 
  drawshape() 
  pokec(dl+4,screen0)
return

Step 3: Draw Shapes

Code walk through


Variables

The usual CIO equates

Display list pointers $cb & $cc

some temporary pointers

Pointers to the shapes coordinates. There's a huge number of coordinates so we can't use

LDA location, y

I'm using screennumber to track which screen is being displayed


Setting up

First, change the screen mode to Graphics 8 and customise the display list to show a title is graphics mode 2, and change the screen addresses. Addresses $230 and $231 are updated with the new display list address ($7a00).

Then store the coordinates address's in the pointers


Main loop

The main loop checks for a key press and, if there is, branches to the exit routine (and crashes!)

In the absence of a key press the loop proceeds to pick up the next coordinates to draw from (x1 and y1), then gets the coordinates to draw to (x2 and y2). The coordinates created by the Action! program in step 2 have been hard coded into the assembly routine, starting at $4700. It doesn't have to be done this way; importing the coordinates would allow for different shapes.

$ff is used as a flag to indicate the end of the data, and the demonstration restarts when detected.

A line starting at x1, y2 and ending at x2, y2 is drawn in the memory area relating to the screen not currently displayed.

The shape has 21 sets of coordinates and the variable 'counter' is used to track the coordinates. The routine branches to the 'update_pointers' and 'show_screen' subroutines when all the lines have been drawn


Updating pointers

The pointers to the coordinates addresses are updated to the next set of coordinates for the next 'frame'. This is done by adding the value in the Y register (being used to count the coordinates) to the previous pointer values. The Y register and the counter variable are reset to zero at the end of the sub routine.


Show screen sub routine

Dependent on the value in screennumber, either screen0 or screen1 is displayed. In either case, the display list is updated with the start address of the new screen, and again for the second LMS instruction. The drawn shape now appears.

Addresses $58 and $59 are updated with the starting address of the other screen (not being displayed) so that the line drawing sub routine knows where to draw.

Screen0 starts at $8000, and Screen1 starts at $a000. Must follow the 4k boundary rule (start at $x000)

The screen not displayed is then cleared by the 'delete' sub routine and the screennumber variable is updated.


Line drawing routine

This is based on the BASIC program shown in step 1 but with some modifications to allow for the way the screen memory works. It's faster than the OS version but less robust. The attached file has the drawto subroutine annotated so it can be compared to the BASIC program.

The big change is the way the points are plotted along the line. In graphics mode 8 there are 320 x-coordinates per screen row fitted into 40 bytes, which means that each byte contains 8 x-coordinates. Try poking the screen address (found by looking at locations 88 and 89 in BASIC, or from the 4th and 5th bytes in the display list) with a selection of values. You should see the pixels in the top left corner switch on and off.

The memory address of a pixel is therefore

scrn+((y)*bytes per row)+(x/8)

where scrn is screen memory address, y is the y coordinate and x is the x coordinate.

However, as we're not using floating point maths, x/8 is a bit tricky. Instead I'm using a small table to hold the values that should be poked into the screen memory to turn on a specific pixel.

VALUE .BYTE 128,64,32,16,8,4,2,1

To turn on the first pixel, we'll poke 128 into the screen memory. The second would be 64, etc. The variable 'bits' is used to select the values and is the remainder from the calculation

bits = x-(x/8)*8

Avoiding overwriting any existing values in the byte is done by getting the current value and ORing it with the value pointed to by bits.

Attachments

Step 4: Further Development

How about using sprites to do 3D rotations? They don't read to be re-drawn for horizontal movement, and vertical movement only requires a short assembly routine. We'll only be re-calculating when the shape rotates.

3D matrix calculations in assembly language are horrible!