アバターやループアニメに使用できる軽量なフォーマットについて考える -02

前回の続き

https://qiita.com/platoronical/items/650a5a34c0e4999e8791

何をしたいのか伝えられていない気がするのだけど、まだもう少し続ける

前回のJSONを拡張

```json
    "canvasWidth": 4096, //テクスチャ幅サイズ
    "canvasHeight": 4096, //テクスチャ高さ
    "layerCount": 2,
    "baseCanvasWidth": 1920,　//レンダリングキャンバスサイズ
    "baseCanvasHeight": 1080,　//レンダリング高さ
    "TextureNum": [　//何枚のテクスチャを使用するか
        {
            "imageCount": 1,
            "texImg": ["texture_room_4096.png"]
        }
    ],
    "layers": [
        {
            "basefileName": "01.png",　
            "imgType": "Texture",　// Texture or Sprite
            "assignID": "layer_1", // 参照名
            "animID":"", 
            "x": 0,
            "y": 0,
            "width": 1920,
            "height": 1080,
            "basePosition_x": 0,
            "basePosition_y": 0,
            "textureZIndex": 0
        }
    ],
    "sprites":[ //スプライトアニメの定義
        {
            "anim01":[　//アニメーション
                {
                    "fps":8,
                    "loop": 1,　//0がループなし 1がループあり
                    "useTex":[
                        "s_01","s_02","s_03","s_04","s_05","s_06","s_07","s_08","s_09","s_10"
                    ]
                }

            ]
        }
    ]
}

```

追加した仕様
 - キャンバスサイズとレンダリング領域について定義
 - テクスチャを複数持てるように
 - Spriteアニメを定義できるように
     - fps
     - ループ
     - 使用するテクスチャを配列で指定


<details><summary>JSONのサンプル</summary>

```json
{
    "canvasWidth": 4096,
    "canvasHeight": 4096,
    "layerCount": 2,
    "baseCanvasWidth": 1920,
    "baseCanvasHeight": 1080,
    "TextureNum": [
        {
            "imageCount": 1,
            "texImg": ["texture_room_4096.png"]
        }
    ],
    "layers": [
        {
            "fileName": "",
            "imgType": "Texture",
            "assignID": "layer_1",
            "animID":"",
            "x": 0,
            "y": 0,
            "width": 1920,
            "height": 1080,
            "basePosition_x": 0,
            "basePosition_y": 0,
            "textureZIndex": 0
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_01",
            "animID":"anim01",
            "x": 0,
            "y": 1080,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_02",
            "animID":"anim01",
            "x": 408,
            "y": 1080,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_03",
            "animID":"anim01",
            "x": 816,
            "y": 1080,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_04",
            "animID":"anim01",
            "x": 1224,
            "y": 1080,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_05",
            "animID":"anim01",
            "x": 1632,
            "y": 1080,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_06",
            "animID":"anim01",
            "x": 0,
            "y": 1416,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_07",
            "animID":"anim01",
            "x": 408,
            "y": 1416,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_08",
            "animID":"anim01",
            "x": 816,
            "y": 1416,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_09",
            "animID":"anim01",
            "x": 1224,
            "y": 1416,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        },
        {
            "fileName": "",
            "imgType": "Sprite",
            "assignID": "s_10",
            "animID":"anim01",
            "x": 1632,
            "y": 1416,
            "width": 408,
            "height": 336,
            "basePosition_x": 415,
            "basePosition_y": 563,
            "textureZIndex": 1
        }
    ],
    "sprites":[
        {
            "anim01":[
                {
                    "fps":8,
                    "loop": 1,
                    "useTex":[
                        "s_01","s_02","s_03","s_04","s_05","s_06","s_07","s_08","s_09","s_10"
                    ]
                }

            ]
        }
    ]
}

```

</details>

pygameでレンダリングがうまくいかなかったので、今回はcanvasでレンダリングをしています

