動機
うちの子が「魔理沙のコスプレしたい」と言い出したのが去年のハロウィン。「洋服」を作るスキルはないので買い物で済ませたが、高かったので一度だけではもったいないと考えていた。
そんな中、更に「例大祭(博麗神社例大祭)に行きたい」と言い出したのとほぼ同時に「ハッケロが欲しい」と言い出したのでリサーチ開始。どうやら正しくは「ミニ八卦炉」というアイテムを魔理沙が使っているらしい。
ミニ八卦炉は「マスタースパーク」という技?を出すためのアイテムらしい?
※ファンの方々、申し訳ありません。その程度の知識です。
色々調べていくと、可動式のメカニカルな、いかにも何か技を打てそうなすごいのを作っている方もいるが、当然非売品。そんな中で唯一販売されていたのが、Herreria(アトリエ エレリア)さんのミニ八卦炉。
早速購入して雰囲気の良さに感動しつつも、弾幕シューティングっぽくないな、という、作品はリプレイ動画しか見たことないくせに謎の改造心が目覚めてしまった。
魔理沙コスプレに、それっぽく改造したミニ八卦炉を持たせれば唯一無二なのでは?
コスプレ衣装は既製品なので、組み合わせでオリジナリティを持たせるのがせめてもの親心です
無限ミラー開発要件
とっ散らからないようにするため、脳内でざっと要件定義を済ませてから開発に着手した。
L0要件
- 弾幕っぽいこと
- 既存のアイディアではないこと
- 扱いやすいこと
- 購入したミニ八卦炉そのものには加工を加えないこと
L1要件
弾幕っぽい表現はフルカラーLEDを制御することで実現することとした。YouTubeなどで確認する限り、同様のアイディアは見られなかったため、それを軸に要件をフローダウンすることとした。
- フルカラーLEDを可能な限り多く実装し、制御すること
- 奥行方向の表現を実現するギミックを持たせること
- フルカラーLEDの制御パターンは容易に作成できること
- フルカラーLEDの制御パターンを複数準備し、容易に切り替え可能なこと
- イベント中(5~6h)程度は連続使用可能なこと
- 安価であること
L2要件
奥行について検討した結果、いわゆる「無限ミラー」の構造を取ることでミニ八卦炉の深さ方向以上の奥行を表現できるとして、それを軸にさらにフローダウンをすることとした。
- NeoPixel LED(WS2812B等のシリアルで少ない回路数で同時制御できるもの)をミニ八卦炉の内壁になるべく多数配置すること
- 反射像の階層間(n回目の反射像とn+1回目の反射像)のピッチをなるべく詰め、密集間を出すこと
- 無限ミラーはアクリル材のオンラインカットサービスで安価に発注可能であること
- 点灯パターンは30fps以上であること
- 制御パターンのデータは外部に持たせ、無線にてミニ八卦炉側に送信すること
- 制御パターンを持つ外部機器は片手で持てるサイズであること
- 制御パターンデータは何らかの画像ファイルとし、誰でも編集可能なこと
- ミニ八卦炉側のバッテリーは600mAH以上の容量を有すること
- ミニ八卦炉側にバッテリー充電機能を有し、USB給電で充電可能なこと
L3要件
だいぶシステム構成を固めることができ、自分の手持ち部品や経験をもとに、開発をすることとした。
- NeoPixel LEDテープをミニ八卦炉内壁に貼り付けること
- LEDテープの幅のうち、LED本体分の寸法のみで無限ミラー構造を有すること
- 制御パターンを送信するデバイスとしてM5Stackを用いる
- 制御パターンを送信するプロトコルとしてESP-Nowを使用する
- ミニ八卦炉側はSeeeduino XIAO ESP32C3を使用する
- 制御パターンデータは24bit BMPファイルとし、BMPのRGBデータをそのまま送信する
開発項目
以上より、以下の4点を開発項目とした
1. NeoPixel搭載手法
2. 無限ミラー
3. 電池
4. 30fps以上のデータ転送
PoC
1. NeoPixel LEDの搭載手法
既に先行していた別案件でサンプル購入していたWS2812BのLEDテープを使用することとした。一番多くLEDを搭載するためには八卦炉の八角形の各辺に沿ってLEDを実装すればよい。
2. 無限ミラー
1.で決めた内容に沿いつつ、LEDテープのLED分のみで無限ミラーを形成するため、LEDテープの厚み分縮小した正八角形にミラーとハーフミラーを切り出し、各辺に2枚の支柱となる透明のアクリルを接着すればよい。
問題点
- 正八角形の切り出しサービスがない
図面を書けばカットしてもらえるがどう考えても高額になるので、正方形の角を落として正八角形とした。 - アクリルは接着が非常に困難
当初より心配していたが、セメダインスーパーXの多用途などをもってしても十分な接着力が得られない。
対応
結局正八角形構造を断念し、ミニ八卦炉に内接する円形とした。1で仮決めしたLED取り付けも、結局は完全固定とはせずに円形に巻いたLEDテープを差し込む方式とした。ミラー構造についても、
マイコン+バッテリー→仕切り板(円形の黒いアクリル板)→LEDテープ+内接する円形のアクリルミラー→LEDテープも覆う径のアクリルハーフミラー
とした。
結局、NeoPixel LEDの素子数は38素子となった。
3. 電池
過去の経験上、ESP32系マイコンボードはWiFiを有効化すると大体VBATで100~200mA程度なので、手持ちの薄型900mAhで十分と判断した。
今回はリチウムポリマー電池をそのまま接続しますが、過放電防止のためBMSをつけるべきです
4. 30fps以上のデータ転送
当然ながら(?)ここが一番工数がかかった。
まず始めに、
- Seeeduino XIAO ESP32C3はなぜかWiFi使用時にNeoPixelの制御がうまくいかない
という事象に悩まされたことを記述しておく。
事象としては、一見問題なく制御されているもののおよそ1Hz程度で全LEDが一瞬ほぼ全点灯するような、脈を打つような異常動作で、本来ならオシロなどで生の信号を見るべきだがこれが判明したのが本番2週間前なのであきらめた。プルアップ・プルダウン・LED側のシリアルレベルをVBATにレベル変換しても解消せず。
結論として、ハードをM5StampC3に変更したら全く問題が出なくなった。同じマイコンなのに?
XIAO ESP32C3の異常動作事象は今後深堀をする予定です。
制御データの構造
BMPファイルの連続した38ピクセルを1フレームの情報とし、各LEDのRGB情報としてそのまま流す。X方向とY方向を両方トライしたが、予想通りY方向だとSDカードから連続したデータとして読み出せないため、シークが間に合わず30fpsどころか10fpsも出ない結果となった。よってBMPファイルとしてはX方向のサイズが38のn倍、Y方向についてはデータのフレーム数による仕様となった。長いデータの場合ペイントソフトなどで作成・参照が困難になると予想されるため、Y方向の端まで行くとX方向に38ピクセルずれた位置からさらに読み込んでいく仕様とした。
以上をもとに、内部データの仕様を確定した。
#include <FastLED.h>
#define LED_NUM 38
typedef struct _sendpacket{
char Head[2];
CRGB Led_CRGB[LED_NUM];
} SendPacket;
typedef union _senddata{
SendPacket data_struct;
uint8_t data_send[sizeof(SendPacket)];
} SendData;
SendData LEDs;
構造体SendPacketがESP-Nowでミニ八卦炉側に送信するデータそのもので、ESP-Nowのパケット形式に合わせられるよう、共用体SendDataを定義した。SendPacket内のchar Head[2]
は完全に無精の産物で、MACアドレスによる宛先制御が面倒でFF:FF:FF:FF:FF:FF宛にブロードキャストする仕様とする都合上、念のためヘッダ情報を2バイト入れてパケットの有効性を識別させるためのもの。
BMP処理
BMP読み込みについては、ArduinoやM5Stackにおいてあまり参考になる作例がなかったため、24bitデータ限定としたうえでヘッダ処理を自前で実装した。
typedef struct _BMPHead{
uint8_t FileType[2];
uint32_t FileSize;
uint16_t res1;
uint16_t res2;
uint32_t HeadSize;
uint32_t InfoSize;
int32_t Width;
int32_t Height;
uint16_t Planes;
uint16_t Colbit;
uint32_t CompType;
uint32_t CompSize;
uint32_t HRes;
uint32_t VRes;
uint32_t NCol;
uint32_t ImpCol;
} BMPHead;
BMPHead procHead(uint8_t* h_buf){
BMPHead retBMP;
retBMP.FileType[0] = h_buf[0];
retBMP.FileType[1] = h_buf[1];
retBMP.FileSize = (uint32_t)h_buf[2] | (uint32_t)h_buf[3] << 8 | (uint32_t)h_buf[4] << 16 | (uint32_t)h_buf[5] << 24;
retBMP.res1 = (uint16_t)h_buf[6] | (uint16_t)h_buf[7] << 8;
retBMP.res2 = (uint16_t)h_buf[8] | (uint16_t)h_buf[9] << 8;
retBMP.HeadSize = (uint32_t)h_buf[10] | (uint32_t)h_buf[11] << 8 | (uint32_t)h_buf[12] << 16 | (uint32_t)h_buf[13] << 24;
retBMP.InfoSize = (uint32_t)h_buf[14] | (uint32_t)h_buf[15] << 8 | (uint32_t)h_buf[16] << 16 | (uint32_t)h_buf[17] << 24;
retBMP.Width = (uint32_t)h_buf[18] | (uint32_t)h_buf[19] << 8 | (uint32_t)h_buf[20] << 16 | (uint32_t)h_buf[21] << 24;
retBMP.Height = (uint32_t)h_buf[22] | (uint32_t)h_buf[23] << 8 | (uint32_t)h_buf[24] << 16 | (uint32_t)h_buf[25] << 24;
retBMP.Planes = (uint16_t)h_buf[26] | (uint16_t)h_buf[27] << 8;
retBMP.Colbit = (uint16_t)h_buf[28] | (uint16_t)h_buf[29] << 8;
retBMP.CompType = (uint32_t)h_buf[30] | (uint32_t)h_buf[31] << 8 | (uint32_t)h_buf[32] << 16 | (uint32_t)h_buf[33] << 24;
retBMP.CompSize = (uint32_t)h_buf[34] | (uint32_t)h_buf[35] << 8 | (uint32_t)h_buf[36] << 16 | (uint32_t)h_buf[37] << 24;
retBMP.HRes = (uint32_t)h_buf[38] | (uint32_t)h_buf[39] << 8 | (uint32_t)h_buf[40] << 16 | (uint32_t)h_buf[41] << 24;
retBMP.VRes = (uint32_t)h_buf[42] | (uint32_t)h_buf[43] << 8 | (uint32_t)h_buf[44] << 16 | (uint32_t)h_buf[45] << 24;
retBMP.NCol = (uint32_t)h_buf[46] | (uint32_t)h_buf[47] << 8 | (uint32_t)h_buf[48] << 16 | (uint32_t)h_buf[49] << 24;
retBMP.ImpCol = (uint32_t)h_buf[50] | (uint32_t)h_buf[51] << 8 | (uint32_t)h_buf[52] << 16 | (uint32_t)h_buf[53] << 24;
return retBMP;
}
ヘッダに含まれる情報をすべて格納する構造体BMPHeadを定義し、BMPのファイルポインタの先頭を渡すとヘッダの情報をBMPHead型の変数に格納するprocHead()関数を定義した。エンディアンが同一なら2バイト以上のデータはそのまま格納できるが不明だったため、念のためわざわざ決め打ちで計算させている。
また、実際に表示するBMPファイルをSDカードに複数保存して切り替えられるように、ルート階層にある*.BMPファイル数を返し、名前を取得する関数を定義した。
char BMPFile[256][256];
uint8_t getBMPfiles(File fp){
uint8_t Nfiles=0;
char tempName[256], tempRootName[256], tempFiles[256][256];
File entry;
while(1){
entry = fp.openNextFile(FILE_READ);
if(!entry){
entry.close();
return Nfiles;
}
if(!entry.isDirectory()){
strcpy(tempName, entry.name());
if(strstr(tempName, ".bmp")){
sprintf(BMPFile[Nfiles], "%c%s", '/', tempName);
Nfiles++;
}
}
entry.close();
}
}
これも無精でファイル数等を256上限で組んでいます。そんなにファイルを保存しないよね、という前提で。前提の崩れたところにバグあり。
BMPピクセル読み込み処理
以上により読み込み・処理可能となったBMPファイルの各ピクセルを実際に38ピクセルずつ読み込んでバッファに入れていく。
まずはファイルの情報からそのファイルの総フレーム数を算出する。
int32_t frame = 0;
int32_t max_frame;
#define FRM_RATE 60
const unsigned long FRM_INT = 1000000 / FRM_RATE;
BMPHead BMP;
void readBMP(uint8_t file_no){
myFile = SD.open(BMPFile[file_no], FILE_READ);
uint8_t head_buf[54];
myFile.read(head_buf, 54);
BMP = procHead(head_buf);
M5.Display.setCursor(0, disp_BMPY);
M5.Display.printf("File= %-20s\n", BMPFile[file_no]);
M5.Display.printf("Size= %'6d bytes\n", BMP.FileSize);
M5.Display.printf("Width= %4d pix\n", BMP.Width);
M5.Display.printf("Height= %4d pix\n", BMP.Height);
max_frame = BMP.Width /38 * BMP.Height;
M5.Display.printf("Total Frames= %'5d\n", max_frame);
M5.Display.printf("Duration= %2d:%02d:%02d\n", max_frame / 60 /FRM_RATE, (max_frame / FRM_RATE) %60, max_frame % FRM_RATE);
}
総フレーム数はmax_frame
に格納される。現在フレーム位置はframe
を参照することとする。
ヘタレなので(?)M5Unifiedを利用しています。余計なことを考えずにどんどんスケッチを書けるので気に入っています。printf文デバックっぽくなっているところは参考にしないようにお願いします。
ここまで準備ができたところで、
void PixRead(uint32_t frm_pos){
uint32_t pos_x, pos_y;
uint8_t read_buf[38*3];
pos_x = frm_pos / BMP.Height *38;
pos_y = frm_pos % BMP.Height;
switch((BMP.Width *3) %4){
case 0:
myFile.seek(BMP.HeadSize + BMP.Width * pos_y *3 + pos_x *3);
break;
case 1:
myFile.seek(BMP.HeadSize + (BMP.Width *3 +3)* pos_y + pos_x *3);
break;
case 2:
myFile.seek(BMP.HeadSize + (BMP.Width *3 +2)* pos_y + pos_x *3);
break;
case 3:
myFile.seek(BMP.HeadSize + (BMP.Width *3 +1)* pos_y + pos_x *3);
break;
}
myFile.read(read_buf, sizeof(read_buf));
for(uint8_t i =0; i <(38*3); i++){
switch(i %3){
case 0:
LEDs.data_struct.Led_CRGB[i /3].b = read_buf[i];
break;
case 1:
LEDs.data_struct.Led_CRGB[i /3].g = read_buf[i];
break;
case 2:
LEDs.data_struct.Led_CRGB[i /3].r = read_buf[i];
break;
}
}
}
フレーム位置を指定して一定周期ごとにPixRead()
関数を呼び出し、各ピクセル情報を送信バッファにコピーする。
BMPファイルの仕様として、X方向のデータが1列あたりちょうど4バイトの倍数となるようゼロパディングするようになっています。なのでswitch((BMP.Width *3) %4)
でゼロパディングを避けるよう、シーク位置をX方向のサイズ別に調整しています。
今このページを作成していて気づきましたが、この関数ではNeoPixelの素子数を38と決め打ちしていますね…。せっかく#define LED_NUM 38
でマクロを組んでいたので、本来それを利用すべきです。よくない例です。
あとはloop()
でmillis()
やmicors()
を利用するなどして一定周期でフレーム番号frame
をインクリメントさせながらPixRead()
を呼べばよい。
ミニ八卦炉側の処理
ESP-Nowのデータを受信し、ヘッダ2バイトを照合して有効であれば表示用のバッファに入れてNeoPixelに送る、だけなので省略。
実装
↑M5StampC3の取り付け状態。念のためカプトンテープで絶縁している。
高さを合わせるため発泡ゴムを貼り付けている。
↑アクリルミラーを乗せた状態。もう1mm程度大径でも良かったかも知れない。
↑制御側のM5Stack。何となくCore2にしたが後悔している。
うちの子曰く「秋季例大祭も行きたい」とのことなので、何らかの改善を施したうえでまた持っていくことになる予定。
改善点
- ミニ八卦炉側のバッテリー状態把握
AD入力で電圧を読めば済む話だが空中配線が怖い。基板化するか? - M5Core2のタッチパネル誤反応改善
ファイル選択・動作開始等のためにボタン操作をするが、歩いている途中に誤ってタッチパネルのボタンが反応してしまっていた。おとなしく物理スイッチのGrayにするか、キーロック機能を入れるかする必要がある。 - ESP-Now到達距離のロングレンジ化
いろいろな意味で可能なのか検討したい。目測7m程度で苦しかった。
謝辞
改善点はあるものの、うちの子も大満足な出来になったのは、そもそものミニ八卦炉が良かったからです。でないとそもそもこれを作ろうとは考えつきませんでした。Herreria(アトリエ エレリア)さん、どうもありがとうございました。