バッファを使う

複数のデーカーを一枚の画像として扱えるようにするには
もう一つのアプローチがある。

スナップショットデーカーの動作サンプルを見ればわかるが、
複数のデーカーを一枚の画像として扱えるようになってはいるものの、
画像は停止してしまっていた。
スナップショットなのだから当然と言えば当然である。

毎フレームスナップショットをとるようにして、
デーカーを動かしながら一枚の画像として扱えるようにできなくもないが
ちょっと非効率なかんじがする。

Decor_Buffer バッファデーカー

そんな時はバッファデーカーを使う。
バッファデーカーは子デーカーの表示を自身が持つバッファに写しこみ、
一枚の画像として扱えるようにするデーカーである。
子デーカーが動くとバッファの内容は自動的に適切に更新される。

バッファデーカーを作るにはCreateBuffer命令を使用する。


CreateBuffer(name="バッファ", w=1280, h=720);

組み込んでみよう。


class 煙クラス
{
  method 煙クラス()
  {
  }

  method OnEnter()
  {
    int $number = 0;
    while(true)
    {
      string $name = "煙粒"+String($number++);
      ThreadCreate(call=@煙粒スレッド(name=$name));
      wait 100;
    }
  }

  method 煙粒スレッド(string $name)
  {
    CreateImage(name=$name
      , x=0, y=0, sx=20%, sy=20%, ox="Center", oy="Middle"
      , angle=rand_range(0,359), image="煙粒.png");
    Enter(to=$name);

	int $time = 2000;
    int $angle = 90+rand_range(-10,10);
    float $rad = radian(Float($angle));
    float $move = 300.0;
    float $x = $move*cos($rad);
    float $y = -$move*sin($rad);

    Move(to=$name, time=$time, x=$x, y=$y, step="DecSin");
    Zoom(to=$name, time=$time, sx=200%, sy=200%);
    Opaque(to=$name, time=$time, alpha=0%, step="AccSin");
    WaitDecor(to=$name);
    Delete(to=$name);
  }
}

method Test(string $effect)
{
  Transform(to="バッファ", effect=$effect, time=1000);
  wait 1000;
  Transform(to="バッファ", effect=$effect, time=1000, begin=100%, end=0%);
  wait 1000;
}

method Main()
{
  CreateImage(name="画像2", image="画像2.png");
  CreateBuffer(name="バッファ", w=1280, h=720);
  CreateImage(name="バッファ/画像1", image="画像1.png");
  CreateObject(name="バッファ/煙", x=1280/2, y=720/2+150, class=@煙クラス());
  Enter(to="*");
  Enter(to="バッファ/*");
  wait 3000;

  call @Test(effect="LinearLeft");
  call @Test(effect="BlindLeft");
  call @Test(effect="CurtainLeft");
  call @Test(effect="ShaveLeft");
  call @Test(effect="SlashH");
  call @Test(effect="BoxCenter");
  call @Test(effect="HoleCenter");
  call @Test(effect="FanCenter");
  call @Test(effect="TensileLeft");
  call @Test(effect="Mosaic");
}
YouTube Preview Image

単に対象をバッファデーカーの子デーカーにしているだけにしか見えないが
これだけでデーカーを動かしたまま一枚の画像として扱えるようになる。


スナップショットをとる

ここまでに紹介したTransit, UniversalTransit, Transformは
デーカーの見た目を変化させる命令であり、描画命令と呼ぶ。
描画命令は画像を表示する機能を持つデーカーに共通して使用できる。
イメージデーカー、テキストデーカー、バルーンデーカーなどだ。

しかしこれらの命令の効果は、その命令を受けたデーカーの見た目だけに限定される。
つまりこれらの命令を使って”複数のデーカーを合体させたもの”を
まとめてトランジッションしたりまとめて変形させるといったようなことはできない。

だがそのようなことがまったく不可能なわけではない。
別の機能と組み合わせることで実現できる。

Decor_Snapshot スナップショットデーカー

スナップショットデーカーはイメージデーカーの特殊版とも言えるデーカーで
他のデーカーの表示を取り込んで自身の画像とする機能を持つデーカーである。
このデーカーを使えば、”複数のデーカーを合体させた画像”を作ることができる。
そしてそのスナップショットデーカーに対してトランジッションを行えば、
“複数のデーカーを合体させたもの”をトランジッションさせるようなことができる。

スナップショットデーカーを作るにはCreateSnapshot命令を使う。


CreateSnapshot(name="スナップ");

そしてスナップショットを取りたい位置でRequest命令で@Snapshot()を指示する。


Request(to="スナップ", order=@Snapshot(target="画像1", w=1280, h=720));

target パラメータにはスナップショットをとりたい対象のデーカー名を指定する。
省略すると画面全体が対象となる。
w パラメータはスナップショット画像の横幅を指定する。
h パラメータはスナップショット画像の縦幅を指定する。
実際に組み込んでみよう。


class 煙クラス
{
  method 煙クラス()
  {
  }

  method OnEnter()
  {
    int $number = 0;
    while(true)
    {
      string $name = "煙粒"+String($number++);
      ThreadCreate(call=@煙粒スレッド(name=$name));
      wait 100;
    }
  }

  method 煙粒スレッド(string $name)
  {
    CreateImage(name=$name
      , x=0, y=0, sx=20%, sy=20%, ox="Center", oy="Middle"
      , angle=rand_range(0,359), image="煙粒.png");
    Enter(to=$name);

    int $time = 2000;
    int $angle = 90+rand_range(-10,10);
    float $rad = radian(Float($angle));
    float $move = 300.0;
    float $x = $move*cos($rad);
    float $y = -$move*sin($rad);

    Move(to=$name, time=$time, x=$x, y=$y, step="DecSin");
    Zoom(to=$name, time=$time, sx=200%, sy=200%);
    Opaque(to=$name, time=$time, alpha=0%, step="AccSin");
    WaitDecor(to=$name);
    Delete(to=$name);
  }
}

method Test(string $effect)
{
  Transform(to="スナップ", effect=$effect, time=1000);
  wait 1000;
  Transform(to="スナップ", effect=$effect, time=1000, begin=100%, end=0%);
  wait 1000;
}

method Main()
{
  CreateImage(name="画像2", image="画像2.png");
  CreateImage(name="画像1", image="画像1.png");
  CreateObject(name="画像1/煙", x=1280/2, y=720/2+150, class=@煙クラス());
  CreateSnapshot(name="スナップ");
  Enter(to="*");
  Enter(to="画像1/*");
  wait 3000;

  Request(to="スナップ", order=@Snapshot(target="画像1", w=1280, h=720));
  Exit(to="画像1");

  call @Test(effect="LinearLeft");
  call @Test(effect="BlindLeft");
  call @Test(effect="CurtainLeft");
  call @Test(effect="ShaveLeft");
  call @Test(effect="SlashH");
  call @Test(effect="BoxCenter");
  call @Test(effect="HoleCenter");
  call @Test(effect="FanCenter");
  call @Test(effect="TensileLeft");
  call @Test(effect="Mosaic");
}
YouTube Preview Image

