2
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?

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

Last updated at Posted at 2025-01-31

前回の続き

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

前回の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
    • ループ
    • 使用するテクスチャを配列で指定
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"
                    ]
                }

            ]
        }
    ]
}

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

Image from Gyazo

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

Image from Gyazo

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

大きな目的が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の構造

{
    "canvasWidth": 4096,
    "canvasHeight": 4096,
    "layerCount": 2,
    "baseCanvasWidth": 1920,
    "baseCanvasHeight": 1080,
    "TextureNum": [
        {
            "imageCount": 1,
            "texImg": ["texture_room_4096.png"]
        }
    ],
    "layers": [...],
    "sprites": [...]
}

3. 各フィールドの詳細

(1) canvasWidth / canvasHeight
"canvasWidth": 4096,
"canvasHeight": 4096,
  • 全体のキャンバスサイズを指定
  • この値は通常の描画領域とは異なり、オリジナルのテクスチャサイズ
  • 実際の描画には baseCanvasWidth / baseCanvasHeight を使用する
(2) baseCanvasWidth / baseCanvasHeight
"baseCanvasWidth": 1920,
"baseCanvasHeight": 1080,
  • 1枚目のJSONのキャンバスサイズとして適用
  • 2枚目以降のJSONは、baseCanvasWidth に合わせて等比縮小される
(3) TextureNum(テクスチャ情報)
"TextureNum": [
    {
        "imageCount": 1,
        "texImg": ["texture_room_4096.png"]
    }
]
  • texImg にテクスチャの画像ファイル名を指定
  • 複数のテクスチャがある場合は imageCount で管理可能
(4) layers(レイヤー情報)

各レイヤーごとに、画像の配置や種類を指定。

"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(スプライトアニメーションの設定)
"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を読み込む
fetch('room_texture.model.json')
    .then(response => response.json())
    .then(jsonData => addJsonToCanvas(jsonData));
(2) キャンバスに描画
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) スプライトアニメーション
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 でスプライトアニメーションを管理
    • fpsloop で動作を制御
    • useTex でフレームを指定

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

次回

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

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

おまけ

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();
}

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

2枚目のロード

Image from Gyazo

2
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
2
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?