はじめに
TouchDesigner Advent Calender 2024 の3日目です!
左と下にWebアプリが表示されており、複数人が操作しても右上のTouchDesignerのパネルが同期して動いています。Touch側を操作した場合もブラウザアプリに反映されています。
Why !?
Why? WebからTouch
先日受講したセミナーでWeb2Touchに出会い、とても面白い!と感銘を受けました。ただ前述のリポジトリでは内部構造がわからないようになっていたので、今後応用アプリを作れるように自分でも作ってみようと思いました。
また、最近必要に迫られ、長年手をつけてこなかったWebテク(ReactやNextあたり)を学んでいましたが、せっかくなのでTouchDesignerで作るようなクリエイティブアートにつなげたいと考えトライしてみました。
Why? MQTT
仕組み的にはWebアプリとTouchDesignerが双方向に通信できればいいのですが、Touchでよくやる方法として下記の通信手段が考えられます。
-
OSCでやりとりする(WebアプリはネイティブなTCP/UDP通信は利用できない) -
Touch in/outをつかう(同上) - Websocketをプログラムする(まず思いつくのはこれ)
が、今回WebsocketではなくMQTTでやってみました。
理由はまず 「面白そうだったから。」 ものづくりにかかるモチベーションはこれにつきますね。
その上で、非機能要件と制約の厳しいIoTに向けに作られたもので、信頼性や効率性の観点で期待できるのではと、Websocketで独自に作るより発展性がありそう?ということで使ってみました。実際には色々な意見があるようですが、PubSub形式のメッセージングというものにも触れたかったのも一因です。
という感じなんですが、結局は (WebアプリはネイティブなTCP/UDP通信は利用できない)の理由によりWebsocketを通信経路に使うMQTT(over Websocket)を使うことになります。本気のMQTTの性能を引き出したい場合はネイティブアプリ化が必須です。
How !?
Docker Desktopを使ってMQTT Brokerを立ち上げる
MQTT BrokerはWebアプリ側とTouchDesigner側のMQTTをつなぐ中継サーバで、MQTTでは必須のコンポーネントです。最もシンプルで軽量と名高い、オープンソース実装のMosquittoを使ってみます。ラズパイで簡単にたてたりもできるみたいですが、今回はPC一台でやりたかったのでDocker Desktop上で動作させます。
こちらを参考にさせていただき、Websocketによる接続を許容する設定も入れました。
.
├ compose.yaml
└ config
└ mosquitto.conf
$cat compose.yaml
services:
mosquitto:
container_name: mosquitto
image: eclipse-mosquitto:2
volumes:
- ./config:/mosquitto/config
ports:
- 1883:1883
- 9001:9001 # WebSocket
$ cat config/mosquitto.conf
# config/mosquitto.conf
# MQTT
listener 1883 0.0.0.0
protocol mqtt
# WebSocket
listener 9001 0.0.0.0
protocol websockets
# 認証設定
allow_anonymous true
compose.yamlがあるフォルダにて、下記コマンドで立ち上がります。
docker compose up
テスト用で誰からでもアクセス可能で、認証もありません。
また通信自体の暗号化も今回は実施していません。
外部公開の際はセキュリティに留意してください。
TouchからMQTT Clientで接続する
次にTouchDesignerです。シーンの中をこういう構成にしてください。
mqttはDATのMQTT Clientで、sliderはPallete→UI→Basic Widgetsにある Slider 2Dです。2つともOPの名称を買えています。
その先にNullとCHOP Executeをつなげています。
DockerのMQTT Brokerが正しく立ち上がっていれば、DATを配置した時点ですぐに接続されますので、そのログが見れるはずです。Reconnectを押して接続したログなどを確認してみましょう。
mqttclient1_callbacksをEditして、下記のように変更しておいてください。
MQTTとしては"slider/xy"というTopicをSubscribe、更新があったらSliderの値を直接書き換えるという操作のみです。
def onConnect(dat):
dat.subscribe('slider/xy')
return
(中略)
def onMessage(dat, topic, payload, qos, retained, dup):
if topic == 'slider/xy':
slider = op('slider')
payload_dict = eval(payload.decode())
slider.par.Value0 = float(payload_dict['x'])
slider.par.Value1 = float(payload_dict['y'])
return
Topicという概念がMQTTにあり、面白いところです。
このMQTT ClientのDATは、dat.subscribe('slider/xy')
で'slider/xy'というTopicをSubscribe(購読する)としていて、そのtopicで発信されたメッセージを受信できるようにしてあります。
され次にChop Executeはこのようにコードを変更しておいてください。
Touch UIを操作してポジションに変化があった場合、mqtt.publish('master/slider/xy', json_bytes)
の箇所で"master/slider/xy"というTopicに対してデータのPublishを行います。これをSubscribeしているのはWebアプリケーション側であり、これによりTouchDesigner側での操作した結果のポジションをうけとってWebアプリに反映させることができます。
(前略)
def onValueChange(channel, sampleIndex, val, prev):
import json
mqtt = op('mqtt')
pos = op('pos')
if mqtt:
json_data = {'x': float(pos[0]),'y': float(pos[1])} # Convert Channel values to float
json_bytes = json.dumps(json_data).encode('utf-8') # Convert string to bytes
mqtt.publish('master/slider/xy', json_bytes)
return
v0を使ってWebアプリを作成する
v0はNext.jsの開発元であるVercel社のサービスで、ReactをベースとしたWebアプリのUIが特に得意な印象です。
デザイナーがいればデザインをベースにつくってもよいですし、自然言語で指示してもいい感じのものができました。下記のコードを利用してもよいですが、テストだけであれば新たに開発するのも簡単です。
.
├ TouchPad.tsx
└ app
└ page.tsx
'use client'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { useMqttState } from 'mqtt-react-hooks'
export default function TouchPad() {
const [position, setPosition] = useState({ x: 0, y: 0 })
const { client } = useMqttState()
const padRef = useRef<HTMLDivElement>(null)
const updatePosition = useCallback((x: number, y: number) => {
setPosition({ x, y })
console.log(`Position updated: x=${x}, y=${y}`) // デバッグ用ログ
}, [])
useEffect(() => {
if (client) {
client.subscribe('master/slider/xy')
client.on('message', (topic, message) => {
if (topic === 'master/slider/xy') {
try {
const { x,y } = JSON.parse(message.toString())
updatePosition(x, y)
console.log(`Received message: x=${x}, y=${y}`) // デバッグ用ログ
} catch (error) {
console.error('Invalid message format:', error)
}
}
})
}
return () => {
if (client) {
client.unsubscribe('master/slider/xy')
client.removeAllListeners('message')
}
}
}, [client, updatePosition])
const handleTouch = (event: React.TouchEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>) => {
if (!padRef.current) return
const rect = padRef.current.getBoundingClientRect()
let clientX: number, clientY: number
if ('touches' in event) {
clientX = event.touches[0].clientX
clientY = event.touches[0].clientY
} else {
clientX = event.clientX
clientY = event.clientY
}
const x = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1))
const y = 1 - Math.max(0, Math.min((clientY - rect.top) / rect.height, 1))
updatePosition(x, y)
if (client) {
client.publish('slider/xy', JSON.stringify({ x, y }))
}
}
useEffect(() => {
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleTouch)
document.removeEventListener('mouseup', handleMouseUp)
}
return () => {
document.removeEventListener('mousemove', handleTouch)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [])
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
handleTouch(event)
document.addEventListener('mousemove', handleTouch)
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', handleTouch)
})
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-purple-500 to-pink-500">
<div
ref={padRef}
onTouchStart={handleTouch}
onTouchMove={handleTouch}
onMouseDown={handleMouseDown}
className="w-80 h-80 bg-white bg-opacity-20 backdrop-blur-md border-2 border-white border-opacity-30 rounded-2xl shadow-lg relative cursor-pointer overflow-hidden"
role="slider"
aria-valuemin={0}
aria-valuemax={1}
aria-valuenow={(position.x + position.y) / 2}
aria-label="左下を原点とするタッチパッド"
>
<div
className="w-6 h-6 bg-white rounded-full absolute shadow-md"
style={{
left: `${position.x * 100}%`,
bottom: `${position.y * 100}%`,
transform: 'translate(-50%, 50%)'
}}
/>
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3">
{[...Array(9)].map((_, i) => (
<div key={i} className="border border-white border-opacity-20" />
))}
</div>
</div>
<div className="mt-8 text-xl text-white font-semibold">
X: {position.x?.toFixed(2) ?? '0.00'}, Y: {position.y?.toFixed(2) ?? '0.00'}
</div>
</div>
)
}
'use client'
import { Connector } from 'mqtt-react-hooks'
import TouchPad from '../TouchPad'
export default function Home() {
return (
<Connector brokerUrl="ws://localhost:9001">
<TouchPad />
</Connector>
)
}
Done!!
以上で冒頭のデモのようにWebアプリとTouchが同期するシステムが完成しました。
ここから先は取得した値を使って色々な作品やシステムにTouchDesignerの中で自由に組んでいけます。
ToBe
課題
よく見るとWebブラウザ側を操作したときポイントがカタついています。これは、
- Touch→WebのMQTTは一方向の通信で終わっているが、
- Web→Touchの通信はTouchでMQTTを受信しSlider 2Dの位置を変更した後、またCHOP Executeによって再度ブラウザ宛のMQTTを発信しており、これがループ処理を起こしているため
と考えられます。
本質的に解決するにはTouchDesigner側で、UI操作による発火コードの中(onUIChange?)でMQTTトピックをPubしないといけません。作り込みのときには対応します。
発展
今回、MQTTらしさは全然使っておりませんが、QoSを使った堅牢なシステムにしたり、ユーザごとにPub Topicを変更して「誰が操作しているかをわかるようにする」「一つの操作を複数人に分割して、協力ゲームみたいな作りにする」などの応用が考えられます。