x86 VGA 中的双缓冲
Double buffering in x86 VGA
为了唤起一些回忆,我决定坐下来在 VGA 模式下编写一个小的汇编游戏 13h - 直到我意识到视觉输出闪烁得要命。
起初我怀疑这可能是我的清屏程序。实际上,通过使用 STOSW 而不是一次将单个字节写入视频内存,闪烁不那么烦人但仍然存在。
进一步挖掘我记得我可能必须等待垂直回溯并在之后立即更新我的屏幕,但这并没有让事情变得更好。
所以我知道的最终解决方案有点像这样:
- 在单独的内存区域执行所有图形操作 - 清屏、设置像素
- 等待垂直回撤
- 将内存复制到显存
理论当然很简单,但我就是不知道如何写入缓冲区并最终将其 blit 到显存中!
这是我为 TASM 编写的代码的精简版(尽管有效)片段:
VGA256 EQU 13h
TEXTMODE EQU 3h
VIDEOMEMORY EQU 0a000h
RETRACE EQU 3dah
.MODEL LARGE
.STACK 100h
.DATA
spriteColor DW ?
spriteOffset DW ?
spriteWidth DW ?
spriteHeight DW ?
enemyOneA DB 0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,1,0,1,1,0,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,0,1,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,1,0,0,0,0
spriteToDraw DW ?
buffer DB 64000 dup (0) ; HERE'S MY BUFFER
.CODE
Main:
MOV AX,@DATA;
MOV DS,AX
MOV AH,0
MOV AL,VGA256
INT 10h
CLI
MainLoop:
MOV DX,RETRACE
Vsync1:
IN AL,DX
TEST AL,8
JZ Vsync1
Vsync2:
IN AL,DX
TEST AL,8
JNZ Vsync2
CALL clearScreen
CALL updateSprites
JMP MainLoop
mov AH,1
int 21h
mov AH,0
mov AL,TEXTMODE
int 10h
; program end
clearScreen PROC NEAR
MOV BX,VIDEOMEMORY
MOV ES,BX
XOR DI,DI
MOV CX,320*200/2
MOV AL,12
MOV AH,AL
REP STOSW
RET
clearScreen ENDP
drawSprite PROC NEAR
MOV DI,0
MOV CX,0
ForLoopA:
PUSH CX
MOV SI,CX
MOV CX,0
ForLoopB:
MOV BX,spriteToDraw
MOV AL,[BX+DI]
CMP AL,0
JE DontDraw
MOV BX,spriteColor
MUL BX
PUSH SI
PUSH DI
PUSH AX
MOV AX,SI
MOV BX,320
MUL BX
MOV BX,AX
POP AX
POP DI
ADD BX,CX
ADD BX,spriteOffset
MOV SI,BX
MOV BX,VIDEOMEMORY
MOV ES,BX
MOV ES:[SI],AL
POP SI
DontDraw:
INC DI
INC CX
CMP CX,spriteWidth
JNE ForLoopB
POP CX
INC CX
CMP CX,spriteHeight
JNE ForLoopA
RET
drawSprite ENDP
updateSprites PROC NEAR
MOV spriteOffset,0
MOV spriteColor,15
MOV spriteWidth,16
MOV spriteHeight,8
MOV spriteOffset,0
MOV spriteToDraw, OFFSET enemyOneA
CALL drawSprite
RET
updateSprites ENDP
END Main
第一个问题是您处于实模式。这意味着您正在使用 64 KiB 段。对于“320*200 256 色”,缓冲区需要 64000 字节;如果您尝试让一个数据段包含所有内容,您将只剩下 1535 个字节用于非缓冲区的内容(精灵、全局变量等)。它限制太多(迟早你会想要动画精灵,或者 level/map/background 风景,或者......)。
下一个问题是您不希望可执行文件中有 64000 个字节的零。通常你会使用“.bss 部分”来避免这种情况(一个特殊区域,用于“假设初始化为零”或“假设未初始化”数据不在可执行文件中)。
解决这两个问题;我会为缓冲区分配内存(例如,可能使用 int 0x21, ah = 0x48
DOS 函数)并有一个特殊的缓冲区段。在这种情况下,将缓冲区 blitting 到视频内存可能如下所示:
push es
push ds
mov ax,VIDEO_MEMORY_SEGMENT
mov bx,[bufferSegment]
mov es,ax
mov ds,bx
mov cx,320*200/2
cld
xor si,si ;ds:si = bufferSegment:0 = address of buffer
xor di,di ;es:di = VIDEO_MEMORY_SEGMENT:0 = address of video memory
rep movsw
pop ds
pop es
ret
注意 1:使用 mov cx,320*200/4
和 rep movsd
一次复制 4 个字节是 better/faster,但这需要 32 位 CPU(不适用于 80286 或更高版本)。如果 CPU 支持,32 位指令在 16 位代码中工作正常(它只是一个操作数大小前缀来更改默认大小,您不需要切换使用保护模式)。
注2:cld
(设置清除“方向标志”)可能是不必要的。通常,您在程序开始时清除一次方向标志(或者依赖于“在程序启动时由 OS 保证清除”的标志),这样您就不需要确保每次使用时都清除字符串指令(例如 rep movsw
)。
对于写入缓冲区,除了将 es
设置为 buffer_segment
而不是将 es
设置为 VIDEO_MEMORY_SEGMENT
之外,所有代码都将保持不变。
注意 3:与其在多个位置(在 clearScreen
、drawSprite
(!) 的循环中间等)加载 es
相同的值,不如加载它最好在程序初始化时设置一次 es
,当你需要使用 es
做其他事情时(在 blitting 函数中)设置 save/restore ;这样您就可以避免在所有绘图代码中加载(相对昂贵的)段寄存器(例如 mov es,bx
)。
还有;如果你最终想要一个背景图像(从 level/map 数据生成,或者......)你可以使用第三个“背景缓冲区”。这基本上是相同的——为背景分配另外 64000 字节(并且有一个 background_segment
),然后将背景绘制到缓冲区一次(当你加载关卡或一般地图或..);然后将“已经绘制”的背景数据从后台缓冲区复制到主缓冲区而不是清除缓冲区,并在其上绘制精灵,然后将缓冲区 blit 到视频。
为了唤起一些回忆,我决定坐下来在 VGA 模式下编写一个小的汇编游戏 13h - 直到我意识到视觉输出闪烁得要命。
起初我怀疑这可能是我的清屏程序。实际上,通过使用 STOSW 而不是一次将单个字节写入视频内存,闪烁不那么烦人但仍然存在。
进一步挖掘我记得我可能必须等待垂直回溯并在之后立即更新我的屏幕,但这并没有让事情变得更好。
所以我知道的最终解决方案有点像这样:
- 在单独的内存区域执行所有图形操作 - 清屏、设置像素
- 等待垂直回撤
- 将内存复制到显存
理论当然很简单,但我就是不知道如何写入缓冲区并最终将其 blit 到显存中!
这是我为 TASM 编写的代码的精简版(尽管有效)片段:
VGA256 EQU 13h
TEXTMODE EQU 3h
VIDEOMEMORY EQU 0a000h
RETRACE EQU 3dah
.MODEL LARGE
.STACK 100h
.DATA
spriteColor DW ?
spriteOffset DW ?
spriteWidth DW ?
spriteHeight DW ?
enemyOneA DB 0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,1,0,1,1,0,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,0,1,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,1,0,0,0,0
spriteToDraw DW ?
buffer DB 64000 dup (0) ; HERE'S MY BUFFER
.CODE
Main:
MOV AX,@DATA;
MOV DS,AX
MOV AH,0
MOV AL,VGA256
INT 10h
CLI
MainLoop:
MOV DX,RETRACE
Vsync1:
IN AL,DX
TEST AL,8
JZ Vsync1
Vsync2:
IN AL,DX
TEST AL,8
JNZ Vsync2
CALL clearScreen
CALL updateSprites
JMP MainLoop
mov AH,1
int 21h
mov AH,0
mov AL,TEXTMODE
int 10h
; program end
clearScreen PROC NEAR
MOV BX,VIDEOMEMORY
MOV ES,BX
XOR DI,DI
MOV CX,320*200/2
MOV AL,12
MOV AH,AL
REP STOSW
RET
clearScreen ENDP
drawSprite PROC NEAR
MOV DI,0
MOV CX,0
ForLoopA:
PUSH CX
MOV SI,CX
MOV CX,0
ForLoopB:
MOV BX,spriteToDraw
MOV AL,[BX+DI]
CMP AL,0
JE DontDraw
MOV BX,spriteColor
MUL BX
PUSH SI
PUSH DI
PUSH AX
MOV AX,SI
MOV BX,320
MUL BX
MOV BX,AX
POP AX
POP DI
ADD BX,CX
ADD BX,spriteOffset
MOV SI,BX
MOV BX,VIDEOMEMORY
MOV ES,BX
MOV ES:[SI],AL
POP SI
DontDraw:
INC DI
INC CX
CMP CX,spriteWidth
JNE ForLoopB
POP CX
INC CX
CMP CX,spriteHeight
JNE ForLoopA
RET
drawSprite ENDP
updateSprites PROC NEAR
MOV spriteOffset,0
MOV spriteColor,15
MOV spriteWidth,16
MOV spriteHeight,8
MOV spriteOffset,0
MOV spriteToDraw, OFFSET enemyOneA
CALL drawSprite
RET
updateSprites ENDP
END Main
第一个问题是您处于实模式。这意味着您正在使用 64 KiB 段。对于“320*200 256 色”,缓冲区需要 64000 字节;如果您尝试让一个数据段包含所有内容,您将只剩下 1535 个字节用于非缓冲区的内容(精灵、全局变量等)。它限制太多(迟早你会想要动画精灵,或者 level/map/background 风景,或者......)。
下一个问题是您不希望可执行文件中有 64000 个字节的零。通常你会使用“.bss 部分”来避免这种情况(一个特殊区域,用于“假设初始化为零”或“假设未初始化”数据不在可执行文件中)。
解决这两个问题;我会为缓冲区分配内存(例如,可能使用 int 0x21, ah = 0x48
DOS 函数)并有一个特殊的缓冲区段。在这种情况下,将缓冲区 blitting 到视频内存可能如下所示:
push es
push ds
mov ax,VIDEO_MEMORY_SEGMENT
mov bx,[bufferSegment]
mov es,ax
mov ds,bx
mov cx,320*200/2
cld
xor si,si ;ds:si = bufferSegment:0 = address of buffer
xor di,di ;es:di = VIDEO_MEMORY_SEGMENT:0 = address of video memory
rep movsw
pop ds
pop es
ret
注意 1:使用 mov cx,320*200/4
和 rep movsd
一次复制 4 个字节是 better/faster,但这需要 32 位 CPU(不适用于 80286 或更高版本)。如果 CPU 支持,32 位指令在 16 位代码中工作正常(它只是一个操作数大小前缀来更改默认大小,您不需要切换使用保护模式)。
注2:cld
(设置清除“方向标志”)可能是不必要的。通常,您在程序开始时清除一次方向标志(或者依赖于“在程序启动时由 OS 保证清除”的标志),这样您就不需要确保每次使用时都清除字符串指令(例如 rep movsw
)。
对于写入缓冲区,除了将 es
设置为 buffer_segment
而不是将 es
设置为 VIDEO_MEMORY_SEGMENT
之外,所有代码都将保持不变。
注意 3:与其在多个位置(在 clearScreen
、drawSprite
(!) 的循环中间等)加载 es
相同的值,不如加载它最好在程序初始化时设置一次 es
,当你需要使用 es
做其他事情时(在 blitting 函数中)设置 save/restore ;这样您就可以避免在所有绘图代码中加载(相对昂贵的)段寄存器(例如 mov es,bx
)。
还有;如果你最终想要一个背景图像(从 level/map 数据生成,或者......)你可以使用第三个“背景缓冲区”。这基本上是相同的——为背景分配另外 64000 字节(并且有一个 background_segment
),然后将背景绘制到缓冲区一次(当你加载关卡或一般地图或..);然后将“已经绘制”的背景数据从后台缓冲区复制到主缓冲区而不是清除缓冲区,并在其上绘制精灵,然后将缓冲区 blit 到视频。