[![Image from Gyazo](https://i.gyazo.com/a26741ba665c9ad69261aa0213251b9b.gif)](https://gyazo.com/a26741ba665c9ad69261aa0213251b9b)

1枚のテクスチャで部分的にアニメを再生させています

[![Image from Gyazo](https://i.gyazo.com/1479c36870638eaad3e0f2a0bb9f7a53.png)](https://gyazo.com/1479c36870638eaad3e0f2a0bb9f7a53)

かなりでかい画像を使っているのと（分かりにくいサンプルで）すいません
元のテクスチャに対して余裕があるので、何カ所かアニメが可能です

大きな目的が3つあり、
 - きれいにするにはでかくなる
 - 作るのが大変だし覚えるのも大変
 - 仕様が不明

上記を解決したいと思って今回のプロトタイプを作成しています
（実装のjsも以下に置きますが、主体はJSONとテクスチャファイルです）

### きれいにするにはでかくなる

何がでかくなるのかというと、ファイルサイズとレンダリングコストです
lo-fi girlを実装しよう考えたところ、ループアニメ専用の形式がないように思いました
APNGやGIFということも考えられますが、今回のデモでも同じ大きさの10枚のPNGを用意して、圧縮する必要があります
また次の指摘にも関わりますが、一つの動画ファイルにするとインタラクティブな要素が埋め込めないという大きな課題があります
スプライトアニメ自体は古くからあるアプローチですが課題の解決にはいいのかなと考えています
圧縮時に画質が落ちてしまうのは動画では避けられない課題です

### 作るのが大変だし覚えるのも大変

動画作成やアバター制作においてツールの学習コストが高い
ミドルウェアとしてファットにならないことはコンテンツ制作者に有利なのではないでしょうか

### 仕様が不明

仕様は明らかであるべきです
FlashでもMMDもブラックボックスなところが不利に働いた歴史を歩いてきました
少なくともインディーズでは誰もがさわれる形のほうが良いのではないでしょうか

## 以下ChatGPTが書いてくれたドキュメント

### **JSONデータのドキュメント**
このドキュメントでは、**`room_texture.model.json`** のデータ構造とその使用方法について説明します。

---

#### **1. JSONデータの概要**
このJSONファイルは、2Dグラフィックのレイヤー管理とスプライトアニメーションの設定を行うためのものです。

- **テクスチャ（`Texture`）**
  - 静的な画像をキャンバス上に描画するためのデータ。
- **スプライト（`Sprite`）**
  - アニメーションを持つレイヤーで、フレームごとに異なる画像を切り替えて再生。
- **テクスチャ情報**
  - 使用するテクスチャファイルの情報。
- **スプライトアニメーション設定**
  - フレームレートやループ設定を含む。

---

#### **2. JSONの構造**
```json
{
    "canvasWidth": 4096,
    "canvasHeight": 4096,
    "layerCount": 2,
    "baseCanvasWidth": 1920,
    "baseCanvasHeight": 1080,
    "TextureNum": [
        {
            "imageCount": 1,
            "texImg": ["texture_room_4096.png"]
        }
    ],
    "layers": [...],
    "sprites": [...]
}
```

---

#### **3. 各フィールドの詳細**

##### **(1) `canvasWidth` / `canvasHeight`**
```json
"canvasWidth": 4096,
"canvasHeight": 4096,
```
- **全体のキャンバスサイズを指定**
- **この値は通常の描画領域とは異なり、オリジナルのテクスチャサイズ**
- **実際の描画には `baseCanvasWidth` / `baseCanvasHeight` を使用する**

##### **(2) `baseCanvasWidth` / `baseCanvasHeight`**
```json
"baseCanvasWidth": 1920,
"baseCanvasHeight": 1080,
```
- **1枚目のJSONのキャンバスサイズとして適用**
- **2枚目以降のJSONは、`baseCanvasWidth` に合わせて等比縮小される**

##### **(3) `TextureNum`（テクスチャ情報）**
```json
"TextureNum": [
    {
        "imageCount": 1,
        "texImg": ["texture_room_4096.png"]
    }
]
```
- **`texImg` にテクスチャの画像ファイル名を指定**
- **複数のテクスチャがある場合は `imageCount` で管理可能**

##### **(4) `layers`（レイヤー情報）**
各レイヤーごとに、画像の配置や種類を指定。

```json
"layers": [
    {
        "imgType": "Texture",
        "assignID": "layer_1",
        "animID": "",
        "x": 0, "y": 0, "width": 1920, "height": 1080,
        "basePosition_x": 0, "basePosition_y": 0,
        "textureZIndex": 0
    },
    {
        "imgType": "Sprite",
        "assignID": "s_01",
        "animID": "anim01",
        "x": 0, "y": 1080, "width": 408, "height": 336,
        "basePosition_x": 415, "basePosition_y": 563,
        "textureZIndex": 1
    }
]
```

**各フィールドの詳細**
| フィールド名 | 説明 |
|-------------|------|
| `imgType` | `"Texture"`（静的画像）または `"Sprite"`（アニメーション） |
| `assignID` | 一意のID（スプライトアニメーションでも使用） |
| `animID` | スプライトがアニメーションする場合のアニメーションID |
| `x`, `y` | **テクスチャ内の座標**（この範囲から画像を切り抜く） |
| `width`, `height` | **切り抜く範囲のサイズ** |
| `basePosition_x`, `basePosition_y` | **キャンバス上での描画位置** |
| `textureZIndex` | **描画順序（大きいほど手前に表示）** |

---

##### **(5) `sprites`（スプライトアニメーションの設定）**
```json
"sprites": [
    {
        "anim01": [
            {
                "fps": 8,
                "loop": 1,
                "useTex": [
                    "s_01", "s_02", "s_03", "s_04",
                    "s_05", "s_06", "s_07", "s_08",
                    "s_09", "s_10"
                ]
            }
        ]
    }
]
```

**各フィールドの詳細**
| フィールド名 | 説明 |
|-------------|------|
| `animID` | スプライトのアニメーションID（`anim01`） |
| `fps` | 1秒間に何回フレームを更新するか |
| `loop` | `1` → ループ、`0` → 1回のみ再生 |
| `useTex` | `assignID` を指定し、アニメーションする画像の順番を決定 |

---

#### **4. 使用方法**
##### **(1) JSONを読み込む**
```javascript
fetch('room_texture.model.json')
    .then(response => response.json())
    .then(jsonData => addJsonToCanvas(jsonData));
```

##### **(2) キャンバスに描画**
```javascript
function renderCanvas() {
    const canvas = document.getElementById('textureCanvas');
    const ctx = canvas.getContext('2d');
    
    jsonData.layers.forEach(layer => {
        const textureImage = new Image();
        textureImage.src = "texture_room_4096.png";

        textureImage.onload = () => {
            ctx.drawImage(
                textureImage,
                layer.x, layer.y, layer.width, layer.height,
                layer.basePosition_x, layer.basePosition_y, layer.width, layer.height
            );
        };
    });
}
```

##### **(3) スプライトアニメーション**
```javascript
function startSpriteAnimation(ctx, jsonData, layer, textureImage) {
    const spriteConfig = jsonData.sprites[0][layer.animID][0];

    function animateSprite() {
        const frameIndex = animationFrames[layer.assignID] % spriteConfig.useTex.length;
        const frameAssignID = spriteConfig.useTex[frameIndex];
        const frameLayer = jsonData.layers.find(l => l.assignID === frameAssignID);

        if (frameLayer) {
            ctx.drawImage(
                textureImage,
                frameLayer.x, frameLayer.y, frameLayer.width, frameLayer.height,
                frameLayer.basePosition_x, frameLayer.basePosition_y, frameLayer.width, frameLayer.height
            );
        }

        animationFrames[layer.assignID]++;
        if (spriteConfig.loop === 1) {
            setTimeout(animateSprite, 1000 / spriteConfig.fps);
        }
    }

    animateSprite();
}
```

---

#### **5. まとめ**
- **テクスチャ情報 (`TextureNum`) を指定**
- **`layers` にレイヤー情報を設定**
  - `"Texture"` は静的画像
  - `"Sprite"` はアニメーション用
- **`sprites` でスプライトアニメーションを管理**
  - `fps` や `loop` で動作を制御
  - `useTex` でフレームを指定

---

このドキュメントを参照し、JSONデータの適切な利用を行ってください！ 🚀

## 次回

もう少しJSONの構造を考えるのと、プレイヤー部分を何とかしたいところ

今回の部屋の素材はニコニコモンズからおかりました
https://commons.nicovideo.jp/works/agreement/nc225712
南部休みさんありがとうございます

おまけ

<details><summary>jsのサンプル</summary>

```js
document.getElementById('loadJsonButton').addEventListener('click', () => {
    const fileInput = document.getElementById('jsonFileInput');
    const file = fileInput.files[0];

    if (!file) {
        alert('Please select a JSON file.');
        return;
    }

    const reader = new FileReader();
    reader.onload = function (event) {
        const jsonData = JSON.parse(event.target.result);
        addJsonToCanvas(jsonData);
    };
    reader.readAsText(file);
});

document.getElementById('resetCanvasButton').addEventListener('click', resetCanvas);

let jsonDataList = [];
let baseCanvasSizeSet = false;
let secondLayerOffset = { x: 0, y: 0 };
let secondLayerCanvas = null;
let secondLayerScale = 1;
let animationFrames = {};
let activeAnimations = {};

// スライダーイベント設定
document.getElementById('sliderX').addEventListener('input', (event) => {
    secondLayerOffset.x = parseInt(event.target.value);
    requestRender();
});
document.getElementById('sliderY').addEventListener('input', (event) => {
    secondLayerOffset.y = parseInt(event.target.value);
    requestRender();
});

let renderRequested = false;

function requestRender() {
    if (!renderRequested) {
        renderRequested = true;
        setTimeout(() => {
            renderCanvas();
            renderRequested = false;
        }, 50);
    }
}

function addJsonToCanvas(jsonData) {
    jsonDataList.push(jsonData);

    if (!baseCanvasSizeSet) {
        // 1 枚目の JSON は即時描画
        document.getElementById('textureCanvas').width = jsonData.baseCanvasWidth;
        document.getElementById('textureCanvas').height = jsonData.baseCanvasHeight;
        baseCanvasSizeSet = true;
        renderCanvas();
    } else {
        // 2 枚目の JSON はオフスクリーンキャンバスで描画して縮小
        secondLayerCanvas = document.createElement('canvas');
        secondLayerCanvas.width = jsonData.baseCanvasWidth;
        secondLayerCanvas.height = jsonData.baseCanvasHeight;
        const secondLayerCtx = secondLayerCanvas.getContext('2d');

        const textureInfo = jsonData.TextureNum[0];
        const textureImage = new Image();
        textureImage.src = textureInfo.texImg[0];

        textureImage.onload = () => {
            jsonData.layers.forEach(layer => {
                secondLayerCtx.drawImage(
                    textureImage,
                    layer.x, layer.y, layer.width, layer.height,
                    layer.basePosition_x, layer.basePosition_y, layer.width, layer.height
                );
            });

            let scaleW = document.getElementById('textureCanvas').width / secondLayerCanvas.width;
            let scaleH = document.getElementById('textureCanvas').height / secondLayerCanvas.height;
            secondLayerScale = Math.min(scaleW, scaleH);

            requestRender();
        };
    }
}

function renderCanvas() {
    const canvas = document.getElementById('textureCanvas');
    const ctx = canvas.getContext('2d');
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    jsonDataList.forEach((jsonData, index) => {
        const textureInfo = jsonData.TextureNum[0];
        const textureImage = new Image();
        textureImage.src = textureInfo.texImg[0];

        textureImage.onload = () => {
            const sortedLayers = jsonData.layers.sort((a, b) => a.textureZIndex - b.textureZIndex);
            sortedLayers.forEach(layer => {
                if (index === 0) {
                    // 1 枚目の JSON のレイヤーを即時描画
                    ctx.drawImage(
                        textureImage,
                        layer.x, layer.y, layer.width, layer.height,
                        layer.basePosition_x, layer.basePosition_y, layer.width, layer.height
                    );
                } else if (index === 1 && secondLayerCanvas) {
                    // 2 枚目の JSON は縮小して描画
                    ctx.drawImage(
                        secondLayerCanvas,
                        0, 0, secondLayerCanvas.width, secondLayerCanvas.height,
                        secondLayerOffset.x, secondLayerOffset.y,
                        secondLayerCanvas.width * secondLayerScale, secondLayerCanvas.height * secondLayerScale
                    );
                }
            });

            // スプライトアニメーションを開始
            jsonData.layers.forEach(layer => {
                if (layer.imgType === "Sprite") {
                    startSpriteAnimation(ctx, jsonData, layer, textureImage);
                }
            });
        };

        textureImage.onerror = () => {
            console.error("Failed to load texture image:", textureImage.src);
        };
    });
}

function startSpriteAnimation(ctx, jsonData, layer, textureImage) {
    const spriteConfig = jsonData.sprites[0][layer.animID][0];
    if (!spriteConfig) return;

    animationFrames[layer.assignID] = 0;
    activeAnimations[layer.assignID] = true;

    function animateSprite() {
        if (!activeAnimations[layer.assignID]) return;

        const frameIndex = animationFrames[layer.assignID] % spriteConfig.useTex.length;
        const frameAssignID = spriteConfig.useTex[frameIndex];
        const frameLayer = jsonData.layers.find(l => l.assignID === frameAssignID);

        if (frameLayer) {
            ctx.clearRect(frameLayer.basePosition_x, frameLayer.basePosition_y, frameLayer.width, frameLayer.height);
            ctx.drawImage(
                textureImage,
                frameLayer.x, frameLayer.y, frameLayer.width, frameLayer.height,
                frameLayer.basePosition_x, frameLayer.basePosition_y, frameLayer.width, frameLayer.height
            );
        }

        document.getElementById('updateLog').textContent = `Animating ${layer.assignID}: Frame ${frameIndex + 1}`;

        animationFrames[layer.assignID]++;
        if (spriteConfig.loop === 1 || animationFrames[layer.assignID] < spriteConfig.useTex.length) {
            setTimeout(animateSprite, 1000 / spriteConfig.fps);
        } else {
            activeAnimations[layer.assignID] = false;
        }
    }

    animateSprite();
}

function resetCanvas() {
    jsonDataList = [];
    baseCanvasSizeSet = false;
    secondLayerOffset = { x: 0, y: 0 };
    secondLayerCanvas = null;
    secondLayerScale = 1;
    animationFrames = {};
    activeAnimations = {};
    document.getElementById('textureCanvas').width = 0;
    document.getElementById('textureCanvas').height = 0;
    document.getElementById('sliderX').value = 0;
    document.getElementById('sliderY').value = 0;
    renderCanvas();
}

```

</details>

jsonは二つ目のロードもできますが、canvasだとぶっ壊れてしまうので考えものです

<details><summary>2枚目のロード</summary>

[![Image from Gyazo](https://i.gyazo.com/d48f47369b8ade40f677e8eb0548894b.gif)](https://gyazo.com/d48f47369b8ade40f677e8eb0548894b)

</details>