カプさばインタビュー

Posted 2015.07.30 in 雑記

超遅い宣伝ですけど…
絶賛発売中のTYPE-MOONエースvol.10に
『カプセルさーばんと』の制作秘話がちょろっとだけ掲載されています。(100Pあたり)

是非に!

TYPE-MOONエースvol.10の紹介サイトへ


カプセルさーばんと

Posted 2014.10.01 in 雑記

ここ最近のお仕事の成果物がようやく公開され始めたのでこちらでも宣伝☆

『Fate/hollow atraxia』PS Vita版のゲーム内ミニゲーム『カプセルさーばんと』の
企画、ディレクション、ゲームデザイン、レベルデザイン、プログラミングを担当しました。

PSVita「フェイト/ホロウ アタラクシア」
カプセルさーばんと

どんなゲームかは以下の公式動画をご覧あれ。

フェイト ホロウ アタラクシア カプセルさーばんとPV
YouTube Preview Image

【シロウ編】 『カプセルさーばんと』プレイ動画
YouTube Preview Image

【リン編】 『カプセルさーばんと』プレイ動画
YouTube Preview Image

PS Vitaをお持ちの方は是非に~


レイヤーを可視にするタイミング

Posted 2013.06.28 in 吉里吉里

前回の記事で 『無効領域の発生タイミング』 について説明したが、
それについてさらに重要な事実がある。

レイヤーが不可視の時は無効領域は発生しない!

のである。

動作の確認

前回のスクリプトを次のように修正してみよう。


      // レイヤーを移動
      layer.setPos(100, 100);
      layer.visible = true;

レイヤーを可視にしてから移動していたのを
レイヤーを移動してから可視にするようにした。
これを実行すると…

KiriKiri_setPos3

左上の領域が再描画されなくなった!
作成したばかりのレイヤーは不可視なので、setPos 関数で位置を変更しても無効領域は発生しない。
よって左上の領域が無効領域にならなくなったのである。

いままで可視にしてから移動することで、無駄に無効領域を発生させてしまっていた。
移動させてから可視にすることで無駄を削減することができた!

ちなみにこの場合、left,top プロパティを使った方法でも同様の結果になる。


      // レイヤーを移動
      layer.left = 100;
      layer.top = 100;
      layer.visible = true;

このようなことからレイヤーを可視にするのは、
位置やサイズの調整を全て終えた後、最後に行うべきであると言える。

表示状態が関係ないケース

レイヤーが非表示の場合は無効領域は発生しないことはわかったが、
非表示でも無効領域が発生するケースがある。

absolute, order プロパティの操作は、
対象レイヤー自体の表示状態に関係なく無効領域を発生させるので注意が必要だ。
この時発生する無効領域はちょっと特殊で、
順序を変更するレイヤーとその影響を受けるレイヤーとが重なる領域が無効領域になる。
実質的に順序が変わらない場合、無効領域は発生しない。

動作を確認してみよう。


      // 別のレイヤーを作成
      var other = new Layer(this, primaryLayer);
     add(other);
      other.setImageSize(200, 10);
      other.setSizeToImageSize();

      // レイヤーを移動
      layer.absolute = 100;
      layer.setPos(100, 100);
      layer.visible = true;

順序の変更が生じるように、別のレイヤーを作成している。

KiriKiri_setPos4

レイヤーが不可視であるにも関わらず、左上の領域で妙な無効領域が発生しているのがわかるだろう。
さっき説明したように、これは other と layer が重なっていた部分の領域である。

このように absolute, order プロパティの操作が
レイヤーが不可視でも無効領域を生じてしまうのは、仕様というより実装上の不備かもしれない。
とにもかくにも、このようなことから absolute, order プロパティの操作は
次のようにレイヤーを可視する直前に行うのが無駄がなく効率的だと思われる。


      // レイヤーを移動
      layer.setPos(100, 100);
      layer.absolute = 100;
      layer.visible = true;

親レイヤーが非表示のケース

親レイヤーが非表示の場合、子レイヤーに対する操作は
その子レイヤーの表示状態に関わらず無効領域を生じない。
よって、親レイヤーを可視にするのは子レイヤーを全て作成し終わった後にすべきである。

まとめ

『レイヤーを可視にするタイミング』 は想像以上に重要だ。

「レイヤーを可視にするのは最後にすべき!」

なんて言われなくても
慣習的になんとなく一番最後に visible=true していた人は多いかもしれない。
しかしそのことは、効率面に置いて非常に重大な効果があるのである。


left,top より setPos の方が高速

Posted 2013.06.26 in 吉里吉里

レイヤーの縦横位置を変更するには


layer.left = 100;
layer.top = 100;

とする方法と


layer.setPos(100, 100);

とする方法があるが

setPos の方がはるかに高速である!

2つの方法の違い

この2つの方法はまったく同じ意味に見えるが…
実は2つの方法には大きな違いがある。

left, top の方が関数を呼び出していない分、より高速なのではないかと思うかもしれないが、
left, top はプロパティなので、実質的には関数を2回呼び出していることと同じだ。
よってむしろ setPos の方が関数呼び出し1回で済む点において高速である。

しかしそんなことは些細な違いでしかない。

それよりもずっと大きな、決定的な違いがある。
それは

無効領域の発生タイミング

