Edited at

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

More than 5 years have passed since last update.

最近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を使ったやつも書いて、パーティクルらしいパーティクルを書いて、ベンチをとってみようと思う