Difference between revisions of "Programming:Fast Sprites"

From CPCWiki - THE Amstrad CPC encyclopedia!
Jump to: navigation, search
m (categ)
Line 214: Line 214:
  
 
[[User:Executioner|Executioner]] 19:55, 12 July 2006 (CDT)
 
[[User:Executioner|Executioner]] 19:55, 12 July 2006 (CDT)
 +
[[Category:Programming]]

Revision as of 10:07, 13 August 2006

Fast Sprites

Here's some tips for sprite/screen handling.

1. The best screen width to use is 32 (MODE 1 characters in CRTC register 1). This is wide enough for most games, and it's special property is that if your screen base address is on a 64 byte boundary, no pixel row will cross a 256 byte page boundary, hence you can use INC reg8 to move to the next display byte rather than INC reg16 (where reg8 is an 8-bit register, and reg16 is a 16-bit register). This only saves 1 microsecond, but in a tight loop, every one counts.

2. Avoid using PUSH/POP inside your sprite loop. These are slow instructions. Similarly, avoid using index registers (IX+n) and (IY+n) are slow. One case where it may be wise to use an index register is for a loop counter, so you can keep BC for something else.

3. If it doesn't need to be transparent, don't draw it that way. The quickest way to move the data in most cases is LDI. Draw solid sprites separately, and possible, build a large sprite from smalled ones with some transparent sections, some opaque.

4. Keep your sprite data from crossing page boundaries on pixel rows (or at all if possible). For the same reason as 1 above, you may be able to increment your data pointer using INC reg8.

5. Use mask tables to save memory. If you've got plenty of memory left, you can put your AND and OR masks for each sprite with the sprite data, this is the fatest approach for transparent sprites:

LD A,(DE)
AND (HL)
INC L
OR (HL)
INC L
LD (DE),A
INC E ;Total = 11 μs per byte

But in most cases, you don't have that much memory to store all your graphics. You can create a 256 AND mask table, and possibly also a 256 byte OR mask table. This is the quickest way to mask the data while saving some memory.

LD A,(BC)
INC C
LD L,A
LD A,(DE)
AND (HL)
INC H
OR (HL)
DEC H
LD (DE),A
INC E ;Total = 15 μs per byte

The other advantage of using this method is that with a small change to the above code you can quite easily use different mask tables. In ZACK, I use a reversing table which has the two MODE 0 pixels reversed, essentially flipping the byte left to right. By then changing the INC C to a DEC C (and starting at a different offset in the sprite data) it's easy to flip a sprite, saving memory for sprites which can face both ways. Another table has the OR masks all set to ink 15 for every non-transparent pixel. This can then be masked again with a colour to create a solid colour version of a sprite.

Out of registers?

As you can see with the above code, all the 8 bit registers are used. What's left for loop counters etc?

1. One method is to use the alternate register set (beware if the OS is still being used). The EXX instruction will swap BC, DE and HL in one cycle.