にまつわる動作の違いである。
ちなみに無効領域とは、画面上において再描画が行われる領域のことである。

動作の確認

動作の違いを確認してみよう。


class TestWindow extends Window
{
  function TestWindow()
  {
    super.Window();
    
    // プライマリレイヤー作成
    var w = 200;
    var h = 200;
    add(new Layer(this, null));
    primaryLayer.setImageSize(w, h);
    primaryLayer.setSizeToImageSize();
    setInnerSize(w, h);
    primaryLayer.fillRect(0, 0, w, h, 0); // 黒
  }
  
  function onKeyDown(key, shift)
  {
    // Enterキーが押されたらレイヤーを作成する
    if(key == VK_RETURN)
    {
      // レイヤーを作成
      var w = 100;
      var h = 100;
      var layer = new Layer(this, primaryLayer);
      add(layer);
      layer.setImageSize(w, h);
      layer.setSizeToImageSize();
      layer.fillRect(0, 0, w, h, 0xFFAAAAAA); // 灰
      
      // レイヤーを移動
      layer.visible = true;
      layer.left = 100;
      layer.top = 100;
    }
  }
}

var win = new TestWindow();
win.visible = true;

これを実行したらまずウィンドウを選択して Shift+F11キー を押し
更新矩形を表示するモードにする。
そして Enterキー を押すと次のようになる。

KiriKiri_setPos1

黄色い枠で示されているのが無効領域となり、再描画が行われた領域である。
細かく区切られているように見えるのは、キャッシュ効率を上げるための描画分割による作用だ。

レイヤーが移動する時、無効領域は2つ発生する。
『レイヤーが移動する前の領域』 と
『レイヤーが移動した後の領域』 だ。
画面の左上の領域が 『レイヤーが移動する前の領域』
画面の右下の領域が 『レイヤーが移動した後の領域』 である。

注目して欲しいのは、なんの関係もない右上の領域が再描画されている点だ。
では、これを setPos に置き換えて実行してみよう。


      // レイヤーを移動
      layer.visible = true;
      layer.setPos(100, 100);

KiriKiri_setPos2

右上の領域が無駄に再描画されなくなった!

何が起きているのか

この動作の違いは、先に述べたように 『無効領域の発生タイミング』 に起因している。

無効領域は、レイヤーに対して
操作を行った直後に発生する。

left, top プロパティで位置を変更する方法の場合
left = 100; とした時点で無効領域が発生し
top = 100; とした時点で無効領域が再度発生するという動作になる。
そう、これによって右上に無駄な無効領域が発生し、無駄な再描画が行われてしまったのだ。

setPos の場合、leftとtopを両方変更してから無効領域が発生する。
よって、無駄な無効領域が発生せず、無駄な再描画も行われない。
最初に「setPos の方がはるかに高速」と言ったのはこのことである。

まとめ

このようなことからレイヤーの縦横位置を同時に変更する時は
left, top プロパティよりも setPos 関数を使うべきだと言えるだろう。


[] より . を使おう

Posted 2013.05.30 in 吉里吉里

吉里吉里(TJS2)ではオブジェクトのメンバ(または辞書の要素)へのアクセス方法には
直接メンバ選択演算子 . による直接参照と
間接メンバ選択演算子 [] による間接参照があるが
今回は

[] より . の方が速い!

というお話。

実験

次のようなテストスクリプトを書いて
それぞれの書き込み時と読み込み時の速度を計測してみた。


function test_pureLoop(testNum)
{
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) ;
  var end = System.getTickCount();
  return end-begin;
}

function test_set1(testNum)
{
  var dic = %[];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic["X"] = 999;
  var end = System.getTickCount();
  return end-begin;
}

function test_set2(testNum)
{
  var dic = %[];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic["XXXXXXXXXX"]  = 999;
  var end = System.getTickCount();
  return end-begin;
}

function test_set3(testNum)
{
  var dic = %[];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic["XXXXXXXXXXXXXXXXXXXX"]  = 999;
  var end = System.getTickCount();
  return end-begin;
}

function test_set4(testNum)
{
  var dic = %[];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic.X = 999;
  var end = System.getTickCount();
  return end-begin;
}

function test_set5(testNum)
{
  var dic = %[];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic.XXXXXXXXXX  = 999;
  var end = System.getTickCount();
  return end-begin;
}
  
function test_set6(testNum)
{
  var dic = %[];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic.XXXXXXXXXXXXXXXXXXXX = 999;
  var end = System.getTickCount();
  return end-begin;
}

function test_get1(testNum)
{
  var dic = %[ "X" => 999 ];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic["X"];
  var end = System.getTickCount();
  return end-begin;
}

function test_get2(testNum)
{
  var dic = %[ "XXXXXXXXXX" => 999 ];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic["XXXXXXXXXX"];
  var end = System.getTickCount();
  return end-begin;
}

function test_get3(testNum)
{
  var dic = %[ "XXXXXXXXXXXXXXXXXXXX" => 999 ];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic["XXXXXXXXXXXXXXXXXXXX"];
  var end = System.getTickCount();
  return end-begin;
}

function test_get4(testNum)
{
  var dic = %[ "X" => 999 ];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic.X;
  var end = System.getTickCount();
  return end-begin;
}

