時間と空間を超えてつながる体験型アート：学祭展示作品

## 1. はじめに：時間を超えてつながる体験型アート
この記事では、学祭で展示した体験型アート作品について紹介します。
本作品のテーマは「コミュニケーション」。日常的に文字や言葉で行うコミュニケーションを、視覚的で非言語的な形で表現する試みです。小さな子どもから大人まで、幅広い層が楽しめるよう設計しました。
![art.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940995/dc01c00e-d1a1-74c7-be66-01a3a6ce6fb7.jpeg)

この作品では、訪れた人々が残した言葉を「星」として空間に投影します。それらの星がつながり合うことで、時間や空間を超えた人々の交わりを表現しました。

## 2. プロジェクションの全体像
作品の中心となる要素は「星の生成と動き」と「星同士のつながり」です。
### 2.1 星の生成と動き
- **言葉のベクトル化**: Pythonを用いて、訪問者が入力した文章を4次元ベクトルに変換
- **3次元空間への投影**: Unityでベクトルを受信し、星（sphereオブジェクト）として生成
- **視点の動き**: 時間経過に応じて視点が動く仕組みを採用し、星が動的に変化

### 2.2 星同士のつながり
- **新たな文章の生成**: Pythonがランダムに2つの星を選び、その中間の意味を持つ新しい文章を生成
- **線の描画**: Unityで選ばれた2点をつなぐ線を描画し、時空間を超えたつながりを表現

近い意味を持つ文章同士が4次元空間内で近接配置され、3次元空間に投影してもその関係性が保たれます。これにより、言葉の「意味」が星の位置や動きとして表現されます。

![システム全体像.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940995/8796deee-ad90-5047-2c8a-f8845f1a7ddf.png)

## 3. Unityによる投影ロジック：4次元ベクトルから星の投影まで
ここからはUnityを用いた技術的ロジックを詳しく説明します。

**使用したC#スクリプトの概要**
以下の4つのクラスを実装しました。
1. ```OscReceiver```
Pythonと通信し、受信した4次元ベクトルを管理して星を生成

2. ```VectorProjection```
4次元ベクトルを3次元空間に投影

3. ```LineManager```
星同士をつなぐ線を描画

4. ```TextManager```
生成された文章を表示



### 3.1 星の生成とインスタンス化
Pythonから送信されたベクトルをUnityで管理し、以下の手順で星を生成します

1. **ベクトルの受信**: Pythonからのデータを辞書形式で保存
1. **星の生成**: 星が遠くの位置に生成され、視覚的に登場の瞬間を演出
1. **位置の移動**: 生成された星は、Pythonから受け取った4次元ベクトルの位置へ徐々に移動

データの通信には```extOSC```を使用しました。
<details><summary>C#スクリプト</summary>

#### 3.2.1 ベクトルの受信 (```OscReceiver```)
Pythonから送信されたベクトルを受信し、Unity内で管理します。4次元ベクトルとIDは```/vectore```で受け取ります。
```
void Start()
    {
        receiver.Bind("/vector", _OnReceiveVector);
        receiver.Bind("/delete", _OnDeleteMessageReceived);
        receiver.Bind("/message", _OnReceiveMessage);
        Debug.Log("OSC Receiver started and listening for /vector and /delete and /message");
    }
```
```/vectore```にベクトルが送られてきた時の処理を```OscReceiver```クラスに追加します。
```
    private void _OnReceiveVector(OSCMessage message)
    {
        if (message.Values.Count % 5 == 0)
        {
            try
            {
                {
                    int id = message.Values[0].IntValue;
                    float x = message.Values[1].FloatValue;
                    float y = message.Values[2].FloatValue;
                    float z = message.Values[3].FloatValue;
                    float w = message.Values[4].FloatValue;

                    Vector4 receivedVector = new Vector4(x, y, z, w);
                    receivedVectors[id] = receivedVector;
                    Debug.Log("Received Vector4 with ID: " + id + ", Vector: " + receivedVector);

                    // 新たなベクトルに対応する球体を生成
                    _CreateSphereForVector(id, receivedVector);
                }
            }
            catch (System.Exception e)
            {
                Debug.LogError("Failed to process received vector: " + e.Message);
            }
        }
    }
```

