わくわくアバター作成日記リターンズ

ひさびさに開発っぽくない最近のあれこれをばをば。

今年の2~3月ごろに、わたしの3Dモデルが欲しいなとふと思い、アバター作成をしていた時期がありました。

garakuta-toolbox.hatenablog.com garakuta-toolbox.hatenablog.com

f:id:piyorinpa:20190204232600p:plain
こういうやつ

一応それっぽいアバターは出来たのですが、以下のような気に入らなさポイントがありました。

  • 等身が思っていたより小さい...
    • 画面上で見ると「おお~いいじゃん」感を(少なくとも私は)感じていたのですが、ヘッドマウントディスプレイをつけてVR空間で見ると、明らかに頭が大きく感じる
    • まるで着ぐるみを着ているかのよう...
  • 一部のディティールがふわふわしている
    • 手とか、足とか、あごまわりとか
  • 服を体が貫通しちゃう問題
    • 鬼の調整業によってだいぶマシにはなったものの、どうしてもポーズによっては皮膚がはみ出ちゃう

特に、等身が気に入らないのは致命的です。第一印象が完全に「着ぐるみ」になってしまうので...。

敗因はおそらく、キャラクターの設定不足なのだろうなという反省をしました。当時はとにかくさっさと3Dモデルづくりを試してみたかったということで、半ば見切り発車的にモデルを作っていましたが、これが失敗の原因だと感じています。

最近「やっぱり自身がウェブ上に存在するなら3Dモデルはほしいよな~」という気持ちに再びなったので、今度はキャラクターをある程度詰めてモデリングに挑もう、ということで、わくわくアバター作成日記を再開したいなと思っています。

f:id:piyorinpa:20191026181951p:plain
靴はまだ履いていただいていません...

蛇足ですが、いままでは一般的なお絵かきソフトと慣れないペンタブをつかって設定画を描いていましたが、私の絵心ではどうもうまくいかないようなので、ドロー系ソフト(ベクタ画像をつくるソフト)でお絵かきしてみた結果が上のものです。少なくとも今のところは、「はじめからこれをつかっておけばよかった...」という程度には作業しやすいです。線の微修正なども、ベジェ曲線のハンドルをちょっといじるだけで簡単にできたりします。

(ちなみにわたしは Inkscape というドローソフトを好んで使っています)

inkscape.org

ではでは~。ほかのことをしながらゆっくり進めるので、3Dアバターになるのは年末ごろになるかも....。

「みんなでつくるダンジョン」開発中のチャット機能をおためししてみたよ

チャットをお試ししてみたよ!

先日、開発中の「みんなでつくるダンジョン」のチャット機能を時間限定で公開してみました。(チャット機能の概要については 前回の記事 をご覧ください)

以下のようなツイートをして、わたしがマップに待機していたところ、数名の方に来ていただいたのでした。(既にチャット機能は無効になっているので悪しからず。)

チャットな様子はこんなかんじ。ふきだしは5秒ほどで消えてしまうために、他の方とおしゃべりしている様子なスクリーンショットを撮るのが大変で、わたしがしゃべっている様子しか撮れなかった...。

f:id:piyorinpa:20191014223724p:plain
おはなししている様子

数名の方に来ていただいて、以下のような課題が発見できました。

  • ふきだしが数秒で消えてしまうので、ちょっと目をそらすと話の内容が分からなくなる(ログが必要そう)
  • アバターの大きさが人それぞれなので、マップの作りによっては他の人が通れない場所が出そう
    • 今回のマップは高さ64pxなアバターを想定していたのですが、それ以上の大きさなアバターの方が部屋から出られなくて、なるほど~という気持ちになったのでした。

ちなみに、スマートフォンでもおしゃべりできるように、以下のようなUIを調整中です。(スマホは画面の大きさが限られるので、毎度悩まされる..。)

f:id:piyorinpa:20191014225319g:plain
スマホ版のチャットはこんなかんじ

今後の予定

今後は以下のような調整をしていこうかな~と思っています。

絵文字をかんたんにつぶやけるようにしたい

なるべく「会話しなければならない」みたいなプレッシャーを感じてほしくないので、絵文字をかんたんにつぶやけるといいのかな?と思っています。私自身、最近でこそ慣れてきたものの、インターネット越しのコミュニケーションに苦手意識がある時期もあったので、そういう方にも気軽に入っていただけるような仕組みを取り入れたいのでした。(あと、絵文字はかわいいので🐤🌳🍣...というのもある。)

チャット機能をどのように提供するか

いつでも、どのマップでもチャットっぽいことができるのが理想かもしれませんが、サーバのリソースや管理まわり的になかなか難しそうです。現状はとりあえずつくってみた、という感じですが、どのように提供していくかを考えねば~という状況なのです。また、マップの作者が「チャット機能を提供したくない」という状況も考えられそうなので、その点も考慮したいなと思っています。

今後も不定期にテスト公開をしたい

今後もツイートを通じた告知で、こんなかんじのテスト公開をしたいなと考えています。事前告知するかどうかはわかりませんが、開催の際にはTwitterにてつぶやくので、興味がある方はアカウント登録をしてツイートをたまにちらちらとチェックしていただけるとです。

ではでは~!

マップにいるほかのユーザーとおはなしできるようにしてみている

