6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

TouchDesigner Advent Calender 2024 の3日目です!

この記事ではこういうものを作ってみます!
web2touch.gif

左と下に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による接続を許容する設定も入れました。

folder
.
├ compose.yaml
└ config
   └ mosquitto.conf
config
$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があるフォルダにて、下記コマンドで立ち上がります。

shell
docker compose up

テスト用で誰からでもアクセス可能で、認証もありません。
また通信自体の暗号化も今回は実施していません。
外部公開の際はセキュリティに留意してください。

TouchからMQTT Clientで接続する

次にTouchDesignerです。シーンの中をこういう構成にしてください。
mqttはDATのMQTT Clientで、sliderはPallete→UI→Basic Widgetsにある Slider 2Dです。2つともOPの名称を買えています。
image.png

その先にNullとCHOP Executeをつなげています。
DockerのMQTT Brokerが正しく立ち上がっていれば、DATを配置した時点ですぐに接続されますので、そのログが見れるはずです。Reconnectを押して接続したログなどを確認してみましょう。
image.png

mqttclient1_callbacksをEditして、下記のように変更しておいてください。
MQTTとしては"slider/xy"というTopicをSubscribe、更新があったらSliderの値を直接書き換えるという操作のみです。

mqttclient1_callbacks
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アプリに反映させることができます。

Chop Execute
(前略)
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が特に得意な印象です。
デザイナーがいればデザインをベースにつくってもよいですし、自然言語で指示してもいい感じのものができました。下記のコードを利用してもよいですが、テストだけであれば新たに開発するのも簡単です。

folder
.
├ TouchPad.tsx
└ app
   └ page.tsx
TouchPad.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>
  )
}

page.tsx
'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の中で自由に組んでいけます。

webtouch2.gif

ToBe

課題

よく見るとWebブラウザ側を操作したときポイントがカタついています。これは、

  • Touch→WebのMQTTは一方向の通信で終わっているが、
  • Web→Touchの通信はTouchでMQTTを受信しSlider 2Dの位置を変更した後、またCHOP Executeによって再度ブラウザ宛のMQTTを発信しており、これがループ処理を起こしているため

と考えられます。
本質的に解決するにはTouchDesigner側で、UI操作による発火コードの中(onUIChange?)でMQTTトピックをPubしないといけません。作り込みのときには対応します。

発展

今回、MQTTらしさは全然使っておりませんが、QoSを使った堅牢なシステムにしたり、ユーザごとにPub Topicを変更して「誰が操作しているかをわかるようにする」「一つの操作を複数人に分割して、協力ゲームみたいな作りにする」などの応用が考えられます。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?