Browse Source

Merge pull request #24 from jmiskovic/physics-demos

Physics demo: colliders, joints, hand interaction
Bjorn 5 years ago
parent
commit
ed874952a3

+ 0 - 1
examples/Physics/main.lua → examples/Physics/Boxes/main.lua

@@ -32,7 +32,6 @@ function lovr.update(dt)
     if not controllerBoxes[i] then
       controllerBoxes[i] = world:newBoxCollider(0, 0, 0, .25)
       controllerBoxes[i]:setKinematic(true)
-      controllerBoxes[i]:setMass(10)
     end
     controllerBoxes[i]:setPose(lovr.headset.getPose(hand))
   end

+ 130 - 0
examples/Physics/DrawingColliders/main.lua

@@ -0,0 +1,130 @@
+--[[ A utility function that draws all possible physics colliders and joint geometry
+
+Useful for debugging physics (to see if colliders line up with rendered geometry),
+and for experimenting and prototyping with physics, to get the rendering out of way.   --]]
+
+local world
+local count = 100
+
+function lovr.load()
+  gravity = gravity or 2
+  sleepingAllowed = sleepingAllowed or false
+  world = lovr.physics.newWorld(0, -gravity, 0, sleepingAllowed)
+  -- ground plane
+  local box = world:newBoxCollider(vec3(0, 0, 0), vec3(20, 0.1, 20))
+  box:setKinematic(true)
+  box:setUserData({1, 1, 1})
+end
+
+
+function lovr.update(dt)
+  world:update(1 / 72) -- simulation is more stable if executed with fixed step
+  -- every 100ms add random shape until there's enough of them
+  if lovr.timer.getTime() % 0.1 < dt and count > 0 then
+    local collider
+    local colliderType = ({'box', 'sphere', 'cylinder', 'capsule'})[count % 4 + 1]
+    local position = vec3(2 - 4 * lovr.math.random(), 4, 1 - 2 * lovr.math.random())
+    if     colliderType == 'box' then
+      local size = vec3(0.1, 0.2, 0.3)
+      collider = world:newBoxCollider(position, size)
+    elseif colliderType == 'sphere' then
+      local radius = 0.2
+      collider = world:newSphereCollider(position, radius)
+    elseif colliderType == 'cylinder' then
+      local radius, length = 0.1, 0.3
+      collider = world:newCylinderCollider(position, radius, length)
+    elseif colliderType == 'capsule' then
+      local radius, length = 0.1, 0.3
+      collider = world:newCapsuleCollider(position, radius, length)
+    end
+    local shade = 0.2 + 0.6 * lovr.math.random()
+    collider:setUserData({shade, shade, shade})
+    collider:setOrientation(math.pi, lovr.math.random(), lovr.math.random(), lovr.math.random())
+    count = count - 1
+  end
+end
+
+
+function lovr.draw()
+  for i, collider in ipairs(world:getColliders()) do
+    -- rendering shapes of each collider
+    drawCollider(collider)
+    -- debug geometry for joints (no joints are used in this example)
+    drawAttachedJoints(collider)
+  end
+end
+
+
+function drawCollider(collider)
+  local color = collider:getUserData()
+  lovr.graphics.setColor(color or 0x202020)
+  local shape = collider:getShapes()[1]
+  if shape:isSensor() then
+    local r,g,b = lovr.graphics.getColor()
+    lovr.graphics.setColor(r,g,b,0.2)
+  end
+  -- shapes
+  for _, shape in ipairs(collider:getShapes()) do
+    local shapeType = shape:getType()
+    local x,y,z, angle, ax,ay,az = collider:getPose()
+    -- draw primitive at collider's position with correct dimensions
+    if shapeType == 'box' then
+      local sx, sy, sz = shape:getDimensions()
+      lovr.graphics.box('fill', x,y,z, sx,sy,sz, angle, ax,ay,az)
+    elseif shapeType == 'sphere' then
+      lovr.graphics.sphere(x,y,z, shape:getRadius())
+    elseif shapeType == 'cylinder' then
+      local l, r = shape:getLength(), shape:getRadius()
+      local x,y,z, angle, ax,ay,az = collider:getPose()
+      lovr.graphics.cylinder(x,y,z, l, angle, ax,ay,az, r, r)
+    elseif shapeType == 'capsule' then
+      local l, r = shape:getLength(), shape:getRadius()
+      local x,y,z, angle, ax,ay,az = collider:getPose()
+      local m = mat4(x,y,z, 1,1,1, angle, ax,ay,az)
+      lovr.graphics.cylinder(x,y,z, l, angle, ax,ay,az, r, r, false)
+      lovr.graphics.sphere(vec3(m:mul(0, 0,  l/2)), r)
+      lovr.graphics.sphere(vec3(m:mul(0, 0, -l/2)), r)
+    end
+  end
+end
+
+
+function drawAttachedJoints(collider)
+  lovr.graphics.setColor(1,1,1,0.3)
+  -- joints are attached to two colliders; function draws joint for second collider
+  for j, joint in ipairs(collider:getJoints()) do
+    local anchoring, attached = joint:getColliders()
+    if attached == collider then
+      jointType = joint:getType()
+      if jointType == 'ball' then
+        local x1, y1, z1, x2, y2, z2 = joint:getAnchors()
+        drawAnchor(vec3(x1,y1,z1))
+        drawAnchor(vec3(x2,y2,z2))
+      elseif jointType == 'slider' then
+        local position = joint:getPosition()
+        local x,y,z = anchoring:getPosition()
+        drawAxis(vec3(x,y,z), vec3(joint:getAxis()))
+      elseif jointType == 'distance' then
+        local x1, y1, z1, x2, y2, z2 = joint:getAnchors()
+        drawAnchor(vec3(x1,y1,z1))
+        drawAnchor(vec3(x2,y2,z2))
+        drawAxis(vec3(x2,y2,z2), vec3(x1, y1, z1) - vec3(x2,y2,z2))
+      elseif jointType == 'hinge' then
+        local x1, y1, z1, x2, y2, z2 = joint:getAnchors()
+        drawAnchor(vec3(x1,y1,z1))
+        drawAnchor(vec3(x2,y2,z2))
+        drawAxis(vec3(x1,y1,z1), vec3(joint:getAxis()))
+      end
+    end
+  end
+end
+
+
+function drawAnchor(origin)
+  lovr.graphics.sphere(origin, .02)
+end
+
+
+function drawAxis(origin, axis)
+  lovr.graphics.line(origin, origin + axis:normalize() * 0.3)
+end

