Browse Source

Physics demos: colliders, joints, hand interaction

Couple of demos for LOVR physics that show how to use different joints,
how to render all supported colliders, and one way to implement hand
interaction with world.
Josip Miskovic 5 years ago
parent
commit
127950be73

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

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