きょうもきょうとて作業報告をばをば。前回記事 にひきつづき、マップでわいわいできる機能を引き続き作っています。

ただいま「おなじマップにいる他のユーザーとおはなしできる」機能をつくってみています。文章で説明するよりも動画をご覧いただいたほうが説明がはやいので、以下の動画をみてみてください。

いわゆるチャット機能ですね。チャット機能は実装するか否かだいぶなやみました。みんなかならずしも平和にチャットしてくれるとは限らないので、そういう点での管理コストとかどうなんだろう~とか、そもそもチャット機能を嫌う人もいるかな~とか、いろいろ思うところがあったのです。

ただ、技術的に機能の実装を試したかったことや、いろいろなマップを渡り歩けるというサービスの特性から、ほかのひととコミュニケーションがとれてもおもしろいかも、と思ったことなどなどから、ひとまず実装してみることにしました。

はじめはとあるマップ限定で、かつ時間も限定して私がマップで待機しながら様子をみて動かしてみる...ということを本番環境下でお試しできるようにすることを目標に開発を続けています。将来的には、マップの作者が自身のマップ上でのチャット機能のON/OFFを設定できるようにしたいな~と思っています。

ではでは~

ActionCableをつかって複数のひとが集まれるマップをつくっている

こんにちは。みんなでつくるダンジョンに複数のひとが集まれる仕組みがほしいな~とずっと思っていました。というわけで、前回記事の下準備 を経て実装してみています。

こんなかんじのことがやりたい

やりたいことはこんなかんじ。ほかのユーザーをじぶんの画面にも表示できるようにします。おおよそ0.5秒ごとに座標を送るようにしてみたのですが、予想どおり結構カクカクになる...。しかも、たまにデータが詰まったみたいな挙動をしていて、しばらくアバターの位置が反映されないことがありました。

(どうでもいいけど、連動しているよ感がある動画を撮るにはどうしたらいいのだろう~という気持ちになり、結果としてカメラ片手に動画を撮ったのですが、三脚とか持っていないのでなかなか大変だったのでした...)

ちょっとよくなった

動いているアバターは、別ブラウザから操作しています。座標を送る間隔を1.5秒間隔にしつつ、0.2秒間隔でサンプリングしたアバターの位置リストを渡すようにし、かつアバターをばねっぽく移動させるようになどしてみた結果、このくらいまではカクつきが改善されました。

ActionCableはどんなかんじでつかったの?

基本的にはRailsガイドにぜんぶかいてある!というかんじです。

railsguides.jp

上記記事では、JavaScriptファイルをアセットパイプラインに乗せるようにしていますが、みんなでつくるダンジョンのフロントエンドまわりはRails管理下にはなく、Webpackをつかってビルドしています。なので、 こんなかんじにクライアント側ライブラリを別途インストールをします。

npm install --save @rails/actioncable

あとは記事の通りにJS側、Ruby側のソースコードを用意すればOKです。(記事ではRailsapp/javascripts 配下にJavaScriptを置いていますが、ここには置かずに別途Webpackでビルドするかんじ)

基本的に、同じマップにいるひとたち間でのやりとりができればよいので、以下を満たすようにします。

  • stage_?? というチャンネルを用意する(??はマップID)
  • 同じマップにいるひとたちは stage_?? というチャンネルでアバターの位置やアニメーション情報を共有しあう
  • 誰かが送信した情報はマップにいるひとたちすべてに送られる

サーバ側のソースコードは概ねこんなかんじ。 (ここには書いていませんが、 app/channels/application_cable/connection.rbRailsガイドを参考に別途実装します)

# /path/to/app/channels/avatar_channel.rb

class AvatarChannel < ApplicationCable::Channel
  def subscribed
    stream_from "stage_#{params[:stage_id]}"
  end

  def avatar_state(data)
    ActionCable.server.broadcast("stage_#{params[:stage_id]}", data)
  end
end

クライアント側がチャンネルを購読し始めたときに呼ばれる subscribed で 「stage_?? でデータのやり取りをしますよ~」という宣言をします。 また、クライアント側から avatar_state を呼び出したとき、 stage_?? を購読しているみんなにデータをばらまきます( ActionCable.server.broadcast をつかって )。

avatar_state とは私が勝手に定義したメソッドですが、特に難しいことを気にせずに、クライアント側から任意のメソッドを呼び出すように通信できるというのが便利ですね。

リクエストヘッダを送れなさそうですが、Cookieは送信されるので、認証情報などを送りたいときはCookieをつかうとよさそうです。

クライアント側のソースコードはだいたいこんなかんじ。先ほどサーバ側で定義した avatar_state へデータを送りつけたり、 broadcast されたデータを受け取ったりします。

// /path/to/javascript/avatar-socket.js

import { createConsumer } from "@rails/actioncable";

export default class AvatarSocket {
  connect(mapId) {
    const cookieVal = '認証情報のクッキー’;
    document.cookie = cookieVal;
    this._consumer = createConsumer('wss://example.com/cable');
    this._channel = this._consumer.subscriptions.create({ channel: 'AvatarChannel', map_id: mapId }, {
      received(data) {
        // broadcastされたデータを受け取る
        console.log(data);
      },
      sendAvatarState(data) {
        this.perform("avatar_state", data);
      }
    });
  }

