地上の洞窟

どこにも行かず、液晶と「にらめっこ」し続ける人の物語。

Rubyでpng形式の画像を書きだす

三番煎じぐらい(コッチヲ見ロォ~!)
参考にパクらせて頂きました、ありがとうございます(爆)

mametter.hatenablog.com

obelisk.hatenablog.com

目次

前置き

※開発中のゲームです とか一度は書いてみたいやつ

スクリーンショットを撮りたい!」

RPGツクール VXAceでのゲーム開発。
Rubyを元にした独自の言語、RGSS3で動作しています。
ここでのプログラミングが、私の主戦場です。

今回はこのゲームに、現在の画面を画像として保存する、
スクリーンショット」機能を追加してみようというお話です。

RGSS3の仕様

RGSS3では require が使えません。もう致命傷。
バージョンも1.9.2と古く、パッとるりまを見ても使えないメソッドがたくさん。

しかし、拡張ライブラリなしではゲームとしての機能が実装できないので、
以下のライブラリだけは、例外的に組み込まれている様子。

  • dl
  • zlib
  • single_byte
  • utf_16_32
  • japanese_sjis
  • Win32API

今回の.png書き出しでは、この内の「zlib」というライブラリが主役になります。
画像データの圧縮とかその辺を担ってくれます。

pngの構造

pngファイルを「メモ帳」なんかでテキストファイルとして開くと書いてあるアレ。

臼NG

これはシグネチャ(署名)というものであり、PNGファイルとして認識されるための
識別子のようなものです。ファイルの冒頭に必ず配置されます。

正確には16進数で「89 50 4E 47 0D 0A 1A 0A」と表し、
実際には改行などの制御文字を含んでいるので
「臼NG」って打てばいいというわけではありません。

Rubyではこのシグネチャの出力を以下の文字列で行います。

"\x89PNG\r\n\x1a\n"

そしてこのファイルシグネチャに続き、画像ファイルとして必要なデータを
分類ごとにひとまとめにしたデータ「チャンク」にして連ねていきます。
画像データとして必須になるチャンク*1は、以下の通り。

  • "IHDR" - ファイルヘッダー
  • "IDAT" - イメージデータ
  • "IEND" - ファイル終端
チャンクの構造
chunk_size = data.bytesize
[chunk_size, chunk_type, data, Zlib.crc32(chunk_type << data)].pack("NA4A*N")
項目 サイズ
チャンクサイズ 4バイト 32ビット符号なし整数
(ビッグエンディアン)
チャンクの種類 4バイト 文字列
実際のデータ チャンクサイズ バイト 文字列
CRC32
(チャンクの種類 + 実際のデータ)*2
4バイト 32ビット符号なし整数
(ビッグエンディアン)

チャンクは各分類のデータをひとまとめにしたものです。
ここから「実際のデータ」にもまた形式があり、
実装ではそれらの形式に従った文字列に変換してからチャンクにします。

IHDR データ
[width, height, depth, color_type, 0, 0, 0].pack("N2C5")
項目 サイズ
画像の幅 4バイト 32ビット符号なし整数
(ビッグエンディアン)
画像の高さ 4バイト 32ビット符号なし整数
(ビッグエンディアン)
色深度 1バイト 8ビット符号なし整数
カラータイプ 1バイト 8ビット符号なし整数
圧縮形式 1バイト 8ビット符号なし整数
フィルター形式 1バイト 8ビット符号なし整数
インターレース形式 1バイト 8ビット符号なし整数

ファイルのヘッダー。主に画像のフォーマット情報のまとまりです。
データサイズが一定で常に13バイトです。

圧縮形式、フィルター形式は「0」固定でいいっぽい。国際規格らしい。
インターレース形式は0で無し、1で有りのようです。

カラータイプと色深度には組み合わせがあるっぽい。

