LoginSignup
38
39

More than 5 years have passed since last update.

SVGと物理エンジンをマッピングするn個の方法

Last updated at Posted at 2014-08-30

最近SVGにハマっている。ふとした思いつきで、SVGのcircle要素と物理エンジンをマッピングできたら、簡単にパーティクルが作れるのでは、と思ったのでやってみる。

  1. PhysicsJS + Snap.svg(雑な描画マッピング)
  2. PhysicsJS + Snap.svg + 自力の差分管理
  3. PhysicsJS + React

作るのは非常に雑な、こういうやつだ

  • クリックしたらcircle要素が出現する
  • 物理エンジンによって座標計算されて右方向に飛んで行く
  • 50px 右に移動したら消える

物理エンジン的に面白い挙動とかはあとで考えることにする。

物理エンジンの世界を作る

今回はPhysicsJSを使うことにした。box2dはC++由来のAPIが古臭いし使いたくない。matter.js と PhysicsJSがメンテされててモダンな雰囲気がして試してみたのだけど、matter.jsは何かとおせっかいがすぎる + デザインパターンが特殊すぎるので、素直なPhysicJSを使うことにする。

ボールが一つあって、速度を持ってる世界を作る。

world = Physics
  integrator: 'verlet'
  maxIPF: 16
  timestep: 1000.0 / 300

ball = Physics.body 'circle',
  x: 100
  y: 100
  vx: 0.2
  vy: 0.01
  radius: 10

world.add(ball)

Physics.util.ticker.on (time)->
  world.step(time)

Physics.util.ticker.start()

この ticker.on の中身はフレームごとに呼ばれてる。world.step(time) で状態を更新する。
この時点ではまだ何も描画していない。

SVGで描画する

SVGの circle 要素をマッピングする。

world = Physics
  integrator: 'verlet'
  maxIPF: 16
  timestep: 1000.0 / 300

ball = Physics.body 'circle',
  x: 100
  y: 100
  vx: 0.2
  vy: 0.01
  radius: 10

world.add(ball)

$ =>
  $('body').html """
    <svg width=640 height=320 style="background-color: white;"></svg>
  """
  paper = Snap document.querySelector 'svg'
  circle = paper.circle r: 10

  Physics.util.ticker.on (time)->
    for body in world.getBodies()
      circle.attr
        cx: body.state.pos.x
        cy: body.state.pos.y
    world.step(time)

  Physics.util.ticker.start()

非常に雑なコード。何が雑かって、要素が一つだけであることに依存している。

SVG要素を動的に生成する

クリックしたらボールを追加したい。追加したら描画側で勝手に要素を追加してほしい。
設計方針として、MVC でいう M と V を切り離すことを目標にする。

class Renderer
  constructor: (@world, @paper) ->
    @_elementMappings = {} # PhysicsId: Shape

  render: ->
    for body in @world.getBodies()
      circle = @_elementMappings[body.uid]
      unless circle
        circle = @paper.circle r: 10
        @_elementMappings[body.uid] = circle

      circle.attr
        cx: body.state.pos.x
        cy: body.state.pos.y

$ =>
  $('body').html """
    <svg width=640 height=320 style="background-color: white;"></svg>
  """
  paper = Snap 'svg'

  world = Physics
    integrator: 'verlet'
    maxIPF: 16
    timestep: 1000.0 / 300

  renderer = new Renderer world, paper
  Physics.util.ticker.on (time) ->
    world.step(time)
    renderer.render()

  paper.click ->
    ball = Physics.body 'circle',
      x: 100
      y: 100
      vx: 0.2
      vy: 0.01
      radius: 10

    world.add(ball)

  Physics.util.ticker.start()

マウスをクリックする度に生成する。_elementMappings にbodyのuidをキーに、Snapのshapeを保存する。
この方式にも欠点がある。PhisicsJS側で要素を削除した時に、消されたことを検知できない。

差分管理

とりあえずxが150を超えたら削除するようにする。このときRenderer側も自動で変更がかからないものを検知して削除する。

class Renderer
  constructor: (@world, @paper) ->
    @_elementMappings = {} # PhysicsId: Shape

  render: ->
    updatedList = []
    for body in @world.getBodies()
      updatedList.push body.uid
      circle = @_elementMappings[body.uid]
      unless circle
        circle = @paper.circle r: 10
        @_elementMappings[body.uid] = circle

      circle.attr
        cx: body.state.pos.x
        cy: body.state.pos.y

    unstagedList = _.difference Object.keys(@_elementMappings), updatedList.map((i) -> i.toString())
    for key in unstagedList
      @_elementMappings[key]?.remove()
      delete @_elementMappings[key]

$ =>
  $('body').html """
    <svg width=640 height=320 style="background-color: white;"></svg>
  """
  paper = Snap 'svg'

  world = Physics
    integrator: 'verlet'
    maxIPF: 16
    timestep: 1000.0 / 300

  renderer = new Renderer world, paper
  Physics.util.ticker.on (time) ->
    world.step(time)
    bodies = world.getBodies()
    bodies.map (body) ->
      if body.state.pos.x > 150
        world.remove body

    renderer.render()

  paper.click ->
    ball = Physics.body 'circle',
      x: 100
      y: 100
      vx: 0.2
      vy: 0.01
      radius: 10

    world.add(ball)

  Physics.util.ticker.start()

配列と配列の差を比較するのに _.difference を使っている。
差分管理が人間がやるには厳しくなってきた

React で差分描画を行う

Reactは差分描画を行ってくれる。

{DOM} = React
Renderer = React.createClass
  getInitialState: ->
    bodies: []

  onClick: ->
    ball = Physics.body 'circle',
      x: 100
      y: 100
      vx: 0.2
      vy: 0.01
      radius: 10

    @props.world.add(ball)

  render: ->
    DOM.svg
      className: 'content'
      width: 640
      height: 480
      style:
        backgroundColor: 'white'
      onClick: @onClick
    , @state.bodies.map (body) =>
      DOM.circle
        key: body.uid
        cx: body.state.pos.x
        cy: body.state.pos.y
        r: 10

$ =>
  world = Physics
    integrator: 'verlet'
    maxIPF: 16
    timestep: 1000.0 / 300

  renderer = React.renderComponent (Renderer {world}), document.body

  Physics.util.ticker.on (time) ->
    world.step(time)
    bodies = world.getBodies()
    bodies.forEach (body) ->
      if body.state.pos.x > 150
        world.remove body
    renderer.setState bodies: world.getBodies()

  Physics.util.ticker.start()

renderでまるごと描画しているように見えるが、実際は差分だけをReact側で管理してDOMを挿入してくれてる。賢い。
ただこの差分管理の計算コストが発生しているので、上の自前計算の方が速いかもしれない。

次はrequestAnimationFrameを内部的に使ってるClojure/Omを使ったやつも書いて、パーティクルらしいパーティクルを書いて、ベンチをとってみようと思う

38
39
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
39