PPU/DMA/Renderer

今回は予定では PPU と DMA の実装となっていましたが、PPU, DMA それから Renderer を分けると説明がややこしくなりそうだったので、全部まとめてとりあえずキーボード入力なしで動作するところまで実装しました。

Nesdevnestest.nes や「NES研究室」の Hello World! のサンプルはこんな感じで動作します。

PPU の仕様について

PPU の仕様や仕組みについては参考にしたページが詳しいので、そちらを参照してください。

ここでは PPU の仕様や仕組みについてぼんやりとわかっている前提でお話を進めます。

Nes の画像アップデートの流れ

JavaScript のオリジナルでは requestAnimationFrame を使って、表示しているデバイスのフレームレート(大抵は 60fps)でエミュレータ画面をアップデートしています。Nes も 60fps で画面をアップデートしているので requestAnimationFrame で呼ばれるコールバックルーチンの中で Nes の画像を 1画面分作成してアップデートをすれば実行速度的にも良いことになります。

すごく簡単に書くとこんな感じです。

  1. requestAnimationFrame コールバックルーチンが呼ばれる(60fps)
  2. (コールバックルーチンの中で)1画面分の画像データを作成
  3. 1画面分の画像データを使ってエミュレータの画像をアップデート
  4. (次に 60fps で 1の呼び出しが来るまで)終了

それぞれについて macOS での実装を以下で説明していきます。

CVDisplayLink で 60fps のアップデートをする

まずは先のリストの 1. requestAnimationFrame コールバックルーチン(60fps)の部分を実装していきます。

MacOS では CoreVideo に CVDisplayLink という接続されているデバイスのフレームレートに合わせて設定したコードをコールバックしてくれる機能があるので、今回はこれを使ってコールバックルーチンを実装していきます。