#### 3.2.2 星の生成
同様に星を生成する関数```_CreateSphereForVector```を記述します。
```
    private void _CreateSphereForVector(int id, Vector4 receivedVector)
    {
        Vector3 vectorPosition = new Vector3(receivedVector.x, receivedVector.y, receivedVector.z);

        if (spheres.ContainsKey(id))
        {
            spheres[id].transform.position = vectorPosition;
        }
        else
        {
            // 初期位置を少し離れた場所に設定
            Vector4 initialVector4 = receivedVectors[id];

            GameObject newSphere = Instantiate(spherePrefab, _initialPosition, Quaternion.identity);
            newSphere.transform.localScale = Vector3.one * _initSphereScale;

            Renderer renderer = newSphere.GetComponent<Renderer>();

            // 色と発光色をランダムに選択
            int colorIndex = Random.Range(0, _starColors.Length);
            Color starColor = _starColors[colorIndex];
            Color starEmissionColor = _starEmissionColors[colorIndex];
            
            renderer.material.color = starColor;
            renderer.material.EnableKeyword("_EMISSION");
            renderer.material.SetColor("_EmissionColor", starEmissionColor * _emissionIntensity);
            
            newSphere.layer = LayerMask.NameToLayer("StarEffect");
            spheres[id] = newSphere;

            // スケールと位置のアニメーションを開始
            StartCoroutine(_AnimateSphereAppearance(newSphere, receivedVector, id));
        }
    }
```
#### 3.1.3 位置の移動
さらに、新しく生まれた星が登場する様子をわかりやすくするためにアニメーションをつける関数```_AnimateSphereAppearance```を作成します。
```
    private IEnumerator _AnimateSphereAppearance(GameObject sphere, Vector4 receivedVector, int id)
    {
        float elapsedTime = 0;
        Vector3 finalScale = Vector3.one * _sphereScale;
        Vector3 _initialPosition = sphere.transform.position;

        while (elapsedTime < _animationDuration)
        {
            float t = elapsedTime / _animationDuration;
            receivedVectors[id] = Vector4.Lerp(sphere.transform.position, receivedVector, t);
            sphere.transform.localScale = Vector3.Lerp(sphere.transform.localScale, finalScale, t);
            elapsedTime += Time.deltaTime;
            yield return null;
        }

        // 最終位置とスケールを設定
        sphere.transform.localScale = finalScale;
        receivedVectors[id] = receivedVector;
    }
```


星の色や大きさに関するパラメータはHierarchyウィンドウから調整できるようにしておきます。
```
    [Header("Star Settings")]
    [SerializeField]
    private float _initSphereScale = 0.1f;
    [SerializeField]
    private float _sphereScale = 0.01f;
    [SerializeField]
    private Color[] _starColors = new Color[]
    {
        new Color(0.98f, 0.92f, 0.85f), // プロキオンの色味（白っぽい青）
        new Color(1.0f, 0.4f, 0.2f),    // ベテルギウスの色味（赤橙）
        new Color(0.7f, 0.8f, 1.0f),    // シリウスの色味（青白）
        new Color(1.0f, 0.65f, 0.3f),   // 一般的なオレンジ
        new Color(1.0f, 1.0f, 0.5f)     // 一般的な黄色
    };

    [SerializeField]
    private Color[] _starEmissionColors = new Color[]
    {
        new Color(0.49f, 0.46f, 0.42f), // プロキオンの発光色（淡い白青）
        new Color(1.0f, 0.2f, 0.1f),    // ベテルギウスの発光色（赤橙の強い光）
        new Color(0.4f, 0.6f, 1.0f),    // シリウスの発光色（強い青白）
        new Color(1.0f, 0.45f, 0.15f),  // オレンジの発光色
        new Color(1.0f, 0.9f, 0.4f)     // 黄色の発光色
    };
    [SerializeField]
    private Vector3 _initialPosition = new Vector3(5, 5, 5);
    [SerializeField]
    private float _emissionIntensity = 5f;
    [SerializeField]
    private float _animationDuration = 5.0f; // 演出の時間
```
</details>

### 3.3 ベクトルの投影 (```VectorProjection```)
次に星を構成する4次元ベクトル$v_{i}$を3次元空間に落とし込みます。星の動きは星の4次元ベクトルに対して視点の回転行列$R$と投影行列$P$を適用することで表現します。

#### 4次元ベクトルの定義
まず、星の位置を次のような4次元ベクトルで表します。

```math
v_{i} = 
\begin{bmatrix}
x_{i} \\ y_{i} \\z_{i} \\ w_{i}
\end{bmatrix}
```
ここで、$x_{i}, y_{i}, z_{i}$は空間座標を表し、$w_{i}$は4次元目の成分です。この$w_{i}$が後の投影プロセスで遠近感を表現するために用いられます。



#### 4次元座標の回転行列
星の動きを動的に見せるためには、視点を時間に応じて回転させる必要があります。この回転には、4次元の回転行列を用います。例えば$XY$平面に沿った回転行列$R_{XY}$は次のように定義されます。