ずいぶん長いが重要なところは黄色で示されている部分だけだ。
Exit命令はEnter命令とは逆にデーカーを消す命令である。
スナップショットをとったら元の画像は必要ないのですぐ消すようにしている。

実行結果を見ると確かにスナップショットをとって
それを一つのイメージデーカーのように扱えているのがわかるだろう。
スナップショットデーカーは場面転換の演出を作る際などにうってつけである。


Dictionaryを高速化する

Posted 2013.03.01 in 吉里吉里

Dictionaryの作成が遅くなる理由が判明したわけだが、
これはちょっと工夫すれば格段に高速化できる余地がありそうだ。
しかしズバリ言って、現バージョンの吉里吉里ではおそらく解決方法はない。
Dictionaryクラスには内部のハッシュテーブルを操作するような関数が存在しないからだ。
そこで吉里吉里のソースコードをいじる形で、いくつか解決案を考えてみよう。

案1: 検索しないようにする

辞書への要素の追加が重くなっているそもそもの原因は、
同一キーの要素がないか検索を行っている処理にある。
辞書に追加するキーに重複がないと予めわかっている場合は、
この検索処理は完全に無駄な処理だ。

検索処理を省いてしまえば、ハッシュテーブルが小さくても
要素の追加処理の負荷は軽微なものになる。
これは抜本的な解決策のように思える。

例えば要素追加時に検索を行わないようにするDirtyフラグを作り、
絶対に同一キーの要素が追加されないとわかっている時にフラグをONにして要素を追加し、
追加し終わったらフラグをOFFにする…といったような実装が考えられる。

しかし同一キーの要素がないか検索しないということは底知れない危険性を孕んでいる。
人間はミスをする生き物なので、うっかり同一キーの要素を入れてしまったり
何かの手違いでフラグをONにしっぱなしにしてしまう可能性がある。
要素を一気に流し込むassignのような関数内でON/OFFを行うようにすれば
OFFにし忘れるミスは防げるが…人為的ミスが入り込む可能性は依然拭いきれない。

仮に同一キーの要素を複数ハッシュテーブルに追加してしまった場合…
おそらくソレによって生じるバグの原因を突き止めることは相当困難だろう。
検索自体を省く方法は確実に効果が見込めるが、そこには大きなリスクがつきまとう。

案2: 検索を高速化する

検索を省かず、検索処理を見直して高速化する方法はどうだろう。
例えば同一値のリストをただのリストではなく『ソート済みリスト』にする。
ソート済みリストにすればリストの最後まで要素を調べなくても
リストの途中で同一キーの要素がないことを判断することが可能になる。
平均2倍程度の高速化が期待できる。

しかしリストをソート済みリストにすると、
高速化の目的で行われている別のトリックが使えなくなる問題がある。
そのトリックとは、要素が検索された時にその要素をリストの先頭に繰り上げて
次回の検索を最適化する処理だ。
この処理ができなくることとのトレードオフはなんとも難しい。
またこのような挙動変更は互換の点においてもややリスクがある。

案3: ハッシュテーブルを予め大きくしておく

検索が遅くなる原因は、ハッシュテーブルが小さすぎて
同一ハッシュ値の要素のリストがものすごく伸びてしまうことにある。
これは裏を返せば、ハッシュテーブルを予め十分な大きさにしてから
要素を追加すれば問題を回避できるということである。

この方法が効果的なシチュエーションは限定的ではあるが、
検索処理は省かないし、人為的ミスが入り込む余地も少ないし、
挙動も変化しないので、リスクはほとんどないと言っていい。
そして何よりDictionaryの仕組み自体には手を入れないので
ほんの少しの修正で実現可能だ。

ただし追加する要素数が少ない場合は、
ハッシュテーブルの再構築が逆に無駄な処理になるので
ある程度の数で処理を足きりする必要があるだろう。
そのためにも具体的にハッシュテーブルの初期サイズがどのくらいなのか確認しておこう。

吉里吉里のハッシュテーブルの実装はこちら。
tjsObject.h
tjsObject.cpp

初期サイズはtjsObject.hのTJS_NAMESPACE_DEFAULT_HASH_BITSで定義されている。
値は3だ。実際のテーブルサイズは(1<<bits)で計算されるので…
要するに8である。すごく小さい!

そしてこのテーブルサイズにおける適切な要素数は
tjsObject.cppの”decide new hash table size”の部分で行われている計算式に基づけば、
テーブルサイズの半分からその半分の間…つまり2~3である。

ハッシュテーブルのサイズを予め大きくしておくことはかなり効果がありそうだ。
それではこの方向性で具体的な高速化策を考えてみよう。

高速化策1: ハッシュテーブルを再構築する関数を追加する

RebuildHashの仕組みを流用して、ハッシュテーブルを
指定の要素数に適したサイズに再構築するrebuild関数を追加する。
スクリプトで要素を大量に追加する前にこの関数を呼び出すことで
大幅な高速化が期待できる。

高速化策2: ロード時に予め再構築する

辞書に大量の要素を追加する際に極めて重くなるのは、
辞書の読み込み処理においても同じである。
そこで辞書の読み込み処理の部分でも、
ハッシュテーブルを適切なサイズにしてから要素を追加するように改造する。

具体的には辞書に追加する要素をいったんリストに蓄え、
要素数が判明したら再構築を行って、その後で辞書に要素を流し込むようにする。

高速化策3: アサイン時に予め再構築する

配列や辞書のアサイン時にもはやり同様に重くなる。
アサイン時には追加要素数は自明なので、この改造はすごく簡単だ。

実験してみる

で、実際にこの3つの対策を施したexeを作りテストしてみた。
テストスクリプトは以下の通りだ。


class SimpleWindow extends Window
{
  function SimpleWindow()
  {
    super.Window();
    add(new Layer(this, null));
    visible = true;
  }
  function finalize()
  {
    super.finalize();
  }
}

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

//////////////////////////////////////////////////////////////////////
// 補助
//////////////////////////////////////////////////////////////////////

// 計測開始/終了
var g_timeBegin;
function testBegin()
{
  System.doCompact(); // メモリ強制開放

  g_timeBegin = System.getTickCount();
}
function testEnd()
{
  return System.getTickCount() - g_timeBegin;
}

//////////////////////////////////////////////////////////////////////
// テスト本体
//////////////////////////////////////////////////////////////////////

var FIND_NUM = 1000; // 検索回数
var FILE_NAME = System.dataPath+"test.txt"; // 一時ファイル名
  
