Help us understand the problem. What is going on with this article?

●●WebGL(PIXI.js + glsl)と物理演算(matter.js)で可愛い絵本風タピオカ作ったので解説●●

こんにちは。WebGLのお勉強1週間目くらいの絵描き系エンジニア「ゆき」です。最近Qiita界隈でもタピオカが流行っているらしいので、今日は勉強中の技術をフルに活用してタピオカを作ってみました。

tapi.gif
ここで試せます(スマホ未対応): https://tapioca-pixi.firebaseapp.com

この記事の内容と想定読者

この記事ではJavaScriptの描画エンジンの定番の一つであるPIXI.jsと、物理演算ライブラリのmatter.jsを使って、タピオカを可愛くふわふわさせる表現のアプローチとポイントを解説します。

  • 物理エンジンと描画エンジンの連携方法
  • シェーダーによるオリジナルの表現
  • 物理演算で表現したいものを作るためのチップスいくつか

シェーダー(glsl)周りは結構独特なので別途もうちょっとちゃんとした解説記事を書く予定です。この記事はどちらかというと駆け足のネタ記事になってしまうかと思いますので、気楽に流し読みして頂いてみなさんのモチベアップにつながればうれしいです:relaxed:

技術選定と環境構築

今回のタピオカ開発は仕事でも個人のプロダクト開発でもなく、純粋にお勉強用です。よって、如何にすばやく環境を作って新しいことを試せるか、にフォーカスして環境を作ります。

「とりあえずお勉強」だと、CodePenつかったり適当にhtml作ってCDNからライブラリ引っ張ってきて...みたいなやり方もありますが、個人的には勉強の時にも「それなりの」環境構築はするべきだと思っています。
使い慣れたエディタ使えなかったり、新しいES2018の文法が使えなかったり、依存の管理が辛くなったり、そういうのはモチベ削ぐので🙅‍♀️NG🙅‍♂️

今回はTypeScriptのビルドにオススメ! Parcel入門を参考にParcelを使ってTypeScriptが書ける環境を作ります。

  • 言語 : TypeScript
  • ビルド・バンドル : Parcel
  • ライブラリ : PIXI.js(描画) + matter.js(物理演算)
  • エディタ : VSCode

エモいタピミのための技術ポイント

ここからはエモいタピミ1を実現するためのイケてる技術メンバーを紹介していくよ!

手書き風エフェクトの実装

image.png

いきなりですが、まずはエフェクトから作ります。絵描き的にはただ黒い●描いて「タピオカです!」っていうのはなかなかモチベ維持が難しいので、見た目優先でいきたいと思います。

PIXI.jsで●を描く

とはいえまずは●を書かないことには始まらないので、最初にPIXI.jsで●を描画します。PIXI.jsのステージを作成・保持するクラスを作り、タピオカ粒を適当に描画するメソッドを用意します。

PixiStage.ts
import * as PIXI from 'pixi.js'

export default class PixiStage {
  private app: PIXI.Application

  /**
   * 新しくPIXIのインスタンスを作ります
   * @param wrapperSelector Canvasを作成する親要素のセレクター
   */
  constructor(wrapperSelector: string = 'body') {
    this.app = new PIXI.Application({ width: 800, height: 520, transparent: true })
    const wrapper = document.querySelector(wrapperSelector)
    wrapper.appendChild(this.app.view)
  }

  /**
   * タピオカを描画するテスト用のメソッドです。
   * 横一列に指定した数のタピオカを描画します。
   * @param x 開始座標x
   * @param y 開始座標y
   * @param size タピオカの半径
   * @param count 描画する数
   * @param gap タピオカの感覚
   */
  public drawTapi(x: number, y: number, size: number = 20, count: number = 10, gap: number = 5): void {
    const g = new PIXI.Graphics()
    g.beginFill(0xaa8833)
    Array(count).fill(0).forEach((v, i) => {
      g.drawCircle( x + (size * 2 + gap) * i, y, size)
    })
    g.endFill()
    this.app.stage.addChild(g)
  }
}

これをindex.tsから呼び出します。

index.ts
import PixiStage from './PixiStage'

let pixi: PixiStage
const init = () => {
  pixi = new PixiStage('.pixi') //index.htmlの要素を指定してステージ作成
  pixi.drawTapi(50, 50, 10, 10, 5) //タピオカを10粒描く
}