$$
R_{XY} = 
\begin{bmatrix}
\cos\theta & -\sin\theta & 0 & 0\\ \\\sin\theta & \cos\theta & 0 & 0\\ \\\0 & 0 & 1 & 0\\ \\\0 & 0 & 0 & 1 \\
\end{bmatrix}
$$

ここで、$\theta$は時間に応じて変化する回転角で、時間の進行に応じて変化します。
このような回転行列$R$を入力ベクトル$v_{i}$に適用すると、回転後のベクトル$v_{i}'$が得られます。
$$v_{i}' = 
Rv_{i}  
$$
実際の展示では、$XW, YW, ZW$軸での回転も組み合わせることで、より多様に視点が動くようにしました。
<details><summary>C#スクリプト</summary>

時間の経過に伴う動きのある投影を担う```VectorProjection```クラスに視点の回転行列を計算する関数```_RotatePoint```を作成します
```
private Vector4 _RotatePoint(Vector4 point, float time)
{
    float angle = _rotationSpeed * time;

    float cosXY = Mathf.Cos(angle); // XY回転用
    float sinXY = Mathf.Sin(angle); // XY回転用

    // XY回転
    float x = rotatedPoint.x * cosXY - rotatedPoint.y * sinXY;
    float y = rotatedPoint.x * sinXY + rotatedPoint.y * cosXY;

    Matrix4x4 rotationMatrix = new Matrix4x4(
        new Vector4(cosXY, -sinXY, 0, 0),
        new Vector4(sinXY, cosXY, 0, 0),
        new Vector4(0, 0, 1, 0),
        new Vector4(0, 0, 0, 1)
    );

    Vector4 rotatedPoint = rotationMatrix * point;

    return rotatedPoint;
}
```
</details>

#### 透視投影行列
次に、回転後のベクトル$v_{i}'$を3次元空間に投影します。これには投影行列$P$を用います。投影行列$P$は次のように定義されます。

$$
P= 
\begin{bmatrix}
1 & 0 & 0 & 0\\ \\\0 & 1 & 0 & 0\\ \\\0 & 0 & 1 & 0\\
\end{bmatrix}
$$
この行列を適用することで、4次元ベクトル$v_{i}'$の$x, y, z$成分が抽出され、平行投影ベクトル$v_{3D}$が得られます。
$$v_{3D}=Pv'$$
しかし、この平行投影では$w_{i}$の情報が失われるため、遠近感を表現することはできません。遠近感を表現するために、$w_{i}$の情報を用いてスケール係数$w_{\text{scale}}$を計算します。
$$
w_{\text{scale}} = \frac{d}{d - w_{i}}
$$
ここで、$d$はカメラとオブジェクトの距離です。このスケール係数を$v_{3D}$に掛けることで、カメラに近い星ほど大きく、遠い星ほど小さく表示されるようになります
$$v_{3D}'=w_{scale}v_{3D}$$

これにより、$w_{i}$の情報を活かした透視投影を実現し、遠近感のある3次元空間の表現が可能となります。

<details><summary>C#スクリプト</summary>


```VectorProjection```クラスに4次元ベクトルを3次元に落とし込むを関数```_ProjectTo3D```を作成します
```
// 4次元から3次元への透視投影
private Vector3[] _ProjectTo3D(Vector4[] points4D)
{
    Vector3[] points3D = new Vector3[points4D.Length];
    for (int i = 0; i < points4D.Length; i++)
    {
        // Vector4 rotatedPoint = points4D[i];
        Vector4 rotatedPoint = RotatePoint(points4D[i], Time.time);

        float w = _projectionDistance / (_projectionDistance - rotatedPoint.w);
        Vector3 projected = new Vector3(rotatedPoint.x, rotatedPoint.y, rotatedPoint.z) * w * _scaleFactor;

        points3D[i] = projected;

    }
    return points3D;
}
```

回転行列$R$と投影行列$P$によって算出した座標で星(sphere)の位置を更新します。
```
// 投影するsphereの座標更新
private void _UpdatePointPosition(Vector3[] projectedPoints)
{
    int i = 0;
    foreach (var kvp in oscReceiver.receivedVectors)
    {
        int id = kvp.Key;
        // id に基づいて sphere を取得して位置を更新
        if (oscReceiver.spheres.TryGetValue(id, out GameObject sphere))
        {
            if (i < projectedPoints.Length)
            {
                sphere.transform.position = projectedPoints[i];
            }
        }
        i++;
    }
}
```

