User Tools

Site Tools


sd:a_guide_to_graphics_programming_on_the_sd-8516

This is an old revision of the document!


A Guide to Graphics Programming on the SD-8516

This is a general guide to graphics programming. It's not necessarily intended for games, it could be useful for UI programmers as well.

Introduction

The SD-8516 has all the capabilities you would expect from a late 80s “next generation” microcomputer. It also has some powerful features that were usually only available on consoles. You will find that programming graphics on the SD-8516 is fun, easy and rewarding.

For BASIC programmers, you can skip the sections that have assembly code in them. These are most useful for Assembly, Forth and C coders who need to do manual memory management. BASIC covers all of this for you; if you program in BASIC you can just focus on the sections that deal with BASIC commands.

Before we begin, let's introduce some key concepts about graphics programming on the SD-8516, such as the various Modes, the Framebuffer, the PPU arcade board, and INT 18h.

Graphics Modes

The text modes (1, 2 and 6) do not have graphics ability. They are solely used for representing text, so we won't discuss them here.

Beyond the text modes are the graphics modes:

Mode Resolution Colors Description
3 320×200 16 This is a standard 320x200x16 bread-and-butter mode meant to evoke the best of the 8-bit graphics era.
4 256×224 256 This is a 16/256 color console-style screen. Let's just say, some games look better in this format!
8 128×128 16 An homage to the PICO-8. 128×128 16 color mode.

Almost everything that is discussed here will be applicable to all graphics modes. So, we will use Mode 3 as an example and note anything you need to watch out for in other modes.

The Framebuffer

The SD-8516 uses a memory-mapped framebuffer. To render a frame, the Video Chip scans the framebuffer's memory and builds an image from that data. This happens at the refresh rate of your monitor, usually sixty times per second but faster if you have set your monitor to a higher refresh rate.

It's important to understand how the framebuffer works. It's very simple; the bytes in the framebuffer represent pixels. In 8bpp mode, each byte represents the color of one pixel on the screen. In 4bpp mode, each byte represents two pixels; the low-order nibble represents the even pixel and the high-order nibble represents the odd pixel. That means if you POKE a value into one byte, you are treating the screen like it's half resolution; many games worked this way to save code-space, such as Jumpman and King's Quest.

Therefore the BASIC command:

  POKE $020000, 1

will set the pixel at 0,0 to palette color 1 in an 8bpp mode, and will set the pixels at 0,0 to palette color 1 in 4bpp mode; but

  POKE $020000, $11

will set both 0,0 and 1,0 to color 1. Here, the tens digit represents the high nibble (pixel 1,0).

Here's another example. If you want to set 0,0 to color 1 and 1,0 to color 2 at the same time, you can do this:

  POKE $020000, $21

This sets the low nibble to 1 and the high nibble to 2. Remember, $21 is a hexidecimal number; each place in hex is 4 bits.

Framebuffer Memory Map

The framebuffer for mode 3 is stored in the first 32kb of Bank 2. Some modes, such as mode 5 and 7 use almost all of bank 2. Mode 9 uses both bank 2 and bank 3. Memory which isn't being used can be used by your programs; so if you're using Mode 3, you can store data in the second half of bank 2.

Accessing the Framebuffer

For BASIC, you can PEEK and POKE at framebuffer memory or use the PIXEL/PEXEL commands:

  10 LET A = PEXEL(10,10)
  20 LET A = A + 1
  30 PIXEL 10, 10, A

This program reads a pixel and updates it's color.

Packing and Unpacking pixels

For Assembly and other languages, you will need to pack and unpack the pixel. In Assembly you can use PAB and UAB, but in Forth or other languages you must mask the byte (and do a bit shift for the upper nibble).

In assembly:

  LDAL [$020000] ; read the byte at 0,0
  UAB            ; This command splits AL into AL=low nibble and BL = high nibble. 
  LDAL $0E       ; Set 0,0 to palette color $E (decimal 14)
  LDBL $0C       ; set 1,0 to palette color $C (decimal 12)
  PAB            ; Pack BL into high nibble of AL. AL is now $CE
  
  STAL [$020000] ; Stores $CE, the color of two pixels, in the framebuffer.

The stride of a 320×200 screen in 4bpp is 160 bytes. Therefore, if you wanted to deal with a pixel at location X, Y:

  LDELM $020000  ; load framebuffer base
  MUL Y, #160    ; Skip ahead to the correct row
  ADD ELM, Y
  
  MOV T, X
  AND T, #1      ; T is now whether X was even or odd.
  SHR X          ; Divide ELM by 2.
  ADD ELM, X     ; Skip forward by 1 byte per 2 pixels
  
  ; Elm now points to the even and odd pixel you need.
  LDAL [ELM]
  UAB            ; AL & BL now contain the even and odd pixel color

At this point, if T is 0 you can deal with the color in AL, and if T is 1 you can deal with the color in BL. When you're done,

  PAB
  STAL [ELM]     ; Write the pixels back into the framebuffer

For 8bpp modes you don't need to worry about this at all. The byte is the pixel.

The Three Main Systems

The SD-8516 has a PPU (picture processing unit) which aids in graphics programming. This is the fastest way to do graphics programming. Stellar BASIC uses the PPU to accelerate graphics performance, so BASIC programmers are already using the fast path here.

For Assembly programmers, there are two other systems. The KERNAL includes INT 0x18 which is a graphics services library. This library is the reference implementation for various graphics drawing algorithms. The main difference between drawing to the framebuffer yourself and using INT 18h is the price of an INT system call (essentially a CALL+RET).

The PPU is the second system. The PPU is accessed via INT 0x03 AH = $01. The subcommand is in AL. We will discuss each command and their calling conventions in the sections below.

The third system is a direct call to the PPU via an opcode. This is just like how the FPU works on older 80×86 systems; or how cartridges can add extra opcodes. The opcode instructions and their calling conventions will be discussed in the sections below.

1. Clearing the Screen

Clearing the screen is a good way to ensure a default initial state for a program.

In BASIC, the CLS command will clear the screen of the mode you are in.

For Assembly, INT 18h, AH=20h will clear the screen in a graphics mode.

  LDAH $20    ; Clear Screen
  INT 0x18

You can also use the PPU to do this. This way is very fast.

  LDA $0100   ; AH=$01 (PPU)  AL=$00 (Clear Screen)
  INT 0x03

2. Drawing (and reading) a Pixel

We've discussed how to do this in BASIC and Assembly above under “Accessing the Framebuffer” and “Packing and Unpacking Pixels”.

However, Assembly programmers will be pleased to know the PPU calls for this.

  LDA $0101   ; AH=$01 (PPU)  AL=$01 (plot_pixel)
  INT 0x03

or

  PPIXEL

PPIXEL is not faster than calling INT $3, however, you don't have to LDA $0100 and that can save time. In a loop where LDA can remain $0101 they are approximately as fast as each other.

The calling convention is X, Y, CK for X position, Y position, and pixel color. The PPU is mode-aware, so it will automatically do 4bpp pixel conversion for you.

To read from a pixel quickly you can load it from the framebuffer, or use the PPU:

  LDA $0102   ; AH=$01 (PPU)  AL=$02 (read_pixel)
  INT 0x03

The color will be returned in CL.

3. Drawing a Line

In BASIC, you can use the LINE command:

  LINE x1, y1, x2, y2, color

Assembly has AH=$12 INT 0x18:

  ; ============================================================================
  ; AH=21h - Draw Line (Bresenham's Algorithm)
  ; Input:  X = x0, Y = y0 (start point)
  ;         I = x1, J = y1 (end point)
  ;         CL = color
  ; Output: None
  ; ============================================================================

However, the fastest way to draw a line is with the PPU. The calling convention is to draw a line from X,Y to I,J in color CL.

  LDA $0103    ; AH=$01 (PPU)  AL=$03 (draw_line)
  INT 0x03

you can also use PLINE:

  PLINE

PLINE is like PPIXEL, it's sometimes faster to use PPIXEL than make a call to INT 0x03.

PPU draw_line demo

Here's a short demo that replicates the BASIC line drawing demo from SD-8516 Stellar BASIC:

.address $C000

start:
    LDA $4003       ; set mode to mode 3
    INT $10
    
    ;LDA $4003      ; set palette for mode 3
    LDB #1          ; 1 = Colordore (for fun!)
    INT $18

    LDA $0107       ; PPU clear_screen
    LDCL $00        ; ...to black
    INT $03

    ; initialize loop counters/line locations
    LDX #0
    LDY #0
    LDI #0
    LDJ #199
    LDC #0
x_loop:
    PLINE           ; PPU draw_line

    INC X
    INC I
    CMP X, #320     ; if X is >= 320, quit.
    JC @done

    ; calc color
    MOV C, X
    DIV C, #20      ; 0-319 --> 0-15
    JMP @x_loop

done:
    ; Diagonal line
    LDX #0
    LDY #0
    LDI #199
    LDJ #199
    LDCL #1
    PLINE

    ; Press any key
wait_key:
    LDAH $02        ; AH=02h = Blocking getkey
    INT $10

    LDA $4001       ; mode 1
    INT $10

    RET

4. Drawing Rectangles and Circles

Rectangles, squares, and “boxes” come in two flavors; outline and solid. The command in BASIC is:

   RECT X1, Y1, X2, Y2, C        : REM DRAW RECTANGLE OUTLINE
   RECT X1, Y1, X2, Y2, C, 0     : REM DRAW RECTANGLE OUTLINE
   RECT X1, Y1, X2, Y2, C, 1     : REM DRAW SOLID RECTANGLE

The 1 indicates whether or not the rectangle is filled. If it is 0 or omitted, it will draw the outline only.

The PPU draw_rectangle commands are similar:

  LDA $0104          ; draw_rectangle
  INT 0x03           ; Call PPU
  
  LDA $0105          ; fill_rectangle
  INT 0x03

The calling convention for draw and fill rectangle is X, Y, I, J, C for x1, y1, x2, y2, and color.

Drawing a circle or an “ellipse” is similar. In BASIC:

  CIRCLE x1,y1, xr, yr, color

It just struck me that I didn't add a fill circle command in BASIC. I'll add one later.

In Assembly you can use INT 18h or the PPU; the calling convention is the same:

  LDAH $26    ; draw_ellipse_outline
  X, Y -- circle center
  I, J -- X radius, Y radius
  CL   -- color
  INT 0x18

Now I realize why there's no fill circle in BASIC. I didn't write one. Well, the PPU has one for both circles and ellipses, solid and outline:

  LDAH $0106    ; PPU draw_circle
  LDAH $0107    ; PPU fill_circle

The calling convention for the PPU circle/ellipse commands is the same as in BASIC and INT 18h:

  X, Y -- center
  I, J -- x radius, y radius
  CL   -- color

5. Drawing Sprites

Sprites are fun! Let's dig in. First, although we say “sprites”, these are technically tiles. No metadata about the tile such as X and Y location is kept by the PPU. This allows you to use the library as a tile library or as a sprite library as you see fit.

There are three main ways to import tile and sprite data. One is the INT 18h import_sprite function. Here's a quick demo that uses INT 18h to load and display a sprite:

.address $C000

start:
    LDA  $4003              ; Mode 3 (320x200x16 4bpp)
    INT  0x10

    ; Clear screen
    LDA  $0107              ; PPU clear_screen
    LDCL $00                ; (clear to black)
    INT  0x03

    ; Step 1: Load sprite/tile data
    LDELM @tile_player
    LDFLD $A000             ; load it into some free area
    LDAH $30                ; AH=30h import_sprite_4bpp
    INT 0x18

    ; Step 2: Draw the tile to screen
    LDAH $32                ; draw_sprite_4bpp
    LDELM $A000             ; sprite_data (header+pixels)
    LDX #100                ; X location to draw sprite
    LDY #92                 ; Y location to draw sprite
    INT 0x18

    RET

tile_player:
    .bytes #16, 0, #16, 0
    .bytes "                "
    .bytes "      666       "
    .bytes "     66666      "
    .bytes "      666    7  "
    .bytes "       6     7  "
    .bytes "   FF88Fbb6  7  "
    .bytes "   F8FFFbb 6 7  "
    .bytes "   FF8FFb   777 "
    .bytes "   FFF8Fb    7  "
    .bytes "    F8F4        "
    .bytes "     F444       "
    .bytes "     44 44      "
    .bytes "    44   44     "
    .bytes "    66   66     "
    .bytes "   666   666    "
    .bytes "                "

As you can see, the INT 18h sprite data format includes a 4 byte header containing the width and height of the sprite.

The image format is simply the palette colors in 4bpp format. There are 8bpp versions of these commands as well ($31 and $33) but they are considered reserved since we do yet have an 8bpp graphics mode.

PPU Load Tile

Now, the same data format that INT 18h uses, minus the header, can be used with the PPU. Just set B to the sprite slot, point ELM at the data, set X and Y, clear C and call:

  LDA $0110
  INT 0x03

Calling Convention:

    B = tile slot index (0-255)
  ELM = source pixel data address (just pixels, no header)
    X = tile width in pixels
    Y = tile height in pixels
   CL = transparent color
   CH = bpp (4 or 8)

As mentioned earlier, the pixel data is 4bpp or 8bpp data in a row; the same kind of data you get from INT 18h import_sprite (see above).

PPU Load Tile from Screen

This function is untested, but is designed to work on either a spritesheet or the screen itself. The calling convention is the same as above but adds I and J as sprite-sheet indices. These are indexes, so if you are looking for the third sprite down and in position 10 you would use I=9, J=2.

PPU draw_tile

This is the most fun of all commands. This is where we get to draw the tile!

  LDA $0112        ; PPU draw_tile
  LDB #0           ; tile index
  LDX #100         ; x location to draw tile
  LDY #92          ; y location to draw tile
  INT 0x03         ; Call PPU

Set Tile Transparency

This command is under construction.

  LDA $0113        ; PPU set_tile_transparency
  INT 0x03         ; Call PPU

Calling convention:

   B = tile slot index
  CL = transparent color
  CH = flags (bit 0 = no transparency, bits 1-2 = flip flags)

Copy a Tile

If you want to have animation where you flip a sprite or do other transforms it is recommended that you copy the data, do your transform, and use the transformed copy. Here's how to copy a tile:

  LDA $0113       ; PPU copy_tile
  LDB #1          ; copy from tile #1
  LDC #2          ; copy into tile #2
  INT 0x03

Transforms

  LDA $0120       ; flip_h
  LDA $0121       ; flip_v
  LDA $0122       ; rot 90
  LDA $0123       ; ROT deg
  LDA $0124       ; scale
  LDA $0130       ; set flip_h flag (experimental)

For all of these INT 0x03 PPU commands, B indicates the tile number. For rotate and scale, C indicates the degree of rotation or the amount to scale (1000 == 100.0%).

These all transform in-place except for LDA $0130, which is considered reserved for now.

6. Frame Sync

To synchronize the frame, you have two main options. One is to use blocking input for turn based games, and then clear the screen before drawing. The second way is to use VSTEP to control buffer flips.

The reason why you have to use VSTEP is because the screen refresh may occurr between the time you clear the screen and draw the sprites. Flickering often shows up if there is a lot of animation on the screen. Here are two demos illustrating the techniques:

Sprite Demo with blocking input

This looks fine because there is a significant delay between drawing one frame and another due to the blocking input routine.

.address $C000

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
start:
    LDA  $4003       ; mode 3 (320x200x16 4bpp)
    INT  $10

    ; Clear screen
    LDA  $0100       ; PPU clear_screen
    LDCL $00         ; (clear to black)
    INT  $03

    ; Step 1. Load sprite/tile data
    LDELM @tile_player
    LDFLD $A000      ; load it into some free area
    LDAH $30         ; This is just the import_sprite_4bpp from below.
    INT $18

    ; Step 2: Load the converted data into PPU tile slot 0
    LDB   #0           ; slot 0
    LDELM $A000        ; skip 2-byte header, point to pixel data
    LDX   [ELM, +]     ; tile width
    LDY   [ELM, +]     ; tile height
    LDCL  #0           ; transparent color = 0
    LDCH  #4           ; 4bpp
    LDA   $0110        ; PPU load_tile
    INT   $03

    ; Init player position
    LDX #5
    STX [@player_x]

    LDY #2
    STY [@player_y]
    JMP @draw_game     ; Initial draw, then auto-jumps to game_loop

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
game_loop:

    ; 1. Check player input
check_keys:
    LDAH $02           ; blocking getkey
    INT $10

    LDAH $0D           ; INT 12h conv_ch_to_lc
    INT $12

    ; handle hjkl, wasd and arrow key movement
    CMP AL, #'h'       ; h = left
    JZ @move_left
    CMP AL, #'j'       ; j = down
    JZ @move_down
    CMP AL, #'k'       ; k = up
    JZ @move_up
    CMP AL, #'l'       ; l = right
    JZ @move_right

    CMP AL, #'w'       ; w = up
    JZ @move_up
    CMP AL, #'a'       ; a = left
    JZ @move_left
    CMP AL, #'s'       ; s = down
    JZ @move_down
    CMP AL, #'d'       ; d = right
    JZ @move_right

    CMP AL, #128       ; left arrow
    JZ @move_left
    CMP AL, #129       ; down arrow
    JZ @move_down
    CMP AL, #130       ; up arrow
    JZ @move_up
    CMP AL, #131       ; right arrow
    JZ @move_right

    CMP AL, #'q'       ; q = quit
    JZ @quit

    JMP @check_keys    ; invalid input; check again.

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
move_up:
    LDY [@player_y]
    CMP Y, #0
    JZ @check_keys     ; invalid command, try again.
    DEC Y              ; move up is y = y - 1
    STY [@player_y]    ; store y position
    JMP @draw_game     ; continue

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
move_down:
    LDY [@player_y]
    CMP Y, #11
    JZ @check_keys     ; invalid command, try again.
    INC Y              ; move down is y = y + 1
    STY [@player_y]    ; store y position
    JMP @draw_game     ; continue

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
move_left:
    LDX [@player_x]
    CMP X, #0
    JZ @check_keys     ; invalid command, try again.
    DEC X              ; move left is x = x - 1
    STX [@player_x]    ; store x position
    JMP @draw_game     ; continue

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
move_right:
    LDX [@player_x]
    CMP X, #15
    JZ @check_keys     ; invalid command, try again.
    INC X              ; move right is x = x + 1
    STX [@player_x]    ; store x position
    JMP @draw_game     ; continue

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
quit:
    ; return to mode 1 and exit:
    LDA  $4001         ; mode 1 (40x25 text)
    INT  $10

    RET


;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
draw_game:
    LDA  $0100         ; PPU clear_screen
    LDCL $00           ; (clear to black)
    INT  $03

    ; Get position on screen
    LDX [@player_x]
    LDY [@player_y]

    ; Translate to screen co-ordinates
    MUL X, #16
    MUL Y, #16

    LDA  $0112         ; PPU draw_tile
    LDB  #0            ; draw tile slot 0
    INT  $03

    JMP @game_loop

;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Variables and Data
;;;;;;;;;;;;;;;;;;;;;;;;;;
player_x:
    .bytes 0,0
player_y:
    .bytes 0,0

tile_player:
    .bytes #16, 0, #16, 0
    .bytes "                "
    .bytes "      666       "
    .bytes "     66666      "
    .bytes "      666    7  "
    .bytes "       6     7  "
    .bytes "   FF88Fbb6  7  "
    .bytes "   F8FFFbb 6 7  "
    .bytes "   FF8FFb   777 "
    .bytes "   FFF8Fb    7  "
    .bytes "    F8F4        "
    .bytes "     F444       "
    .bytes "     44 44      "
    .bytes "    44   44     "
    .bytes "    66   66     "
    .bytes "   666   666    "
    .bytes "                "

Sprite demo with frame sync

To sync to the frame, add the following code directly before JMP @game_loop:

      ; Drawing done; draw one frame
      LDAH $52     ; VSTEP: Draw one frame then halt the renderer.
      INT 0x18
      LDAH $54     ; Wait for the above frame to finish drawing
      INT 0x18

This tells the renderer to draw exactly one frame, then it halts the renderer. This will guarantees that on the next draw cycle you can clear the screen and draw all the sprites before the next frame.

Also, in quit: right after switching to mode one, add this code to restart the renderer:

      ; VSTART (resume standard refresh)
      LDAH $50 ; VSTART
      INT 0x18

If you don't do this, you will still be in VSTEP mode when the game exits!

sd/a_guide_to_graphics_programming_on_the_sd-8516.1778057802.txt.gz · Last modified: by appledog

Donate Powered by PHP Valid HTML5 Valid CSS Driven by DokuWiki