遮蔽の条件

Posted 2013.05.07 in 吉里吉里

以前に、遮蔽処理が行われるのは
「type==ltOpaque && opacity==255」
を満たす時であると述べたが、これはあくまで遮蔽処理の大前提となる条件であり
実際に遮蔽処理が行われるのはさらに限定的な状況だけである。

この条件については
「遮蔽する条件」
「遮蔽される条件」
「遮断を試行する条件」
の3つに分けて考えると考えやすい。

遮蔽する条件

以下の条件を全て満たす場合にレイヤーは遮蔽矩形を生成する。

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

より手前のレイヤーによって生成された遮蔽矩形が既にある場合は、
既存の遮蔽矩形と交差する矩形を新たな遮蔽矩形とする。

遮蔽される条件

以下の条件を全て満たす場合に
UpdateExcludeRect に遮蔽矩形が設定され、レイヤーは遮蔽される。

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

前回紹介したレイヤーが遮蔽されないようにするトリックは
この2つのめの条件を利用したものである。

遮断を試行する条件

上記2つの条件によって作成された UpdateExcludeRect の情報を元に描画を省くわけだが
その際にもさらに条件がある。

chached がtrueの時
・領域全体について遮断が試行される

chached がfalseの時
・可視の子レイヤーが1個以上あれば、
 子レイヤーと重なる領域について遮断が試行される
・可視の子レイヤーがなければ遮断は行われない
・子レイヤーと重ならない領域については遮断は行われない

また遮断の試行において、実際に遮断が行われるのは
UpdateExcludeRect が描画矩形に縦方向にまたがっている場合だけである。

遮蔽処理の実装

遮蔽処理の動作は、主に次の3つの関数に実装されている。

LayerIntf.cpp
tTJSNI_BaseLayer::QueryUpdateExcludeRect
tTJSNI_BaseLayer::Draw
tTJSNI_BaseLayer::CopySelf

これらの条件がどのように実装されているかはそちらを参照されたし。

まとめ

それにしても

ややこしい!

こうして条件を確認してみると、いくつか妙な部分があることに気がつく。
そのあたりの考察についてはさらに次回へ続く!


piledCopyと遮蔽

Posted 2013.05.06 in 吉里吉里

遮蔽処理にはさらに複雑な問題がある。

piledCopy によって起こる現象

遮蔽処理は Layer クラスの piledCopy 関数を実行した時にも行われる。
次の tjs スクリプトを実行してみて欲しい。


class TestWindow extends Window
{
  var layerGray;
  var layerBlack;
  var layerPiled;
  var timer;

  function TestWindow()
  {
    super.Window();
    setInnerSize(800, 600);

    // プライマリレイヤー
    add(new Layer(this, null));
    primaryLayer.setImageSize(innerWidth, innerHeight);
    primaryLayer.setSizeToImageSize();
    primaryLayer.fillRect(0, 0, innerWidth, innerHeight, 0xFFFFFFFF); // 白

    // 黒レイヤーを作る
    layerBlack = new Layer(this, primaryLayer);
    initLayer(layerBlack, 100, 100, 400, 400, 0xFF000000, 0, ltAlpha); // 黒

    // 黒レイヤーに紫の子レイヤーを作る
    var sub = new Layer(this, layerBlack);
    add(sub);
    initLayer(sub, 100, 100, 200, 200, 0xFFFF00FF, 0, ltAlpha); // 紫

    // タイマーを起動
    timer = new Timer(onTimer, "");
    timer.interval = 1000;
    timer.enabled = true;
  }

  function finalize()
  {
    invalidate layerGray;
    invalidate layerBlack;
    invalidate layerPiled;
    invalidate timer;

    super.finalize();
  }

  function initLayer(layer, x, y, w, h, color, absolute, type)
  {
    layer.left = x;
    layer.top = y;
    layer.setImageSize(w, h);
    layer.setSizeToImageSize();
    layer.fillRect(0, 0, w, h, color);
    layer.absolute = absolute;
    layer.type = type;
    layer.visible = true;
  }

  var step = 0;
  function onTimer()
  {
    switch(step)
    {
    case 0:
      // 灰色レイヤーを黒レイヤーの上に作る(遮蔽する)
      layerGray = new Layer(this, primaryLayer);
      initLayer(layerGray, 100, 100, 400, 400, 0xFF777777, 5, ltOpaque); // 灰
      break;

    case 1:
      // 黒レイヤーをpiledCopyしたレイヤーを一番上に作る
      layerPiled = new Layer(this, primaryLayer);
      initLayer(layerPiled, 100, 100, 400, 400, 0xFFFF0000, 100, ltAlpha);
      layerPiled.piledCopy(0, 0, layerBlack, 0, 0, 400, 400);
      break;
    }
    step++;
  }
}

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

これを実行すると次のようになる。

KiriKiri_Exclude3_1

黒レイヤーを描画できていない!

本来次のようになるはずである。

KiriKiri_Exclude3_2

回避方法