カラータイプ 色深度 画像の形式
0 1, 2, 4, 8, 16 グレースケール画像
2 8, 16 トゥルーカラー画像
それぞれのピクセルでR,G,Bのデータを持つ
3 1, 2, 4, 8 インデックスカラー画像
それぞれのピクセルでカラーパレットの番号を持つ
4 8, 16 グレースケール + アルファ値 画像
6 8, 16 トゥルーカラー + アルファ値 画像
それぞれのピクセルでR,G,B,Aのデータを持つ(たぶん)

今回は「カラータイプ = 2, 色深度 = 8」での実装となります。
というかそれしかわからん。

IDAT データ

画像の実際のデータ。
恐らく、カラータイプと色深度によっても表記が変わってくるのだと思う。

まず、画像の1ピクセルのデータ

[255, 128, 64] # R,G,B

いわゆる光の三原色でピクセルの色を表しています。
これを実際の画像の、ピクセルの位置に対応するように配置した配列を作ってみます。
(幅3, 高さ3)

こんな感じの画像を配列化すると思ってね
raw_data = [
  [[255, 128, 64], [255, 128, 64], [255, 128, 64]],
  [[255, 128, 64], [255, 128, 64], [255, 128, 64]],
  [[255, 128, 64], [255, 128, 64], [255, 128, 64]]
]

この各ピクセル配列の頭に「0」*3を追加。

[0, [255, 128, 64], [255, 128, 64], [255, 128, 64]] # 行配列の頭に0を追加

そして現在、多次元配列となっているこの構造を、一次元配列として連結。

raw_data = [
  0, 255, 128, 64, 255, 128, 64, 255, 128, 64,
  0, 255, 128, 64, 255, 128, 64, 255, 128, 64,
  0, 255, 128, 64, 255, 128, 64, 255, 128, 64
]

これが生の画像データとなります。
そしてこれを「Array.pack("C*")」でバイナリ文字列に変換して

string = raw_data.pack("C*") # => "\x00\xFF\x80@\xFF\x80@\xFF\x80@\x00\xFF\x80@\xFF\x80@\xFF\x80@\x00\xFF\x80@\xFF\x80@\xFF\x80@"

「Zlib::Deflate.deflate」を用いて圧縮。

data = Zlib::Deflate.deflate(string) # => "x\x9Cc\xF8\xDF\xE0\x00A\fXX\x00\xF2\x8E\x0F\xB8"

この文字列が画像データになります。

IEND データ

ファイルの終端に配置されます。
データ自体は空で、"IEND"というタイプが書かれた空チャンクが置いてあるだけ。

仮実装(テストコード)

まずは適当な「グラデーション」の画像を作成するテストコード。

require "zlib" # RGSS3では省略

width = 128
height = 128

# "IHDR" =======================================================================

# ヘッダーデータ
# [横幅, 縦幅, 色深度, カラータイプ, 圧縮形式, フィルター形式, インターレース形式]
header_data = [width, height, 8, 2, 0, 0, 0]
header_data = header_data.pack("N2C5")  # データをバイナリ文字列に変換

# IHDRチャンク
# [サイズ, 種類, データ, CRC32]
header_chunk = [
  13,
  "IHDR",
  header_data,
  Zlib.crc32("IHDR" << header_data)
].pack("NA4A*N")

# "IDAT" =======================================================================

# 画像データ
# グラデーションを作成
raw_data = []
rate = 255.0 / width
rate_r = rate * 0.0
rate_g = rate * 0.62
rate_b = rate * 1.0

(0...height).each do |y|
  raw_data << 0 # 行頭
  (0...width).each do |x|
    raw_data.push(x * rate_r, x * rate_g, x * rate_b)
  end
end

raw_data = raw_data.pack("C*") # データをバイナリ文字列に変換
data = Zlib::Deflate.deflate(raw_data) # 更に、バイナリ文字列を圧縮

