三番煎じぐらい(コッチヲ見ロォ~!)
参考にパクらせて頂きました、ありがとうございます(爆)
目次
前置き
「スクリーンショットを撮りたい!」
RPGツクール VXAceでのゲーム開発。
Rubyを元にした独自の言語、RGSS3で動作しています。
ここでのプログラミングが、私の主戦場です。
今回はこのゲームに、現在の画面を画像として保存する、
「スクリーンショット」機能を追加してみようというお話です。
RGSS3の仕様
- Ruby 1.9.2
- requireが使えない
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」って打てばいいというわけではありません。
"\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, [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はインスタンスとして生成できます。
これに合わせて
を設定します。
そして、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上で実際に動かして、処理落ちが見られなかったので成功と言えると思います。
トータルタイム自体も意外と増えてません。まとめて処理した場合とほぼ同じ。
まとめ
簡単なんだか大変なんだか。いや大変だった。
(特にちゃんと理解して説明できるレベルになるのが)
スクリーンショットを撮る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)