この現象は遮蔽処理にまつわるものなのだが、
absolute の変更によって起きた問題とはまた原因が異なる。
UpdateExcludeRect の更新ができていないわけではない。
試しに次のように記述しても動作は何も変わらない。


    case 1:
      // 黒レイヤーをpiledCopyしたレイヤーを一番上に作る
      layerPiled = new Layer(this, primaryLayer);
      initLayer(layerPiled, 100, 100, 400, 400, 0xFFFF0000, 100, ltAlpha);

      layerBlack.opacity = 0;
      layerBlack.opacity = 255;
      layerPiled.piledCopy(0, 0, layerBlack, 0, 0, 400, 400);
      break;

この問題を回避するには次のようにする。


    case 1:
      // 黒レイヤーをpiledCopyしたレイヤーを一番上に作る
      layerPiled = new Layer(this, primaryLayer);
      initLayer(layerPiled, 100, 100, 400, 400, 0xFFFF0000, 100, ltAlpha);

      layerBlack.visible = false;
      layerPiled.piledCopy(0, 0, layerBlack, 0, 0, 400, 400);
      layerBlack.visible = true;
      break;

piledCopy 対象のレイヤーを不可視にしてから piledCopy するようにしている。
なぜこれで問題を回避できるのか…

原因

この問題の原因は、

遮蔽処理には常に全てのレイヤーが考慮される

という点にある。

黒レイヤーを piledCopy しているので、
遮蔽処理は黒レイヤー以下のレイヤーについてのみ考慮されるものと期待するだろうが
実際には黒レイヤーの兄弟レイヤーである灰色レイヤーも考慮される。

つまり灰色レイヤーによる遮蔽によって、
黒レイヤーの描画が省かれてしまったというわけだ。

回避方法の意味

では対象レイヤーを不可視にすると、なぜこの現象を回避できるのか。
この回避方法は

「レイヤーが遮蔽されるのは、
 レイヤーが可視の時だけである」

という条件を逆手に取ったものだ。

レイヤーを不可視にすることで、
レイヤーが他のレイヤーによって遮蔽されないようにできるのである。
対象レイヤーが不可視でも、piledCopy は実行できるので問題ない。

単純に、遮蔽している灰色レイヤーの方を不可視にしてもこの問題は回避できるが、
どのレイヤーが遮蔽しているのかわかりやすいケースばかりとは限らないので
対象を不可視にするというトリックは便利だろう。

まとめ

今回は遮蔽処理の条件を逆手にとって問題を回避したが
遮蔽処理が実施される条件は実際のところかなり複雑だ。

黒レイヤーの描画が省かれる一方で、その子レイヤーである紫レイヤーが
ちゃんと描画されている点について不思議に思ったかもしれない。
これもまた遮蔽処理の複雑な条件に理由がある。

詳しくは次回にでも~


遮蔽処理の仕組み

Posted 2013.05.06 in 吉里吉里

前回の記事では遮蔽処理にまつわる不可思議な現象を再現してみたが、
この現象を回避する方法は、現象の複雑さに対して実に簡単だ。

不具合の回避

onTimer関数に次の記述を追加してみよう。


  var step = 0;
  function onTimer()
  {
    switch(step)
    {
    case 0:
      // 黒レイヤーを上に移動(不具合発生)
      layerBlack.absolute = 10;
      break;
    case 1:
      // 無意味にopacityを変更する(不具合回避)
      layerBlack.opacity = 0;
      layerBlack.opacity = 255;
      break;
    }
    step++;
  }

一見無意味に思える処理だが、実際この処理を行うと正しい動作結果になる。
この回避方法の意味を知るには、吉里吉里の遮蔽処理の仕組みを理解する必要がある。

遮蔽処理の仕組み

全てのレイヤーはそれぞれ個別に UpdateExcludeRect(更新遮断矩形) という情報を持つ。
UpdateExcludeRect は、そのレイヤーが他のレイヤーによって遮蔽されている領域を表す。

UpdateExcludeRect の情報は、レイヤーの再描画時に利用される。
レイヤーの再描画範囲が UpdateExcludeRect に被る時、
再描画範囲は UpdateExcludeRect にって分割され
UpdateExcludeRect に含まれる領域の再描画が省かれる。
まさしく「更新を遮断する矩形」なのである。
これによって遮蔽されていてどうせ見えない領域の描画の無駄が削減される。

重要なのは UpdateExcludeRect の情報が更新されるタイミングである。
UpdateExcludeRect は再描画のたびに毎回更新されるわけではない。
UpdateExcludeRect の更新は、
再描画時に VisualStateChanged フラグが立っている時だけ行われる。
VisualStateChanged フラグは、遮蔽状態に変化がある操作が行われた時に立てられる。
よって遮蔽状態に変化がなければ UpdateExcludeRect は更新されない。

回避方法の意味

先の不具合は、この UpdateExcludeRect の情報が正しく更新されないことによって起こる。
absolute の操作によって遮蔽状態が変化するにもかかわらず、
VisualStateChanged フラグが立てられていないからである。

この不具合を回避するには、立てられていない VisualStateChanged フラグを立ててやればよい。
opacity を操作すれば VisualStateChanged フラグが立てられるので、
opacity を無意味に変更すれば VisualStateChanged フラグを間接的に立てることができる。
これにより不具合を回避することができるのである。

よりスマートな回避方法