function testMain(CreateNum, testCase, count)
{
  //------------------------------------------------------------------
  // 準備
  //------------------------------------------------------------------

  var CREATE_NUM = CreateNum; // 作成要素数

  // 検索対象をランダムに作成
  // *2 して「ヒットしないもの」も探す
  var findTable = [];
  for (var i = 0; i < FIND_NUM; i++)
  {
    findTable[i] = int (Math.random()*(CREATE_NUM*2-1));
  }

  //------------------------------------------------------------------
  // 配列
  //------------------------------------------------------------------

  // 作成
  var array1 = [];
  testBegin();
  for(var i = 0; i < CREATE_NUM; i++)
  {
    var name = "hash_number_is_" + "%06d".sprintf(i);
    array1[i] = name;
  }
  var timeArrayCreate = testEnd();

  // 保存
  testBegin();
  array1.saveStruct(FILE_NAME);
  var timeArraySave = testEnd();

  // 読み込み
  testBegin();
  var array2 = Scripts.evalStorage(FILE_NAME);
  var timeArrayLoad = testEnd();

  // アサイン実行
  testBegin();
  var array3 = [];
  array3.assign(array1);
  var timeArrayAssignA = testEnd();

  // 検索
  // findは返り値を受けとらないと処理されないことに注意
  testBegin();
  var temp;
  for (var i = 0; i < FIND_NUM; i++)
    temp = array1.find("hash_number_is_" + "%06d".sprintf(findTable[i]));
  var timeArrayFind = testEnd();

  //------------------------------------------------------------------
  // 辞書
  //------------------------------------------------------------------

  // 作成
  var dictionary1 = %[];
  testBegin();
  for(var i = 0; i < CREATE_NUM; i++)
  {
    var name = "hash_number_is_" + "%06d".sprintf(i);
    dictionary1[name] = i;
  }
  var timeDictionaryCreate = testEnd();

  // 作成2(指定数でRebuildHashする)
  var dictionaryX = %[];
  var timeDictionaryCreate2 = -1;
  if(typeof global.Dictionary.rebuild != "undefined")
  {
    testBegin();
    (global.Dictionary.rebuild incontextof dictionaryX)(CREATE_NUM); // 追加関数
    for(var i = 0; i < CREATE_NUM; i++)
    {
      var name = "hash_number_is_" + "%06d".sprintf(i);
      dictionaryX[name] = i;
    }
    timeDictionaryCreate2 = testEnd();
  }

  // 保存
  testBegin();
  (global.Dictionary.saveStruct incontextof dictionary1)(FILE_NAME);
  var timeDictionarySave = testEnd();

  // 読み込み
  testBegin();
  var dictionary2 = Scripts.evalStorage(FILE_NAME);
  var timeDictionaryLoad = testEnd();

  // アサイン実行(辞書:assign)
  testBegin();
  var dictionary3 = %[];
  (global.Dictionary.assign incontextof dictionary3)(dictionary1);
  var timeDictionaryAssignD = testEnd();

  // アサイン用配列作成
  var dictionary_array = [];
  for(var i = 0; i < CREATE_NUM; i++)
  {
    var name = "hash_number_is_" + "%06d".sprintf(i);
    dictionary_array[i*2] = name;
    dictionary_array[i*2+1] = i;
  }

  // アサイン実行(配列)
  testBegin();
  var dictionary4 = %[];
  (global.Dictionary.assign incontextof dictionary4)(dictionary_array);
  var timeDictionaryAssignA = testEnd();

  // アサイン実行(辞書:assignStruct)
  testBegin();
  var dictionary5 = %[];
  (global.Dictionary.assignStruct incontextof dictionary5)(dictionary1);
  var timeDictionaryAssignS = testEnd();

  // 辞書検索時間測定
  testBegin();
  for (var i = 0; i < FIND_NUM; i++)
    temp = dictionary1["hash_number_is_" + "%06d".sprintf(findTable[i])];
  var timeDictionaryFind = testEnd();

  // 辞書検索時間測定(指定数でRebuildHashしたもの)
  var timeDictionaryFind2 = -1;
  if(timeDictionaryCreate2 != -1)
  {
    testBegin();
    for (var i = 0; i < FIND_NUM; i++)
      temp = dictionaryX["hash_number_is_" + "%06d".sprintf(findTable[i])];
    timeDictionaryFind2 = testEnd();
  }

  //------------------------------------------------------------------
  // 記録
  //------------------------------------------------------------------

  data_timeArrayCreate[testCase][count] = timeArrayCreate;
  data_timeArraySave[testCase][count] = timeArraySave;
  data_timeArrayLoad[testCase][count] = timeArrayLoad;
  data_timeArrayAssignA[testCase][count] = timeArrayAssignA;
  data_timeArrayFind[testCase][count] = timeArrayFind;

  data_timeDictionaryCreate[testCase][count] = timeDictionaryCreate;
  data_timeDictionaryCreate2[testCase][count] = timeDictionaryCreate2;
  data_timeDictionarySave[testCase][count] = timeDictionarySave;
  data_timeDictionaryLoad[testCase][count] = timeDictionaryLoad;
  data_timeDictionaryAssignD[testCase][count] = timeDictionaryAssignD;
  data_timeDictionaryAssignA[testCase][count] = timeDictionaryAssignA;
  data_timeDictionaryAssignS[testCase][count] = timeDictionaryAssignS;;
  data_timeDictionaryFind[testCase][count] = timeDictionaryFind;
  data_timeDictionaryFind2[testCase][count] = timeDictionaryFind2;

  //------------------------------------------------------------------
  // 後始末
  //------------------------------------------------------------------

  invalidate findTable;

  invalidate array1;
  invalidate array2;
  invalidate array3;

  invalidate dictionary1;
  invalidate dictionary2;
  invalidate dictionary3;
  invalidate dictionary4;
  invalidate dictionary5;
  invalidate dictionary_array;
  invalidate dictionaryX;
}

//////////////////////////////////////////////////////////////////////
// テストループ
//////////////////////////////////////////////////////////////////////

var data_createNum = [];

var data_timeArrayCreate = [];
var data_timeArraySave = [];
var data_timeArrayLoad = [];
var data_timeArrayAssignA = [];
var data_timeArrayFind = [];
var data_timeDictionaryCreate = [];
var data_timeDictionaryCreate2 = [];
var data_timeDictionarySave = [];
var data_timeDictionaryLoad = [];
var data_timeDictionaryAssignD = [];
var data_timeDictionaryAssignA = [];
var data_timeDictionaryAssignS = [];
var data_timeDictionaryFind = [];
var data_timeDictionaryFind2 = [];