+ 149 - 0
examples/Physics/HandPhysics/main.lua

@@ -0,0 +1,149 @@
+--[[ Hand interaction with physics world: use trigger to solidify hand, grip to grab objects
+
+To manipulate objects in world, we create box collider (palm) for each hand controller. This box
+is updated to track location of controller.
+
+The naive approach would be to set exact location and orientation of physical collider with values
+from hand controller. This results in lousy and unconvincing collisions with other objects, as
+physics engine doesn't know the speed of hand colliders at the moment of collision.
+
+An improvement is to set linear and angular speed of kinematic hand colliders so that they
+approach the target (actual location/orientation of hand controller). This works excellent for one
+controller. When you try to squeeze an object between two hands, physics break. This is because
+kinematic hand controllers are never affected by physics engine and unrealistic material
+penetration cannot be resolved.
+
+The approach taken here is to have hand controllers behave as normal dynamic colliders that can be
+affected by other collisions. To track hand controllers, we apply force and torque on collider
+objects that's proportional to distance from correct position.
+
+This means hand colliders won't have 1:1 mapping with actual hand controllers, they will actually
+'bend' under large force. Also the colliders can actually become stuck behind another object. This
+is sometimes frustrating to use, so in this example hand colliders can ghost through objects or
+become solid using trigger button.
+
+Grabbing objects is done by creating two joints between hand collider and object to hold them
+together. This enables pulling, stacking and throwing.                                      --]]
+
+local hands = { -- palms that can push and grab objects
+  colliders = {nil, nil},     -- physical objects for palms
+  touching  = {nil, nil},     -- the collider currently touched by each hand
+  holding   = {nil, nil},     -- the collider attached to palm
+  solid     = {false, false}, -- hand can either pass through objects or be solid
+} -- to be filled with as many hands as there are active controllers
+
+local world
+local collisionCallbacks = {}
+local boxes = {}
+
+local framerate = 1 / 72 -- fixed framerate is recommended for physics updates
+
+function lovr.load()
+  world = lovr.physics.newWorld(0, -2, 0, false) -- low gravity and no collider sleeping
+  -- ground plane
+  local box = world:newBoxCollider(vec3(0, 0, 0), vec3(20, 0.1, 20))
+  box:setKinematic(true)
+  table.insert(boxes, box)
+  -- create a fort of boxes
+  lovr.math.setRandomSeed(0)
+  for angle = 0, 2 * math.pi, 2 * math.pi / 12 do
+    for height = 0.3, 1.5, 0.4 do
+      local pose = mat4():rotate(angle, 0,1,0):translate(0, height, -1)
+      local size = vec3(0.3, 0.4, 0.2)
+      local box = world:newBoxCollider(vec3(pose), size)
+      box:setOrientation(quat(pose))
+      table.insert(boxes, box)
+    end
+  end
+  -- make colliders for two hands
+  for i = 1, 2 do
+    hands.colliders[i] = world:newBoxCollider(vec3(0,2,0), vec3(0.04, 0.08, 0.08))
+    hands.colliders[i]:setLinearDamping(0.2)
+    hands.colliders[i]:setAngularDamping(0.3)
+    hands.colliders[i]:setMass(0.1)
+    registerCollisionCallback(hands.colliders[i],
+      function(collider, world)
+        -- store collider that was last touched by hand
+        hands.touching[i] = collider
+      end)
+  end
+end
+
+
+function lovr.update(dt)
+  -- override collision resolver to notify all colliders that have registered their callbacks
+  world:update(framerate, function(world)
+    world:computeOverlaps()
+    for shapeA, shapeB in world:overlaps() do
+      local areColliding = world:collide(shapeA, shapeB)
+      if areColliding then
+        cbA = collisionCallbacks[shapeA]
+        if cbA then cbA(shapeB:getCollider(), world) end
+        cbB = collisionCallbacks[shapeB]
+        if cbB then cbB(shapeA:getCollider(), world) end
+      end
+    end
+  end)
+  -- hand updates - location, orientation, solidify on trigger button, grab on grip button
+  for i, hand in pairs(lovr.headset.getHands()) do
+    -- align collider with controller by applying force (position) and torque (orientation)
+    local rw = mat4(lovr.headset.getPose(hand))   -- real world pose of controllers
+    local vr = mat4(hands.colliders[i]:getPose()) -- vr pose of palm colliders
+    local angle, ax,ay,az = quat(rw):mul(quat(vr):conjugate()):unpack()
+    angle = ((angle + math.pi) % (2 * math.pi) - math.pi) -- for minimal motion wrap to (-pi, +pi) range
+    hands.colliders[i]:applyTorque(vec3(ax, ay, az):mul(angle * dt * 1))
+    hands.colliders[i]:applyForce((vec3(rw:mul(0,0,0)) - vec3(vr:mul(0,0,0))):mul(dt * 2000))
+    -- solidify when trigger touched
+    hands.solid[i] = lovr.headset.isDown(hand, 'trigger')
+    hands.colliders[i]:getShapes()[1]:setSensor(not hands.solid[i])
+    -- hold/release colliders
+    if lovr.headset.isDown(hand, 'grip') and hands.touching[i] and not hands.holding[i] then
+      hands.holding[i] = hands.touching[i]
+      lovr.physics.newBallJoint(hands.colliders[i], hands.holding[i], vr:mul(0,0,0))
+      lovr.physics.newSliderJoint(hands.colliders[i], hands.holding[i], quat(vr):direction())
+    end
+    if lovr.headset.wasReleased(hand, 'grip') and hands.holding[i] then
+      for _,joint in ipairs(hands.colliders[i]:getJoints()) do
+        joint:destroy()
+      end
+      hands.holding[i] = nil
+    end
+  end
+  hands.touching = {nil, nil} -- to be set again in collision resolver
+end
+
+
+function lovr.draw()
+  for i, collider in ipairs(hands.colliders) do
+    local alpha = hands.solid[i] and 1 or 0.2
+    lovr.graphics.setColor(0.75, 0.56, 0.44, alpha)
+    drawBoxCollider(collider)
+  end
+  lovr.math.setRandomSeed(0)
+  for i, collider in ipairs(boxes) do
+    local shade = 0.2 + 0.6 * lovr.math.random()
+    lovr.graphics.setColor(shade, shade, shade)
+    drawBoxCollider(collider)
+  end
+end
+
+
+function drawBoxCollider(collider)
+  -- query current pose (location and orientation)
+  local pose = mat4(collider:getPose())
+  -- query dimensions of box
+  local shape = collider:getShapes()[1]
+  local size = vec3(shape:getDimensions())
+  -- draw box
+  pose:scale(size)
+  lovr.graphics.box('fill', pose)
+end
+
+
+function registerCollisionCallback(collider, callback)
+  collisionCallbacks = collisionCallbacks or {}
+  for _, shape in ipairs(collider:getShapes()) do
+    collisionCallbacks[shape] = callback
+  end
+  -- to be called with arguments callback(otherCollider, world) from update function
+end