問題のあるプロパティをオーバーライドしておくと
プロパティを操作する度にいちいち気を遣う必要がなく便利だろう。
把握している限りでは order, type プロパティにも同様の問題があるので、そちらも対処しておく。


class OcclusionLayer extends Layer
{
  function OcclusionLayer(window, parent)
  {
    super.Layer(...);

    // 遮蔽するように設定
    type = ltOpaque;
  }

  // VisualStateChanged フラグを間接的に立てる
  function notifyVisualStateChanged()
  {
    var temp = opacity;
    opacity = !opacity;
    opacity = temp;
  }

  property absolute
  {
    setter(v){ super.absolute = v; notifyVisualStateChanged(); }
    getter(){ return super.absolute; }
  }

  property order
  {
    setter(v){ super.order = v; notifyVisualStateChanged(); }
    getter(){ return super.order; }
  }

  property type
  {
    setter(v){ super.type = v; notifyVisualStateChanged(); }
    getter(){ return super.type; }
  }
}

まとめ

実はこの absolute の問題は、わたなべごうさんに修正していただき
最新の開発版の吉里吉里では修正されている。
今回紹介した回避方法は、古いバージョンの吉里吉里を使っている場合に
この問題に遭遇した時の応急策として使えるだろう。


遮蔽処理の不具合

Posted 2013.05.06 in 吉里吉里

吉里吉里では、かなり限定された状況で遮蔽処理が行われるが、
その遮蔽処理には、いくつか問題が潜んでいる。
遮蔽処理にまつわる問題は非常に複雑怪奇な現象を引き起こし
とにかく原因を特定しづらい。

不可思議な動作

まずは次のtjsスクリプトを実行して見て欲しい。


class TestWindow extends Window
{
  var layerGray;
  var layerBlack;
  var timer;

  function TestWindow()
  {
    super.Window();
    setInnerSize(800, 600);

    // プライマリレイヤー
    add(new Layer(this, null));
    primaryLayer.setImageSize(innerWidth, innerHeight);
    primaryLayer.setSizeToImageSize();
    primaryLayer.fillRect(0, 0, innerWidth, innerHeight, 0xFFFFFFFF); // 白

    // 灰色レイヤーを上に作る
    layerGray = new Layer(this, primaryLayer);
    initLayer(layerGray, 50, 50, 400, 400, 0xFF777777, 5, ltOpaque); // 灰
    
    // 黒レイヤーを下に作る
    layerBlack = new Layer(this, primaryLayer);
    initLayer(layerBlack, 100, 100, 400, 400, 0xFF000000, 0, ltOpaque); // 黒
    
    // 黒レイヤーに子レイヤーを作る
    var sub = new Layer(this, layerBlack);
    add(sub);
    initLayer(sub, 100, 100, 200, 200, 0xFFFF00FF, 0, ltAlpha); // 紫
    sub.opacity = 128; // 半透明
    
    // このタイミングで黒レイヤーを上に移動させると正しい動作結果になる
    // layerBlack.absolute = 10;
    
    // タイマーを起動
    timer = new Timer(onTimer, "");
    timer.interval = 1000;
    timer.enabled = true;
  }

  function finalize()
  {
    invalidate layerGray;
    invalidate layerBlack;
    invalidate timer;

    super.finalize();
  }

  function initLayer(layer, x, y, w, h, color, absolute, type)
  {
    layer.left = x;
    layer.top = y;
    layer.setImageSize(w, h);
    layer.setSizeToImageSize();
    layer.fillRect(0, 0, w, h, color);
    layer.absolute = absolute;
    layer.type = type;
    layer.visible = true;
  }

  var step = 0;
  function onTimer()
  {
    switch(step)
    {
    case 0:
      // 黒レイヤーを上に移動(不具合発生)
      layerBlack.absolute = 10;
      break;
    }
    step++;
  }
}

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

これを実行すると次のような動作結果になる。(version2.32 rev2 で確認)

KiriKiri_Exclude1_1

おかしい動作結果なのだが、何がおかしいかわかるだろうか?

紫色が明るすぎる!

のである。
次の部分のコメントアウトを外すと、正しい動作結果になるので確認してみよう。


    // このタイミングで黒レイヤーを上に移動させると正しい動作結果になる
    // layerBlack.absolute = 10;

KiriKiri_Exclude1_2

紫色が暗くなった!
opcity=128なのだから、この程度の色合いが正しい。

さらに不可思議な動作

子レイヤーを作る部分を次のように書き換えると、さらに現象がわかりやすくなる。


    // 黒レイヤーに子レイヤーを作る
    for(var x=0; x<10; x++)
    {
      for(var y=0; y<10; y++)
      {
        var sub = new Layer(this, layerBlack);
        add(sub);
        initLayer(sub, 50+x*30, 50+y*30, 8, 8, 0xFFFF00FF, 0, ltAlpha); // 紫
        sub.opacity = 128; // 半透明
      }
    }

子レイヤーとして紫の四角をたくさん作成するようにしただけだ。
これを実行すると次のようになる。

KiriKiri_Exclude2_1

背後の灰色が透けてしまっている!

コメントアウトをはずすとやはり正しい動作になるので確認しておこう。

KiriKiri_Exclude2_2

何がおきているのか