var testCase = 0;
for(var CreateNum=100000; CreateNum<=100000; CreateNum+=10000)
{
  data_createNum[testCase] = CreateNum;

  // 計測データを格納する配列を作成  
  data_timeArrayCreate[testCase] = new Array();
  data_timeArraySave[testCase] = new Array();
  data_timeArrayLoad[testCase] = new Array();
  data_timeArrayAssignA[testCase] = new Array();
  data_timeArrayFind[testCase] = new Array();
  data_timeDictionaryCreate[testCase] = new Array();
  data_timeDictionaryCreate2[testCase] = new Array();
  data_timeDictionarySave[testCase] = new Array();
  data_timeDictionaryLoad[testCase] = new Array();
  data_timeDictionaryAssignD[testCase] = new Array();
  data_timeDictionaryAssignA[testCase] = new Array();
  data_timeDictionaryAssignS[testCase] = new Array();
  data_timeDictionaryFind[testCase] = new Array();
  data_timeDictionaryFind2[testCase] = new Array();

  // 割り込みによって値にぶれが出る場合があるので
  // 同じケースについて何度か計測する
  // 計測したうちのもっとも小さい値を使う
  for(var count=0; count<5; count++)
  {
    testMain(CreateNum, testCase, count);
  }
  
  // 計測データを昇順にソート  
  data_timeArrayCreate[testCase].sort();
  data_timeArraySave[testCase].sort();
  data_timeArrayLoad[testCase].sort();
  data_timeArrayAssignA[testCase].sort();
  data_timeArrayFind[testCase].sort();
  data_timeDictionaryCreate[testCase].sort();
  data_timeDictionaryCreate2[testCase].sort();
  data_timeDictionarySave[testCase].sort();
  data_timeDictionaryLoad[testCase].sort();
  data_timeDictionaryAssignD[testCase].sort();
  data_timeDictionaryAssignA[testCase].sort();
  data_timeDictionaryAssignS[testCase].sort();
  data_timeDictionaryFind[testCase].sort();
  data_timeDictionaryFind2[testCase].sort();

  // 出力
  Debug.message("===========================================");
  Debug.message("create num           : %8d"
    .sprintf(data_createNum[testCase]));
  Debug.message("find num             : %8d"
    .sprintf(FIND_NUM));
  Debug.message("array      create    : %8d ms"
    .sprintf(data_timeArrayCreate[testCase][0]));
  Debug.message("array      save      : %8d ms"
    .sprintf(data_timeArraySave[testCase][0]));
  Debug.message("array      load      : %8d ms"
    .sprintf(data_timeArrayLoad[testCase][0]));
  Debug.message("array      assignA   : %8d ms ※arrayをassign"
    .sprintf(data_timeArrayAssignA[testCase][0]));
  Debug.message("array      find      : %8d ms"
    .sprintf(data_timeArrayFind[testCase][0]));
  Debug.message("dictionary create    : %8d ms"
    .sprintf(data_timeDictionaryCreate[testCase][0]));
  Debug.message("dictionary create2   : %8d ms ※予めrebuildしたもの"
    .sprintf(data_timeDictionaryCreate2[testCase][0]));
  Debug.message("dictionary save      : %8d ms"
    .sprintf(data_timeDictionarySave[testCase][0]));
  Debug.message("dictionary load      : %8d ms"
    .sprintf(data_timeDictionaryLoad[testCase][0]));
  Debug.message("dictionary assignD   : %8d ms ※dictionaryをassign"
    .sprintf(data_timeDictionaryAssignD[testCase][0]));
  Debug.message("dictionary assignA   : %8d ms ※arrayをassign"
    .sprintf(data_timeDictionaryAssignA[testCase][0]));
  Debug.message("dictionary assignS   : %8d ms ※dictionaryをassignStruct"
    .sprintf(data_timeDictionaryAssignS[testCase][0]));
  Debug.message("dictionary find      : %8d ms"
    .sprintf(data_timeDictionaryFind[testCase][0]));
  Debug.message("dictionary find2     : %8d ms ※予めrebuildしたもの"
    .sprintf(data_timeDictionaryFind2[testCase][0]));
  Debug.message("===========================================");

  testCase++;
}

ループしてないfor文や、いちいちデータを蓄えてる無駄っぽい処理はグラフ作成用である。
まずは改造前のkrkr.exeで速度を計測してみよう。
手持ちの2.32.2.426で試した。


create num           :   100000
find num             :     1000
array      create    :       77 ms
array      save      :      515 ms
array      load      :      127 ms
array      assignA   :        1 ms ※arrayをassign
array      find      :     5311 ms
dictionary create    :     5685 ms
dictionary create2   :       -1 ms ※予めrebuildしたもの
dictionary save      :      976 ms
dictionary load      :     2579 ms
dictionary assignD   :     1355 ms ※dictionaryをassign
dictionary assignA   :     5252 ms ※arrayをassign
dictionary assignS   :     1327 ms ※dictionaryをassignStruct
dictionary find      :       85 ms
dictionary find2     :       -1 ms ※予めrebuildしたもの

そしてこちらが改造版。


create num           :   100000
find num             :     1000
array      create    :       90 ms
array      save      :      549 ms
array      load      :      121 ms
array      assignA   :        2 ms ※arrayをassign
array      find      :     5402 ms
dictionary create    :     5646 ms
dictionary create2   :       88 ms ※予めrebuildしたもの
dictionary save      :     1025 ms
dictionary load      :      216 ms
dictionary assignD   :       32 ms ※dictionaryをassign
dictionary assignA   :       18 ms ※arrayをassign
dictionary assignS   :       32 ms ※dictionaryをassignStruct 
dictionary find      :       86 ms
dictionary find2     :        1 ms ※予めrebuildしたもの

軽く10~100倍速くなってる!

ちょっと驚きの数字だが、もっともこれはあくまで追加する要素数が10万個の場合の数値である。
辞書の作成が重い原因は、リストが伸びれば伸びるほど検索に時間がかかることに起因しているので
要素数に対する処理時間のグラフは2次曲線を描く。
またキーとなっている文字列の長さも大きく影響している。
つまり追加する要素数がめちゃくちゃ多いからこそこれほど劇的な差になっているのであって
これをもってして辞書が超高速になったと思うのは早合点である。

しかしこの高速化策によって要素数に対する処理時間のグラフは一次曲線(直線)になる。
この高速化策は導入コストもリスクも低いので、ほとんどの場合有用だろう。
実際にグラフを作ってみたのでご覧いただきたい。

改造前
DictionarySpeedTest1

改造版
DictionarySpeedTest2

検索のグラフがちょっとがくがくしてるが、検索回数が少ないのと、
検索をランダムにしているためだろう。検索回数は1000回固定である。

まとめると…
Dictionaryは決して遅くはない。むしろ速い。
しかし使い方を誤ると極端に遅くなってしまう場合がある。
けれどもその仕組みを理解し適切に扱うことで問題を回避できる。
ということである。

ご参考までに。


Dictionaryは遅い?

Posted 2013.02.28 in 吉里吉里

吉里吉里のDictionaryの作成がやたらめったら遅いという話を
方々で見かけたので、こちらのサイトを参考に原因を探ってみた。
※有用な情報を公開されている先人の方々に感謝いたします。

吉里吉里/KAG小技集 > 配列と辞書、どちらが早い?

掲載されているテストスクリプトを実行してみると…


配列作成中 ...完了。
ary作成時間は93msです。
hash作成時間は5562msです。
ary検索時間は2msです。
hash検索時間は63msです。

確かに書かれているような結果になった。
しかし吉里吉里のソースコードを見ながら原因を探っていくうちに
どうもこのテストスクリプトにはいくつか複雑な問題があることがわかった。

Dictionaryの仕組み

Dictionaryクラスは連想配列(辞書配列)の一種で『ハッシュテーブル』という仕組みで実装されている。
通常の配列は整数値をインデックスとして使いその値の位置にデータを格納するが、
連想配列は文字列などの任意の値をキーとして使いデータを格納する。
Dictionaryクラスは文字列をキーとするハッシュテーブルである。

ハッシュテーブルの構造は基本的にはただの配列だ。
ハッシュテーブルではキーからハッシュ値を計算し、
そのハッシュ値をインデックスとして配列に値を格納する。

