展示や体験型アプリケーションへの興味
科学館や展示などに行くと子供が少し遊べるような体験型のアプリケーションをよく見かけます。
凝った演出からシンプルな構成のものまで様々ですが、子どもたちが喜んで遊ぶので、自宅でもそのようなことが出来ないかなと思うことがあります。
また、最近のスマホはLiDARセンサーなども搭載しており非常に高性能なのでうまく連携して遊べるものが出来ないかと考えてみました。
これらの構成に強くなることで、エンタメコンテンツに限らず生産管理の現場などにも考え方が応用できそうです。
iPhoneから深度や画像を取得しWebアプリケーションに連携
まずは、Xcode(Swift)でLiDARやカメラから取得したデータをWebSocketを用いてWebアプリケーション側に連携するiOSアプリを開発し、p5.jsで描画することを考えました。
Swiftの知見はほとんどありませんでしたが、Claude Codeに色々と教えてもらいながら、無事にローカルネットワーク越しにデータを連携しブラウザ表示するものが完成しました。
しかし、ポイントクラウドをかっこよく表示させたり、エフェクトをどのようなロジックで組み立てると破綻しないかを細かく手作業で確認しながら進めるのは現実的でないと感じました。
パフォーマンスの観点や閾値調整の手間などを考えると、低レイヤーから独自実装するより既製品と連携するほうが合理的かもしれません。
そこで、いくつかiOSアプリを試してみました。
Record3D
3D動画録画アプリです。数百円課金することで、USBでつないでデータ連携できるようになります。
連携用のPythonライブラリとデモプログラムを配布しています。
課金して少し触ってみましたが、ポイントクラウドデータを自作でゼロから取得し調整するより、効率的かもしれません。
ZIG SIM
株式会社ワントゥーテンが開発した『フィジカル・プロトタイピングをプロトタイピングする』ためのアプリケーションとのことです。ZIG SIM Proが490円なので購入してみました。
開発ドキュメントも用意されています。
プロモーション用のYouTube動画もあり、これは創作意欲を刺激するなかなか良い動画です。
自分の子供に「算数やプログラミングを学ぶと、こういうものが自分で作れるようになるんだよ。」とこの動画をみせるととても興味を持ってくれました。
運用環境で使えるような製品ではありませんが、“プロトタイピングをプロトタイピングする”という説明のとおり議論するために少し動くものを試すには便利そうなアプリです。
OBS
OBS StudioとDistroAVを用いると簡単にiPhoneのカメラ映像をPC側に連携することができます。
TouchDesignerとの出会い
画面描画においては、Three.jsやp5.jsなどの利用を想定していましたが、カメラやセンサー情報取得側だけでなく、処理や計算と描画を担う部分も何らかの専用ソフトを使ったほうが良さそうです。
映像演出やシステムが作れるビジュアル制作ツールはいくつかありますが、TouchDesignerというカナダのDerivative社が開発したソフトウェアを利用することにしました。
TouchDesignerはライブ演出、メディアアート、常設展示などで広く使われているそうです。
ノードベースのビジュアルプログラミング環境であることや、用途がUnityと重なる部分がありますが、Unityと違って法人でも商用利用ではなく、まずは理解するまでの学習用途であればインストールと試用までは無料で試せる(※一部機能制限あり)ことが魅力的でした。
Unityほどではありませんが、TouchDesignerも世界中で広く使われているため、YouTubeで多くの人がチュートリアル動画を上げており、書籍も何冊か発売されています。
私は『Visual Thinking with TouchDesigner プロが選ぶリアルタイムレンダリング&プロトタイピングの極意 改訂第2版』を購入してみました。
ちなみに前述のZIG SIMはTouchDesignerとの連携サンプルファイルも配布しています。
つくるもの
YouTube動画や書籍を通して、TouchDesignerで出来ることや優位性が掴めてきたところで、まずはどのようなものをつくるか成果物を考えてみました。
8歳の息子が最近ドラゴンボールを読み終えて、それに感化されてか6歳の娘を連れて修行といってベッドで飛び跳ねています。
折角なので、そのような運動を可視化して戦えるゲームを目指してみます。
運動量の算出
Claude Codeと相談しながら実現方法を検討します。
動いた量に応じてスコア反映する方法は通常のカメラ入力だけで十分ということがわかりました。
他にもいろいろなアイデアを検討しましたが、多くの場合がLiDARの深度データは使わずカメラの映像入力だけで十分でした。
TouchDesignerでは、下記のようにオペレータを繋ぐことで簡単に実現できます。
| ノード種類 | 役割 | パラメータ |
|---|---|---|
| Video Device In TOP | カメラからの入力 | - |
| Cache TOP | 前フレームを保持 | Cache Size: 1, Step Size: 4 |
| Difference TOP | 現フレームと前フレームの差分 | - |
| Monochrome TOP | グレースケール化 | - |
| Threshold TOP | ノイズ除去 | Threshold: 0.08 |
| Analyze TOP | ピクセル平均値を算出 | Function: Average |
カメラに向けて手を振ったときの実際の動作です。
動きのある手の部分だけが検出されていることがわかります。
Analyze TOP の出力値(0.0〜1.0)が運動量となります。
この値に係数をかけて、毎フレーム累積加算してスコアとします。
Analyze TOP の値は下記のようなPythonコードで参照できます。
top = op(top_name)
if not top:
return 0.0
color = top.sample(x=0, y=0)
return float(color[0])
あとは、鏡合わせになるように Flip TOP で左右反転したり、 Crop TOP で左右それぞれ切り出したり、それぞれに色を載せたり、スコア表示のためのパーツを配置します。
Feedback TOPを用いた演出
ゲーム中はトレイルエフェクトが発生すると楽しそうです。
TouchDesignerでは、トレイルエフェクトを含め多くの演出効果で Feedback TOP を用います。
少しノードのつなぎ方や設定が難しいのですが、書籍や下記の動画を参考にすると良いでしょう。
今回は Difference TOP を Feedback TOP と連携させトレイルエフェクトを作りました。
制御用のPythonコード
ゲームとして制御するためのPythonコードはClaude Codeに書いてもらいました。
一般的な開発時と違って透過的にエラーを検知・修正をしてくれないため、書いてもらったコードをTouchDesigner上に貼り付けては発生したエラーの内容を伝えるような往復作業が発生しましたが、何度かやり取りを進めると想定通りの動作となりました。
game_controller
base_game という名前の Base COMP を作成し、その中に game_controller という Text DAT を設置し下記のコードを格納します。
GAME_DURATION = 90 # ゲーム時間(秒)
COUNTDOWN_DURATION = 3 # 開始カウントダウン(秒)
SCORE_MULTIPLIER = 1000 # Analyze TOPの値(0-1)をスコアに変換する倍率
FPS = 60 # フレームレート
IDLE = 0
COUNTDOWN = 1
PLAYING = 2
RESULT = 3
def init_game(storage):
storage['state'] = IDLE
storage['score_left'] = 0.0
storage['score_right'] = 0.0
storage['timer'] = GAME_DURATION
storage['countdown'] = COUNTDOWN_DURATION
storage['winner'] = None
def start_game(storage):
storage['state'] = COUNTDOWN
storage['score_left'] = 0.0
storage['score_right'] = 0.0
storage['timer'] = GAME_DURATION
storage['countdown'] = COUNTDOWN_DURATION
storage['winner'] = None
def update(storage, dt, motion_left, motion_right):
state = storage['state']
if state == COUNTDOWN:
storage['countdown'] -= dt
if storage['countdown'] <= 0:
storage['state'] = PLAYING
storage['countdown'] = 0
elif state == PLAYING:
storage['timer'] -= dt
storage['score_left'] += motion_left * SCORE_MULTIPLIER * dt
storage['score_right'] += motion_right * SCORE_MULTIPLIER * dt
if storage['timer'] <= 0:
storage['timer'] = 0
storage['state'] = RESULT
if storage['score_left'] > storage['score_right']:
storage['winner'] = 'LEFT'
elif storage['score_right'] > storage['score_left']:
storage['winner'] = 'RIGHT'
else:
storage['winner'] = 'DRAW'
def get_timer_fraction(storage):
return 1.0 - (storage['timer'] / GAME_DURATION)
def get_timer_text(storage):
remaining = max(0, int(storage['timer']))
return str(remaining)
def get_countdown_text(storage):
if storage['state'] != COUNTDOWN:
return ''
return str(max(1, int(storage['countdown']) + 1))
def get_score_text(storage, side):
key = 'score_left' if side == 'left' else 'score_right'
return str(int(storage[key]))
def get_result_text(storage):
if storage['state'] != RESULT:
return ''
winner = storage['winner']
if winner == 'LEFT':
return 'BLUE WINS!'
elif winner == 'RIGHT':
return 'RED WINS!'
else:
return 'DRAW!'
exec_frame
exec_frame という名前の Execute DAT を設置し、下記のコードを格納します。
def _get_game():
return op('base_game').mod('game_controller')
def _get_storage():
return op('base_game').storage
def _read_motion(top_name):
top = op(top_name)
if not top:
return 0.0
color = top.sample(x=0, y=0)
return float(color[0])
def _play_audio(name, loop=False):
audio = op(name)
if not audio:
return
if hasattr(audio.par, 'cuepulse'):
audio.par.cuepulse.pulse()
audio.par.play = True
if loop and hasattr(audio.par, 'loop'):
audio.par.loop = True
def _stop_audio(name):
audio = op(name)
if not audio:
return
audio.par.play = False
def _stop_all_audio():
_stop_audio('audio_countdown')
_stop_audio('audio_bgm')
def onStart():
game = _get_game()
game.init_game(_get_storage())
_stop_all_audio()
def onFrameStart(frame):
base = op('base_game')
if not base:
return
game = _get_game()
storage = _get_storage()
if 'state' not in storage:
game.init_game(storage)
dt = 1.0 / project.cookRate
motion_left = _read_motion('analyze_left')
motion_right = _read_motion('analyze_right')
game.update(storage, dt, motion_left, motion_right)
prev_state = storage.get('prev_state', game.IDLE)
new_state = storage['state']
storage['prev_state'] = new_state
if prev_state != new_state:
if new_state == game.COUNTDOWN:
_stop_all_audio()
_play_audio('audio_countdown')
elif new_state == game.PLAYING:
_stop_audio('audio_countdown')
_play_audio('audio_bgm', loop=True)
elif new_state == game.RESULT:
pass
elif new_state == game.IDLE:
_stop_all_audio()
if op('level_timer'):
op('level_timer').par.opacity = 1.0 if storage['state'] == game.PLAYING else 0.0
if op('text_timer'):
op('text_timer').par.text = game.get_timer_text(storage)
if op('text_score_left'):
op('text_score_left').par.text = game.get_score_text(storage, 'left')
if op('text_score_right'):
op('text_score_right').par.text = game.get_score_text(storage, 'right')
if op('text_countdown'):
op('text_countdown').par.text = game.get_countdown_text(storage)
if op('text_result'):
op('text_result').par.text = game.get_result_text(storage)
出来上がったもの
このようなアプリケーションはプロトタイプからしっかりとした体験として成り立つものに仕上げるまでの道のりが非常に険しく、時間がかかるものですが今回は技術検証と課題に対しての選択肢の幅を広げることが目的だったので、ひとまずこれを区切りにしたいと思います。
今回の構成とほぼ同じもので、だるまさんがころんだや窓拭きゲームのようなものもつくれそうですね。
TouchDesignerについて
今回はカメラ入力と単純な処理だけのアプリケーションとなりましたが、TouchDesignerは3Dデータのレンダリングやプロジェクションマッピングなど実に様々な機能を有します。
また、KinectやRealSenseなどのセンサー類との親和性も高く、アイデア次第で可能性が広がる良いツールだと感じました。
機会があれば実際の業務プロジェクトで深く利用していきたいと思います。


