🎨

自前 Processing 実装の互換性検証に p5.rb を使ってみた話

2023/12/03に公開

この記事は Processing Advent Calendar 2023 の3日目の記事です。

はじめに

私は Processing gem というものを作っています。Ruby 向けの Processing 互換ライブラリーです。

今日はこの Processing gem 自体の開発について、最近とてもいい感じにテストコードが書けるようになったのでその話を書いてみたいと思います。

Processing gem の紹介

この Processing gem 自体については 3年前の Advent Calendar で書いた Ruby で Processing する gem(とアプリ)の紹介 で紹介記事を書いていますので詳細についてはぜひそちらを御覧頂きたいと思いますが、簡単に特徴を書くと以下のようなライブラリーです。

  • Ruby で、Processing や p5.js と同じ事がほぼ同じ書き方ができる
  • Ruby の処理系としては一番広く利用されている CRuby (MRI) で利用可能
    • JRuby への依存は無く、実行環境としてブラウザ等も必要なし
  • C++/OpenGL で完全にゼロから独自に実装
  • 現状の対応プラットフォームは macOS と iOS のみ
    • 過去には Win32 にも対応していた(Windows の開発環境が無く放置してしまっている)
    • 将来的には Linux や Android なども対応したい

使い方

紹介記事にも書いてありますが、一応簡単にここでも使い方を書きます。

インストール

まずはインストール方法です。単純に gem install するだけです、が、C++ のコンパイルがあるので Xcode が必要になります。

$ gem install processing

実行方法

サンプルコードを用意して単純に ruby コマンドで実行します。

$ cat test.rb
# processing gem をロードする
require 'processing'

# おまじない
using Processing

setup do
  # タイトル文字列を設定
  setTitle("Rotating Rectangle")

  # ウィンドウサイズを指定
  size(500, 400)
end

draw do
  # 毎秒60回呼ばれる
  
  # 背景色を指定
  background(100)

  # 座標をウィンドウの中心に
  translate(width / 2, height / 2)

  # 経過フレーム数に応じて回転
  rotate(frameCount / 10.0)

  # 矩形の色を指定
  fill(255, 100, 100)
  
  # 枠線の色を指定
  stroke(100, 255, 100)
  
  # 枠線の太さを指定
  strokeWeight(20)

  # 矩形は中心座標を指定して描画するモードに
  rectMode(CENTER)

  # 矩形を描画する
  rect(0, 0, 200, 100)
end

$ ruby test.rb

実行するとこうなります。

https://twitter.com/tokujiros/status/1730952789415845900

Processing gem の開発状況

Processing gem 開発の現状はこんな感じです。

  • Processing の関数のうち約70%程度[1]が実装済み
  • 残り30%の機能を現在実装中 (ただし 3D 系の関数は除く)
    • Rubyアソシエーションの開発助成金[2]により開発中で、今年度内に開発完了の予定
    • 今は beginShape(), endShape(), vertex() などを実装中[3]
  • Processing gem 自体はただのラッパーライブラリー
  • 未実装の Processing 関数はわりと実装が難しいのを残しているので、C++ での実装範囲が多い

テストコードが書きにくい問題

Processing gem の実装自体はこれまで割りと順調に来ているのですが、ずっと困っていたこととしてユニットテストでテストコードが書きにくいという課題がありました。

例えば次のコードを実行した場合、どのような画像が描画されるでしょうか。

background(100)
fill(255, 0, 0)
stroke(0, 255, 0)
strokeWeight(50)

beginShape()
vertex(100, 100)
vertex(100, 500)
vertex(500, 500)
vertex(500, 400)
vertex(300, 400)
vertex(300, 300)
vertex(500, 300)
vertex(500, 100)
endShape()

手元で実行したところ、このような画像になりました。

さて、この描画結果は正しいのでしょうか。ここで言う結果の正しさとは本家の Processing/p5.js の描画結果と同じかどうか、ということです。

結論としては正しい、つまり p5.js と同じ描画結果なのですが、開発中はこれを検証し続ける必要があるのです。

beginShape() の実装のようす

例えば beginShape()、endShape()、vertex() を C++ で実装するとしましょう。

beginShape() にはいくつかの形状種別を渡す事ができますが、それぞれの種別が渡されたときの描画処理を実装するとします。

まず beginShape(TRIANGLES) の中身を実装したとして、以下の検証コードで動作確認をしてみます。

beginShape(TRIANGLES)
vertex(100, 100)
vertex(100, 500)
vertex(400, 200)
vertex(500, 100)
vertex(500, 500)
vertex(900, 200)
endShape()

結果はこちら、正しく描画できました。

では次に beginShape(QUADS) の中身を実装します。同様に検証コードを書いて動作確認します。

beginShape(QUADS)
vertex(100, 100)
vertex(100, 500)
vertex(400, 400)
vertex(400, 200)
vertex(500, 100)
vertex(500, 500)
vertex(800, 400)
vertex(800, 200)
endShape()