+ 50 - 0
examples/Physics/NewtonsCradle/main.lua

@@ -0,0 +1,50 @@
+-- Newton's cradle: array of balls suspended from two strings, demonstrating conservation of energy / momentum
+-- Strings are modeled with distance joints, which means they behave more like rods.
+
+local world
+local frame
+local framePose
+local balls = {}
+local count = 10
+local radius = 1 / count / 2
+-- small air gap between balls results in collisions in separate frames, to carry impulse through to last ball
+-- without this gap the physics engine would need to calculate transfer of impulses between contacts
+local gap = 0.01
+
+
+function lovr.load()
+  world = lovr.physics.newWorld(0, -9.8, 0, false)
+  -- a static geometry from which balls are suspended
+  local size = vec3(1.2, 0.1, 0.3)
+  frame = world:newBoxCollider(vec3(0, 2, -1), size)
+  frame:setKinematic(true)
+  framePose = lovr.math.newMat4(frame:getPose()):scale(size)
+  -- create balls along the length of frame and attach them with two distance joints to frame
+  for x = -0.5, 0.5, 1 / count do
+    local ball = world:newSphereCollider(vec3(x, 1, -1), radius - gap)
+    ball:setRestitution(1)
+    lovr.physics.newDistanceJoint(frame, ball, vec3(x, 2, -1 + 0.25), vec3(x, 1, -1))
+    lovr.physics.newDistanceJoint(frame, ball, vec3(x, 2, -1 - 0.25), vec3(x, 1, -1))
+    table.insert(balls, ball)
+  end
+  -- displace the last ball to set the Newton's cradle in motion
+  local lastBall = balls[#balls]
+  lastBall:setPosition(vec3(lastBall:getPosition()) + vec3(5 * radius, 5 * radius, 0))
+  lovr.graphics.setBackgroundColor(0.1, 0.1, 0.1)
+end
+
+
+function lovr.draw()
+  lovr.graphics.setColor(0, 0, 0)
+  lovr.graphics.box('fill', framePose)
+  lovr.graphics.setColor(1, 1, 1)
+  for i, ball in ipairs(balls) do
+    local position = vec3(ball:getPosition())
+    lovr.graphics.sphere(position, radius)
+  end
+end
+
+
+function lovr.update(dt)
+  world:update(1 / 72)
+end

+ 41 - 0
examples/Physics/SaloonDoor/main.lua

@@ -0,0 +1,41 @@
+-- Saloon doors attached with hinge joint to frame, and with distance joint to each other
+
+local world
+local door1, door2
+
+function lovr.load()
+  world = lovr.physics.newWorld(0, -9.8, 0, false)
+  -- a static geometry that functions as door frame
+  local frame = world:newBoxCollider(vec3(0, -0.1, -1), vec3(2.2, 0.05, 0.2))
+  frame:setKinematic(true)
+  door1 = world:newBoxCollider(vec3( 0.55, 0.5, -1), vec3(1, 1, 0.2))
+  door2 = world:newBoxCollider(vec3(-0.55, 0.5, -1), vec3(1, 1, 0.2))
+  door1:setAngularDamping(0.01)
+  door2:setAngularDamping(0.01)
+  -- attach doors to frame, with hinges being oriented vertically (up vector is 0,1,0)
+  lovr.physics.newHingeJoint(frame, door1, vec3( 1, 0, -1), vec3(0,1,0))
+  lovr.physics.newHingeJoint(frame, door2, vec3(-1, 0, -1), vec3(0,1,0))
+  -- model door springiness by attaching loose distance joint between two doors
+  local joint = lovr.physics.newDistanceJoint(door1, door2, vec3(door1:getPosition()), vec3(door2:getPosition()))
+  joint:setTightness(0.2)
+  joint:setResponseTime(10)
+  lovr.graphics.setBackgroundColor(0.1, 0.1, 0.1)
+end
+
+function lovr.draw()
+  for i, boxCollider in ipairs(world:getColliders()) do
+    lovr.graphics.setColor(i / 3, i / 3, i / 3)
+    local pose = mat4(boxCollider:getPose())
+    local size = vec3(boxCollider:getShapes()[1]:getDimensions())
+    lovr.graphics.box('fill', pose:scale(size))
+  end
+end
+
+function lovr.update(dt)
+  world:update(1 / 72)
+  -- every few seconds simulate a push
+  if lovr.timer.getTime() % 3 < dt then
+    door1:applyForce(0, 0, -50)
+    door2:applyForce(0, 0, -50)
+  end
+end

+ 100 - 0
examples/Physics/WreckingBall/main.lua

@@ -0,0 +1,100 @@
+--[[ Wrecking ball suspended from rope. Cyrus-free.
+
+Making realtime rope simulation is finicky on any physics engine. At certain weight the force becomes
+too much to be successfully distributed among rope elements.
+
+Some steps that can help solve the issue:
+1) lower the mass of suspended weight or lower the gravity constant
+2) increase mass of each rope element, preferably having more mass at top of rope and less mass at bottom
+3) decrease number of rope elements
+4) decrease the simulation time step
+5) modify engine code to use direct solver instead of iterative solver (step() instead of quickStep())
+--]]
+
+local world
+
+function lovr.load()
+  world = lovr.physics.newWorld(0, -3, 0, false)
+  -- ground plane
+  local box = world:newBoxCollider(vec3(0, -0.05, 0), vec3(20, 0.1, 20))
+  box:setKinematic(true)
+  -- hanger
+  local hangerPosition = vec3(0, 2, -1)
+  local hanger = world:newBoxCollider(hangerPosition, vec3(0.3, 0.1, 0.3))
+  hanger:setKinematic(true)
+  -- ball
+  local ballPosition = vec3(-1, 1, -1)
+  local ball = world:newSphereCollider(ballPosition, 0.2)
+  -- rope
+  local firstEnd, lastEnd = makeRope(
+    hangerPosition + vec3(0, -0.1, 0),
+    ballPosition   + vec3(0,  0.3, 0),
+    0.02, 10)
+  lovr.physics.newDistanceJoint(hanger, firstEnd, hangerPosition, vec3(firstEnd:getPosition()))
+  lovr.physics.newDistanceJoint(ball, lastEnd, ballPosition, vec3(lastEnd:getPosition()))
+  -- brick wall
+  local x = 0.3
+  local even = true
+  for y = 1, 0.1, -0.1 do
+    for z = -0.5, -1.5, -0.2 do
+      world:newBoxCollider(x, y, even and z or z - 0.1, 0.08, 0.1, 0.2)
+    end
+    even = not even
+  end
+
+  lovr.graphics.setBackgroundColor(0.1, 0.1, 0.1)
+end
+
+
+function lovr.update(dt)
+  world:update(1 / 72)
+end
+
+
+function lovr.draw()
+  for i, collider in ipairs(world:getColliders()) do
+    local shade = (i - 10) / #world:getColliders()
+    lovr.graphics.setColor(shade, shade, shade)
+    local shape = collider:getShapes()[1]
+    local shapeType = shape:getType()
+    local x,y,z, angle, ax,ay,az = collider:getPose()
+    if shapeType == 'box' then
+      local sx, sy, sz = shape:getDimensions()
+      lovr.graphics.box('fill', x,y,z, sx,sy,sz, angle, ax,ay,az)
+    elseif shapeType == 'sphere' then
+      lovr.graphics.setColor(0.4, 0, 0)
+      lovr.graphics.sphere(x,y,z, shape:getRadius())
+    end
+  end
+end
+
+
+function makeRope(origin, destination, thickness, elements)
+  local length = (destination - origin):length()
+  thickness = thickness or length / 100
+  elements = elements or 30
+  elementSize = length / elements
+  local orientation = vec3(destination - origin):normalize()
+  local first, last, prev
+  for i = 1, elements do
+    local position = vec3(origin):lerp(destination, (i - 0.5) / elements)
+    local anchor   = vec3(origin):lerp(destination, (i - 1.0) / elements)
+    element = world:newBoxCollider(position, vec3(thickness, thickness, elementSize * 0.95))
+    element:setRestitution(0.1)
+    element:setGravityIgnored(true)
+    element:setOrientation(quat(orientation))
+    element:setLinearDamping(0.01)
+    element:setAngularDamping(0.01)
+    element:setMass(0.001)
+    if prev then
+      local joint = lovr.physics.newBallJoint(prev, element, anchor)
+      joint:setResponseTime(10)
+      joint:setTightness(1)
+    else
+      first = element
+    end
+    prev = element
+  end
+  last = prev
+  return first, last
+end