# IDATチャンク
# [サイズ, 種類, データ, CRC32]
data_chunk = [
  data.bytesize,
  "IDAT",
  data,
  Zlib.crc32("IDAT" << data)
].pack("NA4A*N")

# "IEND" =======================================================================

# 終端データは空

# IENDチャンク
# [サイズ, 種類, データ, CRC32]
terminal_chunk = [0, "IEND", "", Zlib.crc32("IEND")].pack("NA4A*N")

# 出力 =========================================================================

# 「バイナリモード」でファイル書き出し
open("./test.png", "wb") do |file|
  file.print "\x89PNG\r\n\x1a\n"
  file.print header_chunk
  file.print data_chunk
  file.print terminal_chunk
end

結果: 工作員カラーっぽいグラデーションが出来たぞー!

自己主張強すぎワロタ

問題点

しかしこのコードを流用して
そのまんま「スクリーンショット機能」を作ろうとしても問題があります。

  • raw_dataの作成が遅すぎる
  • Array.packが遅すぎる
  • Zlib::Deflate.deflateも割と遅い

実際に計測してみる(幅・高さは実際のゲームに合わせて変更)。

require "zlib"
require "benchmark"

width = 544
height = 416

raw_data = []
rate = 255.0 / width
rate_r = rate * 0.0
rate_g = rate * 0.62
rate_b = rate * 1.0

Benchmark.bm(8) do |r|
  r.report("raw_data") {
    (0...height).each do |y|
      raw_data << 0 # 行頭
      (0...width).each do |x|
        raw_data.push(x * rate_r, x * rate_g, x * rate_b)
      end
    end
  }

  r.report("pack"){
    raw_data = raw_data.pack("C*") # データをバイナリ文字列に変換
  }

  r.report("deflate"){
    data = Zlib::Deflate.deflate(raw_data) # 更に、バイナリ文字列を圧縮
  }
end

#                user     system      total        real
# raw_data   0.047000   0.000000   0.047000 (  0.057358)
# pack       0.109000   0.000000   0.109000 (  0.095877)
# deflate    0.000000   0.000000   0.000000 (  0.010281)

な~んだ、トータルで1秒もかかってないし大したことないじゃん
と思うかもしれませんが

スクリーンショットは「ゲーム上」で行われる事で、
60fpsで動いているゲームで、この処理を割り込ませる必要があります。

処理時間を合計すると約0.16秒であり「1/60秒の10倍程度」の時間がかかっちゃいます。
これを割り込ませると、スクショする度に一瞬フリーズします。

本実装(スクリーンショット)

スクリーンショットをしながら
ゲームプレイに支障をきたさないように、とするなら

スクリーンショットの処理を複数回に分割して、少しずつ行う」
というのが、一つの対処法になります。

以下は画像処理を分割しながらスクリーンショットを行う風の擬似的なコードです。
(RGSS3の独自処理がややこしいため、実際に画面のキャプチャから
 ピクセル情報を抽出する処理を省いたもので紹介させてください。)

require "zlib"