コードは Nes.swift に実装しました。

    fileprivate var displayLink: CVDisplayLink?

    init(renderer: CanvasRenderer) {
        self.renderer = renderer
        
        super.init()
        
        setUpCVDisplayLink()
    }
    
    func setUpCVDisplayLink() -> Void {
        let displayLinkOutputCallback: CVDisplayLinkOutputCallback = {(displayLink: CVDisplayLink, inNow: UnsafePointer<CVTimeStamp>, inOutputTime: UnsafePointer<CVTimeStamp>, flagsIn: CVOptionFlags, flagsOut: UnsafeMutablePointer<CVOptionFlags>, displayLinkContext: UnsafeMutableRawPointer?) -> CVReturn in
            
            let nes = unsafeBitCast(displayLinkContext, to: Nes.self)
            
            nes.frame()
            
            //  We are going to assume that everything went well, and success as the CVReturn
            return kCVReturnSuccess
        }
        
        CVDisplayLinkCreateWithActiveCGDisplays(&displayLink)
        CVDisplayLinkSetOutputCallback(displayLink!, displayLinkOutputCallback, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
    }

コード自体は簡単で CVDisplayLink のプロパティ displayLink を宣言。Nes が作成されたタイミングで setUpCVDisplayLink() を呼び出して CVDsiplayLinkOutputCallback のコールバック displayLinkOutputCallback を設定します。このコールバック はブロックになっていて、中で Nes の frame() を呼び出しています。作成したコールバック displayLinkOutputCallback を CVDisplayLinkSetOutputCallback で displayLink に設定したら終了です。
あとは、CVDisplayLinkStart, CVDisplayLinkStop でコールバックの呼び出しの On/Off をするだけです。

1画面分の画像データを作成する

CVDisplayLink のおかげで Nes の frame() が 60fps で呼び出されます。
これが上記リストの 2.に該当する部分になり、ここで 1画面分の画像データを作成します。

    func frame() {
        guard let cpu = cpu, let dma = dma, let ppu = ppu else { return }
        while true {
            var cycle = 0
            if dma.isProcessing {
                dma.runDMA()
                cycle = 514
            }
            cycle += cpu.run()
            if let renderingData = ppu.run(cycle * 3) {
                renderer.render(renderingData)
                break
            }
        }
    }

コードは、CPU を 1ステップ動かしてそれにかかった cycle * 3 のサイクル分だけ PPU を動かすということを、1画面分の画像データが作成できるまでひたすら続けます。(PPU は CPU の 3倍のサイクル数動作します。)
PPU から画像データ(RendaringData)が返ってきたら次のエミュレータの画像をアップデートに移ります。

ビットマップデータを表示する

1画面分の画像データができたら、次に 3.エミュレータの画像をアップデート を行います。
PPU が作った 1画面分の画像データは [PPU.Tile], [PPU.SpriteWithAttribute], Palette.PaletteRAM のデータです。PPU が作成したこれらデータをビットマップデータにして CGImage に変換、それを UI の NSImage に表示します。

データは以下のように変換されていきます。

  1. RenderingData ([PPU.Tile], [PPU.SpriteWithAttribute], Palette.PaletteRAM)
  2. BitmapImage(RGBA 32bit)
  3. CGImage
  4. NSImageView@ViewController

まずは RenderingData をビットマップデータ(BitmapImage(RGBA 32bit))にします。
RennderingData の中身はそれぞれ以下のようになっています。

  • PPU.Tile
    8×8ドットの画像データ(スプライト)、色のデータ、x、y のスクロール量で、これが 32×30個で 256×240ドットの 1画面(バックグラウンド)になります。(RenderingData に渡されるのはスクロールでオフセットする分も含めた 33×31個のデータです。)
  • PPU.SpriteWithAttribute
    スプライト、表示する場所(x, y)、アトリビュート、スプライト番号で、これは 1画面に最大 64個表示できますが、1ラインでは同時に 8個しか表示できません。
  • Palette.PaletteRAM
    使用するパレット情報です。
    このパレット情報を使って Colors.Table から RGB の色情報を取り出します。
    struct SpriteWithAttribute {
        let sprite: Sprite
        let x: Byte
        let y: Byte
        let attr: Byte
        let spriteId: Byte
    }
    
    struct Tile {
        let sprite: Sprite
        let paletteId: Byte
        let scrollX: Byte
        let scrollY: Byte
    }

これらを使って 256×240 ドットのビットマップデータを作成するのが CanvasRenderer.swift です。
以下は Tile を RGBA の 32bit データに変換するコードです。

    func renderTile(tile: PPU.Tile, tileX: Int, tileY: Int, palette: Palette.PaletteRAM) {
        let offsetX = Int(tile.scrollX % 8)
        let offsetY = Int(tile.scrollY % 8)
        
        for i in 0..<8 {
            for j in 0..<8 {
                let paletteIndex = Int(tile.paletteId) * 4 + Int(tile.sprite[i][j])
                let colorId = Int(palette[paletteIndex])
                if colorId >= Colors.Table.count { continue }
                let color = Colors.Table[colorId]
                let x = tileX + j - offsetX
                let y = tileY + i - offsetY
                if (x >= 0 && 0xFF >= x && y >= 0 && y < 240) {
                    let index = (x + (y * 256))
                    imageData[index].r = color[0]
                    imageData[index].g = color[1]
                    imageData[index].b = color[2]
                }
            }
        }
    }

Tile の sprite、8×8ドット分ループを回して、それぞれのドットに対して Palette の ID から RGB の色情報 color を求め RGBA データを作成します。
全ての Tile が終わったら、次に全ての Sprite も同様に RGBA のデータにしていきます。

BitmapImage(RGBA 32bit)のビットマップデータが出来上がったら次にそれを CGImage に変換するのが ImageBitmapCreator です。
クラスメソッド imageFromRGBA32Bitmap に RGBA のビットマップデータと width, height 情報を渡して CGImage を作成します。

class ImageBitmapCreator: NSObject {
    struct PixelData {
        var r: UInt8
        var g: UInt8
        var b: UInt8
        var a: UInt8 = 0xFF
    }
    static let sizeOfPixelData = MemoryLayout<PixelData>.size
    
    static private let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
    static private let bitmapInfo: CGBitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
    
    static public func imageFromRGBA32Bitmap(pixels: [PixelData], width: Int, height: Int) -> CGImage? {
        let bitsPerComponent: Int = 8
        let bitsPerPixel: Int = 32
        
        var data = pixels
        if let providerRef = CGDataProvider(data: NSData(bytes: &data, length: data.count * sizeOfPixelData)) {
            let image = CGImage(
                width: width,
                height: height,
                bitsPerComponent: bitsPerComponent,
                bitsPerPixel: bitsPerPixel,
                bytesPerRow: width * sizeOfPixelData,
                space: rgbColorSpace,
                bitmapInfo: bitmapInfo,
                provider: providerRef,
                decode: nil,
                shouldInterpolate: true,
                intent: CGColorRenderingIntent.defaultIntent
            )
            return image
        }
        return nil
    }
}

CGImage 作成には色々と設定する項目が多いのですが、ピクセルのデータの持ち方と ColorSpace が決まればそこから他の項目も決まるので特に難しい項目はありません。

最後に、変換された CGImage は NSImage にしてから NSImageView に表示されます。

                self.nesView.image = NSImage(cgImage: image, size: CGSize.zero)

以上が 60fps で画像が作成される流れです。

まとめ

今回は 60fps で Nes を動かして画面を更新する部分をザックリ見てみました。
CPU, PPU のタイミング合わせをどうするのか全然わからなかった自分には 1画面分 CPU と PPU を動かして描画したら次のフレームの呼び出しを待つというやり方はなるほど〜と思いました。

が、キャプチャでわかるとおり現状では 30fps 程度しか速度が出てません。正直 8年前のマシンでも 60fps 位は余裕でいけると思っていたのですが甘かったみたいです。毎フレーム Tile と SpriteWithAttribute のオブジェクトを removeAll してまた全部作るってのをやっていたら、まぁ効率は悪すぎるし時間はかかるかな…。細かい話をしたら PixelData.r, g, b のアクセスも時間かかるし。その辺りを一気に UnsafeMutableBufferPointer でやったら 60fps は出せそうな気がするんだけどどうかな〜(というか、逆にこんなに遅いコードを書ける方が凄いのかも!)

というわけで PPU/DMA/Renderer は無事動作しました。後は KeyPad になるのですが、まだ PPU/DMA のコードで見ていない部分が多く残っているので次回も PPU/DMA のお話です。