init()

image.png
こんな感じです。誰がなんといってもタピオカですね!

PIXI.jsでフィルタを当てる

PIXI.jsには標準でいくつかのフィルタが用意されており、色々なエフェクトを簡単に当てられる仕組みになっています。例えばぼかしを適用するBlurFilterであれば...

PixiStage.ts
public drawTapi(...): void {
  ...  ...
  g.endFill()
  // フィルタを追加
  g.filters = [new PIXI.filters.BlurFilter(10)]
  this.app.stage.addChild(g)
}

このように1行追加するだけで...
image.png

フィルタが適用できました!簡単ですね。

フィルタを自作する

このフィルタ、結構すごいものが揃っているので、目的に合えばこれをそのまま使えばOKです。ですが、オリジナルのタピ道を追求するため2、今回はこのフィルタを自作します。

上にリンクを貼ったフィルタのデモで使用されているフィルタはGitHubでソースが公開されているので、これを写経しつつ、自分のやりたい表現を組み込んでいくのが基本的なアプローチです。今回はこの中からOutlineフィルタを参考にして進めました。

フィルタを自作するには、ざっくり以下の2つのファイルが必要になります:

  1. PIXI.Filterを継承した自作フィルタのクラス(JavaScript)
  2. フィルタの本体処理をGLSLのソースファイル

PIXI.jsで使うときには、GLSLという言語で書かれたフィルタ(シェーダー)のソースコードをPIXI.Filterで読み込んであげる流れになります。

下に今回のパステル調フィルタのソースを貼っておきます。詳細は解説しませんが、コメントも載せておくので自作するときの参考にしてください。

PastelFilter.ts
import * as PIXI from 'pixi.js'
import shaderSource from './PastelFilter.glsl'

/**
 * パステル調の表現を加えるためのフィルタです。
 */
export default class PastelFilter extends PIXI.Filter {
  constructor () {
    super()
    // 周辺ピクセルをサンプリングする数
    const sampleCount = 25 
    // サンプル間の角度(360度 / サンプル数、ラジアン表現)
    const angleStep = (Math.PI * 2 / sampleCount).toFixed(7)
    super(
      null, // 頂点シェーダーのソース(今回はいらないのでnull)
      // フラグメントシェーダーのソース:
      // GLSL内でループ処理するときのループカウンタ増分を事前に計算し、
      // GLSLのソースに直接漉き込む。GLSLではループ文の条件はコード内で
      // 動的に変更できないので、必要なものは事前にJS側で計算し、コードに埋め込むことが必要
      shaderSource.replace(/<ANGLE_STEP>/g, angleStep),
      // シェーダー側に渡すパラメータの宣言
      {
        uSize: new Float32Array([0, 0]),
        uShColor1: new Float32Array([0.3, 0.1, 0.0]),
        uShColor2: new Float32Array([0.3, 0.1, 0.0]),
        uThickness: 24.0
      }
    )
  }

  // フィルタを適用する際の処理(PIXIが呼んでくれる)。
  // パラメータの値更新等、前処理を記述できる
  apply(filterManager, input, output, clear) {
    const thickness = this.uniforms.uThickness
    this.uniforms.uSize[0] = thickness / input._frame.width;
    this.uniforms.uSize[1] = thickness / input._frame.height;
    filterManager.applyFilter(this, input, output, clear);
  }

  /**
   * PIXIの色数値を[R,G,B]形式のFloat32Arrayに変換します
   * @param c 0x000000 形式で表現される色
   */
  private colorNumToVec(c: number): Float32Array {
    return new Float32Array([
      /* R */ ((c & 0b1111_1111_0000_0000_0000_0000) >> (4 * 4)) / 0xff,
      /* G */ ((c & 0b0000_0000_1111_1111_0000_0000) >> (4 * 2)) / 0xff,
      /* B */ ((c & 0b0000_0000_0000_0000_1111_1111)) / 0xff
    ])
  }

  /** 影の色1を設定します */
  set shColor1 (c: number) {
    const arr = this.colorNumToVec(c)
    for(let i = 0; i < 3; i++) this.uniforms.uShColor1[i] = arr[i]
  }
  /** 影の色2を設定します */
  set shColor2 (c: number) {
    const arr = this.colorNumToVec(c)
    for(let i = 0; i < 3; i++) this.uniforms.uShColor2[i] = arr[i]
   }
  /** フチの太さを設定します。エッジをザラザラにするノイズの量と、エッジ周辺の影の太さに影響します */
  set thickness(v: number) {
    this.uniforms.uThickness = v
  }
}