通常の配列に比べて余計な手間がかかっているように思うかもしれないが
このようにすることでキーに整数以外の値を使うことができるし、
要素の挿入・削除・検索を通常の配列よりもずっと高速に行うことができる。

ハッシュテーブルの落とし穴

しかしハッシュテーブルには2つの大きな落とし穴がある。

1つ目の落とし穴は異なるキーであってもハッシュ値が同じ値になる場合があるということである。
これはすなわちテーブルの同じ位置に2つ以上の要素が入る可能性があることを意味する。
Dictionaryではテーブルに格納される要素を連結リストにして複数の値を格納できるようにしている。

2つ目の落とし穴は、テーブルとなる配列を十分な大きさで確保しておかなければならないという点だ。
キーから計算されたハッシュ値はテーブルのサイズに合わせて切り取って使われる。
しかしテーブルのサイズが小さすぎると、切り取られたハッシュ値が高い頻度で重複するようになる。
ハッシュ値が頻繁に重複すると、テーブルの各要素のリストがどんどん伸びていくような形になる。
最悪の場合、ただのリストとほとんど変わらなくなり、連想配列である恩恵が失われてしまう。

かと言って、使うかもわからないの巨大なテーブルを常に用意しておくのもそれはそれで無駄だ。
キャッシュ効率が悪くなり逆に遅くなってしまう場合もある。
すなわちハッシュテーブルを効果的に使うには、適切なテーブルのサイズをいかに決めるか。
そこが肝心なのである。

Array.findが速すぎる

ハッシュテーブルについて理解したところで、さっきのテストスクリプトの結果を見てみよう。
1つ明らかに不可解な点がある。
配列の検索に比べて辞書の検索が遅すぎるのだ。
辞書の検索は配列に比べて高速になるはずである。
というかよくよく考えてみると辞書の検索が遅いわけではなく、むしろ…

配列の検索が爆速すぎる!

いくらなんでも速過ぎである。
吉里吉里のソースコードを見てみると原因は明らかだった。

tjsArray.cpp
findの実装は877行目あたり(2013/2/28現在)

Array.findは、戻り値を返す先がないと何も処理しないような実装になってる!
つまりそもそも検索処理は一切行われていない。
爆速なのは当たり前だ。

正しく検索されるようにするには


var temp = ary.find("hash_number_is_" + "%06d".sprintf(chknums[i]));

のようにして、返り値の受け取り先を用意する必要がある。
このようにして実行しなおすと…


配列作成中 ...完了。
ary作成時間は94msです。
hash作成時間は5520msです。
ary検索時間は5350msです。
hash検索時間は37msです。

期待通り(?)激遅になった!

辞書検索が速くなっているが、これはおそらくキャッシュ効率の影響だろう。

辞書作成が重い原因

そしてもう1つ不可解な点がある。
それは辞書作成の遅さに対して辞書検索が速すぎる点だ。
数値が小さいのは試行回数が少ないからだが、それにしてもちょっと速い。
不可解じゃない?いやいや不可解なのである。
なぜ不可解なのか説明するには、
そもそも辞書作成がなぜこんなに遅くなってるのかについてまず言及する必要があるだろう。

吉里吉里のDictionaryのソースコードはこちら。
tjsDictionary.cpp

辞書作成が遅い原因をずばり言おう。
辞書作成が遅いのは、辞書に追加する要素数に対して

ハッシュテーブルが小さすぎるせいである。

ハッシュテーブルが小さいとどういうことが起こるのか。
それはさっき述べた通りだ。
ハッシュ値の重複が頻繁に起き、ハッシュテーブル内の同じ位置に要素がどんどん追加されていく。
同ハッシュ値の要素はリストとして繋がれていく。
ハッシュテーブルが小さく、追加する要素数がものすごく多い場合、
このリストはものすごい長さになってしまう。

ただ…要素をリストに繋ぐ処理の負荷は軽微なもので、それ自体はさほどたいした負荷ではない。
重さの原因になっているのは、リストに新しく要素を繋ぐ際に、
リスト内に同じキーの要素がないか検索する処理の部分だ。
特に前出のサンプルスクリプトのようなケースの場合、
リスト内に同じキーの要素があるはずがないので
処理上最悪のケース…すなわちリストの頭から末尾まで毎回毎回検索が行われる。
この処理による負荷は膨大なものになる。

すなわちハッシュテーブルが小さいことによって、
辞書検索処理が極めて重くなってしまっているというのが、
辞書作成が異常に重くなる真相である。

辞書検索が速すぎる

これを理解すると、さっき辞書検索が速すぎる!と言った意味がわかるだろう。
むちゃくちゃ速いというわけではないが、なんだか想像より速い。
辞書検索処理はハッシュテーブルが小さいことによってかなり重くなってるはずなのだ。
配列の時のように検索処理自体が行われなくなっているわけでもない。
検索はキッチリ行われている!
どういうことなのか。

これはソースコードを注意深く見ると理由がわかる。
辞書検索を速くしているのは、RebuildHashという仕組みだ。
1.5秒ごとに発生するイベントによって辞書にRebuildHashフラグが立てられ、
このフラグが立った辞書を参照するとハッシュテーブルの再構築が行われるという
トリッキーな仕組みである。

ハッシュテーブルの再構築とは、辞書に格納されている要素数に応じて
ハッシュテーブルのサイズを調整するという処理だ。
この処理によってハッシュテーブルが拡張され同一要素のリストが十分に短くなる。
同一要素のリストが十分に短くなれば、リスト検索の負荷もほとんどなくなる。
ゆえに速くなるのである。

ハッシュテーブルの再構築の効果

ここでまたテストスクリプトを振り返ってみよう。
このテストスクリプトの2つめの問題は
辞書を作成した後に「完了。」と表示を行っている部分にある。
それこそが辞書検索が速くなっている鍵なのだ!

吉里吉里はイベントシステムであり、溜まったイベントはシステムに処理が返った時に実行される。
つまり『1.5秒ごとに発生するイベント』は、システムに処理を返した時に初めて実行される。
そう、「完了。」と表示している部分、そこでシステムに処理が返りイベントが実行されているのである。
これにより次に辞書を参照した時、最初に辞書の再構築が実施される。

この動作を確認するために、試しに辞書検索速度を計測する前に
1回だけ辞書検索する処理を入れて計測してみよう。


配列作成中 ...完了。
ary作成時間は94msです。
hash作成時間は5861msです。
ary検索時間は5497msです。
hash検索時間は1msです。

辞書検索が爆速!

実は30ms以上かかっていたのはハッシュテーブルの再構築の処理であり、
辞書検索自体はこれだけ高速なのである。
ただしハッシュテーブルが適切な大きさならば、だ。

次に完了と表示している部分を削除して実行してみよう。
これで、ハッシュテーブルが再構築されなかった場合の辞書検索の速度がわかる。


配列作成中 ...
ary検索時間は5354msです。
hash検索時間は90msです。

再構築処理分を加味した値よりちょっと重い。
再構築された場合と比べると…尋常じゃなく重い!
これがハッシュテーブルのサイズが適切でない場合の辞書検索の重さである。
辞書作成時にはこの負荷が大きくのしかかっているのだ。