function test_get5(testNum)
{
  var dic = %[ "XXXXXXXXXX" => 999 ];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic.XXXXXXXXXX;
  var end = System.getTickCount();
  return end-begin;
}

function test_get6(testNum)
{
  var dic = %[ "XXXXXXXXXXXXXXXXXXXX" => 999 ];
  var begin = System.getTickCount();
  for(var i=0; i < testNum; i++) dic.XXXXXXXXXXXXXXXXXXXX;
  var end = System.getTickCount();
  return end-begin;
}

// 割り込みなどによる計測誤差をなくすために複数回計測して最小の値を採用する
function repeatTest(func)
{
  var TEST_NUM = 1000000;
  var PEPEAT_NUM = 100;
  var minTime;
  for(var i=0; i < PEPEAT_NUM; i++)
  {
    var time = func(TEST_NUM);
    if(minTime === void)
      minTime = time;
    else
      minTime = Math.min(minTime, time);
  }
  return minTime;
}

function test()
{
  var base = repeatTest(test_pureLoop);
  Debug.message("pure loop                         : %d ms"
    .sprintf(base));

  Debug.message(">set");
  Debug.message("dic[\"X\"] = 999                    : %d ms"
    .sprintf(repeatTest(test_set1)-base));
  Debug.message("dic[\"XXXXXXXXXX\"] = 999           : %d ms"
    .sprintf(repeatTest(test_set2)-base));
  Debug.message("dic[\"XXXXXXXXXXXXXXXXXXXX\"] = 999 : %d ms"
    .sprintf(repeatTest(test_set3)-base));
  Debug.message("dic.X = 999                       : %d ms"
    .sprintf(repeatTest(test_set4)-base));
  Debug.message("dic.XXXXXXXXXX = 999              : %d ms"
    .sprintf(repeatTest(test_set5)-base));
  Debug.message("dic.XXXXXXXXXXXXXXXXXXXX = 999    : %d ms"
    .sprintf(repeatTest(test_set6)-base));

  Debug.message(">get");
  Debug.message("dic[\"X\"]                          : %d ms"
    .sprintf(repeatTest(test_get1)-base));
  Debug.message("dic[\"XXXXXXXXXX\"]                 : %d ms"
    .sprintf(repeatTest(test_get2)-base));
  Debug.message("dic[\"XXXXXXXXXXXXXXXXXXXX\"]       : %d ms"
    .sprintf(repeatTest(test_get3)-base));
  Debug.message("dic.X                             : %d ms"
    .sprintf(repeatTest(test_get4)-base));
  Debug.message("dic.XXXXXXXXXX                    : %d ms"
    .sprintf(repeatTest(test_get5)-base));
  Debug.message("dic.XXXXXXXXXXXXXXXXXXXX          : %d ms"
    .sprintf(repeatTest(test_get6)-base));
}

var win = new Window();
win.visible = true;
Debug.console.visible = true;
test();

実行結果。


pure loop                         : 17 ms
>set
dic["X"] = 999                    : 68 ms
dic["XXXXXXXXXX"] = 999           : 67 ms
dic["XXXXXXXXXXXXXXXXXXXX"] = 999 : 67 ms
dic.X = 999                       : 41 ms
dic.XXXXXXXXXX = 999              : 41 ms
dic.XXXXXXXXXXXXXXXXXXXX = 999    : 41 ms
>get
dic["X"]                          : 53 ms
dic["XXXXXXXXXX"]                 : 72 ms
dic["XXXXXXXXXXXXXXXXXXXX"]       : 96 ms
dic.X                             : 34 ms
dic.XXXXXXXXXX                    : 34 ms
dic.XXXXXXXXXXXXXXXXXXXX          : 34 ms

ちなみに比較しやすいように各値からは pure loop 分を差し引いている。
パッと見で明らかに

間接参照による取得が遅い!

キー文字列の長さに比例して遅くなっているように思える。
10文字で直接参照に対して倍近い速度差。
これは場合によってはちょっと無視できない差だ。

何が起きているのか

この現象を理解するにはまず、文字列が保持する『ヒント』情報について知る必要がある。

文字列が辞書に対するキー文字列として使われる時、キー文字列からハッシュ値が計算される。
ハッシュ値は文字列の内容全体から計算されるので、文字列が長ければ長いほど計算に時間がかかる。
計算で得られたハッシュ値はハッシュテーブル内の位置を示す値として使われる一方で、
文字列に『ヒント』情報として保持される。

保持された『ヒント』は次回辞書要素参照時にハッシュ値のかわりに使用され
ハッシュ値の計算を省いた簡易的な検索が行われる。
こうなるとキー文字列の長さは関係なくなる。
キー文字列の長さに関わらず速度がほぼ一定の計測結果が得られたのはそのためである。

ではなぜ間接参照による取得時だけ速度が一定ではないのか。
それは

間接参照による取得時だけヒントが使われない

からだ。
すなわち、毎回ハッシュ値が再計算されているのである。

検証

間接参照による取得の実装は tTJSInterCodeContext::GetPropertyIndirect にある。
tjsInterCodeExec.cpp

本当にヒントを使わないことが原因なのか確認するために
間接参照による取得時にもヒントを使うように改造してみた。