やることは基本的にはシェーダーのソースをimportして初期化するだけです。
ただし、GLSLはJavaScriptに比べてできることが非常に限定される3ので、ループ条件等は必要に応じて事前に計算し、ソースに埋め込んであげる必要が出てきます(今回だと<ANGLE_STEP>の部分)。

続けてパステル調を実現するフィルタの本体です。GLSLという言語で記述するもので、Unityみたいな3D界隈でもよく出てくるみたい。「神シェーダー使い」と呼ばれるレベルの方だと何もないところから世界を創成できるらしいのですが、神々の戯れは文系絵描きにはハードなので、単純に入力画像のエッジをいじってパステル風にモサモサさせる処理を書いています。以下ちょっと長いですが、数学的には文系の私でもできるsin/cosレベルの内容です。

PastelFilter.glsl
precision mediump float;

varying vec2 vTextureCoord;
uniform sampler2D uSampler;
uniform vec4 filterClamp;

uniform vec2 uSize;
uniform vec3 uShColor1;
uniform vec3 uShColor2;
const float DOUBLE_PI = 3.14159265358979323846264 * 2.;
const float ANGLE_STEP = <ANGLE_STEP>; // ← コンパイル前にJS側で計算した値に置換される
const float EDGE_RATE_OF_OUTLINE = 0.43;
const float ANGLE_STEP_EDGE = ANGLE_STEP * EDGE_RATE_OF_OUTLINE * EDGE_RATE_OF_OUTLINE;

/* ランダムなノイズを生成する。PIXIのnoiseフィルターより */
float rand(vec2 co) {
  return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453);
}

/* ベースの色に影の色をのせた色を求める */
vec4 shadowColor (vec4 baseRgba, float amount, float shAngle) {
  vec3 sh2 = uShColor1;
  vec3 sh1 = uShColor2;
  vec3 sh = mix(sh1, sh2, shAngle);
  return vec4(mix(sh, baseRgba.rgb, 1.0 - amount), baseRgba.a);
}

/* フィルタのメイン処理 */
void main(void) {
  float noizeX = (rand(vTextureCoord * 100.0) - 0.5) * uSize.x * 0.1;
  float noizeY = (rand(vTextureCoord * 100.0 + 10.0) - 0.5) * uSize.y * 0.1;
  vec2 sourceCoord = vTextureCoord + vec2(noizeX, noizeY);
  vec4 ownColor = texture2D(uSampler, sourceCoord);
  vec4 curColor;
  vec2 displaced;
  vec4 FC = vec4(0., 0., 1., 1.);

  /* サンプリング1周目。エッジ判定を行い影をつける量を求める */
  float minAlphaOl = 1.0;
  float totalAlphaOl = 0.0;
  for (float angle = 0.; angle <= DOUBLE_PI; angle += ANGLE_STEP) {
    displaced.x = vTextureCoord.x + uSize.x * cos(angle);
    displaced.y = vTextureCoord.y + uSize.y * sin(angle);
    curColor = texture2D(uSampler, clamp(displaced, FC.xy, FC.zw));
    minAlphaOl = min(minAlphaOl, curColor.a);
    totalAlphaOl += curColor.a;
  }

  /* サンプリング2周目。1周目と異なる条件で
  エッジ判定を行い、エッジに適用するモサモサのノイズ量を求める */
  vec2 sizeEdge = uSize * EDGE_RATE_OF_OUTLINE;
  float minAlphaEd = 1.0;
  float totalAlphaEd = 0.0;
  for (float angle = 0.; angle <= DOUBLE_PI; angle += ANGLE_STEP_EDGE) {
    displaced.x = vTextureCoord.x + sizeEdge.x * cos(angle);
    displaced.y = vTextureCoord.y + sizeEdge.y * sin(angle);
    curColor = texture2D(uSampler, clamp(displaced, FC.xy, FC.zw));
    minAlphaEd = min(minAlphaEd, curColor.a);
    totalAlphaEd += curColor.a;
  }

  /* サンプリングで求めた値を元に色とアルファ値を決める */
  float avrAlphaOl = 1.0 - totalAlphaOl / (DOUBLE_PI / ANGLE_STEP);
  float avrAlphaEd = 1.0 - totalAlphaEd / (DOUBLE_PI / ANGLE_STEP_EDGE);
  float noise1 = rand(vTextureCoord * 10000.0);
  float noise2 = rand(vTextureCoord * 1000.0) + 0.3;
  float noise3 = rand(vTextureCoord * 10000.0 * noise2);
  float outlineAlpha = avrAlphaOl * noise1;
  float edgeAlpha = (1.0 - pow(avrAlphaEd * noise2, 2.0)) * ownColor.a * min(noise3 + 0.9, 1.0);
  float shadowAngle =  distance(vec2(.0, .0), vTextureCoord) / 1.41421356;
  vec3 resultRgb = shadowColor(ownColor, outlineAlpha,shadowAngle).rgb;
  resultRgb *= edgeAlpha;
  gl_FragColor = vec4(resultRgb, edgeAlpha);
}