ということで辞書作成がなぜ重いのか、原因が明確になった。
この問題を解決する方法についてはまた次回~


吉里吉里

Posted 2013.02.27 in 吉里吉里

最近仕事で吉里吉里をいじっている。

吉里吉里とは W.Dee 氏が開発したパソコンゲームで広く使われている
オープンソースのゲームエンジン(マルチメディアエンジン)だ。
多くのコントリビューター(協賛者)によって日々拡張が行われている。

吉里吉里 ダウンロードページ
http://kikyou.info/tvp/

吉里吉里に関しては本当に勉強を始めたばかりで、
まだまだぜんぜん全体像を把握しきれていないのだけれど…
内部構造などごちゃごちゃ研究していたりする。

その最中で気がついたことなどいろいろあるので
共有できそうな情報は書いていこうと思う。
ただ…いかんせん思い込みや勘違いが結構あったりするので
何かトンチンカンなこと言ってるようならズバっとご指摘いただきたく(汗)


画像を変形させる

ユニバーサルトランジッションやクロスフェードトランジッションでは
結局のところ画像をフェードさせることしかできない。
画像を変形させるような演出にはまた別の機能を使用する。

Transform命令

Transform命令はその名のとおり画像を変形させる命令である。


Transform(to="画像1", effect="LinearLeft", time=1000);

effectパラメータには、変形効果の種類を指定する。
かなりの種類があるのだが、いくつか紹介しておこう。

Transform_LinearLeft Transform_BlindLeft Transform_CurtainLeft Transform_ShaveLeft Transform_SlashH
LinearLeft BlindLeft CurtainLeft ShaveLeft SlashH
Transform_BoxCenter Transform_HoleCenter Transform_FanCenter Transform_TensileLeft Transform_Mosaic
BoxCenter HoleCenter FanCenter TensileLeft Mosaic

ユニバーサルトランジッションを使えば実現できるものも多いが
わざわざ画像を用意する必要がない分、場合によってはこっちの方が簡単だ。
さっそく使ってみよう。


method Main()
{
  CreateImage(name="画像2", image="画像2.png");
  CreateImage(name="画像1", image="画像1.png");
  Enter(to="画像1");
  wait 1000;

  Transform(to="画像1", effect="LinearLeft", time=1000);
}
YouTube Preview Image

他の変形も試してみよう。
メソッド化して簡潔に記述する。


method Test(string $effect)
{
  Transform(to="画像1", effect=$effect, time=1000);
  wait 1000;
  Transform(to="画像1", effect=$effect, time=1000, begin=100%, end=0%);
  wait 1000;
}

method Main()
{
  CreateImage(name="画像2", image="画像2.png");
  CreateImage(name="画像1", image="画像1.png");
  Enter(to="*");
  wait 1000;

  call @Test(effect="LinearLeft");
  call @Test(effect="BlindLeft");
  call @Test(effect="CurtainLeft");
  call @Test(effect="ShaveLeft");
  call @Test(effect="SlashH");
  call @Test(effect="BoxCenter");
  call @Test(effect="HoleCenter");
  call @Test(effect="FanCenter");
  call @Test(effect="TensileLeft");
  call @Test(effect="Mosaic");
}

YouTube Preview Image
※動画がなんだかガクついてるのは動画圧縮の問題?

トランスフォームは単に画像を変形させるだけで、
厳密には画像を入れ替える演出ではないが、主にトランジッション効果として使用する。


ユニバーサルトランジッション

ユニバーサルトランジッションは、クロスフェードトランジッションの
パワーアップ版とでも言うべき演出効果である。
ユニバーサルとは「普遍的、万能」という意味だ。
そう、すなわちユニバーサルトランジッションとは…

万能なトランジッションなのである。

なんとも大げさな名前だが、この効果の一般的な呼び名である。
いったい何が万能なのか。

ユニバーサルトランジッションは具体的には、ルール画像を使って変則的なトランジッションを行う。
ルール画像を使うという点以外は、仕組み的にはクロスフェードトランジッションと一緒である。
ルール画像は白黒の画像だ。

UniversalTransit_Rule1 UniversalTransit_Rule2 UniversalTransit_Rule3
ルール1.png ルール2.png ルール3.png

ルール画像の色の黒いところから先にクロスフェードトランジッションが行われる。
ルール画像は単なる画像にすぎないので、自在に描きかえることができる。
これはルール画像次第でクロスフェードの仕方を自由に変えられるということを意味する。
それゆえ「万能」なのである。

UniversalTransit命令

ユニバーサルトランジッションを行うにはUniversalTransit命令を使う。


UniversalTransit(to="画像1", image="画像2.png"
  , rule="ルール.png", fade=20, time=1000);

rule パラメータにはルール画像を指定する。
fade パラメータにはフェード階調数を指定する。
フェード階調数が少ないほどシャープなフェードに、多いほどなだらかなフェードになる。

それでは実際に組み込んでみよう。
前回の立ち絵だとちょっと効果がわかりづらいので、今回の素材はコレで。

UniversalTransit_Image1 UniversalTransit_Image2
画像1.png 画像2.png

method Main()
{
  CreateImage(name="画像1", image="画像1.png");
  Enter(to="*");
  wait 1000;

  UniversalTransit(to="画像1", image="画像2.png"
    , rule="ルール1.png", fade=20, time=1000);
}
YouTube Preview Image

せっかくなので他のルールも試すようにしてみよう。


method Main()
{
  CreateImage(name="画像1", image="画像1.png");
  Enter(to="*");
  wait 1000;

  UniversalTransit(to="画像1", image="画像2.png"
    , rule="ルール1.png", fade=20, time=1000);
  wait 1000;
  UniversalTransit(to="画像1", image="画像2.png"
    , rule="ルール1.png", fade=20, time=1000, begin=100%, end=0%);
  wait 1000;

  UniversalTransit(to="画像1", image="画像2.png"
    , rule="ルール2.png", fade=20, time=1000);
  wait 1000;
  UniversalTransit(to="画像1", image="画像2.png"
    , rule="ルール2.png", fade=20, time=1000, begin=100%, end=0%);
  wait 1000;

  UniversalTransit(to="画像1", image="画像2.png"
    , rule="ルール3.png", fade=20, time=1000);
  wait 1000;
  UniversalTransit(to="画像1", image="画像2.png"
    , rule="ルール3.png", fade=20, time=1000, begin=100%, end=0%);
  wait 1000;
}
YouTube Preview Image

begin, end パラメータは開始時のトランジッション度合いと
終了時のトランジッション度合いを指定するパラメータだ。
本来このパラメータはbegin=0%, end=100%なのだが、
逆にbegin=100%, end=0%とすることで、逆動作をさせている。


表情を変える

パーティクルエフェクトはこのくらいにしておいて
今度は一般的なアドベンチャーゲームでよく見かける
立ち絵の表情を変える演出を作ってみよう。

立ち絵は単なる画像にすぎないので、立ち絵の表情を変えるには
立ち絵の画像を入れ替えるという操作をする。
画像は瞬間的に入れ替えるのではなく、
時間をかけてクロスフェードさせるのがよくある演出だ。