この不可思議な現象は、遮蔽処理にまつわる問題によって引き起こされている。
遮蔽処理にミスが生じ、実際には遮蔽していないにもかかわらず、
遮蔽による最適化が行われてしまっているのである。

この現象の回避は、遮蔽処理の存在を知らないと非常に困難だろう。
詳しい原因の追求と回避方法についてはまた次回~


無効領域と遮蔽

Posted 2013.05.03 in 吉里吉里

めざせ60FPS!の記事で、Foooの無効領域処理と遮蔽処理による最適化を紹介したが
この二つの最適化は、実は吉里吉里でも行われている。

無効領域処理

無効領域処理は常に自動的に行われている。
吉里吉里のウィンドウをアクティブにして「Shift+F11キー」を押すと
更新矩形の発生の様子を確認できる。
複数の矩形で管理されているため、期待通り効率的に動作する。

無効領域は描画関数を使ったり、レイヤーの見た目が変化する操作をすると自動的に発生する。
Layer クラスの update 関数で明示的に無効領域を発生させることもできる。
プラグインで新たに描画関数を作る時は、
描画を行った範囲を update 関数を使って無効領域にする必要がある点に注意が必要だ。
そうしないと描画が画面に反映されない。

ちなみに update 関数には少し癖がある。
詳しくは onPaintが二度呼ばれる問題 の記事を参照のこと。

遮蔽処理

吉里吉里が遮蔽処理を行うことはあまり知られていないかもしれない。
遮蔽処理は無効領域処理と違って、常に行われるわけではない。
遮蔽処理が行われるのは、レイヤーが不透明であることが保証される場合だけである。
言い変えるとレイヤーが

type==ltOpaque && opacity==255

の時に遮蔽処理が行われるということだ。
(厳密に言うともうちょっと複雑だが)

遮蔽処理をいつの間にか使っているケース

描画処理を少しでも軽くしようと思案した時まず思いつくのが
背景レイヤーの type を不透明度を考慮するデフォルトのltAlphaではなく、
不透明度を無視する ltOpaque にすることだろう。
背景は常に不透明であり、不透明度が必要ないからだ。

背景レイヤーの face を書き込み先の不透明度を保証しない dfOpaque にすると、
さらに描画の効率化が期待できる。
もっとも face はデフォルトで dfAuto なので、type を ltOpaque にすると
いちいち face を設定しなくても dfOpaque が設定された状態と同じになる。

face が dfOpaque だと ltAdditive のような不透明度を考慮しない高速なレイヤ表示タイプを
子レイヤーに対して使うことができるというメリットもある。

そうこうしてレイヤーの type を ltOpaque にすると…そう
「type==ltOpaque かつ opacity==255」の条件を満たすことで
遮蔽処理が行われるようになる。
遮蔽処理のことを知らなくても自動的にさらに描画が効率化されるというわけだ!

とは言え…視覚的には何も変わらないので、
遮蔽処理が行われている事実はわかりにくいかもしれない。

まとめ

無効領域処理と遮蔽処理の効果は、Foooの動作サンプルを見てもわかる通り絶大である。
その存在を意識することで、より効率的な動作をさせることが可能となるだろう。
積極的に活用していきたいところだ。

が!
………遮蔽処理には大きな問題がいくつか潜んでいる。
そのお話はまた次回~


== null が激重(再検証)

Posted 2013.05.02 in 吉里吉里

前回の記事で、== nullが激重になる原因が例外が投げられていることにあると述べたが
ソースコードを見て推測しただけで、きっちりトレースして調べたわけではなかったので
本当にそうなのか詳しく調べてみた。

現象の確認

Borland C++ Builder 6 のデバッガでステップ実行してみたところ
やはり例外が投げられていた。
eTJSVariantErrorという種類の例外だ。

この例外はtTJSVariant::NormalCompareのtry-catch節ですぐに捕まえられて
何事もなかったかのようにひっそりと処理されてしまうため
コンソールやSystem.exceptionHandlerなどでは捕捉できない。

負荷の特定

例外が投げられているのは確かだが、
異なる型との == null が重い原因は本当に例外のせいなのだろうか?
負荷の原因を細かく特定するため、ソースコードをいじって速度を計測してみた。

(1) 改変なし 11554 ms
(2) AsReal で TJSThrowVariantConvertError(); を呼ばずに、
throw eTJSVariantError(L””); としたもの。
6448 ms
(3) AsReal で TJSThrowVariantConvertError(); を呼ばずに、
throw 0; としたもの。
6357 ms
(4) AsReal で TJSThrowVariantConvertError(); を呼ばずに、
return 0; としたもの。
74 ms
(5) AsReal を呼ぶ前に左辺と右辺のどちらかが tvtObject なら
return false; としたもの。
47 ms

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

例外処理が負荷の半分を占めている

そしてもう半分を TJSThrowVariantConvertError 内の処理が占めている。
TJSThrowVariantConvertError 内で行われている処理は
エラーメッセージを作成する文字列処理である。

修正案

実験結果から例外の負荷が非常に大きいとわかったので、
これを回避する方法について考えて見よう。