  send(params) {
    this._channel.sendAvatarState({...params});
  }
}

アバターの情報はこんなかんじに送ります。

import AvatarSocket from "/path/to/javascript/avatar-socket.js";

// アバターの情報を送る
// positions はアバターの0.2秒毎の位置情報(時系列順)
this._avatarSocket.send({
  avatar_id: 1,
  positions: [
    [10, 10],
    [10, 20]
  ]
});

ハマりどころ

なぜかbroadcastがうまくいかない~となっていました。原因は以下の通りでした。

  • config.reload_classes_only_on_change = false が設定されており、変更が読み込まれずうまく動かなかった(っぽい?)
  • わたしの環境では config/cable.yml で設定する async adapter だとbroadcastしたデータをうけとれず
    • (いろいろガチャガチャといじっていたので、もしかしたら async adapter でもうまくいくのかもですが...)
    • asyncではプロセス間のpub/subができないようなので、ワーカーの数が2以上だとうまくいかないのかも?
    • 本番環境相当では async adapter は非推奨なので、 redis adapter を利用するようにしました

心配ごと

本番環境で動かしたときに負荷はどうなるんだろう...というのが結構気になっています。みんなでつくるダンジョンのアクセス数的には全然大丈夫だろう、という気持ちでつくってはいるけど、動かしてみないとわからないので心配...。

ActionCableサーバはRailsアプリケーションサーバと同一でなくても良いらしいですが、趣味開発なのでほどほどのお金しかかけられないことを考えると、はじめのほうは様子を見つつ、機能を制限しながらお試し運用する感じになりそうです。

まとめ

ひきつづきこんな感じの機能を実装しています。お楽しみに~

ではでは。

みんなでつくるダンジョンをRails5 -> Rails6にアップグレードしました

みんなでつくるダンジョンのアップグレード業をして、Rails5 -> Rails6 になりました。 使っている人からすると「なんのこっちゃ~」と思われるかもしれませんが、いろいろ開発に必要な足回りの環境が最新のものになったよ!ということです。

なんでアップグレードしたの?

みんなでつくるダンジョンにリアルタイムっぽい機能を付けたくなったのでした。たとえば、ログイン中のユーザーをマップに登場させたり、会話できるようにしたり...などなど。 みんなでつくるダンジョンで使わせていただいているRuby on Railsでは、バージョン5から「ActionCable」という、WebSocketを扱えるフルスタックなフレームワークをサポートしています。 しかしながら、デフォルトJavaScriptクライアントがCoffeeScriptなため、既にES201xなソースコードで開発している部分にどうやって組み込もうかな~などと考える必要がありました。

Rails6ではJavaScriptクライアントがnpmで取得できるようになり、かつES2015なソースコードになったということで、これをつかいたい~ということになりました。

railsguides.jp

アップグレードはどのように行ったの?

Railsガイド(日本語版)のアップグレードガイドの手順に従ってアップグレードしました。

railsguides.jp

まずは既に導入済みのGemがRails6対応しているかどうかを確認しつつ、古いパッケージを一つずつアップデートし、都度テストコードを走らせます。 (趣味開発ながら、一応きちんとテストコードを書いていたのでよかった~...)

つづいてGemfileを書き換えてRails6のバージョンを上げます。いくつかのテストが落ちるので、適切に修正していきます。 ちなみに、みんなでつくるダンジョンのRailsアプリケーションはほぼ単純なAPIサーバなこともあり、なにもせずともテストもだいぶ通過し、修正もさほど大変ではありませんでした。

あとは、テストを走らせるとDeprecatedなメソッドやモジュールの警告が出るので、適宜修正していきます(どのように修正すればよいかは警告文に表示されているのでありがたや~)。

はまったところ

ActiveStorage::Downloading がDeprecatedになったからなのか、明示的に require 'active_storage/downloading' しないと動かない箇所があったくらいだったと思います

まとめ

テストコードの存在や、こまめなGemのバージョンアップのおかげでさほど苦労せずにアップグレード業が完了しました。(ゴールデンパスはそれなりに手元で動かして確認しましたが、趣味開発プロダクトなので、わりとえいやっと本番環境に出してしまったので、不具合を見つけた方はこっそりおしえてください。)

維持管理しながらいろいろ機能を追加しようとしているんだよ!という話でした。

ではでは~

JavaScriptでPNGファイルをつくってみる(バイナリから)

以前のブログ記事でちょっとだけ触れた「JavaScriptPNGファイルをつくっているよ」な開発がなんとなくうまくいきはじめたので、ここまでのあれこれを記事にしてみます。 それにしても、こんなに長文のブログを書くのははじめてでつかれた。。。ぱたり。(もうつかれたので投稿してしまったけど、投稿すると査読能力が上がる説があるので、ちょいちょい書き直すかも...)

JavaScriptでバイナリを扱ってみる

以前から、これ系のネタをやってみたいな~と思っていました。JavaScriptには TypedArrayという、ビット長が固定されている配列を定義することができます。これを Blob(Binary Large Object) APIと組み合わせることで、任意のバイナリ列からファイルをつくれます。TypedArrayの一種、 UInt8Array をつかってPNGフォーマットに従ってバイナリ列を生成し、PNGファイルをつくってみよう~という試行です。

