LoginSignup
4
1

More than 1 year has passed since last update.

PNG(8ビットカラーパレット形式)を使ってJavaScriptでパレットアニメーションさせてみた

Last updated at Posted at 2022-02-22

はじめに

昔、絵本型のカートリッジの幼児向けゲームを作るお仕事をしているときに、パレットを操作してアニメーションなんかをさせてました。
主に点滅させたり、フェードアウトさせたりなんですが・・・
それをWebでできないかなー?あわよくばゲームで使えないかなー?というのがきっかけです。

とりあえず動画のようにパレットの操作ができたので書いておきます。

やってることの概要

やってることはそんなに難しくありませんが、ちょっと面倒です。

  1. PNGをバイナリとして読み込み保持します。
  2. PNGが8bitカラーでかつパレットを持っているか確認します
  3. 読み込んだPNGのパレットを書き換えて、dataURLに変換します。
  4. ImageオブジェクトにdataURLをセットします。
  5. その結果をCanvasに描画します

要はPNGを書き換えてるだけです。

詳細

3つに分けて解説します。

  1. PNGの読み込み
  2. パレットの操作と描画
  3. パレットアニメーションさせる

1.PNG画像の読み込み

まずはPNGのフォーマットがどうなっているかがわからないといけなので、PNG ファイルフォーマットを参考に必要な部分だけ確認。
今回はPNG ファイルシグネチャとIDHRチャンク、PLTEチャンクまでわかればOKということが分かった。

