Dots tunnel: two screens, and clearing only the dots
The second post left the tunnel on the CPC in a first version that is slow, and that flickers. Its measurement put the blame on clearing the 16 KB of the screen, when a picture lights 256 bytes at most.
Here the two faults go, one after the other. The v2 draws into a second, hidden screen, and no longer flickers. The v3 clears only the bytes that the tunnel has lit. Then we measure what each version brings, and what a picture still costs once the clearing weighs almost nothing.
As a reminder, a CPC frame lasts 19,968 NOPs, and the v1 takes 7 of them per picture.
Why the picture flickers
The v1 draws into the visible screen, at #C000. So the beam scans this screen while clear16k empties it, then while the dots come back one by one. For almost 5 frames, it shows a black or half-empty screen, and for 1 or 2 frames a tunnel that is being rebuilt. The eye sees both, and the picture flickers.
Every demo knows the cure: draw into a screen that nobody is looking at, and show it only once it is finished.
Two screens
The CPC has no fixed screen. The CRTC, the chip that generates the video memory addresses, reads the start address of the picture from its registers 12 and 13. Bits 5 and 4 of R12 choose the 16 KB block: 11 for #C000, 10 for #8000. Bits 3 and 2 set the size of the screen, 16 KB when they are 00. Bits 1 and 0 of R12, with R13, give an offset inside this block, which the tunnel leaves at 0.
The memory has room for a second screen. The tables go from #4000 to #69FF, the code starts at #1000, and the block from #8000 to #BFFF is free. The RASM snapshot delivers it filled with zeros, so black.
Choosing the visible screen
show_screen receives the high byte of the screen, #C0 or #80, and writes it into R12. The CRTC has two ports: #BCxx selects the register, #BDxx writes to it:
; show_screen: the CRTC will show the screen whose high byte is A (#C0 or #80),
; from the next picture on.
show_screen:
ld bc,#BC0C ; CRTC: select register 12
out (c),c
rrca
rrca
and #30 ; #C0 -> #30, #80 -> #20
inc b ; #BD: write to the register
out (c),a
ret
Two rrca move bits 7 and 6 of the high byte down to places 5 and 4, and and #30 clears the rest. inc b goes from #BC to #BD without touching C.
The CRTC takes the new address only at the start of the next picture, when it starts again at the top of the screen. So writing R12 just after the start of the VBL lets the current picture finish on the old screen. The next one starts from the new screen, in full.
The loop
back holds the high byte of the work screen, the one we draw into:
back: db #C0 ; high byte of the work screen
Each picture starts by showing the screen that the loop has just finished, then switches to the other one. xor #40 does the swap, because #C0 and #80 differ only in bit 6:
main: call wait_vbl
ld a,(back)
call show_screen ; show the screen we have just drawn
ld a,(back)
xor #40 ; #C0 <-> #80
ld (back),a ; and draw into the other one
border RED ; work starts
ld a,(back)
ld h,a ; the work screen, hidden
ld l,0
call clear16k
On the first pass, back is #C0: the loop shows the #C000 screen, still black, and draws into #8000. On the next pass, it shows #8000 and draws into #C000, and so on.
back must be read again after border RED, because the border macro loads its colour into A. Without this second read, clear16k would clear 16 KB from #4C00, right in the middle of the tables. The tunnel would come apart from the first picture on. The nearest rings would leave solid circles, since the work screen would never be cleared again, and the others, whose shape would be zeroed, would shrink to their centre.
One dot
plot used to add SCREEN>>8, a constant, to the high byte of the address. It now adds back, which changes with each picture:
inc h ; YHI = YLO + 256
ld a,(back)
adc a,(hl) ; + high byte, carry and screen start
ld a,(back) costs 4 NOPs, where ld a,#C0 cost 2. Over 256 dots, that makes 512 NOPs per picture, a trifle next to the clearing.
The result
Once assembled, tunnel-v2.asm gives tunnel-v2.sna. Run in an emulator, this snapshot shows a tunnel that no longer flickers. Each picture on screen is complete, and the drawn screen is identical, byte for byte, to the one of the v1 for the same value of t. We checked it in a Z80 emulator, by comparing the 16 KB of the work screen of both versions for the 256 values of t.
The speed has not moved, at 7 frames per picture, because the loop still clears 16 KB, in a screen that nobody sees.
Clearing only what we drew
The tunnel lights 256 dots per picture, so 256 bytes at most. Two nearby dots can fall into the same byte, so a picture often lights fewer. To clear a picture, it is enough to reset these bytes to 0, provided we know where they are.
plot knows each address at the moment it writes to it. So it can record the address in a list, and the loop of the next picture will read this list back to clear.
One list per screen
With two screens, the picture we clear is not the previous one. The work screen holds the picture drawn two passes earlier, since the loop has drawn into the other screen in the meantime. So we need one list per screen, and the list changes along with the screen.
Each list holds 256 addresses of 2 bytes, that is 512 bytes. The block from #6A00 to #7FFF, between the tables and the second screen, is free, and the two lists sit there on 256-byte boundaries:
LIST_C0 = #7C00 ; the lit bytes in the #C000 screen
LIST_80 = #7E00 ; and in the #8000 screen
erase_list holds the high byte of the list of the work screen:
erase_list: db LIST_C0>>8 ; high byte of its list
Lists ready from the first picture
On the first pass over a screen, its list has never been written. It holds only the zeros of the snapshot, and the clearing would write 256 times to address #0000, outside the screen. So fill_list fills each list with the address of the first byte of its screen:
; fill_list: fill list HL with 256 copies of the address A * 256, the first
; byte of a screen that is still black: the first clearing changes nothing.
fill_list:
ld b,0
next_entry:
ld (hl),0
inc hl
ld (hl),a
inc hl
djnz next_entry
ret
ld b,0 makes djnz loop 256 times, because the first djnz takes B from 0 to 255. The first clearing then writes a 0, 256 times, into a byte that already holds 0.
The program calls fill_list once for each list, just after init:
call init
ld hl,LIST_C0
ld a,#C0
call fill_list
ld hl,LIST_80
ld a,#80
call fill_list
Clearing by following the list
The loop switches the list along with the screen. #7C and #7E differ only in bit 1, and xor #02 does the swap:
ld a,(erase_list)
xor #02 ; #7C <-> #7E: the list follows the screen
ld (erase_list),a
The clearing then takes the place of clear16k. IY walks through the list, two bytes per address, and each address receives a 0:
ld a,(erase_list) ; clear the bytes lit two pictures earlier
ld h,a
ld l,0
push hl
push hl
pop iy ; IY walks through the list
ld b,0 ; 256 addresses
erase: ld l,(iy+0)
ld h,(iy+1)
ld (hl),0
inc iy
inc iy
djnz erase
pop iy ; plot rewrites the list from the start
The double push hl keeps two copies of the start of the list on the stack. The first one goes into IY for the clearing. The second one brings IY back to the start once the clearing is done, so that plot rewrites the list over the old one.
A byte that holds two dots appears twice in the list, and receives 0 twice. The second write costs only time, and destroys nothing: at that moment, no dot of the new picture has been drawn yet.
Recording each address
plot records the address it has just written to, right before its ret:
or (hl) ; keep the other pixels of the byte
ld (hl),a
ld (iy+0),l ; record the address for the clearing
ld (iy+1),h
inc iy
inc iy
ret
Nothing else in the program touches IY, so it can stay reserved for the list during the whole picture.
The tunnel on the CPC
The CPCEC emulator below runs tunnel-v3.sna, assembled from tunnel-v3.asm:
The picture does not flicker, and the tunnel moves much faster. The red border still spills over into a second frame. Here too, the drawn screen is identical, byte for byte, to the one of the v1 for the same value of t.
What each version brings
We measured the three versions the same way as the v1 in the second post, in frames per picture in amspirit-lite (6128), over 1,000 frames. This time the measurement goes through its version without a window, amspirit-lite-headless:
| Version | Frames per picture | Pictures per second |
|---|---|---|
v1: one screen, clear16k |
7 | 7 |
v2: two screens, clear16k |
7 | 7 |
| v3: two screens, erase list | 2 | 25 |
A count by hand explains the gap. ldir clears 16 KB in 98,297 NOPs. One pass of the erase loop costs 23 NOPs:
| Instruction | NOPs |
|---|---|
ld l,(iy+0) |
5 |
ld h,(iy+1) |
5 |
ld (hl),0 |
3 |
inc iy |
3 |
inc iy |
3 |
djnz erase |
4 (3 on the last pass) |
The 256 passes make 255 × 23 + 22 = 5,887 NOPs, 17 times fewer than clear16k. In exchange, plot pays 16 more NOPs per dot to record the address (5 + 5 + 3 + 3), that is 4,096 NOPs per picture.
These 16 NOPs also explain why the v3 does not fit in one frame. The second post already counted more than 78 NOPs per dot. With the 2 NOPs of ld a,(back) and the 16 of the list, a dot now takes more than 96. So the dots alone cost more than 256 × 96 = 24,576 NOPs, beyond the 19,968 NOPs of a frame, before any clearing. Clearing less will not help any more.
Conclusion
The v3 no longer flickers and runs at 25 pictures per second, against 7 for the v1, with the same pictures, byte for byte. It still has these faults:
- 2 frames per picture, so half of the 50 pictures per second of the CPC;
- more than 96 NOPs per dot, between the
call, the variables in memory, the mask loop and now the list; - 1 KB of lists and a second 16 KB screen, the memory that the speed costs.
The next post will tackle the cost of each dot.
The files of this post:
- tunnel-v2.asm and tunnel-v2.sna, the v2 with two screens;
- tunnel-v3.asm and tunnel-v3.sna, the v3 with its erase list;
- TABLES.BIN, the BASIC tables, which both versions load.
Both versions assemble with RASM 3.0.9.