なぜPNGファイルをつくってみたの?

そもそもなぜこれをやろうかと思ったかというと...。つい数か月前までは、Twitterに画像をアップロードするとき、透過情報を含むPNGファイルはJPEGに変換されずにアップロードできるというハックな仕様がありました。がらくたツールボックスの運営サービスのひとつ 「ぴこぴよ」では、この特性を利用し、透過情報とともにドット絵をTwitterにアップロードするシステムとしていました。(ドット絵はその特性上、JPEG圧縮によりドットがぼやけることで作品の魅力を大きく損なうため、ドッターの間ではこの仕様はしばしば利用されていたのでした)

picopiyo.iconclub.jp

しかしながら、Twitterの仕様変更によって、「ある一定の大きさのPNGファイルは圧縮をかけない」「一定以上の大きさのPNGファイルは透過情報を含んだ場合もJPEGに変換する」「パレット付きPNG(PNG8形式)はそのまま投稿される」のように仕様が変更されたようです。

www.webtech.co.jp

ぴこぴよを使って投稿される画像は、「ある一定の大きさを超えない」場合が多く、JPEG圧縮の対象となる画像が投稿される可能性はさほど高くないと思いつつ、PNG8で投稿できればいいな~と思っていました。そこで、クライアント側でCanvas要素のデータをPNG8に変換する術があればな~、という気持ちになっていたのでした。

たぶん探せば似たようなライブラリがありそうですが、今回は「そもそもこれ系のネタに興味があった」ということで、おためしでTrueColorデータなPNGからPNG8に変換する君をつくってみよう、と思ったのでした。

この記事について

わたしはまだPNGファイルの仕様に詳しいわけではないので、当該記事の文章や、掲載したソースコードの正確性や完全性は保証できないことに注意してください。間違った記述やご指摘はコメントなどへぜひ~。

PNGのファイルの構成

PNGデータを作り上げるためには、PNGの構造について知る必要があります(というか、このブログ記事はほぼPNGフォーマットに対応したソースコードの説明になっているような...。)。インターネットにたくさんの資料が公開されています。ありがたや~。今回参考にさせていただいたサイトをぺたり。

上記のウェブサイトを読むことで、PNGファイルの構造を把握できると思います。ざっくりと説明すると、PNGファイルは「これはPNGですよ~」というシグネチャと、データの塊である「Chunk」によって構成されています。Chunkにはいろいろありますが、画像の概要を表す「IDHR Chunk」や、パレットの情報を扱う「PLTE Chunk」、画像データを扱う「IDAT Chunk」、データの終端を表す「IEND Chunk」 などがあります。必須ChunkやオプショナルなChunkを組み合わせて画像ファイルを構成しています。

ということで、作っている途中のソースコード全体はこちら~

github.com

今回つくったPNGデータの構成

今回つくったPNGデータは「PNGファイルシグネチャ」と、「IDHR」「PLTE」「tRNS」「IDAT」「IEND」Chunkを順番に並べて構成しました。

PNGファイルシグネチャ

「これはPNGデータですよ!」と宣言するためのファイルの先頭データです。仕様通りのバイト列を並べればよいだけなので、こんなかんじのデータを用意します。

const PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];

IDHR Chunk

画像の概要情報を格納するChunkです。幅や高さ、ビット深度、カラーモード(パレットモード / True Color / グレースケール)などを設定します。 ソースコードはこちら

IDHR Chunkのバイト列を生成する IdhrChunk クラスを定義しておきます。

export default class IdhrChunk {
  constructor(width, height, depth, colorType) {
    this.width = width;
    this.height = height;
    this.depth = depth;
    this.colorType = colorType;
  }
  // ...(省略)...
}

Chunk全体のバイト数を4バイトで表現します。(IDHR Chunkのバイト数は変化しないので、決め打ちで13bytesを入れておきます)

export default class IdhrChunk {
  // ...(省略)...
  _chunkLength() {
    return [0x00, 0x00, 0x00, 0x0D];
  }
  // ...(省略)...
}

つづいて幅と高さを4バイトで表現します。幅や高さを4バイトに分割し、要素数4の配列に直します。

export default class IdhrChunk {
  // ...(省略)...
  _imageWidth() {
    return [this.width >>> 24, this.width >>> 16, this.width >>> 8, this.width].map( val => val & 0x000000FF);
  }

  _imageHeight() {
    return [this.height >>> 24, this.height >>> 16, this.height >>> 8, this.height].map( val => val & 0x000000FF);
  }
  // ...(省略)...
}

同様に、仕様に則り、ビット深度やインターレースの有無などを記述していきます。(くわしくは idat_chunk.js を参照してください)

最終的にはだいたいこんなかんじ。 ※ bytes.write() というメソッドが突然出てきましたが、これはバイト列を連続して書き込むことを楽に実現するための PngBytes クラスのメンバです。 -> png_bytes.js

export default class IdhrChunk {
  // ...(省略)...
  /* チャンクデータの中身 */
  _chunkDataArray() {
    return this._chunkType()
      .concat(this._imageWidth())       // 幅
      .concat(this._imageHeight())      // 高さ
      .concat(this._depth())            // ビット深度
      .concat(this._colorType())        // カラータイプ(パレット使用・グレースケール・TrueColorなど)
      .concat(this._compressMethod())   // 圧縮形式(0のみが仕様で定義されるので0x00固定)
      .concat(this._filterMethod())     // フィルタ(0のみが仕様で定義されるので0x00固定)
      .concat(this._interlace())        // インタレース
  }

  /* チャンクデータの書き込み */
  write(bytes) {
    bytes.write(this._chunkLength());     // チャンクのバイト長
    bytes.write(this._chunkDataArray());  // チャンクデータ
    bytes.write(this._crc());             // CRC
  }

  /* CRCデータ */
  _crc() {
    const crc = CrcCalculator.calc(this._chunkDataArray());
    return [crc >>> 24, crc >>> 16, crc >>> 8, crc].map( val => val & 0x000000FF);
  }
  // ...(省略)...
}

各Chunkの整合性を確認するために、上記プログラムの CrcCalculator で4バイトの「CRC32」を計算し、Chunkの末尾に付与します。計算ロジックはPNG仕様の以下のサンプルプログラムを参考にしました。

www.libpng.org

上記サンプルプログラムをJavaScriptに書き直した、CRC32の計算を行うプログラムは以下の通り。

https://github.com/piyoppi/png-palette.js/blob/ba16f34b62f48dc80c6daef72224f8e583cec1cb/src/crc_calculator.js

PLTE Chunk

パレット情報を格納するChunkです。

TrueColorデータのPNGの場合は、パレットが不要(画素の値がRGB値のため)なのでこのChunkは不要です。パレットを使う場合は画素の値がパレット番号になるというイメージです。

たとえば、3x1pxの赤、緑、青のPNGの画素を表現するとき、TrueColorデータの場合は 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF (RGB値を並べたバイト列)となりますが、パレットを使う場合は色とインデックスを「赤=0, 青=1, 緑=2」のように割り当て、0, 1, 2 のように表現されます。

パレット情報オブジェクトを受け取り、PLTE Chunkのバイト列を生成するクラス PlteChunk を定義します。ソースコードはこちら

チャンクの長さはパレットの色数によって変わるので、 _chunkLength() 関数のようにパレット数に応じて長さを4バイトのバイト列に変換します。 _chunkType() 関数にて、 PLTE 文字のAsciiコードを返します。

export default class PlteChunk {
  // palette = [ {r: , g: , b: , a: }, ... ] の形式
  constructor(palette) {
    this.palette = palette;
  }

  /* Chunkの長さ */
  _chunkLength() {
    const length = this.palette.length * 3;
    return [length >>> 24, length >>> 16, length >>> 8, length].map( val => val & 0x000000FF);
  }

  /* Chunkのタイプ(=PLTE) */
  _chunkType() {
    return [0x50, 0x4C, 0x54, 0x45];  // PLTE
  }
  // ...(省略)...
}

ChunkのデータはRGBデータを順番に並べてつくっていきます。最初のRGB値の組がインデックス0、次のRGB値の組がインデックス1、となります。

export default class PlteChunk {
  // ...(省略)...
  /* パレットバイト列の作成 */
  _palette() {
    let palette = [];
    this.palette.forEach( color => {
      palette.push(color.r);
      palette.push(color.g);
      palette.push(color.b);
    });

    return palette;
  }
  // ...(省略)...
}

あとは、こんなかんじに並べていけばChunkのバイト列が完成します。

export default class PlteChunk {
  // ...(省略)...
  /* PLTE Chunk バイト列の生成 */
  write(bytes) {
    bytes.write(this._chunkLength());  // データのバイト長
    bytes.write(this._chunkData());    // パレットバイト列
    bytes.write(this._crc());          // CRC
  }
  // ...(省略)...
}

tRNS Chunk

透明度情報を格納する TrnsChunk クラスを定義します。 ソースコードはこちら

PLTE Chunk と同じようにパレットに対応したアルファ値(透明度情報)を順番に並べていきます。

export default class TrnsChunk {
  constructor(palette) {
    this.palette = palette;
  }

  _chunkType() {
    return [0x74, 0x52, 0x4E, 0x53];
  }
  // ...(省略)...
  _alphaPalette() {
    return this.palette.map( color => color.a );
  }
  // ...(省略)...
}

IDAT Chunk

画像データを格納する IdatChunk クラスを定義します。パレットの場合はパレットのインデックス、TrueColorデータの場合はRGB値が格納されます。 ソースコードはこちら

前述にもある通り Chunk の長さは4バイトで表現するため、4バイトで表現できる整数の最大値を超える長さのデータがあるときに、一つのIDAT Chunk では収まらないことになります。なので、 IDAT Chunk は複数存在してもよいことになっているようです。

コンストラクタは、(圧縮前の)生PNGバイト配列 data を受け取ります。生PNGデータの例は以下のサイトの内容が分かりやすいです。(以下のサイトではTrueColor形式のデータが掲載されていますが、RGB値の部分がパレットデータの場合は1画素ごとに1バイトで表現されるインデックスになります)

blog.livedoor.jp