これも正しく描画できました。

と、ここで 最初の beginShape(TRIANGLES) をもう一度実行してみます。

するとあれれ、先程は正しく描画できていた2つ目の三角形が描画されていません。。。どうして。。。

これは、beginShape() の中身を修正して QUADS を受け取ったときの実装を追加したところ、うっかり TRIANGLES が来たときの実装にバグを仕込んでしまったのでした。

自動テストで不具合を見逃さない

運良く beginShape(TRIANGLES) を再実行した結果、バグに気がついて修正できればそれは良かったですが、気まぐれで再実行しないでいたら不具合に気づくのは難しかったでしょう。

特に今回はたまたま関連する近い部分に不具合を入れてしまったので割りと見つけやすかったパターンで、もし全然違う所に影響をあたえて例えばテキスト描画関数の方に不具合でるケース(あり得なくはない)などになると運良く見つける確率も格段に下がります。

こういうときじゃあどうするか、というと検証作業をコードで書いてそれを頻繁に動かし検証する、自動テスト[4]という方法があります。

例えば今回の例だと次のような作業手順になります。

  1. beginShape(TRIANGLES) とそのテストコードを実装する
  2. beginShape(TRIANGLES) のテストコードを含む全テストコードを実行して動作が正しいことを確認する
  3. beginShape(QUADS) とそのテストコードを実装する
  4. 全テストコードを実行するが beginShape(TRIANGLES) のテストが失敗する
    →→ beginShape(TRIANGLES) に不具合があることが見つかる!!

これでテストコードを実装した範囲では、テストを実行すれば確実に不具合を見つけることができるようになります。

Processing 関数のテストコードが書きにくい

ここまできてじゃあさあテスト書こうかとなったとき、beginShape(TRIANGLES) のテストをどう書くか?という問題にブチ当たります。beginShape(TRIANGLES)〜endShape() の中で頂点を6つ指定した場合、どう描画されるのが正解でしょうか。Processing/p5.js と同じ結果になるとはどう検証すればよいでしょうか。。。

p5.rb を使った互換性の検証

単純に考えると、Processing/p5.js で描画した画像と Processing gem で描画した画像を単に比較すれば良さそうです。

じゃあ比較するための画像はどうやって作る? となりますが、単純に考えると Processing で実際に同じコードを書いて実行しその描画結果を画像として保存すれば良いですよね。


本家 Processing で実行してみた

でもこれ結構大変なんです。Processing 側の再現用ソースコードも管理したくなってしまいますし、画像ファイルをどこに置くの?バイナリーファイルはリポジトリにはあんまり入れたくないなあとか、そもそも Processing がバージョンアップしたらどうするの?検証用画像も全部作り直すの?などなど・・・

ということでもっと手間のかからない、メンテフリーな方法が無いものかとずっと悩んでいました。悩んでいたのでテストコード自体も書けずに来ていました。

ruby.wasm と p5.rb の登場

去年のクリスマスにリリースされた Ruby のバージョン 3.2 では WebAssembly に対応しています。そのおかげで CRuby がブラウザー上でも動くようになりました。

また、その ruby.wasm で p5.js を使えるようにした p5.rb というのも公開されています[5]。p5.rb を使うと HTML 内で直接 Ruby を書いて p5.js を呼び出すということができるようになります。

試しに p5.rb WebEditor で beginShape(TRIANGLES) を書いてみたら期待通り動きました。


p5.rb で beginShape(TRIANGLES) を実行するようす

ということで、これを使えば Processing gem のテストコードで使う動作確認のコードがそのまま p5.js でも動かせそう[6]です!

p5.rb をヘッドレスブラウザで動かす

比較検証のためのコードを p5.js でも動かせるようにはできそうですが、その結果画像を手作業で作成するというのは、検証パターンの多さも考えると現実的ではありません。

そこで検証コードを入力として受け付けて p5.js で実行した結果を返すスクリプトを作ることにしました。p5.js の動作環境はブラウザーですので、ブラウザーをスクリプトで操作し p5.js の実行とその結果を取得する仕組みを作ります。

今回は Chrome を Ruby からコントロールできるようにする Ferrum という gem を使いました。

やってることはシンプルですが要点はこうです。

  1. Ferrum::Browser.new で Chrome ブラウザーを立ち上げる
  2. ruby.wasm、p5.js、p5.rb を <script> で読み込んだ HTML 文字列をブラウザに流し込む
  3. draw() 関数が一回実行されるまで sleep する
  4. 描画結果をスクリーンショットとして保存する

こちらが実際のソースコード。ユニットテストのテストコードから呼ぶ想定の作りです。
https://github.com/xord/all/blob/1f10776907943a0bdc1433765dc3e62a3a24499a/processing/test/p5.rb#L51-L60

テストコードから p5.rb を利用する

テストコードから p5.rb を呼ぶ部分はこうです。