作成した関数を```VectorProjection```で呼び出します
```
    void Update()
    {
        // 4次元から3次元へ変換
        Vector3[] projectedPoints = _ProjectTo3D(new List<Vector4>(oscReceiver.receivedVectors.Values).ToArray());

        // 点の位置を更新
        _UpdatePointPosition(projectedPoints);

        // ラインの描画
        lineManager.UpdateLinesByDeltaTime(oscReceiver.spheres);

    }
```
</details>

### 3.3 星同士をつなぐラインの描画
最後に、星同士をつなぐラインの描画について説明します。この部分は、Python側のロジックと連携しており、以下手順で実装しています。

1. **繋がりの生成(Python側)**
Python側で今まで訪れた人が残した言葉を２つ選び、それらから中間の意味を生成します。選ばれた2つの言葉（星）のIDと生成された中間の文章がUnityに送信されます。

2. **ラインと文章の描画（Unity側）**
Unityは受信した星のIDをもとに、それぞれの座標を参照してラインを描画します。さらに、生成された文章をCubeに投影することで、過去と現在、または人と人の「つながり」を視覚的に表現します。

このようなライン描画によって、過去の記録が新たな言葉を生み出し、人々が間接的に交流している様子を表現しました。この「つながり」が時間や空間を超えた非言語的なコミュニケーションを象徴的に示しています。

<details><summary>C#スクリプト</summary>

まず、```OscReceiver```クラスの```/message```で、生成された文章と選ばれた2つのIDを受け取ります。そして```/message```で受信した時に呼び出される```_OnReceiveMessage```関数を作ります。
```
private void _OnReceiveMessage(OSCMessage message)
    {
        Debug.Log("Test received created message: " + message);
         if (message.Values.Count == 3)
        {
            int id1 = message.Values[0].IntValue;
            int id2 = message.Values[1].IntValue;
            // string createdMessage = message.Values[2].StringValue;
            byte[] receivedBytes = message.Values[2].BlobValue; // メッセージが日本語の場合
            string createdMessage = Encoding.UTF8.GetString(receivedBytes);

            Debug.Log("Received Message with ID: " + id1 + ", " + id2 + ", Created Message: " + createdMessage+ "type"+message.Values[2].Type);

            // 球を線で繋ぐ
            try
            {
            lineManager.UpdateLinesByOSC(id1, id2, spheres);
            }
            catch (System.Exception ex)
            {
                Debug.LogError("Error in UpdateLines: " + ex.Message);
            }

            // テキストの更新
            try
            {
            textManager.UpdateText(createdMessage);
            }
            catch (System.Exception ex)
            {
                Debug.LogError("Error in UpdateText: " + ex.Message);
            }
        }
    }

```
そして、この関数で呼ばれる文章を表示する関数を文章表示管理クラス```TextManager```内に記述します。
```
void Start()
    {
        GameObject textObject = new GameObject("CubeText");
        textObject.transform.SetParent(cube.transform);
        textMeshPro = textObject.AddComponent<TextMeshPro>();

        textMeshPro.text = "";
        textMeshPro.fontSize = 1.5f;
        textMeshPro.color = Color.black;
        textMeshPro.font = customFont;
        textMeshPro.alignment = TextAlignmentOptions.Center;

        // テキストオブジェクトの位置
        textObject.transform.localPosition = _textPosition;
        textObject.transform.localRotation = Quaternion.identity;
    }

    public void UpdateText(string message)
    {
        textMeshPro.text = message;
    }
```
そして、```OscReceiver```で呼び出される```UpdateLinesByOSC```関数を星のつながりを描画するクラス```LineManager```内に設計します。この関数では選ばれた星をつなぐラインを生成します。
```
public void UpdateLinesByOSC(int id1, int id2, Dictionary<int, GameObject> spheres)
    {

        // 既存の線を削除
        foreach (var lineRenderer in lineRenderers)
        {
            if (lineRenderer != null && lineRenderer.gameObject != null)
            {
                Destroy(lineRenderer.gameObject);
            }
        }
        lineRenderers.Clear();

        _selectedSphereIds[0] = id1;
        _selectedSphereIds[1] = id2;
        foreach (int id in _selectedSphereIds)
        {
            if (spheres.ContainsKey(id))
            {
                var sphere = spheres[id];
                if (sphere != null)
                {
                    Vector3 spherePos = sphere.transform.position;

                    GameObject lineObject = new GameObject("Line");
                    LineRenderer lineRenderer = lineObject.AddComponent<LineRenderer>();
                    lineRenderer.material = new Material(Shader.Find("Unlit/Color"));
                    Color lineColor = Color.Lerp(Color.white, Color.yellow, 0.5f);
                    lineColor.a = _linealpha;
                    lineRenderer.material.color = lineColor;
                    lineRenderer.positionCount = 2;
                    lineRenderer.SetPosition(0, spherePos);
                    lineRenderer.SetPosition(1, cube.transform.position);
                    lineRenderer.startWidth = _linWidth;
                    lineRenderer.endWidth = _linWidth;
                    lineRenderers.Add(lineRenderer);
                }
                else
                {
                    Debug.LogError("Sphere objects are null. id: " + id);
                }

            }
            else
            {
                Debug.LogError("Spheres dictionary does not contain keys for id: " + id);
            }   
        }

    }
```