2. Another method (if you're not worried about clipping and all your sprites are the same size) is to unroll the loop n times where n is the width in bytes of the sprite, eg. for 4 bytes wide:

.byte0
LD A,(BC):INC C
LD L,A:LD A,(DE)
AND (HL):INC H:OR (HL):DEC H
LD (DE),A:INC E
.byte1
LD A,(BC):INC C
LD L,A:LD A,(DE)
AND (HL):INC H:OR (HL):DEC H
LD (DE),A:INC E
.byte2
LD A,(BC):INC C
LD L,A:LD A,(DE)
AND (HL):INC H:OR (HL):DEC H
LD (DE),A:INC E
.byte3
LD A,(BC):INC C
LD L,A:LD A,(DE)
AND (HL):INC H:OR (HL):DEC H
LD (DE),A:INC E

This is by far the fastest method if you make sure your sprites never need to clip. If they do need to clip, you can use some tricky self modifying code to loop part way through this code. eg. Store the address (byte0, byte1, byte2 or byte3) in IX and do PUSH IX:RET to continue the loop. Non-indexed, ie. not (IX+n) are only one cycle slower than operations on HL.

3. Use undocumented 8 bit operations on IX and IY 8 bit values. You could use HX for your loop counter, for example:

.dobyte
LD A,(BC):INC C
LD L,A:LD A,(DE)
AND (HL):INC H:OR (HL):DEC H
LD (DE),A:INC E
DB #DD:DEC H
JR NZ,dobyte

The same of course applies to the unrolled version, where you could use HX for the row counter.

Incrementing the row

How do we start drawing the next screen line? Since we've already used all the registers, this can be quite tricky. Each scan line is offset by #800 bytes, except that every 8 scan lines the offset is actually (CRTC Register 1 * 2) - #3800. Also, we've been incrementing the screen address each byte by 1, so now our offset is also out by 4 bytes if the sprite is 4 bytes wide. Clipping the sprite makes this even more of a challenge, but I won't get into that :)

With the unrolled version above, with only 4 bytes wide, the easiest method would be to change the last INC E to three DEC E's, ie.

.byte3
LD A,(BC):INC C
LD L,A:LD A,(DE)
AND (HL):INC H:OR (HL):DEC H
LD (DE),A:DEC E:DEC E:DEC E

Then we only need to decrement E two more times to get it back to the original state, and increment the line. If your sprite is more that 4 bytes wide, you're not using unrolled loops, you'll need to either subtract the width from E, or make sure you add the appropriate amount (eg. #7FC rather than #800), but this involved using 16 bit addition which is slower, and we're short of registers as usual.

The technique I normally use is to either use some self modifying code, eg.

LD A,E
.sprwid equ $+1
SUB 4
LD E,A

Or to store the width in one of those undocumented 8 bit registers (HX, HY, LX or LY). eg.

LD A,E
DB #DD:SUB L
LD E,A

Calculating the next screen address is usually relatively simple then, using 8 bit registers:

LD A,D
ADD 8
LD D,A
JR NC,nowrap
LD A,#40
ADD E
LD E,A
LD A,#C0
ADC D
LD D,A

There are of course numerous other ways of doing this, maybe even faster ones. One example is to have the stack pointing to a table of screen addresses for each row, and hold an offset in a register (with interrupts disabled, or interrupt safe timing), eg.

POP DE
LD A,I
ADD E
LD E,A

The only thing left to do now is continue the loop for each row, depending on where you stored your loop counter(s), this could be as simple as:

DB #FD:DEC H
JR NZ,dobyte

A complete sprite routine

Here's a pretty fast transparent sprite drawing routine in full. Note that it assumes the screen with is 32 MODE 1 pixels, and the screen base is #C000 with CRTC register 9 set to 7, and has no clipping. Please note, I've also changed the loop so that it exits with RET Z before calculation the next address if it's the last row. This also allowed me to use JR NC,rowloop to continue the loop if the address doesn't wrap. Please also note that I haven't actually tried to assemble this code yet :) it may need a couple of tweaks to run!

;Entry: BC = Sprite address, D = width, E = height, HL = screen address

DB #DD:LD L,D  ;LX = width
DB #FD:LD H,E  ;HY = height
EX DE,HL
LD H,andmasks / 256
.rowloop
DB #DD: LD H,L  ;HX <= width
.dobyte
LD A,(BC):INC C
LD L,A:LD A,(DE)
AND (HL):INC H:OR (HL):DEC H
LD (DE),A:INC E
DB #DD:DEC H
JR NZ,dobyte
DB #FD:DEC H
RET Z
LD A,E
DB #DD:SUB L
LD E,A
LD A,D
ADD 8
LD D,A
JR NC,rowloop
LD A,E
ADD #40
LD E,A
LD A,D
ADC #C0
LD D,A
JR rowloop

Further Optimisation

This code could be further improved by unrolling the loop, but that would involve using some self modifying code to patch the rowloop jumps. If you have a number of predetermined widths for your sprites, you could rewrite the routine unrolled for each available size. Note that this could make it difficult later if you want to clip the sprites.

If you don't need to either paint the sprite in a solid colour, or flip the sprite left to right (or you've got enough memory to store a flipped version), then you can optimise the routine to plot a single byte even further:

LD A,(BC)
INC C
LD L,A
LD A,(DE)
AND (HL)
OR L
LD (DE),A
INC E ;Total = 12 μs per byte

As you can see, this is now down to 12 microseconds per byte, only 1 microsecond slower than storing the mask with the data, but the sprite data is half the size, so you can store twice as much graphics data in your left-over 2K of memory once you've got the double-buffered screens, music and sound fx code and game logic in place.

Clipping

Depending on the routine you use, clipping can be quite difficult or quite simple.

Clipping vertically is simply a matter of adjusting (a) the start offset in the sprite and (b), the number of rows (passed in E in the example code).

Clipping horizontally presents more of a problem. Simply adjusting the start offset and the width (passed in D in the example) won't do the job, because the INC C won't happen enough times to increment the sprite offset to the next row. To get around this, either store the value of C in a spare register at the start of the loop (I think there's 2 left in the example: LY and I), then at the end of the horizontal loop add the width to the value (remember the width passed in is not the width of the data!), or precalculate the difference between the actual sprite width and the displayed sprite width, and add this value. This is probably the preferred method. eg.

At the start of the code, pass in the clipped width in E, and the sprite width in A for example:

SUB E:DB #FD:LD L,A ;LY = sprite width - displayed width

Then at the end of the loop (just after RET Z):

LD A,C:DB #FD:ADD L:LD C,A

One last thought

Earlier on in this document, I mentioned not using index registers because they are slow. There could, however be some merit in using them, especially for unrolled loops to replace the BC register above. Using the index register may remove the need for the INC C and the LD L,A above, for example:

LD L,(IY+0)
LD A,(DE)
AND (HL)
OR L
LD (DE),A
INC E ;Total = 13 μs per byte

This code is only 1 microsecond slower than the previous, but unrolled (using (IY+1), (IY+2) etc) it won't destroy the value in IY, and it also leaves BC free for loop counters or perhaps extra masks. Not destroying the value in IY means you can simply add the width in bytes to LY even when clipping to ensure the sprite data points to the next valid byte, but remember that adding a value to LY will take at least 5 microseconds, plus one extra microsecond per byte in the loop....

Executioner 19:55, 12 July 2006 (CDT)