例外を投げているのはAsRealなので、
AsRealが例外を投げないようにする案がすぐ思いつくが、それはかなりデンジャラスである。
AsRealは他の場所でも使われているし、何よりAsRealが例外を投げるのはおそらく仕様上の動作であり、
その改変による影響範囲は計り知れない。

ここは実験(5)でやっていたように、
AsRealを呼ぶ前にtvtObjectを別途処理するようにするのが最もシンプルで安全な解決策だろう。
そもそもAsRealが呼ばれる時点で、型はInt、Real、Object、Octetの組み合わせしかないので
エラー検出をAsRealにわざわざ頼る必要は、もはやあまりないように思う。

tvtObjectやtvtOctetを判定ではじいて別途処理した場合、
判定回数が増加するというデメリットがある。
しかしそのデメリットは判定の優先順位の見直しである程度軽減できるだろう。
ObjectやOctetと他の型の値を比較するケースより、
IntとRealを比較するケースの方が圧倒的に多いであろうからだ。

さらなる最適化

またこれはあくまでアイデアにすぎないが、tvtObjectのような型の種類を表す定数を
ビットフラグ的な値にすれば、判定をより効率化できるかもしれない。


if(vt==tvtString || val2.vt==tvtString){ }
if(vt==tvtVoid || val2.vt==tvtVoid){ }
if((vt==tvtInt || vt==tvtReal) && (val2.vt==tvtInt || val2.vt==tvtReal)){ }
if(vt==tvtObject || val2.vt==tvtOject){ }
if(vt==tvtOctet || val2.vt==tvtOctet){ }

のような判定は


tjs_unit32 IntRealOR = tvtInt | tvtReal;
tjs_uint32 vtOR = vt | val2.vt; 
if(vtOR & tvtString){ }
if(vtOR & tvtVoid){ }
if(vtOR == IntRealOR){ } // vt != val2.vt なので == で済む
if(vtOR & tvtObject){ }
if(vtOR & tvtOctet){ }

に簡略できる。

まとめ

などなどいろいろ書いてみたけれど…
=== を使えばおおよそ問題を回避できるので、とりあえずそれで!


== null が激重

Posted 2013.04.28 in 吉里吉里

まずは次のTJSスクリプトを実行してみて欲しい。


function test()
{
  var TEST_NUM = 1000000;
  var begin, end;
  var v = 0;

  Debug.message("Test Num: "+TEST_NUM);

  begin = System.getTickCount();
  for(var i=0; i < TEST_NUM; i++) v == void;
  end = System.getTickCount();
  Debug.message("v(0) ==  void : %d ms".sprintf(end-begin));

  begin = System.getTickCount();
  for(var i=0; i < TEST_NUM; i++) v === void;
  end = System.getTickCount();
  Debug.message("v(0) === void : %d ms".sprintf(end-begin));

  begin = System.getTickCount();
  for(var i=0; i < TEST_NUM; i++) v == null;
  end = System.getTickCount();
  Debug.message("v(0) ==  null : %d ms".sprintf(end-begin));

  begin = System.getTickCount();
  for(var i=0; i < TEST_NUM; i++) v === null;
  end = System.getTickCount();
  Debug.message("v(0) === null : %d ms".sprintf(end-begin));
}

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

TJSの特殊値 void, null の比較の速度を計測している。
これを実行すると次のような結果になる。


Test Num : 1000000
v(0) ==  void : 38 ms
v(0) === void : 33 ms
v(0) ==  null : 11554 ms
v(0) === null : 38 ms

== null が激重!!!

100万回で 10秒近くということは…

1回あたり0.01msもかかっている!

たいしたことないように思えるかもしれないが
60FPSを実現するには17ms以内に1フレーム中の全ての処理を終えないといけないので
たった100回の比較で1ms食うというのは極めて甚大だ。

一方で、void との比較の場合は null のような問題は起きていない。
なぜこのようなことが起こるのか…

void と null の違い

この問題を追求するには、まず void と null の違いを理解する必要がある。
吉里吉里の値に関して詳しくはこちら。
TJS2 リファレンス > 言語リファレンス > 項

void は値が入ってないことを示す特殊値である。
変数に初期値を指定しない時、変数に最初に入っている値がこの void である。
内部実装では tvtVoid型 の値である。

null は何も指し示していないを示すObject型の値である。
内部実装では tvtObject型 の値である。

void と null は一見似たような性質の値に思える。
しかし

void と null は型が異なる

のである。
実はこの違いが決定的な差を生み出している。

== の実装

吉里吉里のソースコードを追いかけて、原因を探ってみよう。
==演算子が行う比較処理の実装は tTJSVariant::NormalCompare にある。
tjsVariant.h
tjsVariant.cpp

実装を見ると、比較する値の型が同一である場合は、なんら問題なく比較が行われている。
問題なのは、比較する値の型が同一でない場合だ。

型が異なる値の比較

型が異なる値を比較する場合、どんな処理が行われるのか。
その実装も tTJSVariant::NormalCompare にある。
型が異なる場合は…

値を実数に変換して比較する

ただしtvtVoid型とtvtString型については特別な実装が施されている。

tvtVoid型を他の型の値を比較する場合、
相手が数値または文字列であれば数値に変換し、voidを0とみなして比較が行われる。
それ以外の型と比較する場合は、常にfalseとなる。