このフィルタを適用します。

PixiStage.ts
import * as PIXI from 'pixi.js'
import PastelFilter from './PastelFilter.ts'

先頭でインポートして、

PixiStage.ts
g.filters = [new PastelFilter()]

標準のブラーフィルタの代わりにnewするだけ
image.png
いい感じに緩くなりましたね。

物理演算でふわふわ

いい感じのフィルタができてモチベが確保されたので、ここからようやく物理演算を取り入れてタピオカワールドを構築しています。

物理演算ライブラリの選定

今回物理演算にはドキュメントとサンプルがわかりやすいmatter.jsを使っていきます。

物理演算には色々なライブラリがあり、かなり得意不得意があるようです。タピオカミルクティーをリアルにシミュレートするには液体(流体)の演算ができないといけないのですがmatter.jsは実は流体の表現ができないようです。流体に適したライブラリとしては、LiquidFunという天下のGoogle製ライブラリが良いようなのですが、最近のメンテ状況が不明なことと、ドキュメント・サンプルが難解なためパスしてます。(もともとJavaScript製のライブラリではないのでドキュメントがC++)

LiquidFun自体はすごいライブラリだと思うので、気にななる方は

あたりを見てみてください。

PIXI.js(描画)とmatter.js(物理演算)の連携

物理演算ライブラリは名前の通り、演算のみを行うのが基本です。物の位置や動きを物理演算で計算し、その結果の座標等を受け取って別途PIXI.jsのような描画エンジンで画面表示を行います。

物理演算の世界を作る

まずPixi.jsの時と同様に、Matter.jsの環境を初期化・管理するクラスを作るところから始めましょう。

MatterWorld.ts
import Matter from 'matter-js'

export default class MatterEngine {
  private engine: Matter.Engine
  private world: Matter.World

  /**
   * 新しい物理演算エンジンのインスタンスとデバッグ用のビューを生成します
   * @param selector デバッグ用のビューを置くhtml要素のセレクター
   */
  constructor(selector: string) {
    const Render = Matter.Render as any
    const WORLD_WIDTH = 800
    const WORLD_HEIGHT = 500
    const engine = this.engine = Matter.Engine.create()
    const world = this.world = engine.world
    const render = Render.create({
      element: document.querySelector(selector),
      engine: engine,
      options: {
        width: WORLD_WIDTH * 1,
        height: WORLD_HEIGHT * 1,
        showAngleIndicator: true,
        showCollisions: true,
        showVelocity: true
      }
    })
    Render.lookAt(render, {
      min: { x: WORLD_WIDTH * 0, y: WORLD_HEIGHT * 0 },
      max: { x: WORLD_WIDTH * 1, y: WORLD_HEIGHT * 1.2 }
    })
    Render.run(render)

    const runner = Matter.Runner.create({})
    Matter.Runner.run(runner, engine)

    // 床を作る
    const floor = Matter.Bodies.rectangle(400, 550, 800, 50, { 
      isStatic: true,
      restitution: 0,
      friction: 1,
      density: 1000 })
    Matter.World.add(world, floor)

    // デバッグ用のマウスコントローラ
    const mouse = Matter.Mouse.create(render.canvas)
    const mouseConstraint = Matter.MouseConstraint.create(engine, {
      mouse: mouse,
      constraint: {
        stiffness: 0.9,
        render: {
          visible: true,
          lineWidth: 1,
          strokeStyle: 'red'
        }
      } as any
    }) 
    Matter.World.add(world, mouseConstraint);
    render.mouse = mouse;
  }
}

