前回のブログにて、ジョジョウォールの内容について書きましたが、今回はどのように作ったのかの中身の部分を書いていこうと思います。本文中にも少し触れていますが、今回の制作は二人で行っており、この記事では自分の制作した範囲の内容について詳しく触れていきます。
機材構成
今回の制作では横幅約6メートルという範囲にコンテンツを照射するために、1280×800の超単焦点プロジェクターを3台使用し、それぞれのプロジェクターに対して一台ずつMacMiniを接続してコンテンツを制御しています。本来であれば、TripleHead2Go等を使い3面を一つの画面としたほうがいいのですが、今回はできるだけ機材構成をシンプルにしようとした結果このようになりました。また、照度を稼ぐために更に3台のプロジェクターを重ねて照射しているので、実際には超単焦点プロジェクター2台とMacMini1台、そしてセンサーを1台組み合わせたものを1セットとし、それを3セット並べて使用しました。
ちなみに、同じ画面を重ねて照射するためにMadMapperを使用しています。
センサーはURGレーザーレンジファインダと呼ばれるものを使っています。当初はKinectを使用しようとしていましたが、Kinectではコンテンツのクオリティを確保することが難しかったため、新しくURGセンサーを購入して制作をチャレンジしました。
ちなみにこのURGセンサーは北陽電機さんで購入することが出来ます。
コンテンツ制作
ジョジョウォールの制作はopenFrameworksで行いました。機材のスペックが限られているため、C++で動作するといったことや、Unityよりも(プログラム的には)直感的に制作をすることが出来たためです。また、企画段階で上がっていたものに対し、リアルタイム制御で行うためにはOpenCVを使用することが必須になっており、その導入が簡単にできるといったこともopenFrameworksを選んだ理由でもあります。
このジョジョウォールの制作は、実は二人で行っています。担当はハードウェア制御&シーン管理といった裏側の部分と、表示されるコンテンツという表側の部分に分かれて制作しました。ハードウェア制御は基本的にはURGセンサーと、PC間の通信を行うために使用したOSCの制御を行っています。シーン管理ではofxStateMachineというアドオンを使用しています。
表側の部分では、実は特に特殊な実装等はされておらず、極めて基本的な作り方で制作されています。唯一、ザ・ハンドだけはOpenCVを使う必要がありました。
シャボン玉の制御
シャボン玉を表現するときに、無数に出現する、それぞれ独立して動作する、消失と出現を繰り返す、消失時にパーティクルを放出するといったことをやる必要がありました。そこで、次々湧き上がってくるシャボン玉を管理するシャボン玉クラスと、割れた時に飛び散るパーティクルを管理するパーティクルクラスを作り、それをメインのクラスで呼び出し、表示したい分だけvectorで管理していました。メインクラスでは基本的にシャボン玉は初期化と呼び出し、更新と描画だけを行うようにしています。
シャボン玉クラスでは、座標移動やアルファの増加、拡大であったり、死亡制御やリスポーン制御を行っています。シャボン玉を触わると、破裂状態に変化するようになっていて、破裂中はアルファが減少し、移動や拡大は行わず、設定されているライフタイムが減少していきます。アルファとライフタイムが0になると、メインクラスで保持しているvector内から一度削除され、再び生成されるといったことを繰り返しています。パーティクルクラスはシャボン玉が破裂状態になると一度だけ生成され、こちらもライフタイムが0になるまで更新と描画を行い、ライフタイムが0になると削除されるようになっています。
実はシャボン玉クラスよりも先にパーティクルクラスを制作しており、パーティクルクラスの内容をシャボン玉ようにアレンジしています。何故別にクラスを制作したのかというと、管理しやすくするというのももちろんですが、シャボン玉が大量に出現することと、それぞれのシャボン玉の状態が同様に動かないために、一つ一つのシャボン玉が独自に状態を変化させることで、生成と削除をvectorに追加と削除という動作だけで完結できることが大きかったです。また、さらにパーティクルでは一つのパーティクルクラスの中にさらに100個の円が描画されており、それぞれの円一個一個に個別にライフタイムが付与されているため、メインクラスとは完全に切り離す必要がありました。
ページを捲るアニメーション
ページを捲るアニメーションについてはofMeshを使用して表現しています。ページを捲るとき以外はofMeshは使用せず、ofImageを描画しています。メッシュは裏側を合わせて2つ描画していて、めくられるページが本の真ん中を越すタイミングで深度情報を入れ替えてます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
for (int y = 0; y < imgH; ++y) { int convert_x = (int)(sin(PI / 2 * MAX(-1, MIN(1, frame - (float)y / imgH))) * (-imgW / 2) + imgW / 2); int convert_x2 = (int)(sin(PI / 2 * MAX(-1, MIN(1, frame - (float)y / imgH))) * imgW + imgW); int depth1; int depth2; depth1 = 0; depth2 = 1; if (convert_x2 > (ofGetWidth() / 2 - SIDE_SLIDE) / 2) { depth1 = 1; depth2 = 0; } page1Mesh.setVertex(2 * y, ofVec3f(imgW, y, depth2)); page1Mesh.setVertex(2 * y + 1, ofVec3f(convert_x2, y, depth2)); page2Mesh.setVertex(2 * y, ofVec3f(imgW, y, depth1)); page2Mesh.setVertex(2 * y + 1, ofVec3f(convert_x2, y, depth1)); } |
削って下絵を表示する
真っ黒な常態から下地の絵を表示するために、下地の絵をマスク画像を使って描画し、そのマスク画像に透過させる範囲を描画していくことによって削りだすといったことを表現しました。単純に画像を2枚重ねて表示する方法もあったのですが、描画が2回に増えてしまうことにより、処理落ちが発生してしまう可能性を排除したかったためです。
また、ここでは違う問題も発生しました。URGセンサーからくる値をそのまま使おうとすると、最大で1080もの座標に対して同時に描画を行うため、いくらOpenCVでも処理落ちが発生してしまいました。そこで、センサーの値に対し、前後の値が一定の距離内であれば同一のオブジェクトを認識していると判断し、そのオブジェクトに対して反応している座標の平均値を使用するようにしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
float POINT_DISTANCE = 50; int SENSOR_NUM = 15; void TheHand::createTouchPoint(){ ofBackground(0); ofMutex mutex; mutex.lock(); vector<ofPoint> tmpPoint; vector<ofPoint> contentPoint; createPoint.clear(); for (int i=0; i<sizeof(getSharedData().acData) / sizeof(getSharedData().acData[0]); i++) { if(getSharedData().acData[i].x > 0 && getSharedData().acData[i].y > CONTENT_TOP && getSharedData().acData[i].x < ofGetWidth() && getSharedData().acData[i].y < ofGetHeight()){ int x = (int)getSharedData().acData[i].x; int y = (int)getSharedData().acData[i].y; contentPoint.push_back(ofPoint(x, y)); } } mutex.unlock(); for (int i = 0; i < contentPoint.size(); i++) { if (i != 0 && i != contentPoint.size()) { float distance1 = 1000; float distance2 = 1000; if (i != 0) { ofPoint vec1 = ofPoint(abs((int)contentPoint[i].x - (int)contentPoint[i-1].x), abs((int)contentPoint[i].y - (int)contentPoint[i-1].y)); distance1 = sqrt(vec1.x * vec1.x + vec1.y * vec1.y); } if (i != contentPoint.size() - 1) { ofPoint vec2 = ofPoint(abs((int)contentPoint[i].x - (int)contentPoint[i+1].x), abs((int)contentPoint[i].y - (int)contentPoint[i+1].y)); distance2 = sqrt(vec2.x * vec2.x + vec2.y * vec2.y); } //前がダメであとが◯=ポイントの最初 if (distance1 > POINT_DISTANCE && distance2 < POINT_DISTANCE) { tmpPoint.clear(); tmpPoint.push_back(ofPoint(contentPoint[i].x, contentPoint[i].y)); }else if (distance1 < POINT_DISTANCE && distance2 < POINT_DISTANCE){ tmpPoint.push_back(ofPoint(contentPoint[i].x, contentPoint[i].y)); }else if ((distance1 < POINT_DISTANCE && distance2 > POINT_DISTANCE) || (distance1 < POINT_DISTANCE && i == contentPoint.size() - 1)){ tmpPoint.push_back(ofPoint(contentPoint[i].x, contentPoint[i].y)); if (tmpPoint.size() > SENSOR_NUM) { ofPoint sumPoint = ofPoint(0, 0); for (int i = 0; i < tmpPoint.size(); i++) { sumPoint += tmpPoint[i]; } createPoint.push_back(sumPoint / tmpPoint.size()); } } } } } |
これでオブジェクトを検出することができ、描画の数も圧倒的に少なくなりました。しかし、このままでは点を打つことはできるのですが、ラインを引くことができず、手を横に動かしても虫喰い穴が連続で続いたような見た目になってしまい、ザ・ハンドのように綺麗に削り取ることが出来ませんでした。そこで、オブジェクト検出と同じように、前回反応した座標から一定値以内であればつながってると判断し、circleで描画するのではなく、lineで描画することで綺麗な線を表示することができました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
if (createPoint.size() > 0) { for (int i = 0; i < createPoint.size(); i++) { ofSetColor(255 , 255, 255); int intensity = matImage.at<unsigned char>(createPoint[i].y, createPoint[i].x); if (intensity == 0) { if (lastPoint.size() > 0) { for (int n = 0; n < lastPoint.size(); n++) { if (abs(lastPoint[n].x - createPoint[i].x) < POINT_LINE_DICTANCE && abs(lastPoint[n].y - createPoint[i].y) < POINT_LINE_DICTANCE) { //cv::circle(colorMat, cv::Point(createPoint[i].x, createPoint[i].y), RADIUS, cv::Scalar(255), -1, CV_AA); cv::line(colorMat, cv::Point(lastPoint[n].x, lastPoint[n].y), cv::Point(createPoint[i].x, createPoint[i].y), cv::Scalar(255), RADIUS, CV_AA); } } } lastPoint.push_back(createPoint[i]); } } }else{ lastPoint.clear(); } |
そして肝心のマスク処理は、線を描いた画像をグレースケールに変換し、マスク画像に合成しています。
1 2 3 4 5 6 7 8 9 10 11 12 |
ofxCvGrayscaleImage colorImage; cv::Mat matImage; cv::Mat baseImage; cv::Mat drawImage; cv::Mat colorMat; void TheHand::draw(){ cv::cvtColor(colorMat, matImage, CV_BGR2GRAY); baseImage.copyTo(drawImage, matImage); colorImage.setFromPixels(drawImage.data, width, height); colorImage.draw(0, 0); } |
終わりに
今回は2015/12/19,20に開催されたジャンプフェスタ2016で制作させていただいた、ジョジョウォールの中身の部分について書きました。もっと詳しく書こうと思えばかなり詳しく書けるのですが、流石にブログとして書くにはあまりにも冗長すぎるので、今回はこのあたりまでにします。
実は自分は今回のジャンプフェスタが初のイベント案件でありましたが、一緒に制作をした方の助けもあり無事に終えることが出来ました。また、ブースにはたくさんの方に体験していただくことができました。このようなコンテンツを作れたことはとても素敵な経験となりました。
是非来年も制作に携わらせていただきたいです。
久々にブログを書きましたが、次回はまたoFについての記事になりそうなきもしますし、ならないような気もします。
では、また。