export default class IdatChunk {
  constructor(data, option = {}) {
    this.data = data;

    this.fdict = option.fdict || 0;          //プリセット辞書の有無
    this.flevel = option.flevel || 2;       // 圧縮レベル
    this.slideWindowMode = option.slideWindowMode || 7;     // 2 ^ (slideWindowMode + 8) = actualSlideWindowSize  // スライド窓の大きさ
    this.dataMode = option.dataMode || DeflateDataType.raw;  // 圧縮モード(データ生成の際の条件分岐に利用)

    this._calculatedCompressedValue = null;  // 圧縮結果バッファ

    // データが渡されて、圧縮モードが固定ハフマン符号の場合は圧縮処理を行う
    if( this.data && (this.data.length > 0) && this.dataMode === DeflateDataType.fixedHuffman ) {
      this.compress();
    }
  }
}

PNGデータはzlib形式で圧縮されます。アルゴリズムについては以下の資料が詳しいです。 - RFC 1950 ZLIB Compressed Data Format Specification version 3.3 日本語訳 - RFC 1951 DEFLATE Compressed Data Format Specification version 1.3 日本語訳

とてもざっくり説明すると、あるバイト列が渡されたときに、「バイトを別の短い符号に置き換える」ことと「同じバイト列がデータ内に含まれている場合は、Nバイト前に長さLぶん同じデータがありますよ、という情報に置き換える」ことで圧縮をしようという可逆圧縮アルゴリズムです。

たとえば、バイト列 0x00 0x00 0xFF 0x30 0x00 0x00 0xFF みたいなデータが存在するときには [A B C] [D] [4つ前の長さ3のデータと同じ] みたいな感じに置き換えるイメージです。

「プリセット辞書」(圧縮率を高めるために設定する事前データ)には今回は対応していないので FDICT には0を代入しています。また、圧縮効率を示す FLEVEL には固定値 2 (デフォルトアルゴリズムを使って圧縮した)を代入しています(このへんの値は不適当かも。。。ただし、解凍に直接かかわる値ではないので、これでも正しいPNGファイルとして認識されます。)

圧縮アルゴリズムに登場する「スライド窓」の大きさ slideWindowMode や、圧縮方式 dataMode (全く圧縮しない or 固定ハフマン符号による圧縮)も設定します。

画像データの中身

画像データは、こんなかんじのバイト列によって構成されます。CMF はスライド窓の大きさを格納し、 FLG はプリセット辞書や圧縮レベルを格納します。 ImageData には圧縮された画像データを、 Adler32 には圧縮前バイト列をもとに生成されるAdler32形式のチェック用のバイト列(4バイト)をセットします。

+-----+-----+=============+---+---+---+---+
| CMF | FLG |  Image Data |    Adler32    |
+-----+-----+=============+---+---+---+---+

CMF FLG バイトは、こんなかんじのコードで生成しています。

export default class IdatChunk {
  // ...(省略)...
  _cmf() {
    return [this.slideWindowMode << 4 | 0x08];
  }

  _flg() {
    const cmf = this._cmf();
    const fval = this.flevel << 6 | this.fdict << 5;
    const fcheck = 31 - (((cmf * 256) + fval) % 31);
    return [fval | fcheck];
  }
  // ...(省略)...
}

とりあえず圧縮せずにファイル生成してみる

前述の ImageData には「圧縮したデータが入ります」といいつつ、実は圧縮しないデータを入れることもできます。(仕様はこちら)https://www.futomi.com/lecture/japanese/rfc1951.html#s3_2_4

圧縮しない場合は以下のように、bfinal ビット(最後のブロックデータの場合は1を立てる)と生データの長さ2byte(とその補数2byte)をセットし、あとはひたすらPNGの生データを詰めていきます。

PngBytes というクラスが突然出てきましたが、これはバイト列を連続して書き込むことを楽に実現するためのラッパクラスです。 -> png_bytes.js

export default class IdatChunk {
  // ...(省略)...
  _raw() {
    const cycle = Math.ceil(this.data.length / 32768);
    const bytes = new PngBytes(this.rawDataLength);
    let writeBitCount = 0;
    let dataCursor = 0;

    for( let i=0; i<cycle; i++ ) {
      const bfinal = (cycle-1) === i ? 1 : 0;
      const dataLength = Math.min(this.data.length, 32768);
      const dataLengthComplement = (~dataLength) & 0xFFFF;
      bytes.write([0x00 | bfinal]);
      bytes.write([dataLength, dataLength >>> 8].map( val => val & 0xFF ));
      bytes.write([dataLengthComplement, dataLengthComplement >>> 8].map( val => val & 0xFF ));

      for( let n=0; n<this.data.length; n++ ) {
        bytes.write([this.data[dataCursor++]]);
      }
    }

    bytes.write(this._adler32());
    return bytes;
  }
  // ...(省略)...
}

Adler32を求めるアルゴリズムは、RFC1950 に掲載されているサンプルコードをJavaScriptに書き直したものをつかいました -> adler32.js

固定ハフマン符号を使った圧縮

無圧縮でもPNGデータを表現できますが、せっかくなのでdeflate圧縮をおためし実装してみました。前述の ImageData にdeflate圧縮したデータを詰めていきます。(愚直に実装しただけなので、パフォーマンスや圧縮効率についてはまったく担保できていません...) -> deflate.js