tvtString型を他の型の値を比較する場合、
他方を文字列に変換して比較が行われる。

実数化の中身

実数化の処理はAsRealに記述されている。
tvtObject型の値がどう変換されるのか見ると…


  TJS_CONST_METHOD_DEF(tTVReal, AsReal, ())
  {
    TJSSetFPUE();

    switch(vt)
    {
    case tvtVoid:    return 0;
    case tvtObject:  TJSThrowVariantConvertError(*this, tvtReal);
    case tvtString:  return String->ToReal();
    case tvtInteger: return (tTVReal)Integer;
    case tvtReal:    return Real;
    case tvtOctet:   TJSThrowVariantConvertError(*this, tvtReal);
    }
    return 0.0f;
  }

例外が投げられている!

コレダー!!!
比較の度に例外が投げられていたのでは…
そりゃあー重いはずである。

AsReal から投げられた例外は tTJSVariant::NormalCompare でキャッチされ、falseを返す。

本当の問題

原因は判明したが、よくよく考えるとこの問題は == null に限った問題ではない。
nullでなくても、Object型の値とInt型などの値を比較すれば発生しうる問題だからだ。
またOctetの場合も同様の問題が発生しうることがわかる。

そもそも null と数値を比較するなんてこと自体がおかしいと思うかもしれない。
しかし「数値とオブジェクトの両方が入りうる利便性の高い変数」を作った場合や
「値は設定されているが、不定であることを表す値」として
null を流用しようとした場合などにありえる話である。

対策

問題の原因を理解していれば、この問題を回避するのは簡単だ。

===を使えばいい

===演算子はより厳密な比較を行う比較演算子であり、
異なる型同士の比較の場合は常にfalseを返し、変換は行わない。

ただしObject型同士の比較は == と === とで作用が異なっている点に注意が必要だ。
===演算子はそのオブジェクトのコンテキスト(this)まで比較する。

まとめ

nullと比較する時は == ではなく === を使おう!


めざせ60FPS!

Foooはありがたいことに、とても快適に動作するという評価をいただいている。
今回はその快適さがどのように実現されているかについて紹介しようと思う。

高い要求と過酷な環境

Foooに最初に要求された動作水準は800×600サイズのフルカラー。
それに対し当時の標準的なPCのスペックは Celeron 800MHz 程度だった。
コンピュータが日々進歩を続けているとは言え、その環境は
800×600サイズのフルカラー表示をソフトウェア処理で行うにはまだまだ過酷だった。

どのくらい過酷かというと、800×600サイズの画像を1枚表示させるだけで30FPSなくらい。
言い換えると

画面に背景を表示するだけで30FPS

になってしまうのだ。
他に画像を表示すればあっという間に
20FPS…10FPS…5FPS…

ちなみに、FPSとは1秒の間に画面を再描画する回数(Frame Per Secnod)のことである。
モニタの標準的なFPSは60FPSであるが、
ゲームを快適にプレイできるようにするためには最低でも平均30FPS程度は出したいところだ。
それが、背景を表示するだけで30FPS…
もっと低スペックの環境も当然ありえるが400MHzだと15FPS、166MHzだと5FPS…

5FPSともなると、もはやまともにプレイできるような状態ではない。
400MHZでも最低30FPS、166MHzでも最低15FPSは出るようにしたい。
そのためには800×600で60FPS出るくらいじゃないとお話にならない。

無理じゃね!?

描画処理の最適化

泣き言を言っても始まらない。
FPSが出ないのは、一にも二にも描画に非常に時間がかかっているからである。
そこで最初に行った対策は

とにかく描画を速くする

ことである。
次のような最適化を施した。

・ケースバイケースごとに細かく処理分けして少しでも無駄な処理を省く
・コンパイラに頼らずアセンブラで最適化
・MMXを使う
・SSEを使う
・パイプラインストールを減らす
・メモリアライメントを意識する
・キャッシュヒット率を考える
・キャッシュにプリフェッチする

そんなこんなで~描画処理を1.5倍程度高速化することができた!
16msかかっていた800×600の画像の描画処理が11msになった。

──────しかしFPSにすると、30FPSだったのが40FPSになったくらい。
60FPSにはまるで届いていない。

無効領域処理

描画処理そのものの高速化はもう限界。
そこでもっと別の角度から高速化を行えないかと考えた。
それは…

無駄な描画をしない

ことである。

無駄な描画を省く最適化は一般的にカリング(Culling)と呼ばれる。
Cullingとは「間引き」という意味だ。
Windowsのウィンドウの描画においても、このカリング処理が行われている。

画面の再描画を行うには、画面を背景から何から全て描き直してしまうのが最もシンプルな方法である。
しかしそこには非常に多くの無駄が存在する。

まず画面が何も変化してないのに画面を書き直すのはまったくの無意味だ。
そのような画面の再描画は根本的に省くことができる。
さらに画面の中で前回描画した時から何も変化していない部分を再描画するのも大きな無駄だ。
何も変化していないのだから再描画する必要はない。
要するに

変化がない部分は再描画する必要がない

のである。
実際の最適化の様子を見てみよう。

YouTube Preview Image

