VRChatでQRコードジェネレータを作ってみた

こちらは、[エンジニア集会 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/engineer-meetup)の17日目の記事です。

この記事ではエンジニア集会のハッカソンで開発していた、VRChat用のQRコードジェネレーターについて紹介します。

## エンジニア集会のハッカソン

2024年の8月から9月にかけて、VRChat上でエンジニア集会とPNGミュージアムがコラボしてハッカソンを開催しました。ハッカソンのテーマは「展」。「展」とVRに関するものであれば何でもOKなハッカソンでした。

https://vr-lifemagazine.com/vrengassoc-hackathon-1/


## VRChat上のQRコードジェネレーターを作ってみた

このハッカソンでは、VRChat上で任意のURLをQRとして表示するQRコードジェネレーターを作ってみました。ハッカソン期間中はQRコードが途中まで表示するところまでしか作れなかったのですが、せっかくなのでハッカソン後も開発を進め、システムを完成させました。

https://x.com/Forks_/status/1864689526838591493

## QRコードジェネレーターの仕様

「どうやって動いてるの？」と思われる方もいると思いますが、まずは仕様について説明させてください。

このQRコードジェネレーターは実は制限がかなり多く、現段階では実用的ではありません。具体的には下記の仕様で動いています
- 表示出来るQRコードはバージョン1のみです
  - 441マスまでしか表現出来ません
  - URLは17文字のものまでしか扱えません
- QRコードが全部表示されるまでに、黒マスの数 x 0.4秒 の時間が必要です(おおよそ2分くらい)
- 後からワールドに入ったユーザには同期されません

・・・こうリストしていくと、本当に使えないなこのQRコード＼(^o^)／

## どうやって動いてるの？

アニメーションと[OSC](https://docs.vrchat.com/docs/osc-overview)の組み合わせで動いています。

### アニメーションについて

アバターに白い板を仕込み、441個(21マス x 21マス)の黒いマスが全て個別のアニメーションで白い板を突き抜けるように設定しています。そしてこれらの個別のアニメーションをOSCを使って操作をしています。

具体的には、1番左上の黒マスを操作するのに下記のpython用のOSCの操作をし、

```python
client.send_message("/avatar/parameters/QRCode", 1)
```

2番目の黒マスを操作するのに下記のようにpythonを書いています。

```python
client.send_message("/avatar/parameters/QRCode", 2)
```

上記の例の場合、`QRCode` を`1`に設定した後に`2`に設定したら、1番目の黒マスは元の位置に戻るんじゃないか？という疑問があると思います。通常の設定ならば確かに戻るのですが、アニメーションのWrite Defaultsをオフにしてる場合、上記の場合1マス目は元に戻りません。

この特性を使い、1から252番目のマスは`QRCode`というパラメータで、253から441番目のマスは`QRCode2`というパラメータで表示/非表示の操作を行っています。ちなみに1つのパラメータではMax256個の値しか扱えないので、パラメータを2つ使用しています。

### 操作するマスを決める方法について

文字列をQRコードに変換する仕様は決められており、[キーエンス社のウェブサイト](https://www.keyence.co.jp/ss/products/autoid/codereader/)から確認することが出来ます。

**でもこのプログラムでは、その仕様はガン無視する方向で進めています。**

まず、[pyqrcode](https://pypi.org/project/PyQRCode/)というpythonのライブラリを使い、任意のURLをQRコードに変換します。

```python
qr = pyqrcode.create(url, version=1, error='L', mode='binary')
```

その後、そのQRコードを0と1のマトリックスで表現をしています。

```python
def print_qr_grid(qr_matrix):
    # Print the QR code as a grid of 0s and 1s
    for row in qr_matrix:
        print(''.join(['1' if cell else '0' for cell in row]))

def main():
    print_qr_grid(qr.code)
```


左上から順番に数字を読み込み、0だったら無視して、1だったらそのX番目の黒マスのアニメーションをOSC経由で動かしています。

```python
qr_string = ''.join([''.join(['1' if cell else '0' for cell in row]) for row in qr.code])

for i, char in enumerate(qr_string, start=1):
    if char == '1':
        if i <= 252:
            # 1 から 252番目のマスを操作
            client.send_message("/avatar/parameters/QRCode", i)
        else:
            # 253 から 441番目のマスを操作
            client.send_message("/avatar/parameters/QRCode2", i - 252)
```

### URLの工夫

17文字のURLしか使えない仕様ですが、その先頭が `https://` の8文字で使われるため、実質残り9文字しか使えません。この残りの文字数は無駄に出来ません。`https://www` で始まるURLは`www`を抜いても問題ないので、プログラムに渡すURLから自動的に削除するようにしています。

```python
if url.startswith("https://www."):
    url = url.replace("https://www.", "https://", 1)
elif url.startswith("www."):
    url = url.replace("www.", "", 1)
```

また、URLが`/`で終わるURLは`/`を取り除いても問題ないので、この処理も実装してます。

```python
if url.endswith("/"):
    url = url[:-1]
```

### 表示スピードの工夫

表示スピードが遅いですね！というのもこれには理由があります。

まず、OSCでのパラメータ操作をVRChatに一気に送ると、VRChat側が処理しきれなく、反応しません。
0.1秒ほどの間を開けてOSCでのパラメータ操作をVRChatに送ると反応はしてくれるのですが、大量のリクエストを送るとアバターが30秒ほどペナルティをくらい、OSC操作を受け付けない状態になってしまいます。

色々と検証した結果、0.4秒ほどの間隔で送ることにより、OSC操作を連続で送ることができ、かつペナルティを受けなくなります。

```python
qr_string = ''.join([''.join(['1' if cell else '0' for cell in row]) for row in qr.code])

for i, char in enumerate(qr_string, start=1):
    if char == '1':
        if i <= 252:
            # 1 から 252番目のマスを操作
            client.send_message("/avatar/parameters/QRCode", i)
        else:
            # 253 から 441番目のマスを操作
            client.send_message("/avatar/parameters/QRCode2", i - 252)
        time.sleep(0.4)
```

## 開発できました！

制限が色々とあるとはいえ、`https://booth.pm/`、`https://x.com/`や`https://yahoo.com`といった別々のURLをQRコードとして生成することが出来ました。また、きちんとカメラで読み込むことが出来たので、一応QRコードの生成には成功しました٩( 'ω' )و

とはいえ、この制限をなるべく緩くするために今後改善していきたいと思います。QRコードのバージョンを上げつつ、QRコード生成の仕様を理解した上で開発すれば、違った形で実装出来るのではないかと思います。

今後もQRコードジェネレータの開発を頑張っていきたいと思います！