それではFoooスクリプトで
「立ち絵をクロスフェードで入れ替えるスクリプト」を書いてみよう。
Opaque命令を使えば簡単にできそうだ。

立ち絵1.png
TransitImage1

立ち絵2.png
TransitImage2


method Main()
{
  CreateImage(name="立ち絵1", image="立ち絵1.png");
  CreateImage(name="立ち絵2", image="立ち絵2.png", alpha=0%);
  Enter(to="*");
  wait 1000;

  Opaque(to="立ち絵1", time=1000, alpha=0%);
  Opaque(to="立ち絵2", time=1000, alpha=100%);
}
YouTube Preview Image

これでうまくいった………
かと思いきや、よく見るとうまくいっていない。
立ち絵が一瞬なんだか白っぽくなってしまっている。
背景が透けてしまっているのだ。

これは画像が描画される順番をよく考えれば当たり前である。
まず画面に『背景』が描かれ、次に『立ち絵1』が半透明で描かれる。
この段階で画面は『背景』と『立ち絵1』が混ざったものになる。
さにらにその上に『立ち絵2』を半透明で描く。
すると…画面は、『背景』と『立ち絵1』と『立ち絵2』が混ざったものになる。

今やりたいことは、『立ち絵1』と『立ち絵2』だけが混ざったものを描くことである。
しかし実のところそのようなことは単純に不透明度をいじるだけでは実現不可能だ。
これを行うにはTransit命令を使う。

Transit命令

Transit命令はデーカーをクロスフェードトランジッションする命令である。
トランジッションとは「変化、遷移」という意味で、CG用語では一般的に場面転換を意味する。
ここではデーカーの変化といったニアンスで使われている。
Transit命令を使うとデーカーのクロスフェードを簡単に実現できる。


method Main()
{
  CreateImage(name="立ち絵1", image="立ち絵1.png");
  Enter(to="*");
  wait 1000;

  Transit(to="立ち絵1", image="立ち絵2.png", time=1000);
}
YouTube Preview Image

透けなくなった!


花火を打ち上げる

花火は代表的な「パーティクルエフェクト」だが、
その見た目のシンプルさに対して案外実装は難しい。
なぜなら粒をランダムにではなく、規則的に放たなければいないからだ。
そうしないと花火っぽく見えないのだ。

ちなみに花火の粒は「星」と呼ぶので、ここでもそう呼ぶことにする。

星.png
Star


class 星クラス
{
  method 星クラス()
  {
    CreateImage(name="Image"
      , ox="Center", oy="Middle", image="星.png", blend="Add");
  }
  method OnEnter()
  {
    Enter(to="Image");

    // ゆっくり落ちていく
    Move(to="Image", y=rand_range(50,300), time=3000, step="Acc5");
  }
}

class 花火クラス
{
  method 花火クラス()
  {
  }

  method OnEnter()
  {
    int $number = 0;

    // 星を用意する
    command $star = @星クラス();

    // 多重の円の形になるように星を飛ばす
    int $ring = rand_range(6,10); // 円の数
    float $max_r = Float(rand_range(100,200)); // 最大半径
    for(int $j=0; $j<$ring; $j++)
    {
      int $divide = $j*4; // 円を構成する星の数
      float $r = $max_r*sin(pi()/2.0*Float($j)/Float($ring)); // 飛距離
      int $offset = mod($j,3)==0 ? 0 : 360/$divide/2; // たまにずらす
      for(int $i=0; $i<$divide; $i++)
      {
        string $name = "星"+String($number++);
        ThreadCreate(call=@星スレッド(name=$name, star=$star
          , move=$r, angle=360*$i/$divide+$offset+rand_range(-2,2)));
      }
    }

    WaitDecor();
    Delete();
  }

  method 星スレッド(string $name, command $star, float $move, int $angle)
  {
    CreateObject(name=$name, class=$star);
    Enter(to=$name);

    float $x = $move*cos(radian(Float($angle)));
    float $y = -$move*sin(radian(Float($angle)));
	int   $time = rand_range(1900,2100);

    Move(to=$name, time=$time, x=$x, y=$y, step="Dec5");
    Opaque(to=$name, time=rand_range(1500,2500), alpha=0%, step="Acc5");
    WaitDecor(to=$name);
    Delete(to=$name);
  }
}

method Main()
{
  CreateColor(name="背景", w=1280, h=720, color=black);
  Enter(to="背景");

  int $number = 0;
  while(true)
  {
    string $name = "花火"+String($number++);
    CreateObject(name=$name
      , x=rand_range(100,1280-100), y=rand_range(260,460)
      , class=@花火クラス());
    Enter(to=$name);
    wait rand_range(200,1000);
  }
}
YouTube Preview Image

このスクリプトのポイントはfor文を使っているところだ。
for文はwhile文と同じように繰り返しを意味する文だが、特定回数繰り返す場合に主に使う。

最初に言ったように星をランダムに放っても花火っぽくはならない。
星は規則的に放つ必要がある。
理由は実物の花火の構造を考えれば自明だ。

花火の玉には星が円形に綺麗に並べて詰められて、その真ん中に爆薬が詰められている。
花火が爆発すると、綺麗に並んでいた星が一斉に放たれる。
この時星の放たれ方は決してランダムではない。
星が詰められていた位置によって決められた方向へと飛んでいくのだ。

さらに実際花火が炸裂した時の姿は円形ではなく球形である。
そのあたりも注意が必要で、飛距離でsin計算をしているのはそのためだ。

うーん、説明しててもややこしい。
とにかく大変なのである。

それではもうちょっと手を入れてみよう。
星が単色で地味なので、カラフルにしてみよう。


class 星クラス
{
  color $m_color2;

  method 星クラス(color $color1, color $color2)
  {
    operate $m_color2 = $color2;

    CreateImage(name="Image"
      , ox="Center", oy="Middle", image="星.png", blend="Add"
      , dynamic_color=$color1, dynamic_blend="Color");
  }
  method OnEnter()
  {
    Enter(to="Image");

    // ゆっくり落ちていく
    Move(to="Image", y=rand_range(50,300), time=3000, step="Acc5");
    Color(to="Image", color=$m_color2, time=1500, step="AccSin");
  }
}

class 花火クラス
{
  method 花火クラス()
  {
  }

  method OnEnter()
  {
    int $number = 0;

    // 星を用意する
    color $color1 = RGB(rand_range(0,255),rand_range(0,255),rand_range(0,255));
    color $color2 = RGB(rand_range(0,255),rand_range(0,255),rand_range(0,255));
    command $star = @星クラス(color1=$color1, color2=$color2);

    // 多重の円の形になるように星を飛ばす
    int $ring = rand_range(6,10); // 円の数
    float $max_r = Float(rand_range(100,200)); // 最大半径
    for(int $j=0; $j<$ring; $j++)
    {
      int $divide = $j*4; // 円を構成する星の数
      float $r = $max_r*sin(pi()/2.0*Float($j)/Float($ring)); // 飛距離
      int $offset = mod($j,3)==0 ? 0 : 360/$divide/2; // たまにずらす
      for(int $i=0; $i<$divide; $i++)
      {
        string $name = "星"+String($number++);
        ThreadCreate(call=@星スレッド(name=$name, star=$star
          , move=$r, angle=360*$i/$divide+$offset+rand_range(-2,2)));
      }
    }

    WaitDecor();
    Delete();
  }

