x86 VGA 中的双缓冲

问题描述

为了唤起一些回忆,我决定坐下来在 VGA 模式下编写一个小汇编游戏13h - 直到我意识到视觉输出闪烁得像地狱一样。

起初我怀疑这可能是我的清屏程序。实际上,通过使用 STOSW 而不是将单个字节写入视频内存,闪烁不那么烦人但仍然存在。

进一步挖掘我记得我可能不得不等待垂直回溯并在之后立即更新我的屏幕,但这并没有让事情变得更好。

所以我知道的最终解决方案有点像这样:

  • 在单独的内存区域执行所有图形操作 - 清除屏幕,设置像素
  • 等待垂直回撤
  • 将内存复制到视频内存

理论当然很简单,但我就是不知道如何写入缓冲区并最终将其写入视频内存!

这是我为 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,1,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,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,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 with 256 colours”,缓冲区需要为 64000 字节;如果您尝试使用一个包含所有内容的数据段,您将只剩下 1535 个字节用于非缓冲区的内容(精灵、全局变量等)。这太严格了(迟早你会想要动画精灵,或者关卡/地图/背景风景,或者......)。

下一个问题是您不希望可执行文件中有 64000 个字节的零。通常,您会使用“.bss 部分”来避免这种情况(用于“假定初始化为零”或“假定未初始化”数据的特殊区域,这些数据不在可执行文件中)。

解决这两个问题;我会为缓冲区分配内存(例如,可能使用 int 0x21,ah = 0x48 DOS 函数)并有一个特殊的缓冲区段。在这种情况下,将缓冲区写入视频内存可能看起来像:

    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/4rep movsd 一次复制 4 个字节会更好/更快,但这需要 32 位 CPU(不适用于 80286或以后)。如果 CPU 支持,32 位指令在 16 位代码中工作正常(它只是一个操作数大小前缀来更改默认大小,您不需要切换使用保护模式)。

注意 2:cld(设置清除“方向标志”)可能是不必要的。通常,您在程序开始时清除一次方向标志(或依赖于“在程序启动时由操作系统保证清除”的标志),这样您就无需在每次使用字符串指令时都确保它清楚(例如像rep movsw)。

为了写入缓冲区,您的所有代码都将保持不变,只是您将 es 设置为 buffer_segment 而不是将 es 设置为 VIDEO_MEMORY_SEGMENT。>

注意 3:与其在多个位置(在 es 中,在 clearScreen(!) 中的循环中间等)加载具有相同值的 drawSprite最好在程序初始化期间设置一次 es 并在您需要将 es 用于其他用途时保存/恢复它(在 blitting 函数中);这样您就可以避免所有绘图代码中的(相对昂贵的)段寄存器加载(例如 mov es,bx)。

还有;如果您最终想要一个背景图像(从关卡/地图数据生成,或者...),您可以使用第三个“背景缓冲区”。这几乎是相同的 - 为背景分配另外 64000 字节(并有一个 background_segment),然后将背景绘制到缓冲区中一次(当您加载关卡或一般地图或..);然后将“已经绘制”的背景数据从背景缓冲区复制到主缓冲区而不是清除缓冲区,并在其上绘制您的精灵,然后将缓冲区 blit 到视频。