Away3D TypeScriptではじめる3次元表現

第12回パーティクルのアニメーションを加える

前回の第11回Away3D TypeScriptが2015年3月13日付でビルドを改めたでは、最新のライブラリを使って床の平面に石畳のテクスチャを貼った(再掲第11回図2⁠。カメラはマウスドラッグで周囲を回る。今回から、お題にしたAnimating particles simulating fireの本論となるパーティクルをつくり始める。パーティクルは仕込みが多く、目に見える動きがなかなか確かめられない。今回、アニメーションを試すのは本稿の終わりのほうになる。しばしご辛抱のうえ、おつき合いいただきたい。

第11回 図2 床の平面に石畳のテクスチャが貼られた(再掲)
第11回 図2 床の平面に石畳のテクスチャが貼られた(再掲)

パーティクルのオブジェクトを加える

炎のパーティクルは、床の中心から描く円周上に置こう。パーティクルのオブジェクトをつくる関数(createParticles())はつぎのように定める。引数は、炎の数と炎から立ち上るパーティクル数、炎の平面上の位置を定める中心からの半径と垂直座標、および3次元空間のシーン(View.sceneプロパティ)だ。そして戻り値として、炎のオブジェクトが入った配列を返す。

createParticles(炎の数, パーティクル数, 炎の位置の半径, 炎の垂直座標, シーン)

このパーティクルをつくる関数(createParticles())は、以下の抜書きのように初期設定の関数(initialize())から呼び出す。そして、戻り値の炎のオブジェクトの配列は変数(fireObjects)に納めた。また、少しだけコードを手直しする。StaticLightPickerオブジェクトは初期設定の関数でつくって変数(lightPicker)に納め、床をつくる関数(createPlane())にはそのオブジェクトを引数として渡すことにした。そして、最後の引数である垂直位置は、今回のお題に合うよう少し引上げている(-300から-20へ⁠⁠。

// var DefaultMaterialManager = require("awayjs-display/lib/managers/DefaultMaterialManager");

var lightPicker;

var fireObjects;

var particleMaterial;
function initialize() {

  lightPicker = new StaticLightPicker([directionalLight]);
  // plane = createPlane(800, 800, directionalLight, -300);
  plane = createPlane(800, 800, lightPicker, -20);
  fireObjects = createParticles(3, 500, 300, 5, scene);

}

function createPlane(width, height, light, y) {
  var material = new MethodMaterial();   // DefaultMaterialManager.getDefaultTexture());

  // material.lightPicker = new StaticLightPicker([light]);
  material.lightPicker = light;

}

なお、MethodMaterial()コンストラクタには、とくに引数は渡さなくてよい[1]⁠。したがって、平面をつくる関数(createPlane())でもコンストラクタから引数を省き、require()関数によるDefaultMaterialManagerクラスの読込みも除いた。

パーティクルをつくる関数(createParticles())の中身は以下のとおりだ。もととなる小さな平面のひながたは、PrimitivePlanePrefab()コンストラクタでつくる。渡す第3および第4引数は、x軸方向とyまたはz軸方向それぞれのセグメント数で、デフォルト値は1だ。第5引数は、面の(法線ベクトルの)向きをy軸に合わせるかどうかのブール値で、デフォルト値がtruefalseはz軸に向ける。

パーティクルは平面で、同じものをたくさんつくる。そこで、いちいちPrefabBase.getNewObject()メソッドは用いず、幾何学情報のGeometryオブジェクト(geometry)PrimitivePrefabBase.geometryプロパティで得る。これとMethodMaterialオブジェクト(material)を後述のMesh()コンストラクタに渡せば、パーティクルの平面オブジェクトはつくれる。

パーティクルに用いるGeometryオブジェクトは、パーティクルと同じ数(numParticles)だけ配列(geometrySet)に加えた。炎のオブジェクトをつくる関数(getFireObjects())は別に定めて呼び出し、でき上がったオブジェクトの配列(fireObjects)を受け取って返す。

function createParticles(numFires, numParticles, radius, y, scene) {

  var primitive = new PrimitivePlanePrefab(10, 10, 1, 1, false);
  var geometry = primitive.geometry;
  var material = particleMaterial = new MethodMaterial(); 
  var geometrySet = [];
  for (var i = 0; i < numParticles; i++) {
    geometrySet[i] = geometry;
  }
  var fireObjects = getFireObjects(geometrySet, numFires, material, animationSet, radius, y, scene);
  return fireObjects;
}

炎のオブジェクトをつくって、配列に入れて返す関数(getFireObjects())はつぎのように定める。第1引数はGeometryオブジェクトを納めた配列、第3引数にはパーティクルに用いる素材オブジェクトを渡す。第4引数のアニメーションの定めについては、後で改めて説明する。他の引数は、この関数を呼び出す前掲のパーティクルをつくる関数(createParticles())が受け取った値だ。

getFireObjects(幾何情報の配列, 炎の数, 表面素材, アニメーションセット, 炎の位置の半径, 炎の垂直座標, シーン)

炎のオブジェクトの配列をつくる関数(getFireObjects())が受け取ったGeometryオブジェクトの配列(geometrySet)は、ParticleGeometryHelper.generateGeometry()メソッドに渡してParticleGeometryオブジェクト(particleGeometry)を得る。ひとつひとつの炎のオブジェクト(mesh)は別の関数(createAnimationParticle())でつくり、床の平面の中心から定められた半径(radius)の円周上に等間隔(anglePerFire)で並べたうえで、3次元空間のシーン(scene)に加えた。

var ParticleGeometryHelper = require("awayjs-renderergl/lib/utils/ParticleGeometryHelper");

function getFireObjects(geometrySet, numFires, material, animationSet, radius, y, scene) {
  var fireObjects = [];
  var particleGeometry = ParticleGeometryHelper.generateGeometry(geometrySet);
  var anglePerFire = Math.PI * 2 / numFires;
  for (var i = 0; i < numFires; i++) {
    var mesh = createAnimationParticle(particleGeometry, material, animationSet, fireObjects);
    var angle = i * anglePerFire;
    mesh.x = radius * Math.sin(angle);
    mesh.z = radius * Math.cos(angle);
    mesh.y = y;
    scene.addChild(mesh);
  }
  return fireObjects;
}

炎のオブジェクトをつくって返す関数(createAnimationParticle())は、Meshクラスのコンストラクタに幾何情報(particleGeometry)と素材オブジェクト(material)を引数に渡して、まずMeshオブジェクトにする。そして、このMeshオブジェクト(mesh)と後述するアニメーションのオブジェクト(animator)を渡した炎のオブジェクトがつくられて、関数の引数に受け取った配列(fireObjects)に納められる。関数の戻り値はMeshオブジェクトだ。なお、配列は参照が渡されるので、炎のオブジェクトは前掲呼出し側の関数(getFireObjects())の変数(fireObjects)に加えられることになる。

var Mesh = require("awayjs-display/lib/entities/Mesh");

function createAnimationParticle(particleGeometry, material, animationSet, fireObjects) {
  var mesh = new Mesh(particleGeometry, material);

  fireObjects.push(new FireObject(mesh, animator));
  return mesh;
}

アニメーションさせる炎のオブジェクトは、以下のクラス(FireObject)で定めたコード1⁠。コンストラクタの引数は、つぎのようにMeshオブジェクト(mesh)とパーティクルのアニメーションを定めたParticleAnimatorオブジェクト(animator)(ParticleAnimatorオブジェクトは後述⁠⁠。クラスに加えたメソッド(startAnimation())については後ほど説明する。なお、JavaScriptでクラスをどのように定義するかは、⁠HTML5のCanvasでつくるダイナミックな表現―CreateJSを使う」第17回簡単なクラスを定義するを参照してほしい。

new FireObject(Meshオブジェクト, ParticleAnimatorオブジェクト)
コード1 アニメーションさせる炎を定めたクラス
function FireObject(mesh, animator) {
  this.strength = 0;
  this.mesh = mesh;
  this.animator = animator;
}
FireObject.prototype.startAnimation = function() {
  this.animator.start();
};

パーティクルにアニメーションを定める

パーティクルのアニメーションは、アニメーションノードのオブジェクトで定める。アニメーションノードをつくるクラスはParticleNodeBaseのサブクラスで、操作するプロパティによって使い分ける。それらのオブジェクトをまとめるのがParticleAnimationSetオブジェクトだ。コンストラクタには、時間の定めを使うかどうかと、アニメーションをループさせるかどうかの2つのブール値を渡す。

new ParticleAnimationSet(時間設定, ループ)

そして、ParticleAnimationSetオブジェクトを引数としてParticleAnimator()コンストラクタからつくったオブジェクトを物体のMesh.animatorプロパティに与えればアニメーションが定まる。

new ParticleAnimator(animationSetオブジェクト)

ParticleAnimationSetオブジェクトをつくる関数(getParticleAnimationSet())と炎のオブジェクトをつくる関数(getFireObjects())は、つぎのようにともにパーティクルをつくる関数(createParticles())から呼び出される。前者はParticleAnimationSet()コンストラクタが生成したオブジェクトを返す。後者はそのオブジェクトからつくられたParticleAnimatorインスタンスをMesh.animatorプロパティに与えている。

var ParticleAnimationSet = require("awayjs-renderergl/lib/animators/ParticleAnimationSet");
var ParticleAnimator = require("awayjs-renderergl/lib/animators/ParticleAnimator");

function createParticles(numFires, numParticles, radius, y, scene) {

  var animationSet = getParticleAnimationSet(animations, initParticle);

  var fireObjects = getFireObjects(geometrySet, numFires, material, animationSet, radius, y, scene);

}
function getFireObjects(geometrySet, numFires, material, animationSet, radius, y, scene) {

  for (var i = 0; i < numFires; i++) {
    var mesh = createAnimationParticle(particleGeometry, material, animationSet, fireObjects);

  }

}
function getParticleAnimationSet(animations, initParticleFunc) {
  var animationSet = new ParticleAnimationSet(true, true);

  return animationSet;
}
function createAnimationParticle(particleGeometry, material, animationSet, fireObjects) {

  var animator = new ParticleAnimator(animationSet);
  mesh.animator = animator;

}

アニメーションノードとして、とりあえず2つのオブジェクトをつくることにする。ひとつは、ParticleBillboardNodeオブジェクトで、パーティクルの角度をつねにカメラに向ける。もうひとつは、パーティクルアニメーションが始まるときの速度を定めるParticleVelocityNodeだ。第1引数のモードに定数ParticlePropertiesMode.GLOBALを与えると、アニメーションのプロパティへの働きがグローバルになる。

new ParticleVelocityNode(モード, 速度ベクトル)

そして、アニメーションノードのオブジェクトは、ParticleAnimationSet.addAnimation()メソッドでParticleAnimationSetオブジェクトに加える。

ParticleAnimationSetオブジェクト.addAnimation(アニメーションノード)

アニメーションノードのオブジェクトは、つぎのようにパーティクルをつくる関数(createParticles())の中で配列(animations)に納めた。その配列をParticleAnimationSetオブジェクトをつくる関数(getParticleAnimationSet())に渡すと、オブジェクトにParticleAnimationSet.addAnimation()メソッドでアニメーションノードすべてが加えられる。

var Vector3D = require("awayjs-core/lib/geom/Vector3D");

var ParticlePropertiesMode = require("awayjs-renderergl/lib/animators/data/ParticlePropertiesMode");
var ParticleBillboardNode = require("awayjs-renderergl/lib/animators/nodes/ParticleBillboardNode");
var ParticleVelocityNode = require("awayjs-renderergl/lib/animators/nodes/ParticleVelocityNode");

function createParticles(numFires, numParticles, radius, y, scene) {
  var GLOBAL = ParticlePropertiesMode.GLOBAL;

  var animations = [
    new ParticleBillboardNode(),
    new ParticleVelocityNode(GLOBAL, new Vector3D(0, 80, 0))
  ];
  var animationSet = getParticleAnimationSet(animations, initParticle);

}

function getParticleAnimationSet(animations, initParticleFunc) {
  var animationSet = new ParticleAnimationSet(true, true);
  var count = animations.length;
  for (var i = 0; i < count; i++) {
    animationSet.addAnimation(animations[i]);
  }

  return animationSet;
}

パーティクルのアニメーションを動かす

ようやくパーティクルをアニメーションとして動かせる。炎のMeshオブジェクトとそのMesh.animatorプロパティに与えたParticleAnimatorオブジェクトは、前掲コード1のクラス(FireObject)のコンストラクタに引数として与えてあった。アニメーションを動かすには、それぞれのParticleAnimatorオブジェクトに対してParticleAnimator.start()メソッドを呼び出す。その呼出しは、すでにクラスのメソッド(startAnimation())としてつぎのように定めた。

FireObject.prototype.startAnimation = function() {
  this.animator.start();
}

床はトゥイーンアニメーションしながら表れるので、パーティクルのアニメーションも始まりに待ちを加えよう。時間待ちはTimerクラスで行う。コンストラクタに待ち時間と繰返し回数の2つの引数を与え、TimerEvent.TIMERイベントのリスナー関数に待ち時間が済んだ後の処理を定める。Timer.start()メソッドを呼び出さないと、待ち時間が始まらないことに注意してほしい。

new Timer(待ち時間, 繰返し回数)

Timerクラスの時間待ちは、つぎのように初期設定の関数(initialize())で定めた。待ち時間が過ぎるごとに、炎のパーティクルをひとつずつアニメーションさせている。プロパティTimer.currentCountは何回目の待ち時間が過ぎたかを示し、Timer()コンストラクタに定めた繰返し回数はTimer.repeatCountで得られる。その回数を終えたら、イベントリスナー(startFire())は除いた。なお、リスナー関数が引数に受取ったイベントオブジェクト(eventObject)Event.targetプロパティで、イベントの起こったオブジェクト(ここではTimerインスタンス)が得られる。

var TimerEvent = require("awayjs-core/lib/events/TimerEvent");

var Timer = require("awayjs-core/lib/utils/Timer");

function initialize() {

  var fireTimer = new Timer(1000, fireObjects.length);
  fireTimer.addEventListener(TimerEvent.TIMER, startFire);
  fireTimer.start();
}

function startFire(eventObject) {
  var fireTimer = eventObject.target;
  var count = fireTimer.currentCount
  var fireObject = fireObjects[count - 1];
  fireObject.startAnimation();
  if (count >= fireTimer.repeatCount) {
    fireTimer.removeEventListener(TimerEvent.TIMER, startFire)
  }
}

パーティクルのアニメーションを動かすにはもうひとつだけ、ParticleAnimationSetオブジェクトのParticleAnimationSet.initParticleFuncプロパティに関数を定めなければならない。ParticleAnimationSetオブジェクトをつくる関数(getParticleAnimationSet())で、つぎのように取りあえず本体が空の関数(initParticle())を書き加えて与えよう。

function getParticleAnimationSet(animations, initParticleFunc) {
  var animationSet = new ParticleAnimationSet(true, true);

  animationSet.initParticleFunc = initParticleFunc;

}

function initParticle(prop) {
}

試してみると、炎の数(3個)だけ小さなパーティクルが立ち上って、画面の上端に消える図1⁠。つぎのように引数を定めた前述のパーティクルをつくる関数には、炎の数3個、パーティクルは500個が与えてあったはずだ(createParticles(3, 500, 300, 5, scene)⁠⁠。炎は床を中心とした円周上に等間隔で位置決めした。だが、パーティクルについてはとくに何も定めていないため、すべて重なってしまったのだ。

createParticles(炎の数, パーティクル数, 炎の位置の半径, 炎の垂直座標, シーン)
図1 パーティクルが3つ浮上がる
図1 パーティクルが3つ浮上がる

実は、パーティクルにはParticleAnimationSet.initParticleFuncプロパティに定めた関数で初期値が与えられる。プロパティParticleProperties.startTimeParticleProperties.durationで、それぞれアニメーションを始める時間と長さが定まる。そこで、パーティクルを初期化する関数(initParticle())で、つぎのようにランダムな時間を与えた。受取る引数(prop)がパーティクルのParticlePropertiesオブジェクトだ。これで、パーティクルのアニメーションの時間がばらつく。

function initParticle(prop) {
  prop.startTime = Math.random() * 5;
  prop.duration = Math.random() * 4 + 0.1;
}

なお、ParticleAnimationSetオブジェクトをつくるコンストラクタの2つの引数には、前述のとおりともにtrueが渡してあった。したがって、パーティクルのアニメーションはつぎの構文のとおりに、定められた時間ずっと繰り返される。改めて試すと、炎の位置からパーティクルがのろしのように立ち上り続ける図2⁠。

new ParticleAnimationSet(時間設定, ループ)
図2 のろしのように立ち上るパーティクル
図2 のろしのように立ち上るパーティクル

これまで書いたスクリプトをコード2にまとめた。クラスの定めはコード1のまま変えていない。あわせて、サンプル1をjsdo.itに掲げた。今回はここまでで区切ろう。次回はパーティクルの見た目を炎らしく整える。

コード2 パーティクルを定めてアニメーションさせる
var LoaderEvent = require("awayjs-core/lib/events/LoaderEvent");
var TimerEvent = require("awayjs-core/lib/events/TimerEvent");
var Vector3D = require("awayjs-core/lib/geom/Vector3D");
var AssetLibrary = require("awayjs-core/lib/library/AssetLibrary");
var URLRequest = require("awayjs-core/lib/net/URLRequest");
var RequestAnimationFrame = require("awayjs-core/lib/utils/RequestAnimationFrame");
var Timer = require("awayjs-core/lib/utils/Timer");
var View = require("awayjs-display/lib/containers/View");
var DirectionalLight = require("awayjs-display/lib/entities/DirectionalLight");
var Mesh = require("awayjs-display/lib/entities/Mesh");

var StaticLightPicker = require("awayjs-display/lib/materials/lightpickers/StaticLightPicker");
var PrimitivePlanePrefab = require("awayjs-display/lib/prefabs/PrimitivePlanePrefab");
var ParticleAnimationSet = require("awayjs-renderergl/lib/animators/ParticleAnimationSet");
var ParticleAnimator = require("awayjs-renderergl/lib/animators/ParticleAnimator");
var ParticlePropertiesMode = require("awayjs-renderergl/lib/animators/data/ParticlePropertiesMode");
var ParticleBillboardNode = require("awayjs-renderergl/lib/animators/nodes/ParticleBillboardNode");
var ParticleVelocityNode = require("awayjs-renderergl/lib/animators/nodes/ParticleVelocityNode");
var DefaultRenderer = require("awayjs-renderergl/lib/DefaultRenderer");
var MethodMaterial = require("awayjs-methodmaterials/lib/MethodMaterial");
var MethodRendererPool = require("awayjs-methodmaterials/lib/pool/MethodRendererPool");
var HoverController = require("awayjs-display/lib/controllers/HoverController");
var ParticleGeometryHelper = require("awayjs-renderergl/lib/utils/ParticleGeometryHelper");
var view;
var plane;
var cameraController;
var lightPicker;
var timer;
var planeDiffuse = "assets/floor_diffuse.jpg";
var lastMouseX;
var lastMouseY;
var lastPanAngle;
var lastTiltAngle;
var fireObjects;

var particleMaterial;
function initialize() {
  var directionalLight = createDirectionalLight(0.25, 0xFFFFFF);
  view = createView(240, 180, 0x0);
  var scene = view.scene;
  lightPicker = new StaticLightPicker([directionalLight]);
  plane = createPlane(800, 800, lightPicker, -20);
  fireObjects = createParticles(3, 500, 300, 5, scene);
  scene.addChild(plane);
  cameraController = setupCameraController(view.camera, 1000, 0, 90, 45, 20);
  AssetLibrary.addEventListener(LoaderEvent.RESOURCE_COMPLETE, onResourceComplete);
  AssetLibrary.load(new URLRequest(planeDiffuse));
  document.onmousedown = startDrag;
  timer = new RequestAnimationFrame(render);
  timer.start();
  var fireTimer = new Timer(1000, fireObjects.length);
  fireTimer.addEventListener(TimerEvent.TIMER, startFire);
  fireTimer.start();
}
function createView(width, height, backgroundColor) {
  var defaultRenderer = new DefaultRenderer(MethodRendererPool);
  var view = new View(defaultRenderer);
  view.width = width;
  view.height = height;
  view.backgroundColor = backgroundColor;
  return view;
}
function createPlane(width, height, light, y) {
  var material = new MethodMaterial(); 
  var plane = new PrimitivePlanePrefab(width, height).getNewObject();
  plane.material = material;
  material.lightPicker = light;
  plane.y = y;
  return plane;
}
function createParticles(numFires, numParticles, radius, y, scene) {
  var GLOBAL = ParticlePropertiesMode.GLOBAL;
  var animations = [
    new ParticleBillboardNode(),
    new ParticleVelocityNode(GLOBAL, new Vector3D(0, 80, 0))
  ];
  var animationSet = getParticleAnimationSet(animations, initParticle);
  var primitive = new PrimitivePlanePrefab(10, 10, 1, 1, false);
  var geometry = primitive.geometry;
  var material = particleMaterial = new MethodMaterial(); 
  var geometrySet = [];
  for (var i = 0; i < numParticles; i++) {
    geometrySet[i] = geometry;
  }
  var fireObjects = getFireObjects(geometrySet, numFires, material, animationSet, radius, y, scene);
  return fireObjects;
}
function getFireObjects(geometrySet, numFires, material, animationSet, radius, y, scene) {
  var fireObjects = [];
  var particleGeometry = ParticleGeometryHelper.generateGeometry(geometrySet);
  var anglePerFire = Math.PI * 2 / numFires;
  for (var i = 0; i < numFires; i++) {
    var mesh = createAnimationParticle(particleGeometry, material, animationSet, fireObjects);
    var angle = i * anglePerFire;
    mesh.x = radius * Math.sin(angle);
    mesh.z = radius * Math.cos(angle);
    mesh.y = y;
    scene.addChild(mesh);
  }
  return fireObjects;
}
function getParticleAnimationSet(animations, initParticleFunc) {
  var animationSet = new ParticleAnimationSet(true, true);
  var count = animations.length;
  for (var i = 0; i < count; i++) {
    animationSet.addAnimation(animations[i]);
  }
  animationSet.initParticleFunc = initParticleFunc;
  return animationSet;
}
function createAnimationParticle(particleGeometry, material, animationSet, fireObjects) {
  var mesh = new Mesh(particleGeometry, material);
  var animator = new ParticleAnimator(animationSet);
  mesh.animator = animator;
  fireObjects.push(new FireObject(mesh, animator));
  return mesh;
}
function initParticle(prop) {
  prop.startTime = Math.random() * 5;
  prop.duration = Math.random() * 4 + 0.1;
}
function startFire(eventObject) {
  var fireTimer = eventObject.target;
  var count = fireTimer.currentCount;
  var fireObject = fireObjects[count - 1];
  fireObject.startAnimation();
  if (count >= fireTimer.repeatCount) {
    fireTimer.removeEventListener(TimerEvent.TIMER, startFire);
  }
}
function createDirectionalLight(ambient, color) {
  var light = new DirectionalLight();
  light.ambient = ambient;
  light.color = color;
  return light;
}
function setupCameraController(camera, distance, minTiltAngle, maxTiltAngle, panAngle, tiltAngle) {
  var cameraController = new HoverController(camera);
  cameraController.distance = distance;
  cameraController.minTiltAngle = minTiltAngle;
  cameraController.maxTiltAngle = maxTiltAngle;
  cameraController.panAngle = panAngle;
  cameraController.tiltAngle = tiltAngle;
  return cameraController;
}
function onResourceComplete(eventObject) {
  var assets = eventObject.assets;
  var material;
  var count = assets.length;
  var url = eventObject.url;
  for (var i = 0; i < count; i++) {
    var asset = assets[i];
    switch (url) {
      case (planeDiffuse):
        material = plane.material;
        material.texture = asset;
        break;
    }
  }
}
function render(timeStamp) {
  view.render();
}
function startDrag(eventObject) {
  lastMouseX = eventObject.clientX;
  lastMouseY = eventObject.clientY;
  lastPanAngle = cameraController.panAngle;
  lastTiltAngle = cameraController.tiltAngle;
  document.onmousemove = drag;
  document.onmouseup = stopDrag;
}
function drag(eventObject) {
  cameraController.panAngle = 0.5 * (eventObject.clientX - lastMouseX) + lastPanAngle;
  cameraController.tiltAngle = 0.3 * (eventObject.clientY - lastMouseY) + lastTiltAngle;
}
function stopDrag(eventObject) {
  document.onmousemove = null;
  document.onmouseup = null;
}
サンプル1 Away3D 15/03/13: Basic particles animation on a floor

おすすめ記事

記事・ニュース一覧