  method 星スレッド(string $name, command $star, float $move, int $angle)
  {
    CreateObject(name=$name, class=$star);
    Enter(to=$name);

    float $x = $move*cos(radian(Float($angle)));
    float $y = -$move*sin(radian(Float($angle)));
    int   $time = rand_range(1900,2100);

    Move(to=$name, time=$time, x=$x, y=$y, step="Dec5");
    Opaque(to=$name, time=rand_range(1500,2500), alpha=0%, step="Acc5");
    WaitDecor(to=$name);
    Delete(to=$name);
  }
}

method Main()
{
  CreateColor(name="背景", w=1280, h=720, color=black);
  Enter(to="背景");

  int $number = 0;
  while(true)
  {
    string $name = "花火"+String($number++);
    CreateObject(name=$name
      , x=rand_range(100,1280-100), y=rand_range(260,460)
      , class=@花火クラス());
    Enter(to=$name);
    wait rand_range(200,1000);
  }
}
YouTube Preview Image

星をカラフルにすると言ったが、新たな素材は何も使っていない。
CreateImage命令のdynamic_color, dynamic_blendというパラメータを使っている。
このパラメータは画像に動的に色を合成することを指定するパラメータで
これを使って星の色をランダムに変えているのだ。

さらにColor命令は指定色を後から変える命令で、
星の色がだんだんと変化するような演出を加えている。


火をともす

火は煙と同じ要領でできる。

火粒.png
Fire


class 火クラス
{
  method 火クラス()
  {
  }

  method OnEnter()
  {
    int $number = 0;
    while(true)
    {
      string $name = "火粒"+String($number++);
      ThreadCreate(call=@火粒スレッド(name=$name));
      wait 50;
    }
  }

  method 火粒スレッド(string $name)
  {
    CreateImage(name=$name
      , sx=100%, sy=100%, ox="Center", oy="Middle", alpha=0%
      , angle=rand_range(0,359), image="火粒.png", blend="Add");
    Enter(to=$name);

    int   $range = 5;
    int   $angle = 90+rand_range(-$range,$range);
    float $rad = radian(Float($angle));
    float $radius = 500.0;
    float $x = $radius*cos($rad);
    float $y = -$radius*sin($rad);
    int   $time = 1500;

    Move(to=$name, time=$time, x=$x, y=$y, step="AccSin");
    Zoom(to=$name, time=$time, sx=50%, sy=50%);
    Opaque(to=$name, time=100, alpha=50%, step="DecSin");
    wait 100;
    Opaque(to=$name, time=$time-100, alpha=0%, step="DecSin");
    WaitDecor(to=$name);
    Delete(to=$name);
  }
}

method Main()
{
  CreateColor(name="背景", w=1280, h=720, color=black);
  Enter(to="背景");

  CreateObject(name="火", x=1280/2, y=720/2+150, class=@火クラス());
  Enter(to="火");
}
YouTube Preview Image

煙がだんだん減速しながら広がっていくのに対して
火はだんだん加速しながら狭まっていく感じにする。

火粒が融合して艶かしい不思議なかんじになっているが
これは合成モードを”Add”(加算)にしている効果だ。
加算合成の画像同士を近づけるとひっついたかんじになるのが加算合成の特徴である。

さて、やはりこれだけでは単純すぎるのでまた工夫してみる。
火元を動かせるようにする。


class 火クラス
{
  method 火クラス()
  {
    CreateNode(name="火元");
  }

  method OnEnter()
  {
    int $number = 0;
    float $old_x;
    float $old_y;
    while(true)
    {
      // 火元が前回の位置から動いていたらハードモードに設定
      decor $target = GetDecor("火元");
      float $tx = $target.GetPosX();
      float $ty = $target.GetPosY();
      bool $hard = ($old_x != $tx || $old_y != $ty);
      operate $old_x = $tx;
      operate $old_y = $ty;

      string $name = "火粒"+String($number++);
      ThreadCreate(call=@火粒スレッド(name=$name, tx=$tx, ty=$ty, hard=$hard));

      // ハードモードなら発生間隔を詰める
      wait $hard ? 10 : 50;
    }
  }

  method 火粒スレッド(string $name, float $tx, float $ty, bool $hard)
  {
    CreateImage(name=$name
      , x=$tx, y=$ty
      , sx=100%, sy=100%, ox="Center", oy="Middle", alpha=0%
      , angle=rand_range(0,359), image="火粒.png", blend="Add");
    Enter(to=$name);

    int   $range = $hard ? 30 : 5;
    int   $angle = 90+rand_range(-$range,$range);
    float $rad = radian(Float($angle));
    float $radius = 500.0;
    float $x = $tx+$radius*cos($rad);
    float $y = $ty-$radius*sin($rad);
    int   $time = 1500;

    Move(to=$name, time=$time, x=$x, y=$y, step="AccSin");
    Zoom(to=$name, time=$time, sx=50%, sy=50%);
    Opaque(to=$name, time=100, alpha=50%, step="DecSin");
    wait 100;
    Opaque(to=$name, time=$time-100, alpha=0%, step="DecSin");
    WaitDecor(to=$name);
    Delete(to=$name);
  }
}

method Main()
{
  CreateColor(name="背景", w=1280, h=720, color=black);
  Enter(to="背景");

  CreateObject(name="火", x=1280/2, y=720/2+150, class=@火クラス());
  Enter(to="火");
  wait 2000;
  Move(to="火/火元", x=-500, time=1000, step="AccSig");
  wait 1000;
  Move(to="火/火元", x=500, time=2000, step="AccSig");
  wait 2000;
  Move(to="火/火元", x=0, y=-200, time=1000, step="AccSig");
  wait 1000;
  Move(to="火/火元", x=-500, y=0, time=1000, step="AccSig");
  wait 1000;
  Move(to="火/火元", x=0, y=0, time=1000, step="AccSig");
  wait 1000;
}
YouTube Preview Image

まず”火元”という名のノードデーカーを作成している。
そしてそのノードデーカーの位置を基準に火粒を発生させるようにしている。
するとノードデーカーを動かすことで、火粒の発生位置を動かすことができるようになる。
…といった寸法だ。

GetDecor関数はデーカーへの参照を得る関数で、
この参照を通してデーカーの様々な状態値を得ることができる。

ハードモードなる判定をしているが、
これは火が移動した時に火の見た目がおかしくなるのを防ぐ細工だ。
この火のエフェクトは火粒がある程度密集していることで火のように見せかけているが
火元がすばやく移動すると、火粒が密集せず散らばってまばらになってしまい、
個々の火粒がはっきりと見え、火のように見えなくなってしまう。

そこで火元が移動する時だけ火粒の発生間隔をつめるようにしている。
これは奇しくも現実の火をすばやく動かすと酸素を多く取り込んで強く燃え上がるのに似ている。
またさらに動いた時の風によるぶれを表現するために火粒の散り具合を上げるなどしている。

火のエフェクトというのは原理は簡単でも調整がすごく難しい…