module ScreenShot
  PATH = "./ScreenShots"
  PACKET = 4096
  @queue = []
  
  class << self
    def update
      push if false # スクリーンショット実行フラグ
      (q = @queue[0]) && q.update
    end
  
    def push
      @queue << Writer.new
    end
    
    def shift
      @queue.shift
    end
  end
  
  class Writer
    def initialize
      @width = 544
      @height = 416
      @x = 0
      @y = 0
      @count = 0
      @rate = 255.0 / @width
      @rate_r = @rate * 0.0
      @rate_g = @rate * 0.62
      @rate_b = @rate * 1.0
      
      @raw_data = []
      @stream = Zlib::Deflate.new # deflateをインスタンス生成
    end
    
    def update
      unless @data
        create_data
      else
        export_image
      end
    end
    
    def create_data
      @count = 0
      buffer = []
      while @y < @height
        buffer << 0
        while @x < @width
          buffer.push(@x * @rate_r, @x * @rate_g, @x * @rate_b)
          @x += 1
        end
        @x = 0
        @y += 1
        if (@count += @width) > PACKET
          @stream << buffer.pack("C*")
          return
        end
      end
      @stream << buffer.pack("C*")
      @data = @stream.finish
      @stream.close
    end
    
    def export_image
      create_png_file(screenshot_path)
      ScreenShot.shift
    end

    def make_missing_folder(target_path)
      # ファイル名を格納しておく空文字列、パスを深さごとに分ける配列を作成
      path = ""
      part = target_path.split('/')
      # 深さごとにフォルダ作成
      part.each do |str|
        Dir.exist?(path << str) || Dir.mkdir(path)
        path << '/'
      end
    end
    
    def screenshot_path
      make_missing_folder(PATH)
      base = "#{PATH}/#{Time.new.strftime("%Y-%m-%d_%H-%M-%S")}"
      name = base
      i = 0
      while File.exist?(name)
        i += 1
        name = "#{base}-#{i}"
      end
      "#{name}.png"
    end

    # 画像ファイルを作成
    def create_png_file(path)
      open(path, 'wb') do |file|
        file.print "\x89PNG\r\n\x1a\n"
        file.print png_header
        file.print png_data
        file.print png_terminal
      end
    end
    
    def png_chunk(type, data)
      [data.bytesize, type, data, Zlib.crc32(type << data)].pack("NA4A*N")
    end
    
    def png_header
      png_chunk(
        "IHDR",
        [@width, @height, 8, 2, 0, 0, 0].pack("N2C5")
      )
    end
    
    def png_data
      png_chunk(
        "IDAT",
        @data
      )
    end
    
    def png_terminal
      png_chunk(
        "IEND",
        ""
      )
    end
  end
end

画像処理を分割する仕組み

@stream = Zlib::Deflate.new

deflateはインスタンスとして生成できます。
これに合わせて

  • @x, @y → 現在処理中のX座標, Y座標
  • @count → 処理したピクセルの数
  • PACKET → 一度に処理するピクセルの上限
  • buffer → 今回処理したピクセルを保管しておく配列

を設定します。

そして、whileループでピクセルを処理させ、
一度の処理数を超えたらbufferをバイナリ文字列に変換して中断します。
(行頭に0を追加する都合、キリのいい所で中断します)

def create_data
  @count = 0 # 処理数のリセット
  buffer = []
  while @y < @height
    buffer << 0
    while @x < @width
      buffer.push(@x * @rate_r, @x * @rate_g, @x * @rate_b)
      @x += 1
    end
    @x = 0
    @y += 1
    # 中断処理
    if (@count += @width) > PACKET
      @stream << buffer.pack("C*")
      return
    end
  end
  @stream << buffer.pack("C*")
  @data = @stream.finish # Zlib::Deflate.deflateの代わり
  @stream.close
end

スクリーンショットのパス作成

PATH = "./ScreenShots"

def make_missing_folder(target_path)
  # ファイル名を格納しておく空文字列、パスを深さごとに分ける配列を作成
  path = ""
  part = target_path.split('/')
  # 深さごとにフォルダ作成
  part.each do |str|
    Dir.exist?(path << str) || Dir.mkdir(path)
    path << '/'
  end
end

def screenshot_path
  make_missing_folder(PATH)
  base = "#{PATH}/#{Time.new.strftime("%Y-%m-%d_%H-%M-%S")}"
  name = base
  i = 0
  while File.exist?(name)
    i += 1
    name = "#{base}-#{i}"
  end
  "#{name}.png"
end

スクリーンショットは大体自動で、日時などを元にしてファイル名が決まってますよね。

まず「PATH」で、スクショを保存するフォルダーを指定しています。
「指定しています」っていうダジャレにもなっていないダジャレ

