最近SVGにハマっている。ふとした思いつきで、SVGのcircle要素と物理エンジンをマッピングできたら、簡単にパーティクルが作れるのでは、と思ったのでやってみる。
- PhysicsJS + Snap.svg(雑な描画マッピング)
- PhysicsJS + Snap.svg + 自力の差分管理
- 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を使ったやつも書いて、パーティクルらしいパーティクルを書いて、ベンチをとってみようと思う