次に、```VectorProjection```クラスで呼び出される```UpdateLinesByDeltaTime```関数を```LineManager```クラス内に設計します。この関数では時間経過で動く星にラインが追跡できるようにしています。
```
public void UpdateLinesByDeltaTime(Dictionary<int, GameObject> spheres)
    {
        var index = 0;
        foreach (int id in _selectedSphereIds)
        {
            if (spheres.ContainsKey(id))
            {
                var sphere = spheres[id];
                if (sphere != null)
                {
                    Vector3 spherePos = sphere.transform.position;
                    // 新しいLineRendererを作成
                    LineRenderer lineRenderer = lineRenderers[index];
                    lineRenderer.SetPosition(0, spherePos);
                }
                else
                {
                    Debug.LogError("Sphere objects are null. id: " + id);
                }

            }
            else
            {
                Debug.LogError("Spheres dictionary does not contain keys for id: " + id);
            }
            index ++;   
        }
    }
```
</details>

## 4. 完成！
### UnityのGame画面
単なるsphereだと星っぽくないのでMaterialでいい感じにぼかしたり発光させたりして完成です！
![unity_star.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940995/9e7a36ec-0757-6217-4814-acecc6a48d43.png)

### 展示の様子
![展示の様子.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940995/ef519d2f-8b21-3375-1772-c99e9c07886d.gif)
星がたくさんあって綺麗ですね！たくさんの人が星を生成してくれました。

### 生成された文章
来場者が残してくれた2つの言葉から実際に生成された文章を見ていきます！

**ほしい時にゲームをプレイして気分転換をするのはいい方法だ**
![game.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940995/b513992b-2086-efcc-da1a-95ed5cc6ea10.jpeg)

受験生もたくさん来てくれました！気分転換は大事ですね！

**瀬戸内海は大きい**
![sea.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940995/45f691d8-98e0-27a4-87b9-72b1c1171f8f.jpeg)
シンプルですね。瀬戸内海は割と小さいと思いますが
外国人が瀬戸内海を見た時に「なんて大きな川なんだ！」と反応することがあるみたいです。海外の川は広いですからね
そういう意味では瀬戸内海は大きいですかね！笑


**屋台の料理は安くておいしいミミズです**
![yatai.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940995/082d1267-e7c6-e2ae-abee-af6a796b5d57.jpeg)
最後の一言で訳のわからない文章になってます笑
学祭なので屋台のコメントがたくさん投稿されていました！農学部キャンパスなので生物の展示を見にいってくれた人の感想と組み合わさったのでしょうか。

色々な言葉が交わり合っていますね！あまり写真に残っていないのが残念ですが他にも面白い文章がたくさん生成されていました！

## 5. 時間と空間を超えるアート体験
このインタラクティブアートは、技術的なロジックを駆使しながらも、幅広い人たちが楽しめるコンテンツになりました。

**星の動き**
視点の移動に伴い星が絶えず変化し、生命感あふれる空間を演出しました。キラキラと光る星の動きによって技術的な知識がない方でも直感的に楽しんでいただけました。

**非言語的なコミュニケーション**
言葉を直接表示するのではなく、星や線として抽象的に表現することで、言葉の意味や感情をより豊かに伝えることができました。

**時空間を超えたつながり**
ランダムに生成される星同士のつながりは、新たな意味や感情を生み出し、多くの来場者が残した言葉からユニークな文章が生成されました。デジタル技術を活かすことで、アナログな来場者ノートでは表現できない、独自のつながりを体験していただけました。



## 6. おわりに
開発チームは「入力」「Python解析」「投影」と各部門1人で開発するような小規模体制ながらアイデアをしっかりと形にすることができました。UX、ハードウェア関係のメンバーも各自の知識を生かして協力し合いながら1つのプロジェクトを完成させることができました。企画に参画してくれた皆さんありがとうございました！
また、体験型アートとして展示した企画なので、たくさんの方に楽しんでいただけたことを嬉しく思います。来場してくださった方々、ありがとうございました！

このプロジェクトのアイデアが、新たなアート作品や技術の応用に少しでも役立つと嬉しいです。