+ 86 - 0
examples/Physics/ZipLine/main.lua

@@ -0,0 +1,86 @@
+--[[ A zipline made of
+
+Capsule is suspended from trolley using distance joint. Trolley is attached to hanger
+using slider joint. Beware that slider joint loses its accuracy/stability when attached
+objects are too far away. Increasing object mass of helps with stability.            --]]
+
+local world
+
+function lovr.load()
+  world = lovr.physics.newWorld(0, -3, 0, false)
+  local hanger = world:newBoxCollider(vec3(1, 1.9, -1), vec3(0.1, 0.1, 0.3))
+  hanger:setKinematic(true)
+  local trolley = world:newBoxCollider(vec3(-1, 2, -1), vec3(0.2, 0.2, 0.5))
+  trolley:setRestitution(0.7)
+  -- calculate axis that passes through centers of hanger and trolley
+  local sliderAxis = vec3(hanger:getPosition()) - vec3(trolley:getPosition())
+  -- constraint the trolley so that it can only slide along specified axis without any rotation
+  joint = lovr.physics.newSliderJoint(hanger, trolley, sliderAxis)
+  -- hang a weight from trolley
+  local weight = world:newCapsuleCollider(vec3(-1, 1.5, -1), 0.1, 0.4)
+  weight:setOrientation(math.pi/2, 1,0,0)
+  weight:setLinearDamping(0.005)
+  weight:setAngularDamping(0.01)
+  local joint = lovr.physics.newDistanceJoint(trolley, weight, vec3(trolley:getPosition()), vec3(weight:getPosition()) + vec3(0, 0.3, 0))
+  joint:setResponseTime(10) -- make the hanging rope streachable
+
+  lovr.graphics.setBackgroundColor(0.1, 0.1, 0.1)
+end
+
+
+function lovr.update(dt)
+  world:update(1 / 72)
+end
+
+
+function lovr.draw()
+  for i, collider in ipairs(world:getColliders()) do
+    lovr.graphics.setColor(0.6, 0.6, 0.6)
+    local shape = collider:getShapes()[1]
+    local shapeType = shape:getType()
+    local x,y,z, angle, ax,ay,az = collider:getPose()
+    if shapeType == 'box' then
+      local sx, sy, sz = shape:getDimensions()
+      lovr.graphics.box('fill', x,y,z, sx,sy,sz, angle, ax,ay,az)
+    elseif shapeType == 'capsule' then
+      lovr.graphics.setColor(0.4, 0, 0)
+      local l, r = shape:getLength(), shape:getRadius()
+      local x,y,z, angle, ax,ay,az = collider:getPose()
+      local m = mat4(x,y,z, 1,1,1, angle, ax,ay,az)
+      lovr.graphics.cylinder(x,y,z, l, angle, ax,ay,az, r, r, false)
+      lovr.graphics.sphere(vec3(m:mul(0, 0,  l/2)), r)
+      lovr.graphics.sphere(vec3(m:mul(0, 0, -l/2)), r)
+    end
+  end
+end
+
+
+function makeRope(origin, destination, thickness, elements)
+  local length = (destination - origin):length()
+  thickness = thickness or length / 100
+  elements = elements or 30
+  elementSize = length / elements
+  local orientation = vec3(destination - origin):normalize()
+  local first, last, prev
+  for i = 1, elements do
+    local position = vec3(origin):lerp(destination, (i - 0.5) / elements)
+    local anchor   = vec3(origin):lerp(destination, (i - 1.0) / elements)
+    element = world:newBoxCollider(position, vec3(thickness, thickness, elementSize * 0.95))
+    element:setRestitution(0.1)
+    element:setGravityIgnored(true)
+    element:setOrientation(quat(orientation))
+    element:setLinearDamping(0.01)
+    element:setAngularDamping(0.01)
+    element:setMass(0.001)
+    if prev then
+      local joint = lovr.physics.newBallJoint(prev, element, anchor)
+      joint:setResponseTime(10)
+      joint:setTightness(1)
+    else
+      first = element
+    end
+    prev = element
+  end
+  last = prev
+  return first, last
+end