pure loop                         : 17 ms
>set
dic["X"] = 999                    : 68 ms
dic["XXXXXXXXXX"] = 999           : 69 ms
dic["XXXXXXXXXXXXXXXXXXXX"] = 999 : 68 ms
dic.X = 999                       : 41 ms
dic.XXXXXXXXXX = 999              : 41 ms
dic.XXXXXXXXXXXXXXXXXXXX = 999    : 40 ms
>get
dic["X"]                          : 46 ms
dic["XXXXXXXXXX"]                 : 46 ms
dic["XXXXXXXXXXXXXXXXXXXX"]       : 46 ms
dic.X                             : 33 ms
dic.XXXXXXXXXX                    : 33 ms
dic.XXXXXXXXXXXXXXXXXXXX          : 33 ms

期待通り一定速度になった!

ヒントを使わない理由

間接参照による設定時にはヒントを使っているので
取得時だけヒントを使わない理由はあまりないように思える。
ソースには
// TODO: verify here needs hint holding
と記述があるので、ヒントを使うべきか検討中…
といったかんじだろうか。

『その文字列の内容から計算されるハッシュ値』と『ヒント』が常にイコールなら
取得時にヒントを使ってもおそらく何も問題は起きないだろう。
しかしどうやら、常にイコールなわけではないようだ。
ヒント作成後に文字列が内部的に改変された場合、ヒントはクリアも更新もされないからである。

文字列が後から改変された状態で辞書を参照すると、ヒントを使った要素の簡易検索に失敗し
ハッシュ値が再計算されヒントが再設定され、もう一度検索が行われる。
すなわちヒントによる検索が逆に無駄な処理になってしまう。

そういった問題を回避するために、間接参照ではヒントを使わないようにしているものと思われる。
直接参照でヒントが使われるは、直接参照のキー文字列は必ず文字列定数だからだろう。
定数なので文字列が後から改変される心配がない、ということだ。

ただTJS上での文字列操作は、文字列の内容をいじるのではなく、
新しく文字列を生成するようになっているため
文字列が後から改変されるといった状況は根本的に起こらないような気もする。
何か見落としているだけかもしれない。

ちなみに辞書への要素の追加時とdelete演算子による削除時にも
文字列が保持しているヒントは実質的に使われず必ずハッシュ値が再計算されるようだ。

まとめ

ヒントが使われない理由はともあれ、このようなことから間接参照による取得は
直接参照に比べて大きくパフォーマンスが落ちる場合がある。
全般的に間接参照よりも直接参照の方が少しだけ速いことからも
直接参照を使える場面では、直接参照を使ったほうが良いと言えるだろう。


affineCopyを-0.5する意味

Posted 2013.05.19 in 吉里吉里

affineCopy のピクセルの特殊な扱いは、入力領域のみならず出力領域においても関係がある。

仕様の再確認

ひとまず affineCopy におけるピクセルの扱いを再確認しておこう。

整数座標上で (0, 0) のピクセルは、
実数座標上では (0.0, 0.0) – (1.0, 1.0) の範囲を覆っていると見なすのが普通である。
それに対して、affineCopyでは (0, 0) の位置にあるピクセルは
(-0.5, -0.5) – (0.5, 0.5) の範囲にあると見なされる。

KiriKiri_AffineCopy3_Image1

出力領域の扱い

このピクセルの扱いは、出力領域においても適用される。
ソレがいったいどんな作用をもたらすのか、ちょっとわかりにくいかもしれない。
このことは

”ラスタライズ処理における、サンプリング位置”

に関係がある。

ラスタライズ

「ラスタライズ」とは、抽象的な描画情報を具体的なピクセルに落とし込む処理のことである。

ここまで述べてきたように整数空間において点であるピクセルは、
実数空間では特定の範囲を持つ矩形として扱われる。

画像のラスタライズ処理において最も気を遣わなければならないのが、
この「実数空間上の入力元ピクセルを、いかに整数空間上の出力先ピクセルに落とし込むか」
言い換えれば

「範囲を、いかに点に落とし込むか」

という部分だ。

サンプリング

ラスタライズ処理において、出力先ピクセルに出力する情報を
入力元から抽出するプロセスを「サンプリング」と呼ぶ。

ある出力先ピクセルに出力する情報は
出力先画像の上に入力元画像を重ね合わせた時、
出力先ピクセルの範囲と、範囲が被っている入力元ピクセルの中から決定する。

この時、出力先ピクセルの範囲内には
複数の入力元ピクセルの範囲が含まれる可能性がある。
そこで問題になってくるのが、範囲が被っている入力ピクセルのうち
どのピクセルの情報を採用するのかという問題だ。

サンプリング点による抽出

この決定は「サンプリング点」によって行う。

サンプリング点は実数空間上のピクセルの範囲の中のいずれかの点だ。
サンプリング点の位置を基準にサンプリングを行う。
最近傍法の場合、サンプリング点の真上にある入力元ピクセルが採用される。

サンプリング点にはピクセル範囲の中心位置を使うのが一般的だ。
一般的なピンセル範囲ではこれは (0.5, 0.5) の位置である。

affineCopy の場合、 ピクセルの範囲は (-0.5, -0.5) – (0.5, 0.5) なので
中心位置は (0.0, 0.0) ということになる。
実際 affineCopy では (0.0, 0.0) の位置がサンプリング点となっている。

KiriKiri_AffineCopy3_Image2

(0.0, 0.0) の位置は一般的なピクセル範囲においては左上の位置を表す。
このことから、一般的なピクセル範囲の考え方において affineCopy は

サンプリング点がピクセルの左上になっている

と見なすことができる。

ズレの真の原因

サンプリング点がピクセルの左上だと……
最近傍法の場合、座標をわずかに動かしただけで
サンプリング対象が変化するという現象をひきおこす。
線形補間の場合、サンプリング位置が左上方向にズレることにより、描画が右下にズレる。
これまでに見てきた現象だ!

そう、すなわちズレの原因はサンプリング点が実質的に
ピクセルの左上になっていることにあるのである。
ただしこのズレは不具合ではなく、

ピクセル範囲の考え方の齟齬によって起こる現象

だと言える。
そしてそれこそがズレの真の原因である。

-0.5の意味

ここまで理解すると-0.5の意味はもうわかるだろう。
出力座標を-0.5すると、ピクセル範囲の考え方のズレを補正することができるのである。

またこのことは別の見方もできる。
出力座標を-0.5することは、出力座標上の入力座標を出力座標+0.5分だけずらすことと同義である。
これはサンプリング点の位置をピクセルの左上 (0.0, 0.0) から
ピクセルの中央 (0.5, 0.5) に移動させることと同じことだ。
よってズレが補正されるのである。

まとめ

回りくどく長々と説明してきたが…
結局のところ、描画のズレはピクセル範囲の考え方の齟齬に起因するものだということだ。
それさえ理解していれば affineCopy はなんら問題なく使うことができる。
またこの問題は operateAffine などでも同様である。

ちなみに stretchCopy は内部的に affineCopy を利用する形で実装されているが、
そこでは出力座標がしっかり -0.5 されている!

affineCopy を使う場合は -0.5 を忘れずに!

[追記]
Direct3D9 や OpenGL はピクセルの範囲が (-0.5, -0.5) – (0.5, 0.5) だったようだ。
affineCopy のピクセルの扱いはそれらに準拠したものかもしれない。
なお Direct3D10 からは ピクセルの範囲は (0.0, 0.0) – (1.0, 1.0) になっている。
参考:座標系 (Direct3D 10)

[追記]
上記のOpenGLは勘違いでした。


affineCopyのピクセルの扱い

Posted 2013.05.19 in 吉里吉里

なぜ affineCopy がズレて描画され、なぜ出力座標を -0.5 するとズレなくなるのか。
ヒントは affineCopy の仕様にある。

仕様の確認

吉里吉里のマニュアルには次のように書かれている。

affineCopy
アフィン変換においては、ピクセルは 1.0 × 1.0 のサイズを持っていると見なされます。
つまり、(0, 0) の位置にあるピクセルは (-0.5, -0.5) – (0.5, 0.5) の範囲にあると見なされます。

一般的には、整数座標上で (0, 0) のピクセルは、
実数座標上では (0.0, 0.0) – (1.0, 1.0) の範囲を覆っていると見なすのが普通である。
それに対して、-0.5 ずれた範囲を覆っているものとして扱われると書かれている。

なるほど -0.5 というあたりからして、ズレの原因はこれっぽいが
(-0.5, -0.5) – (0.5, 0.5) の範囲にあると見なされるとは、
いったいどういう意味だろうか。

入力領域の扱い

まずこの仕様が関係してくるのが

”affine=trueの時に出力領域の計算をする際の入力領域の扱い”

である。
affine=true の時、入力領域全体を -0.5 移動させてから、
アフィン変換行列による変形計算を行い、出力領域が決定される。
これがどういうことかは、次のようにしてみるとわかる。


     var x = 50;
     var y = 50;
     primaryLayer.affineCopy(image, 0, 0, w, h
      , true, 100, 0, 0, 100, x, y, stNearest);

KiriKiri_AffineCopy2_Test1

画像を(50, 50)の位置に100倍に拡大して描画しているが、
左上に大きくズレこんで描画されてしまっている。
これは (-0.5, -0.5) – (0.5, 0.5) の範囲にあるとみなされる入力ピクセルが100倍になり
(-50.0, -50.0) – (50.0, 50.0) の範囲に出力されているからである。

この仕様は正直、あまり直感的ではなく扱いづらく思える。
ちょっと不思議な仕様だ。

affine=true 時のズレ

affineCopy を affine=true で使い、画像を無変形で描画する時
出力座標を -0.5 しなくても描画はズレないように見える。
しかし実は affine=true の場合でも、ズレ自体は起きている。
ではなぜズレがないように見えるのか。

先の仕様を思い出して欲しい。
入力領域全体を -0.5 移動させて、それを出力領域にするということは…
出力領域全体を -0.5 移動させることと同義である。
そう、先の仕様によってズレが補正されているのだ。

ただしこれが同義なのは無変形の場合だけであることに注意が必要だ。
画像を変形させると隠れ潜んでいたズレが姿を現す。

ここでのまとめ

affine=true 時のズレを補正するのは不可能ではないが、ちょっと難しい。
入力座標を +0.5 し、出力座標を -0.5 すれば
仕様を打ち消し、ズレもなくせそうな気がするが…
affineCopy は入力座標に整数しか指定できないため、それはできない。
行列計算で補正する必要がある。

厳密にズレを補正しようと思うなら、affine=false にして
出力座標計算は自力で行った方が簡単だろう。

つづく!


affineCopyは-0.5せよ

Posted 2013.05.16 in 吉里吉里

今回は

『affineCopy を affine=false で使う場合は、
 出力座標を-0.5しなければならない!』

というお話。

-0.5しないと起きる問題

-0.5しないとどうなるのか。
一言で言えば

-0.5しないと画像がずれて描画される!

のである。

サンプルスクリプト

とりあえず、affineCopy を使って普通に画像を描画する tjs スクリプトを書いてみよう。

Box.png
Box


class TestWindow extends Window
{
  function TestWindow()
  {
    super.Window();
    
    // プライマリレイヤー作成
    add(new Layer(this, null));
    primaryLayer.setImageSize(200,200);
    primaryLayer.setSizeToImageSize();
    setInnerSize(primaryLayer.width, primaryLayer.height);
    
    // 画像をロード
    var image = new Layer(this, primaryLayer);
    image.loadImages("Box");
    
    // 画像を描画
    var w = image.imageWidth;
    var h = image.imageHeight;
    var x = 50;
    var y = 50;
    primaryLayer.affineCopy(image, 0, 0, w, h
      , false, x, y, x+w, y, x, y+h, stNearest);

    // 後始末
    invalidate image;
  }
}

var win = new TestWindow();
win.visible = true;

KiriKiri_AffineCopy_Test1

何も問題ないように見える。

線形補間の場合に起こる問題

サンプリング方法を線形補間など(stFastLinear, stLinearなど)にすると現象を明らかに確認できる。


    var x = 50;
    var y = 50;
    primaryLayer.affineCopy(image, 0, 0, w, h
      , false, x, y, x+w, y, x, y+h, stFastLinear);

KiriKiri_AffineCopy_Test2

なんだか変にぼやけている。
というか

画像が右下に
0.5 ピクセル分ずれて描画されている!

では出力座標を-0.5してみよう。


    var x = 50-0.5;
    var y = 50-0.5;
    primaryLayer.affineCopy(image, 0, 0, w, h
      , false, x, y, x+w, y, x, y+h, stFastLinear);

KiriKiri_AffineCopy_Test3

ずれがなくなった!

stNearest の場合に起こる問題

描画がずれる現象は補間を行う場合だけの問題なのだろうか。
そうではない。
stNearest の場合にも、一見わかりにくいが描画がずれている。
症状は画像をわずかに拡大させると確認できる。


    var x = 50;
    var y = 50;
    primaryLayer.affineCopy(image, 0, 0, w, h
      , false, x, y, x+w, y, x, y+h+0.1, stNearest);

KiriKiri_AffineCopy_Test4

サイズが1ピクセル大きくなり、上辺のラインが太くなっている。
+0.2にしても、+0.3にしても上辺のラインが常に太い状態になる。
それの何が問題なのかと思うかもしれないが、
これは不自然な画像の拡大の仕方であると言える。

出力座標を-0.5してみよう。


    var x = 50-0.5;
    var y = 50-0.5;
    primaryLayer.affineCopy(image, 0, 0, w, h
      , false, x, y, x+w, y, x, y+h+0.1, stNearest);

KiriKiri_AffineCopy_Test5

サイズは変化せず、上辺のラインが太くならなくなった。

じりじり拡大させるテスト

stNearest の場合の問題のポイントは、描画サイズのわずかな変化で
実際に描画される画像サイズに大きな変化が起こる点だ。
画像をゆっくりとじりじり拡大させるような演出を行う際、この問題は顕著に現れる。


class TestWindow extends Window
{
  var image;
  var timer;
  
  function TestWindow()
  {
    super.Window();
    
    // プライマリレイヤー作成
    add(new Layer(this, null));
    primaryLayer.setImageSize(200,200);
    primaryLayer.setSizeToImageSize();
    setInnerSize(primaryLayer.width, primaryLayer.height);
    
    // 画像をロード
    image = new Layer(this, primaryLayer);
    image.loadImages("Box");
    
    // タイマー作成
    timer = new Timer(onTimer, "");
    timer.interval = 1000;
    timer.enabled = true;
    onTimer();
  }
  
  function finalize()
  {
    invalidate timer;
    invalidate image;

    super.finalize();
  }
  
  var step = 0;
  function onTimer()
  {
     caption = "step:"+step;
     primaryLayer.fillRect(0, 0
       , primaryLayer.width, primaryLayer.height, 0xFFFFFFFF);
     
     // 画像を描画
     var w = image.imageWidth;
     var h = image.imageHeight;
     var x = 50;
     var y = 50;
     primaryLayer.affineCopy(image, 0, 0, w, h
       , false, x, y, x+w, y, x, y+h+step/10, stNearest);

     step++;
  }
}

var win = new TestWindow();
win.visible = true;

これを実行すると動き出しの部分で画像全体が一気にビクっとずれるのがわかる。
描画サイズのわずかな変化で、大きな変化が起きてしまっているからだ。

これの出力座標を-0.5すると、動き出しで画像が大きく動かなくなる。
そしてサイズの変化に応じてじりじり画像が動くようになる。
わずかな変化で大きく動く前の状態より、こちらの方がより自然な動きだと思わないだろうか。

まとめ

なぜこの問題が起き、なぜ-0.5すると問題が改善するのか。
詳しくは次回~


オーバーライドされたプロパティ

Posted 2013.05.13 in 吉里吉里

派生クラスによってオーバーライドされて隠蔽されてしまい
普通にアクセスできなくなった基底クラスの関数にアクセスするには
incontextof 演算子を使って


(global.Layer.update incontextof layer)()

と書けばよいが、オーバーライドされたプロパティにアクセスしようと同じノリで


(global.Layer.left incontextof layer)

と書いてもこれはエラーになる。
incontextof 演算子の意味を考えればあたり前なのだけれど…
ちょっとハマりがちなところだと思う。

オーバーライドされたプロパティにアクセスするには、次の3つの方法がある。

1. superを使う


var value = super.left;

superキーワードを介して親クラスのプロパティにアクセスできる。

この方法は最も単純明快だが、この方法を使えるのはそのクラス内だけである。
super を使って外からアクセスできるようにするには別名のプロパティを作って
そこから super を介してアクセスするといったような回りくどい方法をとる必要がある。

2. プロパティオブジェクトを使う


var value = *(&global.Layer.left incontextof layer);

親クラスのオブジェクトから&演算子でプロパティオブジェクトを取得し、
incontextof 演算子でコンテキストを置き換えて*演算子で参照する方法。
なんだか難しそうな表記だが、&演算子と*演算子の意味を理解すれば単純だ。

&演算子で取得したプロパティオブジェクトは、変数に格納して使うこともできる。


var propLeft = (&global.Layer.left incontextof layer);
var value = *propLeft;

3. クラスオブジェクトを使う


var value = (global.Layer incontextof layer).left;

親クラスのオブジェクトのコンテキストを置き換えて使う方法。
この方法はたぶんあまり知られていない気がする。
プロパティオブジェクトを使うよりこっちの方が直感的だろう。

変数に格納して使うこともできるので、
たくさんのオーバーライドされたプロパティにアクセスする場合には特に便利だ。


var globalLayer = (global.Layer incontextof layer);
var value = globalLayer.left;

速度の比較

3つのアクセス方法の速度を計測してみよう。


class MyLayer extends Layer
{
  function MyLayer(window, parent)
  {
    super.Layer(...);
    super.left = 222;
  }

  var m_left = 111;
  property left
  {
    setter(v){ m_left = v; }
    getter(){ return m_left; }
  }

  property superLeft
  {
    getter(){ return super.left; }
  }
}

class TestWindow extends Window
{
  function TestWindow()
  {
    super.Window();
    add(new Layer(this, null));
  }

  function test()
  {
    var TEST_NUM = 10000000;
    var layer = new MyLayer(this, primaryLayer);
    var value;
    {
      var begin = System.getTickCount();
      for(var i=0; i < TEST_NUM; i++)
        value = layer.superLeft;
      var end = System.getTickCount();
      Debug.message("value = layer.superLeft : "+(end-begin)+" ms");
    }
    {
      var begin = System.getTickCount();
      for(var i=0; i < TEST_NUM; i++)
        value = *(&global.Layer.left incontextof layer);
      var end = System.getTickCount();
      Debug.message("value = *(&global.Layer.left incontextof layer) : "
        +(end-begin)+" ms");
    }
    {
      var begin = System.getTickCount();
      for(var i=0; i < TEST_NUM; i++)
        value = (global.Layer incontextof layer).left;
      var end = System.getTickCount();
      Debug.message("value = (global.Layer incontextof layer).left : "
        +(end-begin)+" ms");
    }
    Debug.message("------");
    {
      var begin = System.getTickCount();
      var propLeft;
      for(var i=0; i < TEST_NUM; i++)
        propLeft = (&global.Layer.left incontextof layer);
      var end = System.getTickCount();
      Debug.message("propLeft = (&global.Layer.left incontextof layer) : "
        +(end-begin)+" ms");
    }
    {
      var begin = System.getTickCount();
      var propLeft = (&global.Layer.left incontextof layer);
      for(var i=0; i < TEST_NUM; i++)
        value = *propLeft;
      var end = System.getTickCount();
      Debug.message("value = *propLeft : "+(end-begin)+" ms");
    }
    {
      var begin = System.getTickCount();
      var globalLayer;
      for(var i=0; i < TEST_NUM; i++)
        globalLayer = (global.Layer incontextof layer);
      var end = System.getTickCount();
      Debug.message("globalLayer = (global.Layer incontextof layer) : "
        +(end-begin)+" ms");
    }
    {
      var begin = System.getTickCount();
      var globalLayer = (global.Layer incontextof layer);
      for(var i=0; i < TEST_NUM; i++)
        value = globalLayer.left;
      var end = System.getTickCount();
      Debug.message("value = globalLayer.left : "+(end-begin)+" ms");
    }
    invalidate layer;
  }
} 

var win = new TestWindow();
win.visible = true;
Debug.console.visible = true;
win.test();

実行結果。


value = layer.superLeft : 4892 ms
value = *(&global.Layer.left incontextof layer) : 2440 ms
value = (global.Layer incontextof layer).left : 2728 ms
------
propLeft = (&global.Layer.left incontextof layer) : 2414 ms
value = *propLeft : 414 ms
globalLayer = (global.Layer incontextof layer) : 1895 ms
value = globalLayer.left : 862 ms

下の4つはコンテキストを置き換える部分の速度と、
コンテキストを置き換えたオブジェクトを変数に格納してアクセスした場合の速度だ。