赤い箱枠で示されているのが、変化があり再描画が行われている領域である。
箱枠がない部分は再描画の必要のない領域だ。
再描画領域が非常に小さく限定されていることがわかるだろう。

このような再描画の必要のある領域を『無効領域』(Invalid Rect)と呼ぶ。
Update Rect(更新領域), Dirty Rect(汚れた領域)などと呼ばれることもある。

この最適化の効果は絶大だった。
事実、この最適化によって”画面に動きがない部分では”あっさり60FPSを達成できた!

しかしこの最適化を活かすには、演出においてこの最適化を意識することが大切だ。
画面全体を動かすような演出をできるだけ避けるといった工夫である。

無効領域の分割管理

無効領域は効率のために矩形で管理するが、
無効領域は画面の中で飛び飛びに複数発生する可能性がある。
そのような無効領域を矩形で管理しようとすると無駄が発生してしまう。
次の例を見て欲しい。

YouTube Preview Image

雪の粒が画面を埋め尽くしているので、無効領域はほぼ画面サイズと等しくなっている。
しかしこの無効領域には明らかに大きな無駄がある。
再描画の必要のない部分まで無効領域に含めてしまっている。

このような無駄が発生するのは、無効領域を単一の矩形を管理しているせいだ。
この問題を解決するには

無効領域を複数の矩形で管理すればいい

実際の例を見て見よう。

YouTube Preview Image

無効領域が非常に細かく管理され、無駄が省かれている様子がわかるだろう。
けれども無効領域をあまりに細かく管理しすぎると
無効領域の管理の処理負荷が大きくなってしまうため加減が肝心だ。

この最適化により、画面の各所で複数のものが動作する場合でも
非常に高いFPSを出すことができるようになった。

遮蔽される面の除去

描画において大きな無駄がもう1つある。

見えない部分は描画する必要はない

のである。

複数の画像が重なっていて、後ろの画像が手前の画像によって覆い隠されている場合、
後ろの画像はどうせ隠れてしまうので描画する必要がない。
言われて見れば、至極当たり前の最適化である。
この最適化は3D方面ではオクルージョンカリング(Occlusion Culling)と呼ばれる。
Occlusionとは「遮蔽」という意味だ。
実際の動作の様子を見てみよう。

YouTube Preview Image

青い箱枠が遮蔽面、緑の箱枠が遮蔽しない面である。
この箱枠の形に描画処理が行われる。
遮蔽によって、背後の画像の領域が分割されている様子がわかるだろう。

この最適化の効果は思いのほか大きい。
実際の演出では画面の切り替えにフェードイン、フェードアウトが多様されるが
フェード幕で隠れた部品を、幕の背後に残したまま演出を続けるようなことが多々ある。
そのような場合にこの最適化がすごく活きる。

幕の背後の描画を自動的に行わないようになるからだ。
逆にこの最適化がないと、非常に大きな無駄が発生してしまう。
そして、ソレになかなか気がつかない。

Foooの演出において、真四角のコマが多用されているのもこの最適化が関連している。
コマを真四角にすることで、背景を遮蔽しやすくしているのだ。
そのようにすると、コマがたとえ画面に何十枚もある場合でも
背景を1枚描画するのとさほど変わらないコストで画面を描画できるのである。

遮蔽される無効領域の除去

次の最適化は併せ技だ。

見えない部分が変化しても再描画する必要はない

遮蔽されている部分で何かが動いても、見た目に変化は起きないので再描画する必要がない。
要するに「無効領域に対して遮蔽を行う」ということである。
これも実際の動作を見るのがわかりやすいだろう。

YouTube Preview Image

まとめ

全ての最適化を適用したもの。

YouTube Preview Image

このような最適化と、その最適化を意識した演出によって
Foooはソフトウェア処理でありながら高速な(高速に見える)動作を実現しているのである。

以下おまけ。

YouTube Preview Image
YouTube Preview Image


Windows95でも動く!

よく誤解されるのだけれど…

Foooはソフトウェアエンジンである!

グラフィックスのハードウェアアクセラレーション…
いわゆるビデオカードの機能はまったく利用しておらず
ほぼ全ての処理をCPUのみで行っている。

なのでビデオカードを最新のものに買い換えても恩恵はさほどない。
VRAMの容量なんかはパフォーマンスにはほとんど関係ない。
当然ビデオカードの3D機能なんかはまったく使っていない。
前回書いたような3D機能も全てソフトウェア処理で実現されている。

というわけで、いつも動作環境に「3Dアクセラレーション不用」とチラッと書いていたのだけれど
………お気づきになられただろうか(汗)

ハードウェア処理のリスク

ビデオカードが提供するハードウェアアクセラレーションは非常にパワフルな機能である。
またDirectXが提供する強力なフレームワークもプログラマの手間を大きく軽減してくれる。
3Dはすごく簡単に扱えるし、そして何より圧倒的に高速だ。

ハードウェア処理を利用することには極めて大きなメリットがあるが、同時に大きなリスクも孕んでいる。
ハードウェアの機能を利用するということは、
ハードウェアの機能に依存してしまうということと同義だからだ。

ソフトウェア処理のメリット