という訳でPNGを読み込みます。
読み込んだPNGファイルはUint8Array型にします。

    let response = await fetch(url);
    if(response.ok){
        let data = await response.arrayBuffer();
        var buff = new Uint8Array(data);

読み込んだら、PNGファイルシグネチャと一致するかを確認します。

// PNGファイルシグネチャ
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
・・・中略・・・

        let chkData = buff.slice(0, 8);
        if(JSON.stringify(PNG_SIGNATURE) != JSON.stringify(chkData)){
            console.log("[" + url + "] : not png");
            return;
        }

読み込んだバッファから8バイト取り出し、あらかじめ宣言しておいた定数と比較。
ほかに良い方法があるのかもしれませんが、とりあえず簡単に比較するためにJSONに変換して比較してます。
当然ですが、一致しない場合はPNGファイルとはみなしません。

次にビット深度とカラータイプをチェックします。
ビット深度が8なら8ビットカラー、256色ですね。
カラータイプが3ならパレットを使っています。
24バイト目がビット深度、25バイト目がカラータイプになります。
これらも用意した定数と比較します。

/** ビット深度とカラータイプ */
const IMAGE_FORMAT = new Uint8Array([0x08, 0x03]);
/** ビット深度の位置 */
const BIT_DEPTH_POS = 24;

・・・中略・・・

        let fmtData = buff.slice(BIT_DEPTH_POS, BIT_DEPTH_POS + 2);
        if(JSON.stringify(IMAGE_FORMAT) != JSON.stringify(fmtData)){
            console.log("[" + url + "] : not support format");
            return;
        }

一致しない場合は、対応するPNGの形式ではないと判断します。

8ビットカラーでパレットがあることはわかったので、PLETチャンクの長さを取得します。
8ビットカラーなので長さも決まっているので、定数との比較になります。

/** PLTEチャンク のサイズ */
const PLTE_SIZE = new Uint8Array([0x00, 0x00, 0x03, 0x00]);
/** パレットデータの長さの位置 */
const PALETTE_DATA_LENGTH_POS = 33;
・・・中略・・・
        let lenData = buff.slice(PALETTE_DATA_LENGTH_POS, PALETTE_DATA_LENGTH_POS + 4);
        if(JSON.stringify(PLTE_SIZE) != JSON.stringify(lenData)){
            console.log("[" + url + "] : Palette size faild");
            return;
        }

ここでも一致しない場合は対応するデータではないと判断します。

次にChunk Typeを読み込んで定数と比較します。

/** PLTEチャンク名 */
const PLTE_CHUNKTYPE = new Uint8Array([0x50, 0x4c, 0x54, 0x45]);
/** パレットのChunk Typeの位置 */
const PALETTE_CHUNK_TYPE_POS = 37;
・・・中略・・・
        let chunkTypePLT = buff.slice(PALETTE_CHUNK_TYPE_POS, PALETTE_CHUNK_TYPE_POS + 4);
        if(JSON.stringify(PLTE_CHUNKTYPE) != JSON.stringify(chunkTypePLT)){
            console.log("[" + url + "] : Palette data not found");
            return;
        }

ここでも一致しない場合は対応するデータではないと判断します。
ここでChunk Typeも確認できたので、PLETチャンク確定とみなし、PLET Chunkを取得します。

/** PLTEのサイズ */
const PLT_LENGTH = 0x0300;
/** パレットのChunk Typeの位置 */
const PALETTE_CHUNK_TYPE_POS = 37;
・・・中略・・・
        var paletdata = buff.slice(PALETTE_CHUNK_TYPE_POS, PALETTE_CHUNK_TYPE_POS + 4 + PLT_LENGTH);

4を足しているのはCRCも含んでいるためです。
4の位置がよくないのと、定数にしてわかりやすくしたほうがいいですね^^;

ここまで終わったら、読み込んだデータを保持します。

            let binStr = Array.from(buff, e => String.fromCharCode(e)).join("");
            let dataUrl = "data:image/png;base64,"+btoa(binStr);
            let img = new Image();
            img.src = dataUrl;
            imgMap[name] = {
                "buff":buff,
                "image" : img,
                "palette" : paletdata
            };

Imageオブジェクトを作成し、読み込んだPNGのデータをdataURLにして設定します。
そして、PNGのバイトデータ、Imageオブジェクト、パレットを保持します。
このデータ構成はいまだに悩み中。
名前付きで保持しているは、複数のPNGを扱うことを想定している為です。

2.パレットの操作と描画

PNGの描画は以下のようなメソッドを用意しています。
簡単に説明すると、Canvasを白で塗りつぶして、imgIdxの示すキーを使って保持しているPNGのImageオブジェクトをCanvasに描画しています。

function viewImage(){
    let canvas = document.getElementById('canvas');
    let ctx = canvas.getContext('2d');
    ctx.mozImageSmoothingEnabled = false;
    ctx.webkitImageSmoothingEnabled = false;
    ctx.msImageSmoothingEnabled = false;
    ctx.imageSmoothingEnabled = false;
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
    let keys = Object.keys(imgMap);
    let img = imgMap[keys[imgIdx]].image;
    ctx.drawImage(img, 0, 0, img.width * 4, img.height * 4);
}

さて、本題のパレット操作です。

パレット操作を行うメソッドは以下のものになります。

/**
 * パレットの更新
 * @param image Imageオブジェクト
 * @param buff 画像データ配列
 * @param palette パレットのChunk TypeとChunk Data
 */
async function updatePalette(image, buff, palette){
    // CRC32を計算
    let crc = calcCRC32(palette);

    // バッファにパレットを適用
    buff.set(palette, PALETTE_CHUNK_TYPE_POS);
    //CRC32を適用
    buff.set(crc, PALETTE_DATA_POS + PLT_LENGTH);

    // バッファをバイナリ文字列に変換
    let binStr = Array.from(buff, e => String.fromCharCode(e)).join("");

    // dataURLの生成
    let dataUrl = "data:image/png;base64,"+btoa(binStr);

    // Promiseでやる必要性があるかどうかはわからないけど
    // 試したいことがあったのでこんなことしてる。
    let imgPromise = new Promise((resolve, reject) =>{
        image.onload = () => {
            resolve();
        }
        /*
        // 遅延ローディングはしない(意味はあまりない)
        image.loading = 'eager';
        // 同期的な読み込み(意味はあまりない)
        image.decoding = 'sync';
        */
        image.src = dataUrl;
    }).then(() => {
        viewImage();
    });
}

CRCも求めているのでそのメソッドも書いておきます。

function calcCRC32(buff){
    let crcTarget = Array.from(buff, e => String.fromCharCode(e)).join("");
        var crc = CRC32.bstr(crcTarget) >>> 0;
        var crcBuff = [
            (crc & 0xff000000) >>> (8 * 3),
            (crc & 0x00ff0000) >>> (8 * 2),
            (crc & 0x0000ff00) >>> (8 * 1),
            (crc & 0x000000ff)
        ];
    return new Uint8Array(crcBuff);
}

CRCはcrc-32を使っています。
jsファイルCDNで定義されたものを利用しています。

<script src="https://cdnjs.cloudflare.com/ajax/libs/crc-32/1.2.1/crc32.min.js"></script>

さて、パレットを操作するメソッドupdatePalette()の引数ですが、Imageオブジェクトと、パレットを適用するPNGのバイト配列、そして操作するPLETチャンクのバイト配列になります。

引数のパレットのCRCを求める必要があるので、calcCRC32()メソッドで求めておきます。
その後、PNGのバイト配列に、パレットとCRCを適用します。

    // CRC32を計算
    let crc = calcCRC32(palette);
    // バッファにパレットを適用
    buff.set(palette, PALETTE_CHUNK_TYPE_POS);
    //CRC32を適用
    buff.set(crc, PALETTE_DATA_POS + PLT_LENGTH);

パレット適用はこれで終わりです。

あとは、適用したPNGのバイト配列をImageオブジェクトに適用しCanvasに書き込めば終わりです。

    // バッファをバイナリ文字列に変換
    let binStr = Array.from(buff, e => String.fromCharCode(e)).join("");

    // dataURLの生成
    let dataUrl = "data:image/png;base64,"+btoa(binStr);

    // Promiseでやる必要性があるかどうかはわからないけど
    // 試したいことがあったのでこんなことしてる。
    let imgPromise = new Promise((resolve, reject) =>{
        image.onload = () => {
            resolve();
        }
        /*
        // 遅延ローディングはしない(意味はあまりない)
        image.loading = 'eager';
        // 同期的な読み込み(意味はあまりない)
        image.decoding = 'sync';
        */
        image.src = dataUrl;
    }).then(() => {
        viewImage();
    });

ここでPromiseを使っているのですが、ImageオブジェクトのsrcにdataURLを指定して、ロードが終わるまで非同期なんです。
Promiseを使ったらどうにかならないか?と苦戦した残骸になります。
この辺の知識が浅すぎるので何とかしたい!
なので、現状ではPromise使わなくても純粋にImageオブジェクトのonLoadイベントで、Canvasに描画するメソッドviewImage()を呼び出すだけでいいはずです。

不十分なところはありますが。、これでパレットアニメーションさせる準備が整いました。

3.パレットアニメーションさせる

今回操作する画像はこちらです。
image.png

ちなみに、TAKABO SOFTさんのEDGEというドット絵を描くツールで作っています。
20年くらい使っています。

アニメーションは以下のメソッドで行います。

var anicnt = 0;
function animation(){
    try{
        let keys = Object.keys(imgMap);
        let image = imgMap[keys[imgIdx]].image;
        let buff = imgMap[keys[imgIdx]].buff;
        let palette = imgMap[keys[imgIdx]].palette;

        // パレットアニメーション用のパレット情報
        let animePlt = [
            new Uint8Array([
                0, 0, 225,
                72, 72, 225,
                103, 103, 225,
                162, 162, 225,
            ]),
            new Uint8Array([
                162, 162, 225,
                0, 0, 225,
                72, 72, 225,
                103, 103, 225,
            ]),
            new Uint8Array([
                103, 103, 225,
                162, 162, 225,
                0, 0, 225,
                72, 72, 225,
            ]),
            new Uint8Array([
                72, 72, 225,
                103, 103, 225,
                162, 162, 225,
                0, 0, 225,
            ]),
        ];

        let idx =  Math.floor(anicnt / 20);
        palette.set(animePlt[idx], 10 * 3 + 4);
        anicnt++;
        if(anicnt >= 20 * 4){
            anicnt = 0;
        }
        updatePalette(image, buff, palette);
    }catch(e){
        console.log(e);
    }
    window.requestAnimationFrame(animation);
}

まず最初に、必要なPNGの情報を取得します。

        let keys = Object.keys(imgMap);
        let image = imgMap[keys[imgIdx]].image;
        let buff = imgMap[keys[imgIdx]].buff;
        let palette = imgMap[keys[imgIdx]].palette;

次に更新するパレットの情報を定義しています。

        let animePlt = [
            new Uint8Array([
                0, 0, 225,
                72, 72, 225,
                103, 103, 225,
                162, 162, 225,
            ]),
            new Uint8Array([
                162, 162, 225,
                0, 0, 225,
                72, 72, 225,
                103, 103, 225,
            ]),
            new Uint8Array([
                103, 103, 225,
                162, 162, 225,
                0, 0, 225,
                72, 72, 225,
            ]),
            new Uint8Array([
                72, 72, 225,
                103, 103, 225,
                162, 162, 225,
                0, 0, 225,
            ]),
        ];

この情報はパレットの10番目の色から4色分の情報になります。
具体的には赤枠で囲った部分を今回描き替えます
image.png

これを一定のタイミングでパレットに適用します。

パレットの変更は以下のコードです。
カウンタから適用するパレットを求め、そのパレットを適用しています。
描き替えはUint8Arrayのsetメソッドで行います。
10 * 3+4の3は3バイトを表し、3バイトはRGBを意味します。10は10パレット目になります。
4はChunk TypeのPLETのサイズになります。

        let idx =  Math.floor(anicnt / 20);
        palette.set(animePlt[idx], 10 * 3 + 4);
        anicnt++;
        if(anicnt >= 20 * 4){
            anicnt = 0;
        }
        updatePalette(image, buff, palette);

メソッドはwindow.requestAnimationFrame()メソッドで一定間隔で呼び出すようにしています。

window.requestAnimationFrame(animation);

これで、パレットを書き換えてアニメーションさせています。

最後に

ゲームなんかで使う場合はさらに工夫が必要になると思います。
例えば、描画用のPNGを用意して、そこにいろいろ書き込んで最後にパレットを適用して、最後にCanvasに描画することで無駄を省くなどです。
あと、どうしてもImageオブジェクトに適用する際にコストがかかるので、ここも問題になるかもしれません。
アドベンチャーやRPGならいいかもしれませんが、アクションやシューティングには向かないかもしれません。
とはいえ、自力で何とかするというロマンがあるので、作成中のRPGで使いたいと考えています。
(そして、RPGの進捗は牛歩のごとく遅いです・・・)

ソース

  • GitHubからダウンロードできます

参考

4
1
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
4
1