計測結果から次のことがわかる。

・incontextof 演算子は単にオブジェクトのコンテキストを置き換えるだけにすぎないので非常に高速
・別名のプロパティを介してsuperキーワードを使うよりは、incontextof 演算子を使う方がずっと速い
・一度コンテキストを置き換えてしまえば、さらに高速にアクセスできる
・プロパティオブジェクトを使う方法が最速
・複数のプロパティにアクセスする場合は、クラスオブジェクトを使う方法が効率的

とはいえこの速度差は大抵の場合問題にならないだろう。

まとめ

オーバーライドされたプロパティにアクセスする方法には、どの方法にも一長一短がある。
状況に合わせてうまく使い分けていきたい。


遮蔽処理の問題点

Posted 2013.05.08 in 吉里吉里

遮蔽の詳しい条件を確認していて、いくつか妙な点があることに気がついた。

だんだん小さくなる遮蔽矩形

遮蔽面が複数ある場合、それらの面によって作られる遮蔽領域は
より上のレイヤーからより下のレイヤーに向かうにつれて、だんだんと広がっていくはずである。
しかし遮蔽面によって作られる遮蔽矩形が重なった場合、
その交差をとって新たな遮蔽矩形とするので

遮蔽領域は逆にだんだん小さくなっていく。

さらに遮蔽面が重なっていない場合、その交差は空の矩形となるので遮蔽領域がいったん消滅し
次の遮蔽面から新たに遮蔽矩形が作成されるといった奇妙な動作になる。

この交差判定は、遮蔽領域の合成処理であると同時に
子レイヤーの遮蔽領域を、親レイヤーの領域内に制限するためのものだろう。
しかしそれらは本来別個の処理である。
また遮蔽領域を単一の矩形で管理していることがこの問題をややこしくしている。

不要な条件

「遮蔽される条件」として

・レイヤーとその親レイヤー全てが遮蔽有効条件
(レイヤーが可視であり、不透明であり、不透明時に透けない表示タイプである)を満たしている

があるが

この条件はおそらく不要である。

この条件は「遮蔽する条件」の一部でもあるので、
遮蔽レイヤーの親レイヤーは常にこの条件を満たし、遮蔽される。
しかしその兄弟レイヤーなどはこの条件を満たすとは限らない。
この条件により、本来遮蔽されうるはずのレイヤーが遮蔽されなくなってしまっている。

ちなみにレイヤーが遮蔽されないようにするトリックはこの条件を逆手に取ったものであり、
この条件がなくなると使えなくなる。

限定的な遮断

実際に UpdateExcludeRect を使った遮断が行われるのは、
子レイヤーと重なる領域に限定されている。
けれどもよくよく考えると、そのような限定はまったく必要がないように思える。
なぜわざわざそのように限定しているのか。

察するに、吉里吉里の遮蔽処理はおそらく本来

「親レイヤーを遮蔽する子レイヤーがある場合に
 親レイヤーの描画を省く」

ことのみを目的として作られた機能なのであろう。
だとすれば合点が行く。

しかし実際には、全てのレイヤーを考慮した遮蔽処理が行われている。
そのせいでいろいろ不整合が生じているものと思われる。

遮断の手抜き

tTJSNI_BaseLayer::CopySelf を見ると、UpdateExcludeRect が描画矩形を
上下にまたいでいない場合、遮断が行われないことがわかる。
言い換えると

遮断は横方向にしか行われない

ということである。

これは一見ひどい手抜きに思えるが、
実際には吉里吉里の画面描画の仕組みを考慮した設計だろう。

というのも、吉里吉里では画面を再描画する時、
画面領域を縦方向に細かく分割して描画が行われるからだ。
この領域を遮断によって縦方向にさらに分割すると、
かえってコストが高くついてしまう可能性が考えられる。
そこで敢えて”手抜き”をしているものと思われる。

ただし画面領域を分割するかどうかは設定で切り替えられるので、
分割しないように設定した場合は、
この”手抜き”によって遮蔽処理の効果が大幅に低下する可能性がある。

遮断のミス

tTJSNI_BaseLayer::CopySelf において、
UpdateExcludeRect が描画矩形の左辺をまたいだ状態を判定する次の条件───


else if(r.left >= uer.left && r.left < uer.left)

この条件は絶対に成立しない!

このため UpdateExcludeRect が描画矩形の左辺をまたいだ状態の時、遮断が行われない。
これは単純なミスだろう。
正しくは


else if(r.left >= uer.left && r.left < uer.right)

である。

まとめ

ここまで見てきたように、遮蔽処理にはかなりの癖がある。
レイヤーの type を ltOpaque にすると、遮蔽処理が行われ
遮蔽処理がもたらす難解な問題に遭遇する可能性があることに注意が必要だ。
最背面のレイヤーを ltOpaque にすることは得に問題ないだろうが、
手前に ltOpaque なレイヤーを作る場合は気をつけなくてはならない。

深みにはまる前に、根本的に ltOpaque の使用を避けるのも一つの方法だろう。
しかし遮蔽による描画量の削減はそれでもなお魅力的である。
敢えて ltOpaque を使い、遮蔽処理に由来する問題に遭遇した時、
遮蔽処理に関する知識がきっと問題解決の役に立つはずだ。