固定ハフマン符号による圧縮は、ざっくりいうと圧縮アルゴリズムのうち「あるバイトを別の短い符号に置き換える」でいう「符号」を、予め RFC1951で定義された固定ハフマン符号としますよ~という感じです。

たとえば、以下のようなデータが存在したとします。

0xAA, 0x00, 0xFF, 0x00, 0xF0, 0x0F, 0x00, 0xFF, 0x00, 0xB0, 0xB0, 0x0F, 0xF0, 0x0F, 0x00, 0xFF, 0x04

このバイト列を圧縮する場合、以下のようなデータになり得ます(わかりやすいように二進数で記述してみます)。※ 一部のデータが圧縮によって「長さと距離」に置き換えられていることに注意してください。

データの中身 ビット列
bfinal と btype 011
符号化した0xAA 010101011
符号化した0x00 00001100
符号化した0xFF 111111111
符号化した0x00 00001100
符号化した0xF0 000011111
符号化した0x0F 11111100
符号化した長さ3 1000000
符号化した距離5 00100
距離5の拡張ビット 0
符号化した0xB0 000011011
符号化した0xB0 000011011
符号化した0x0F 11111100
符号化した長さ4 0100000
符号化した距離8 10100
距離8の拡張ビット 1
符号化した0x04 00101100
符号化した256(ブロックの終端を表す) 0000000

上の表を上から順に、左からすき間なく並べていくことで圧縮データが表現されます。(すき間なく並べるので、各ビット列はバイトをまたぐ場合もあります)

注意しなければならないのが、「符号化した」データはビットを右から詰めていく必要があり、それ以外のデータはビットを左から詰める必要があります。たとえば、「符号化した0xAA」は、 RFC1951で定義された固定ハフマン符号 をみると、ビット列 110101010 で表現されることが分かりますが、ビットを右から詰める必要があるので、ビット列として並べるときは上記の表のように 010101011 となります。

※筆者はこのことに気づかず、すごくはまってしまいました...。以下のサイトにて解説があり、ようやっと正しいデータが作れたのでした。

darkcrowcorvus.hatenablog.jp

くわしい実装が気になる方は deflate.js をみてみてください。

IEND Chunk

終端情報を格納する IendChank クラスを定義します。 ソースコードはこちら

ソースコードをご覧になるとわかるように、この Chunk のバイト列は固定になります。

Chunkを組み合わせてPNGデータを構成する

これらのChunkを組み合わせて、PNGデータを吐き出してみます。今回はTrueTypeなPNGデータをパレットに変換するクラス PngConv を用意してみます。

コンストラクタで Image を受け取り、 _getPixelData()Canvasをつかってピクセルデータ(RGBA値)配列を取得します。(せっかくここまでPNGデータの取り扱いを頑張ってきたけど、ここでCanvasに頼ってしまったのは、単に面倒だったためなのでした...)

convert() によってパレットデータを作成したあと(この辺の処理はpng.js に定義していますが、本題からそれるので割愛)、fileData() でChunkを組み合わせてPNGデータを作り上げます。

export default class PngConv {
  constructor(img) {
    this.img = img || null;
    this.png = null;
  }

  /* ピクセルの色情報を取得 */
  _getPixelData() {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    canvas.width = this.img.width;
    canvas.height = this.img.height;
    ctx.drawImage(this.img, 0, 0);

    return ctx.getImageData( 0, 0, this.img.width, this.img.height ).data;
  }

  _prepare() {
    const data = this._getPixelData();
    this.png = new Png(data, this.img.width, this.img.height);
  }

  /* パレット色の抽出 */
  convert() {
    if( !this.png ) this._prepare();

    this.png.convertToPaletteMode();
  }