一部as any付いてますがご愛嬌で...4 ちょっと長いですが、Matter.jsで必要な一通りの要素を作成し、デバッグ用のビューを表示するところまでやっています。これをindex.tsから読み込んで使います。

index.ts
import PixiStage from './PixiStage'
import MatterWorld from './MatterWorld'

let pixi: PixiStage
let matter: MatterWorld

const init = () => {
  pixi = new PixiStage('.pixi')
  matter = new MatterWorld('.matter')
}

init()

一旦タピオカ粒の描画は削除して、代わりにMatter.jsの呼び出しを追加しました。この状態で実行すると
image.png

こんな感じで画面の下半分にMatter.jsのデバッグビュー(横長の長方形は床)が出てきます。

物理世界にタピオカを投入する

この物理世界にタピオカを追加します。先ほど作ったMatterWorldクラスにタピを投入するメソッドを追加します

MatterWorld.ts
addTapis(xx: number, yy: number, rows: number, cols: number, size: number) {
  const balls = Matter.Composites.stack(
    xx, yy, rows, cols, 7, 7,
    (x, y) => Matter.Bodies.circle(x, y, size)
  )
  Matter.World.add(this.world, balls) 
}

これをindex.tsから呼んであげると

index.ts
const init = () => {
  pixi = new PixiStage('.pixi')
  matter = new MatterWorld('.matter')
  matter.addTapis(50, 50, 5, 3, 20) //タピ投入
}

image.png
無事タピオカが現れました。

物理世界と描画をつなげる

ようやく役者が揃ったので、この物理世界のタピオカをパステルフィルタで描画します。基本的なアプローチとしては、

  1. 物理演算の更新ごとにタピの座標を伝える
  2. PIXI側でタピの座標を受け取り、レンダリング

を繰り返します。
まずは物理世界から座標を教えてもらわないといけないので、MatterWorldに処理を追加します。引数として物理世界の更新時のコールバックを追加します:

MatterWorld
  addTapis(xx: number, yy: number, rows: number, cols: number, size: number,
      onupdated: (points: Matter.Vector[]) => void) 
  {
    const balls = Matter.Composites.stack(
      xx, yy, rows, cols, 7, 7,
      (x, y) => Matter.Bodies.circle(x, y, size)
    )
    Matter.World.add(this.world, balls) 

    // 座標計算後にコールバックを呼ぶ
    Matter.Events.on(this.engine, 'afterUpdate', (ev) => {
      onupdated(balls.bodies.map(b => b.position))
    })
  }

PIXI側のタピオカ描画メソッドも、この座標配列を使って描くように変更しましょう。

PixiStage.ts
public drawTapis(points: PIXI.Point[], size: number = 20): void {
  if(!this.tapiG) {
    // 繰り返し描画が呼ばれるので、Graphicsは初回に一度だけ作って使い回す
    this.tapiG = new PIXI.Graphics()
    // フィルタを追加
    this.tapiG.filters = [new PastelFilter()]
    this.app.stage.addChild(this.tapiG)
  }
  const g = this.tapiG
  g.clear() // 前回の描画をクリア
  g.beginFill(0xaa8833)
  points.forEach(p => {
    g.drawCircle(p.x, p.y, size)
  })
  g.endFill()
}

最後にこの2つをindex.tsで繋げます。

index.ts
const init = () => {
  pixi = new PixiStage('.pixi')
  matter = new MatterWorld('.matter')
  matter.addTapis(50, 50, 5, 3, 20, points => {
    pixi.drawTapis(points as PIXI.Point[], 20) // 物理世界の座標更新時にタピ描画関数を呼ぶ
  })
}

Matter.VectorPIXI.Pointはどちらもxyの座標値をもつオブジェクトなので、ここでは単純にするためasで定義を変換しています。これを実行すると...

image.png
無事描画できました!下側の物理世界側でタピをドラッグすると動きがリアルタイムで上の描画に反映されるのがわかると思います。

波打つ水面と水位の表現

次にミルクティー部分も作っていきましょう。基本はタピと同じですが、前述したとおりmatter.jsには液体の表現がありません。つまり今回のタピオカたちは:angel:実は水中をたゆたっているのではなく、空気中を浮遊しているだけ:angel:です。タピっていうより胞子🍄ですね。重力の強さやタピの質量を調整するとでそれなりに水中っぽいふわふわした揺れをつけられています。