def test_beginShape_triangles()
  # ここから END までの間の文字列を検証コードとして実行する
  src = <<~END
    beginShape(TRIANGLES)
    vertex(100, 100)
    vertex(100, 500)
    vertex(400, 200)
    vertex(500, 100)
    vertex(500, 500)
    vertex(900, 200)
    endShape()
  END

  # p5.rb の描画結果と 99%以上同じピクセルになるか検査
  assert_p5_draw(src)
end

src の文字列を Processing gem と p5.rb 両方で実行した結果画像を1ピクセルごとに比較し、何%同じかどうかを比較します。アンチエイリアシングの処理結果などで完全に同じにはならないので、デフォルトでは 99%以上同じかどうかを検査するようにしています。

参考までに assert_p5_draw() の定義はこちら。
https://github.com/xord/all/blob/1f10776907943a0bdc1433765dc3e62a3a24499a/processing/test/helper.rb#L108-L122

実際にテストしてみる

ここまでで準備はできたので、実際にテストで利用してみた様子がこちらです。

https://twitter.com/tokujiros/status/1723387760197922980

ヘッドレスブラウザーですが、オプションで Chrome が見えるように動かす[7]こともできます。こんな感じ(動画)になります ↓↓

https://twitter.com/tokujiros/status/1721964435177390569

不具合というのか、Processing gem と p5.rb(p5.js) での挙動の違いも見つかりました。beginShape(QUADS) の endShape() に CLOSE を渡した場合の描画結果が違います。(p5.js の方がおかしいような気がします。。。)


beginShape(QUAD_STRIP) の fill()、stroke() ありなしで3パターンの結果。それぞれで左が Processing gem、右が p5.js

ツイートにも書いてますが、こうしてテストを書くことができるようになったおかげで、テストを書いた範囲ではリグレッションを怖がらずに安心して実装に手を入れられるようになって最高[8]です!

不具合報告歓迎

ということで、Processing 互換ライブラリーとして互換性をできるだけ高めるべく開発を進めていますので、本家 Processing/p5.js と挙動が違うところ[9]を見つけましたらぜひ xord/processingの Issue までご報告いただけますととても助かります!

過去の関連記事

脚注
  1. 実装済みの関数一覧: abs, acos, alpha, angleMode, arc, asin, atan, atan2, background, bezier, blend, blendMode, blue, ceil, circle, clear, clip, color, colorMode, constrain, copy, cos, createCanvas, createCapture, createGraphics, createImage, createShader, createVector, curve, degrees, displayDensity, displayHeight, displayWidth, dist, draw, ellipse, ellipseMode, exp, fill, filter, floor, focused, frameCount, frameRate, green, height, image, imageMode, key, keyCode, keyPressed, keyReleased, keyTyped, lerp, lerpColor, line, loadImage, loadShader, log, loop, mag, map, max, min, motion, motionGravity, mouseButton, mouseClicked, mouseDragged, mouseMoved, mousePressed, mouseReleased, mouseX, mouseY, noClip, noFill, noLoop, noStroke, noTint, noise, norm, pixelDensity, pixelHeight, pixelWidth, pmouseX, pmouseY, point, pop, popMatrix, popStyle, pow, push, pushMatrix, pushStyle, quad, radians, random, rect, rectMode, red, redraw, resetMatrix, resetShader, rotate, round, save, scale, setTitle, setup, shader, sin, size, sq, sqrt, square, stroke, strokeCap, strokeJoin, strokeWeight, tan, text, textAlign, textAscent, textDescent, textFont, textSize, textWidth, tint, touchEnded, touchMoved, touchStarted, touches, translate, triangle, width, windowHeight, windowMove, windowMoved, windowOrientation, windowResizable, windowResize, windowResized, windowWidth, windowX, windowY
    ↩︎

  2. 2023年度Rubyアソシエーション開発助成 公募結果 の「CRuby 用 Processing Gem の、本家 Processing との互換性向上に向けた取り組み」です ↩︎

  3. 開発内容は xord/processingIssue で管理しています ↩︎

  4. 「自動」なので、厳密には CI などで自動的にテストを実行するようにします ↩︎

  5. 今しらべたら ruby.wasm のかわりに Opal を使った rbCanvas/p5 というのもあるようです。ruby.wasm は大きいので Opal の方が軽量ならそっちも試してみたいですね ↩︎

  6. Processing gem で動くコードは p5.js でもほぼそのまま動きます ↩︎

  7. headless: false で動かすと動作が不安定になる気がします ↩︎

  8. ちょうど一昨日くらいに、OpenGL 描画時の三角形分割ライブラリを poly2tri から earcut.hpp に差し替えたときこのテストのおかげで beginShape() の TRIANGlES、QUADS だけ不具合が出ることを見つけることができました ↩︎

  9. Processing/p5.js と挙動が違う所はまだあちこちにたくさんあると思います ↩︎

Discussion