指定したフォルダが無いとファイル作成に失敗してエラーで爆散するので、
make_missing_folder(target_path)で、足りないフォルダを作成しています。

ファイル名のフォーマットは「Time」から作成しています。
一秒以内に何枚もスクショを連射された場合に起きそうな、
名前の重複を検知して避けるようにしています。

結果的に生成されるファイルパスは、こんな感じ。

./ScreenShots/2023-08-17_22-04-27.png

検証

スクリーンショットを一枚撮って
そこから60フレーム処理する間の更新処理の時間を計測します。

ScreenShot.push

Benchmark.bm do |r|
  60.times do
    r.report{
      ScreenShot.update
    }
  end
end

# user     system      total        real
# 0.000000   0.000000   0.000000 (  0.003246)
# 0.000000   0.000000   0.000000 (  0.003246)
# 0.000000   0.000000   0.000000 (  0.003247)
# 0.000000   0.000000   0.000000 (  0.002705)
# 0.000000   0.000000   0.000000 (  0.003247)
#
# 中略 ~~~~~~~~~~~~~~~~~~~
#
# 0.000000   0.000000   0.000000 (  0.003247)
# 0.000000   0.000000   0.000000 (  0.000000)
# 0.000000   0.000000   0.000000 (  0.000542)
# 0.000000   0.000000   0.000000 (  0.000000)
# 0.000000   0.000000   0.000000 (  0.000000)
#
# 以下略 ~~~~~~~~~~~~~~~~~~

スクリーンショットの処理は1フレームに対して19%程で落ち着いています。
フレーム更新処理がどの程度の割合なのか調べていませんが、
RGSS上で実際に動かして、処理落ちが見られなかったので成功と言えると思います。
トータルタイム自体も意外と増えてません。まとめて処理した場合とほぼ同じ。

まとめ

スクショ祭り。変なの混じってるけど、これは行頭の0を追加し忘れたらなった

簡単なんだか大変なんだか。いや大変だった。
(特にちゃんと理解して説明できるレベルになるのが)

スクリーンショットを撮るRGSS3のスクリプト自体は
とっくの昔からあるのは知っているが
自分で作ってみたいから自分で作ってみちゃうよねって話。

IHDRとかIENDはいちいち値が変わらないので、定数文字列として
キャッシュしといてもいいかも。

あと、忘れがち・やらかしがちなのが、「open」のモード。
"wb"だからね!バイナリモードで書きださないとだからね!

さて、あとで自分のスクリーンショットスクリプト上げときますか。
(いまどき需要ないだろという話は置いといて)

余談

ピクセル配列で要素追加を行うと思いますが
この二つはどっちが速いのか

require "benchmark"

Benchmark.bm(4) do |r|
  a = []
  b = []
  r.report("<<")   { 1_000_000.times { a << 1 << 2 << 3 } }
  r.report("push") { 1_000_000.times { b.push(1, 2, 3) } }
end

#            user     system      total        real
# <<     0.078000   0.016000   0.094000 (  0.100650)
# push   0.063000   0.000000   0.063000 (  0.084414)

基本的に配列への要素追加は「<<」の方が速いです、が
一度に追加する要素が3以上の場合は「push」の方が速くなるようです。
(この辺りは環境にもよると思うので、要検証)

これ2個追加の方ね。

require "benchmark"

Benchmark.bm(4) do |r|
  a = []
  b = []
  r.report("<<")   { 1_000_000.times { a << 1 << 2 } }
  r.report("push") { 1_000_000.times { b.push(1, 2) } }
end

#            user     system      total        real
# <<     0.062000   0.000000   0.062000 (  0.060606)
# push   0.063000   0.000000   0.063000 (  0.072511)

*1:ファイルヘッダーの下に、"PLTE"(カラーパレット)という「必須だけどデータによっては省略できる」チャンクがあったりなかったりします。謎

*2:データの破損や誤りを調べるための追加データ

*3:この0はフィルター設定を表すらしい