とはいえタピオカミルクティー的には揺れる水面の表現は外せないですよね。今回はこの水面をゴム紐で実現しています。

image.png

はい... こうやって引っ張ってみるとほんとただの紐ですね。でもこれをほどほどに揺らしてあげると、いい感じに水面ぽく見えるわけです。

image.png
タピ投入時にこのゴム紐にタピ粒がぶつかることで、いい感じの波打ち感が実現されています。

かき混ぜられるストローの実装

やっぱりタピオカミルクティーといえばこの赤くて太いストローですよね(なんで みんな赤なんだろう)。最後にこのストローを作ります。ここまでの応用で行けば一見簡単に思えるのですが、素直にストローを突っ込むと↓こうなります...

image.png
そう...:rainbow:この世界は二次元なんです:rainbow:。ストローに阻まれたタピ粒は決して反対側には行けず、かき混ぜるどころが投入することすらままならない事態に陥ります。

つまり、二次元の世界のタピはストローに邪魔されずに動けないといけないのですが、とはいえ完全にスルーされてしまっても混ぜられないので困ります。
タピオカとストローは少しだけ衝突するという謎仕様を実現しないといけません。

ついでに水面を模したゴム紐にストローが引っかかっているのも最高にダサいですね。。:innocent:

衝突の要件を整理する

matter.jsは「このオブジェクトはこのオブジェクトとは衝突しない」といった干渉の有無を制御する機能を持っています。実装の前に今回求められる要件を整理しましょう

  1. ストローは水面とは衝突しない
  2. タピオカは少しだけストローと衝突する
  3. タピオカは水面と衝突する

太字の部分がよくわからないですね。こうしましょう。

  1. ストローは水面とは衝突しない
  2. タピオカのグループAはストローと衝突する
  3. タピオカのグループBはストローと衝突しない
  4. タピオカはグループA・B共に水面と衝突する

タピオカ粒を2つのグループに分けて、一部分だけがストローと衝突するようにします。結果だけ先に出してしまいますが、下のように一部のタピ粒だけがストローに引っかかることでほどほどに「かき混ぜられるタピオカミルクティー」を実現することができます。

image.png

要件から衝突のカテゴリを決める

matter.jsでは衝突する・しないを制御するために、各オブジェクトがcategorymaskという属性を持っています。ざっくりいうと、categoryが自分自身の種別を、maskが自分が衝突する相手のcategory、です。categorymaskもただの数値型です。分かりやすくするために名前をつけて宣言しておきましょう。

MatterConsts.ts
export default {
  CAT_DEFAULT: 0b1,
  CAT_TEA: 0b10,
  CAT_TAPI: 0b100
}

今回は、デフォルト・ミルクティー・タピ粒の3種類のカテゴリを作ってみました。このカテゴリで先ほどの衝突要件を書き直すと、

  1. ストロー(CAT_DEFAULT)は水面(CAT_TEA)とは衝突しない
  2. タピオカ(CAT_TAPI)のグループAはストロー(CAT_DEFAULT)と衝突する
  3. タピオカ(CAT_TAPI)のグループBはストロー(CAT_DEFAULT)と衝突しない
  4. タピオカ(CAT_TAPI)はグループA・B共に水面(CAT_TEA)と衝突する

この時点でタピオカをさらに2カテゴリに分割しないといけないようにも思えますが、一旦このまま進めます。(結論としては、今回は分割しないでなんとかなります)

カテゴリの数値を決める上で注意しないといけないのは、この数字はなんでも使えるわけではなく、2のn乗 = 1ビットだけ1が立っている数字でないといけないことです。つまり、二進数で書いたときに0b1, 0b10, 0b100 ...のようにになる値(十進数だと2, 4, 8 ...)である必要があります。(なぜこんなルールなのかは次で説明します)

ストローと水面(ゴム紐)を衝突しないようにする

このカテゴリを使ってストロー生成部分を修正します:

MatterStraw.ts
this.straw = Matter.Body.create({
  parts: [
    mainBody, // ストロー本体の棒
    ballast // 先端の三角形の重り(PIXI側では描画しない)
  ],
  // ストローの衝突設定
  collisionFilter: {
    group: 0, // groupを使うとカテゴリよりも大雑把で簡単に衝突設定ができる(今回は機能不足なので使わない)
    category: Consts.CAT_DEFAULT, // このストローのカテゴリ
    mask: Consts.CAT_TAPI + Consts.CAT_DEFAULT // 衝突対象のカテゴリの列挙(値の和)
  }
})

collisionFiltercategoryをデフォルトのカテゴリ(CAT_DEFAULT)に指定し、衝突相手としてタピ粒CAT_TAPIとデフォルト(CAT_DEFAULT)の和を指定します。

この「和を指定」という部分がcategoryが「1ビットだけ1が立っている数字」でないといけない理由です。「1ビットだけ1が立っている数字」であれば足し算された和(今回は0b1 + 0b100 = 0b101)から何が足されたのかを簡単に逆算できるので、一つの数字型のプロパティだけで複数カテゴリのどれと衝突してどれとはしないのか、を簡単に表現できるわけです。

image.png
今回はミルクティー(CAT_TEA)を衝突対象にしなかったので、これでストローと水面は干渉しないことになります。

ストローとタピオカグループAを衝突しないようにする

同様にタピオカ側にも衝突の設定を追加します。カテゴリをCAT_TAPI_A, CAT_TAPI_Bのように分割しても良いのですが、カテゴリが増えると扱いが難しくなるので、今回はタピオカグループBは水面と同じCAT_TEAのカテゴリになってもらうことにします。

先ほどのストローの定義で、ストローは水面とは衝突しないができたので、水面と同じカテゴリにしてしまえば、そのタピ粒はストローとは干渉しなくなるわけです。

以下が最終的なタピ粒の定義です:

MatterTapi.ts
const balls = this.balls = Matter.Composites.stack(
  290, -700, 2, 15, 7, 7, (x, y) => 
  Matter.Bodies.circle(
    x + Common.random(5, 15), 
    y + Common.random(5,  20), 
    Common.random(15,  16), 
    {
      friction: 0.8,
      restitution: Common.random(0.3,  0.55),
      density: 0.003,
      collisionFilter: {
        group: 0,
        category: Math.random() < 0.1 ? Consts.CAT_TAPI : Consts.CAT_TEA,
        mask: -1
      }
    }
  )
)

乱数を使ってcategory: Math.random() < 0.1 ? Consts.CAT_TAPI : Consts.CAT_TEA,の部分でCAT_TAPICAT_TEAの2つのカテゴリに割り振っています。衝突判定的にはタピ粒としてストローとぶつかるのは10%だけで、残りはただのミルクティー(ストローとは衝突しない)として振る舞うことになります。

これで一通りの登場人物を物理世界に登場させ、仕様通りに制御できるようになりました。最後にカップの縁や反射をPIXI側で描画してあげれば、晴れて映えるタピオカミルクティーの完成です。

まとめ

今回は画面描画ライブラリのPIXI.jsと物理演算ライブラリのmatter.jsを使ってタピオカがふわふわする様子を表現することにチャレンジしました。また、GLSLを使い、独自の表現を実現するシェーダー作成し、これをPIXI.jsのフィルタとして利用する方法についても解説しました。

  • PIXI.jsはWebGL(GLSL)を使ったシェーダーでオリジナルの表現を追求できるよ
  • matter.jsの物理演算をPIXI.jsで描画することで、リアルな動きを画面に表示できるよ
  • 正確でリアルな物理シミュレーションは難しいけど、描画側の表現と組み合わせて工夫すると、単純な物理モデルでも結構いい表現ができるよ

レッツWebGL Life!:muscle::relaxed:

参考リンク集


  1. タピミ = タピオカミルクティー 

  2. 実際には先にフィルタ(シェーダー)を勉強したくて、そのための良い環境がPIXI.jsだった、という流れですが... 

  3. GPUによる高速な並列演算を実現するため、条件分岐や終了条件が事前にわからない・計算量がピクセルごとに動的に変化する、といったロジックは基本的に書けないと思っとておくのが吉 

  4. TypeScriptの型定義が間違ってるっぽい 

ics
インタラクションデザイン専門のプロダクション。最先端のウェブテクノロジーを駆使し、オンスクリーンメディアの表現分野で活動しています。最新のウェブ技術を発信するサイト「ICS MEDIA」を運営。
https://ics.media/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした