A9 - Drawing to the Screen

From VO-EM Wiki
Jump to: navigation, search

The last tutorial was a general description of how computer graphical displays (usually) work. This time, we're going to specifically look at one of the VO-EM's graphical output devices.

Graphics on the VO-EM

The VO-EM virtual console actually has five screens, all transparent and layered on top of each other. The first four screens are controlled by a device known as a GPU, which allows us to display graphics efficiently. The top-most screen, however, is very simple - all of its pixels are simply mapped to an area in memory, and whatever is written to that memory will appear on the screen. We'll be using the top-most layer for this tutorial. It is known as the Bitmap Overlay device.

On the Arrangement of Pixels

The bitmap overlay device is 120 pixels wide, and 80 pixels high, meaning it has a total of 9,600 pixels. Every byte between memory address 0x30000 and 0x3257F corresponds to one pixel on the screen, with 0x30000 being the top left pixel and 0x3257F being the bottom right pixel.

It is common to refer to a pixel's position on the screen by its X and Y coordinates. You may remember cartesian coordinates from highschool math. This is much the same thing, except the Y coordinate is inverted - larger values of Y are closer to the bottom of the screen.

So, if we were to graph it out:

                       X axis 
0,0________________________________________________\ 120,0 
  |                                                /      
  |
Y |
  |
a |
x |           (the visible screen is here)
i |
s |
  |
  |
  |             
  V                                              
0,80

In order to draw meaningful data to the screen, we have to be able to figure out which memory address corresponds to which pixel on the screen. Fortunately, this is mathematically very simple to do!

The pixels are ordered left-to-right, top-to-bottom.

Meaning that every pixel on the x axis when y is zero (0,0 to 120,0) is in the first 120 bytes in memory ((byte 0 to byte 119).

Then the next row (from 0,1 to 120,1) is the next 120 bytes (120 - 239). So, every time y increases by 1, we have to add 120, or one row's worth of bytes, to find the memory address offset.

The equation simply looks like

memory_offset = y*screen_width+x

Where, in this case, screen_width is 120.

Obviously, in order to draw things to the screen, we're going to need to write a subroutine that performs this equation for us!

Here's one I prepared earlier:

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;findOS(r1:x, r2:y):uint                          ;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;Finds the memory offset on a 240x160 screen      ;
;given a provided x and y position. Trashes r2    ;
;and returns result in r1.                        ;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
		 
findOS      beqz     r2,f_end      ;is y zero?
            addui    r1,r1,10#120  ;no. Add one row to the offset
            subui    r2,r2,1       ;subtract one from y 
            j        findOS        ;and loop
f_end       jr       r31

All we need to do is pass the x coordinate in to r1, the y cordinate into r2, call the function, and we get back the memory offset.

Remember, we don't have a hardware multiplication function, so we have to do it with addition. Obviously, this is not the most efficient way to do this - the code will run slower the further down the screen we are. But, it'll work for our current purposes. Feel free to come up with something neater if you're looking for a challenge :)

Also, as you can see, this function is quite impolite - it doesn't preserve any registers at all. You know the drill if you want to make it safe!

What's in a Byte?

We know that one byte represents one pixel, and we know how to find the address of the byte that we want to write to to put a dot on the screen. Now, we just need to know what we actually need to write to that byte!

The Bitmap Overlay Device that we're using in this tutorial uses 6-bit colour. What this means is that there are 6 binary digits available to choose the colour that will be drawn, for a total of 64 different possible colours. The byte is laid out like so:

XSRR GGBB

Where

Reserved/unused/doesn't do anything
Turns this pixel on and off - 0 = transparent
How much red to mix - 00 is no red, 11 is maximum red
Same as R, but for green
Same as R and G, but for blue

So, let's try it out!

SCREEN	    .word	16#30000        ;The offset to the Bitmap Overlay Device in memory
			
           .start  main
main       lw		r10,SCREEN  	;Load our offset into r10 so we can write to it
           addui	r1,2#01000011   ;Selecting the colour blue (note the second bit is set to 1) 
           sb		(r10),r1        ;we write our blue to the first pixel on the screen
           wait

If we compile this simple program and run it, we should see that a small blue dot appears in the top left of the screen. Riveting!

Just for something more exciting, here's a program that will loop through all the colours available to us, filling the whole screen:

SCREEN		.word	16#30000
 			
		.start  main
main           lw      r10,SCREEN      ;Our offset for the screen
		addui   r1,2#01000000   ;this contains the on switch
		clr     r2              ;r2 <---- our offset. Start in the top left
		addu	r5,r1,r0        ;r5 <---- our current colour + the on switch
loop		sgei    r4,r2,120*80    ;Check if we've gone past the last pixel on the screen...
		beqz    r4,noloop       ;No, we haven't. Go to the end.
		clr     r2              ;Yes, we have. First, clear our offset 
		addui   r5,r5,1         ;Go to the next colour
		andi	r5,r5,2#00111111;(mask to limit to 64 colours)
		or	r5,r1,r5        ;Add the on switch to our new colour
noloop		addu    r3,r2,r10       ;r3 <--- our offset + screen position in memory
		sb	(r3),r5         ;save our colour to our current pixel
		addui	r2,r2,1         ;move to the next pixel
 		j	loop            ;loop!

While very pretty, it shows that even when the CPU is doing almost nothing but drawing, filling the whole screen pixel-by-pixel is very, very slow.

Making it Interactive

To round off this chapter, we'll tie together a few of the things we've learned to make an interactive program that puts graphics on the screen. We're going to make an effect similar to the old "Tron" style games.

Meaning, our goal is to have the player move around with the arrow keys while leaving a trail on the screen.

Initialization

Firstly, we're obviously going to need a way to update our game world. When making games, it's quite common to do a lot of our work when the screen finishes updating - it means we have the maximum time possible to get things done before the screen updates again. For this, we use the "vertical blank" interrupt. It's fired every time the screen finishes drawing, and by default it's in interrupt channel 5.

So, we'll make it call our "vblank" routine every time it fires, and we'll set our PSW to listen for the interrupt as well.

IRQ7        rfe		     		
IRQ6        rfe		
IRQ5        j        vblank		       			
IRQ4        rfe							
IRQ3        rfe	
IRQ2        rfe
IRQ1        rfe	
IRQ0        rfe                     	

listenTo    .wordu   2#001000000000000000000001 ;our psw settings

This should be a familiar site from our interrupt handlers chapter. Next, we need to set our offsets for the gamepad (so we can find what button is being pressed) and for the screen (so we can draw pixels to it). Then, we'll load all of those things into registers at the start of our program.

GP_OFFSET   .wordu   16#FFFFFF00
DP_OFFSET   .wordu   16#30000

            .start   main 
main        lw       r10,GP_OFFSET         ;load gamepad offset
            lw       r12,DP_OFFSET         ;load screen pos
            lw       r1,listenTo           ;load our psw settings
            movi2s   psw,r1                ;move irq settings into psw register

Next, we need to set the starting position for our "player". We'll start them in the middle of the screen.

            addui    r8,r0,60              ;set starting position x
            addui    r9,r0,40              ;and y

This is going to be a very simple program, so we're actually not going to use any RAM for it - we have plenty of registers to get all of our work done!

These are the registers we're going to use for the program:

; x 		 <--- r8       (horizontal position of the player)
; y 		 <--- r9       (vertical position of the player)
; xv		 <--- r6       (whether we're going left, right, or neither)
; yv		 <--- r7       (whether we're going up, down, or neither)
; gamepad       <--- r10      (the offset of the controls)
; colour	 <--- r11      (the colour that we're drawing)
; screen        <--- r12      (the offset of the screen)

This leaves us to use the other registers for general work.

First up, we need our main loop. This is a pretty boring loop - all it does is wait for the screen to be ready to draw to.

loop        wait                           ;wait for something to happen
            j        loop                  ;go back to waiting

The vertical blank handler

Everything else goes inside the vertical blank interrupt handler! You'll find that most of your more simple games will be the same in this regard. The first thing we need to do is move the player. Our goal for this program is that the player continually moves in one direction until a button is pressed to change that direction. In the beginning, the player is stationary.

;this section is called whenever the screen refreshes (30 times per second)		 
vblank      add      r8,r8,r6              ;x+=xv;
            add      r9,r9,r7              ;y+=yv;

Dealing with input

Next, we need to check for player input. We're going to do the following:

 [load up gamepad up/down register]
                 |
                 V
[Check if a button is being pressed] --no--> [skip to the next section]
                 |
                yes
                 |
                 V
[Set new vertical movement appropriately] 
                 |
                 V                                 (go back and repeat for left/right)
[Cancel horizontal movement (no diagonals]------------------------^

This is pretty easy to turn into code.

            lb       r5,(r10)              ;load the first byte of the gamepad register (up/down)
            beqz     r5,input_lr           ;only change direction if a button is being pressed
            add      r6,r0,r5              ;set the yv to the register value
            add      r7,r0,r0              ;can only move horizontal OR vertical
input_lr    lb       r5,1(r10)             ;load the second byte (left/right)
            beqz     r5,input_end          ;same deal
            add      r7,r0,r5              ;but with xv
            add      r6,r0,r0              ;can only move horizontal OR vertical

As you can see, it's just two sets of four commands, and each command can be read 1-to-1 in our flowchart.

Keeping it in the game world

When the player can move, one thing that is bound to happen is that the player will try to move beyond the boundaries of the game world. There are many ways to react to this - the player's character could bounce, die, or simply stop. In the case of this game, the character is going to wrap around to the other side of the screen. This has been a popular gameplay mechanic since the days of Asteroids arcade consoles.

The logic for wrapping a player around is very simple - especially when your player's avatar on the screen is just a single dot!

All we have to do is two checks for each axis: "If the player's position is less than the minimum, set it to the maximum", and "if the player's position is greater than the maximum, set it to the minimum". Obviously we have to do this for both the horizontal and the vertical axis, here.

We'll be making use of some of DLX's comparative operations. They look like this:

           sgti     target,a,b          ;set greater than (immediate)
           slti     target,a,b          ;set less than (immediate)

In both cases, "target" is a register, "a" is a register, and "b" is a number. If a>b (or a<b in slti's case), then the value of "target" is set to 1. Otherwise, it is set to 0.

Once we have the result stored in "target", we can use beqz or bnez to branch our code based on whether the result was true or false.

Here's what it looks like:

input_end   slti     r3,r8,0               ;if(x<0){
            beqz     r3,wrap_pos_x         ; 
            addi     r8,r0,10#119          ;	x=119;}
wrap_pos_x  sgti     r3,r8,10#119          ;if(x>119){
            beqz     r3,wrap_y             ; 
            addui    r8,r0,0               ;   x=0;}
wrap_y      slti     r3,r9,0               ;if(y<0){
            beqz     r3,wrap_pos_y         ; 
            addi     r9,r0,10#79           ;   y=79;}    
wrap_pos_y  sgti     r3,r9,10#79           ;if(y>79){
            beqz     r3,vb_draw            ;
            addui    r9,r0,0               ;   y=0;}

As you can see, both the horizontal (x) and vertical (y) checks work the same way for both extremes - make a comparison, use beqz to jump if the comparion was false, otherwise wrap the player position to the opposite side of the screen.

Drawing

Finally, we need to actually draw our player on the screen in the right position. For this example, our player is simply represented by a current dot, so we can do this with one save byte command. But, of course, first we need to know where we're saving to, and what colour we're saving!

Finding out the "where" is very simple - we just shuffle the x and y values into registers 1 and 2, and then call our FindOS subroutine from earlier in the chapter:

vb_draw     add      r1,r0,r8              ;find pixel offset positions
            add      r2,r0,r9              ;by putting x and y into r1 and r2
            jal      findOS                ;and calling the function we made earlier
            addu     r1,r1,r12             ;add the screen offset to the pixel offset

Adding r12 to the result puts it in the range of the screen (remember we loaded the screen's memory offset to r12 earlier).

As for the colour - we could have just set a flat colour earlier on, but I wanted to try something a little more cool. We're going to increment the colour just like we did earlier for the whole-screen drawing demo.

            addui    r11,r11,1             ;increment our colour for a cool effect
            ori	     r11,r11,2#01000000    ;(but make sure the on switch stays on)
            sb       (r1),r11              ;save our colour to our pixel offset!
            rfe                            ;and we're done with this interrupt. 

The only command that might be confusing in there is "ori", which is "or immediate". OR-ing two numbers together compares each binary digit of one number against its equivalent in the other number. If either digit is 1, the output is 1. If they're both 0, the output is 0.

So,

   1010 
OR 0110 
-------
=  1110 

This is very useful, among other things, for making sure that a certain bit remains set regardless of the outcome of other operations. In this case, we want to make sure that bit 6, the "colour on-off switch" stays set to 1, so we "ori" the current colour with the 6th bit set to ensure that this is the case.

Finally, we save our colour to our target address, and end our handler, returning to the waiting loop.

Putting it all together & testing

Here's all the code in one place:

IRQ7        rfe		     		
IRQ6        rfe		
IRQ5        j        vblank		       			
IRQ4        rfe							
IRQ3        rfe	
IRQ2        rfe
IRQ1        rfe	
IRQ0        rfe                     	

listenTo    .wordu   2#001000000000000000000001 ;our psw settings
GP_OFFSET   .wordu   16#FFFFFF00
DP_OFFSET   .wordu   16#30000

            .start   main 
main        lw       r10,GP_OFFSET         ;load gamepad offset
            lw       r12,DP_OFFSET         ;load screen pos
            lw       r1,listenTo           ;load our psw settings
            movi2s   psw,r1                ;move irq settings into psw register
            addui    r8,r0,60              ;set starting position x
            addui    r9,r0,40              ;and y
loop        wait                           ;wait for something to happen
            j        loop                  ;go back to waiting
 
; x 		 <--- r8       (horizontal position of the player)
; y 		 <--- r9       (vertical position of the player)
; xv		 <--- r6       (whether we're going left, right, or neither)
; yv		 <--- r7       (whether we're going up, down, or neither)
; gamepad       <--- r10      (the offset of the controls)
; colour	 <--- r11      (the colour that we're drawing)
; screen        <--- r12      (the offset of the screen)

vblank      add      r8,r8,r6              ;x+=xv;
            add      r9,r9,r7              ;y+=yv;
            lb       r5,(r10)              ;load the first byte of the gamepad register (up/down)
            beqz     r5,input_lr           ;only change direction if a button is being pressed
            add      r6,r0,r5              ;set the yv to the register value
            add      r7,r0,r0              ;can only move horizontal OR vertical
input_lr    lb       r5,1(r10)             ;load the second byte (left/right)
            beqz     r5,input_end          ;same deal
            add      r7,r0,r5              ;but with xv
            add      r6,r0,r0              ;can only move horizontal OR vertical
input_end   slti     r3,r8,0               ;if(x<0){
            beqz     r3,wrap_pos_x         ; 
            addi     r8,r0,10#119          ;	x=119;}
wrap_pos_x  sgti     r3,r8,10#119          ;if(x>119){
            beqz     r3,wrap_y             ; 
            addui    r8,r0,0               ;   x=0;}
wrap_y      slti     r3,r9,0               ;if(y<0){
            beqz     r3,wrap_pos_y         ; 
            addi     r9,r0,10#79           ;   y=79;}    
wrap_pos_y  sgti     r3,r9,10#79           ;if(y>79){
            beqz     r3,vb_draw            ;
            addui    r9,r0,0               ;   y=0;}
vb_draw     add      r1,r0,r8              ;find pixel offset positions
            add      r2,r0,r9              ;by putting x and y into r1 and r2
            jal      findOS                ;and calling the function we made earlier
            addu     r1,r1,r12             ;add the screen offset to the pixel offset
            addui    r11,r11,1             ;increment our colour for a cool effect
            ori	     r11,r11,2#01000000    ;(but make sure the on switch stays on)
            sb       (r1),r11              ;save our colour to our pixel offset!
            rfe                            ;and we're done with this interrupt. 

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;findOS(r1:x, r2:y):uint                          ;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;Finds the memory offset on a 240x160 screen      ;
;given a provided x and y position. Trashes r2    ;
;and returns result in r1.                        ;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
		 
findOS      beqz     r2,f_end      ;is y zero?
            addui    r1,r1,10#120  ;no. Add one row to the offset
            subui    r2,r2,1       ;subtract one from y 
            j        findOS        ;and loop
f_end       jr       r31

I saved mine as "simplesnake.dls", assembled it with

java -jar dasm.jar simplesnake.dls -a

and ran it.

Upon hitting play, you should have a pulsing dot in the middle of the screen. Pressing an arrow key should start the dot moving and leaving a coloured trail. Pressing other arrow keys should change the direction of your travel. Leaving the edge of the screen should transport you to the opposite edge.

Conclusion

This marks the end of the "A" series of beginner tutorials! Hopefully this has given you a taste for developing in DLX assembly for the VO-EM console.

The next series of tutorials will go into further detail on opcode encoding, and then start looking at building an actual game!