ハードウェアに依存しないことには、
ハードウェアの恩恵を受けられないハンデを背負ってでも代えがたい
非常に大きなメリットがある。
わかりやすく一言で言えば

FoooはWindows95でも動く!

たぶんWindowsNT4.0でも動く(笑)
え、何の意味があるのかって?
意味なんかない!

動くことが正義である!

というのは半分冗談で、
あらゆる環境で動作するようにと配慮を重ねた結果だ。
Windows95で動けばどんな環境でも動くだろうということで!

今でこそハードウェアアクセラレーションは気軽に使えるものになっているが
Foooが開発された10年前はそんなに気軽に使えるものではなかった。
各社のビデオカードの機能は様々で、動作も様々。

DirectXがそのあたりの差異を適宜ソフトウェア処理で埋めてくれるという謳い文句だったのだが
期待しているような水準にはまったく達しておらず
半透明で描画するように指定したらメッシュ状態で表示したりと散々だった。
確かに透けてるといえば透けてるけど!
そういった中でハードウェアアクセラレーションを利用することはあまりに無謀だったのである。

ソフトウェア処理の強み

その頃の仕様をいまだに引きずってる意味があるのかと言われるとアレだが…
ソフトウェア処理であるということは環境に依存しないこと以外にもメリットがある。
ハードウェアが提供していない処理も自在に行うことができるという点だ。

例えば、昨今では画像を合成する際に様々な合成方法が使われる。
オーバーレイや覆い焼き、焼きこみなどPhotoshopでおなじみだろう。

しかし意外に思うかもしれないが、現在のハードウェア処理では
そういった合成方法を扱うのは非常に難しい。
ソフトウェア処理ならば、比較的低いコストで導入することができる。
これはソフトウェア処理の大きな強みだ。


流動指向とは

流動指向ゲームエンジン『Fooo』と銘打ってはいるが、
「流動指向」とは完全なる造語である。
では「流動指向」とはいったいどういう意味なのか。

それはFoooが、複数のオブジェクトがまるで浮き流れるかのように
同時平行で動き回ることを前提としたシステムであるということを象徴している。
オブジェクトが同時に動くシステムなんて珍しくないと思うかもしれない。
しかしFoooが実現しているその機構は、おそらく他のシステムとは一線を画している。

時間の概念

Foooスクリプトには
言語レベルで時間の概念がある

他の多くのプログラミング言語でも時間を扱うことはできるが、
それらは時間を参照できるとか、時間を元にイベントを起こせるとかいう形である。
Foooがそれらと決定的に違うのは「プログラムが時間に同期して実行される」という部分だ。
言い換えればFoooスクリプトは「実時間同期型の言語」であると言ってもいい。

時間と同期しながらプログラムが実行されるシステムというと
シューティングゲームやアクションゲームのスクリプトシステムを思い出すかもしれない。
それもそのはず、Foooはもともとシューティングゲーム用に作っていたシステムの設計を
転用して作られているのだ。

シューティングゲームやアクションゲームなどのリアルタイム性を要求されるゲームでは、
時間に同期しながらスクリプトを実行するのが一般的だろう。
しかしFoooにはまたそれらの多くのシステムとはおそらく異なる特徴がある。

細かな時間単位

Foooスクリプトは
時間がミリ秒単位である

古典的なリアルタイムゲームシステムでは、
時間の単位が画面の更新フレーム単位であることが多い。
しかしそのようなシステムでは時間がフレームに拘束されてしまい
演出の速度を細かく変更することが難しいという問題がある。

Foooでは時間の単位をミリ秒とすることでフレームに拘束されない柔軟性の高い構造をとっている。
ゲームの進行速度をプレイヤーが自由に変更できるようにするという要件があったためだ。
これにより速度をシームレスに調節するという機構が実現されている。

正確な時間同期

またさらにFoooには
実時間と正確に同期をとる
という大きな特徴がある。

1000msかかる演出を1回やる処理と、
2msかかる演出を500回やる処理と、
1msかかる演出を1000回やる処理が
ぴったり1000msで同時に終わることが完全に保証されている。

1000msかかる演出を実行してから
500ms後に500msかかる演出を実行して
ぴったり1000msで同時に終わることが完全に保証されている。

何を当たり前なことを!
…と思うかもしれないが、当たり前でないことの方がたぶん多いだろう。

多くのプログラミング言語において正確に同期をとるというのは非常に悩ましい問題だ。
複数のプログラムを同時に実行するスレッドやなどは、
プログラマの間では鬼門とされているくらい扱いの難しいしろものだ。

しかしFoooでは、スレッド(正確にはコルーチンであるが)をいとも簡単に扱うことができる。
複数のスレッド、オブジェクトデーカーなどが同時並行で動く場合でも
時間の上で考えて同時になるはずの部分は、想像通り同時になるように動く。
このように時間で同期がとられることが保証されているので、
複数の部品が同時並行的に動作するスクリプトを極めて直感的に記述することができる。

そんなこんなで

───────などとFoooの特徴についてまとめてみたが…
他のシステムについて別段詳しいわけでもないので、
井の中の蛙が勝手に特徴的だと思いこんでるだけかもしれず…
「似たような有名なシステム既にあるよ!」とかあったら教えてください(苦笑)