  /* PNGシグネチャ定義 */
  _pngSignature() {
    return [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
  }

  /* PNGデータのバイト列を生成する */
  fileData() {
    const idhrChunk = new IdhrChunk(this.png.width, this.png.height, 8, ColorType.palette | ColorType.color);
    const plteChunk = new PlteChunk(this.png.palette);
    const trnsChunk = new TrnsChunk(this.png.palette);
    const idatChunk = new IdatChunk(this.png.rawData, {dataMode: DeflateDataType.raw});
    const iendChunk = new IendChunk();

    const byteLength = 8 + idhrChunk.length + plteChunk.length + trnsChunk.length + idatChunk.length + iendChunk.length;
    const bytes = new PngBytes(byteLength);

    bytes.write(this._pngSignature());
    idhrChunk.write(bytes);
    plteChunk.write(bytes);
    trnsChunk.write(bytes);
    idatChunk.write(bytes);
    iendChunk.write(bytes);

    return bytes;
  }
  // ...(省略)...
}

これでPNGデータはできた!わーい!ということで、こんな感じのソースコードを書くと、生成したPNGデータのBlobオブジェクトをつくることができます。(ここでようやっと本来やりたかったBlobが出てきた...)

export default class PngConv {
  // ...(省略)...
  toBlob() {
    const bytes = this.fileData();
    // bytes は PngBytes (png_bytes.js) 形式なので、UInt8Array型のバイト列(bytes.buffer)を与える
    return new Blob([bytes.buffer], {type: 'image/png'});
  }
}

あとは、つくった PngConv クラスを呼び出して、こんなかんじにすると生成したPNGファイルを表示することができます。

var image = new Image();
image.onload = function() {
  var pngConv = new PngConv(image);
  pngConv.convert();
  var blob = pngConv.toBlob();             // バイト列からBlobを生成したものをもらう
  var result = new Image();
  result.src = URL.createObjectURL(blob);  // BlobのURLを取得してImgタグに適用
  document.body.appendChild(result);       // 結果をDOMに追加
}
image.src = '/examples/pngtest2.png';      // 画像のロード

作ったデータの検証

ブラウザにそれっぽく表示されていても、実際はデータが壊れている可能性があります。正しいデータかどうかを検証するために、libpng.org にて公開されているチェックツールをつかいました。

www.libpng.org

f:id:piyorinpa:20190908002745p:plain
PNGデータの検証結果

「No errors detected」と表示されたらよさそう。

あとがき

今回はPNGファイルを題材に、JavaScriptでバイナリからファイルを生成してみるという試みをしてみました。気づけば「JavaScriptの技術を使ってみる」という本来の主題よりも、ひたすらにPNGの仕様について調べる感じになっていた...。 これでファイルの仕様さえわかれば、wavとか、JPEGとか、いろいろ作れるようになるのでは?という気持ちがしてきて楽しくなってきました。

ではでは~。

Picotachiで発表してきたよ&最近の開発などなど

前回の投稿日時を見たら、ずいぶんご無沙汰してしまったな~と思うなどしています。(書くネタがなかったわけではないけど、ブログを書くのが面倒になっていたのでした)

Picotachiで発表してきたよ

f:id:piyorinpa:20190829211750j:plain
会場のピコピコカフェの入り口がかわいい

先日、東京の吉祥寺にある「ピコピコカフェ」( https://picopicocafe.com/ )にて開催された「Picotachi」(ピコたち)というイベントに参加して、みんなでつくるダンジョンについて紹介する発表をさせていただきました。 Picotachiは、いろいろなクリエイターさん(ゲームっぽい作品が多いですがジャンル問わず)が集まって、短めの発表をしてわいわいするというイベントです。毎月開催されているようです。

わたしもそろそろじぶんの作品をどこかで発表するという経験をしておきたいな~と思っていたので、思い切って発表してみることにしたのでした。

ちなみに、発表は事前に主催者の方とメールでのやりとりが必要そうですが、参加するだけならふらっと入れるみたいです。

当日つかったスライドはこちら。

docs.google.com

会場は結構混雑していました。人数は数えていないけど、30人くらいはいたのかな...。19時からはじまり、20時ごろまでは自由な時間みたいな感じでした。 わたしはというと、知り合いもいないのでしばらくはそわそわと居場所をさがしてさまよいつつ、みんなでつくるダンジョンの動作確認などをしていました。 そのうちに他の方々もパソコンを開いて自作ゲームを展示したり、、、というかんじになり、ゲームで遊ばせていただいたりしていました。また、 わたしもパソコンを開いて作業していると、声をかけていただいたり...としているうちに発表の時間になりました。 (発表せず参加の場合でも、なにかじぶんの作品をもっていくと、話のとっかかりができていいかもしれないなと思いました)

ちなみに、結構海外の方がおおいなという印象で、発表も日本語だったり英語だったりでした。「なんかグローバルだな~」という雰囲気。 (言葉はわからなくても、スライドや動画を見ていればおおよその内容はわかるので、ちゃんと楽しめました)

私の発表はというと、持っていったPCとプロジェクタの相性が良くなく急遽会場でPCを貸していただいたり、などなど..なトラブルがあり、 どうなることやら~と思いましたが、なんとか無事にみんなでつくるダンジョンを紹介できました。ひとまずはよかった~。 ただ、会場の反応をみていると、もうちょっとがんばらないとな~という気持ちにもなったりしたのでした。 (でも、発表がおわったら「おもしろいですね」と言ってくださる方もいてよかった!)

結構気軽に発表できる雰囲気なので、はじめての発表をここでできてよかったなと思ったのでした。

あと、やっぱりPICO-8がちょっときになる~というかんじになりました。(ピコピコカフェは、PICO-8を開発しているLexaloffle Gamesさんのスペースなのです)

最近はなにをつくっているの?

みんなでつくるダンジョンのほうは、ここ1か月ちかくは新規機能の開発をせず、維持管理のみ行っているという感じでした。ぜんぜんアクセスされない~といじけてしまったので、 べつの作業をして気分を晴らそう~ということで、JavaScriptでバイナリからPNG画像を生成するという取り組みをしてみています。

作業のようすはこちら。 github.com

もともとJavaScriptでなにかバイナリっぽいことをやってみたいなと思っていたことや、HTML5CanvasではパレットつきPNG画像を吐き出せないという不満があったのを思い出したりして、 それじゃあPNGを生成してみようということでつくってみています。

いまのところ、無圧縮なデータのPNG画像を吐き出すことに成功したので、今度は圧縮をかけてPNG画像を保存してみようという取り組みをしてみています。 これが完成すると、たとえばブラウザ上でTrueColorデータのPNGをパレットなPNGに変換できたりします。ぴこぴよとかに組み込んでみようかな。。。というきもち。

おかげでみんなでつくるダンジョンの開発意欲も復活してきているので、ぼちぼちはじめようかな~と思っているところです。

ではでは~。