Drag & drop in VR with LÖVR
2024-11-29
LÖVR is a delightful to use VR framework. In this post I'll explain how to implement a simple drag & drog feature in it.
For LÖVR beginners, here's a guide on how to get started.
Surprisingly you don't need a headset to run LÖVR apps - you can just call lovr .
in a folder with a main.lua
file. This will launch an emulator on your computer. For our drag & drop feature however, we will need the headset to get the positions, orientations and gestures of our hands. I'm using a Meta Quest 3. If you don't have a headset yet, I'd recommend checking out Meta Quest 3S, because it's so cheap.
Hello World & Launching the app on your headset
We'll start with a simple hello world app:
function lovr.draw(pass)
pass:text("hello world", vec3(0, 1, -1), 0.1, quat())
end
vec3(0, 1, -1)
the position of the text, where negative Z axis is in the direction the headset is facing.
0.1
is the scale of the text.
quat
constructs a default quaternion, a mathematical object storing the information about the orientation in three dimensions.
That's the whole program. You can save it to a file named main.lua
in a new project folder. To launch it on your headset, follow these instructions.
Hand tracking with LÖVR
LÖVR exposes a very simple API for tracking both hands and controllers. The whole API is documented very well in the docs, together with examples.
We can start by copying the Tracked Hands example:
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local x, y, z = lovr.headset.getPosition(hand)
pass:sphere(x, y, z, .1)
end
end
If you have trouble with hand tracking, you can switch to controllers without changing the code.
After launching it on your headset, you'll see two white spheres following your hands positions.
To study what exactly the code does, you can check out these pages in the docs:
User input
Now we need to detect grab events. We'll use the trigger
input fired when the controller trigger is pressed or when the user pinches their fingers.
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local x, y, z = lovr.headset.getPosition(hand)
-- Set white color by default.
pass:setColor(0xffffff)
-- Set red color if the user just tried to grab.
if lovr.headset.wasPressed(hand,'trigger') then
pass:setColor(0xff0000)
end
pass:sphere(x, y, z, .1)
end
end
Now whenever you pinch your fingers, you'll see the spheres flicker red for a single frame.
Let's display something that we can grab.
local box1 = {
position = lovr.math.newVec3(0.5, 1, -0.5),
dimensions = lovr.math.newVec3(0.3, 0.3, 0.3),
}
local box2 = {
position = lovr.math.newVec3(-0.5, 1, -0.5),
dimensions = lovr.math.newVec3(0.5, 0.5, 0.5),
}
local boxes = { box1, box2 }
function lovr.draw(pass)
-- The rest of the owl...
for _, box in ipairs(boxes) do
pass:box(box.position, box.dimensions, quat(), 'line')
end
end
See Pass:box
.
A note on vec3(...) and lovr.math.newVec3(...)
You might notice that in the Hello World example I instantiated a vector using vec3(...)
and here I used lovr.math.newVec3(...)
. It's not random.
If the vector is used only within one frame (it isn't assigned to a variable used outside of lovr.draw
or lovr.update
), I can construct a temporary vector with vec3(...)
. If I need a vector to live for longer, I need to use lovr.math.newVec3(...)
.
If you use a temporary vector during another frame, you'll get an error saying 'Attempt to use a temporary vector from a previous frame'
.
Now we need to detect whether the hand was touching the cube while the grab event fired.
We'll add an isActive
property to each box...
local box1 = {
position = lovr.math.newVec3(0.5, 1.5, -0.5),
dimensions = lovr.math.newVec3(0.3, 0.3, 0.3),
isActive = false,
}
local box2 = {
position = lovr.math.newVec3(-0.5, 1.5, -0.5),
dimensions = lovr.math.newVec3(0.5, 0.5, 0.5),
isActive = false,
}
...convert the coordinates returned by getPosition(...)
into a vector and update code which displays the spheres...
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local handPosition = vec3(lovr.headset.getPosition(hand))
pass:setColor(0xffffff)
pass:sphere(handPosition, .1)
end
end
...then toggle isActive
of a box the user grabbed...
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local handPosition = vec3(lovr.headset.getPosition(hand))
local wasPressed = lovr.headset.wasPressed(hand, 'trigger')
for _, box in ipairs(boxes) do
if wasPressed and isPointInsideBox(handPosition, box) then
box.isActive = not box.isActive
end
end
pass:setColor(0xffffff)
pass:sphere(handPosition, .1)
end
end
We'll also change box's color depending on whether it is active:
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
-- ...
end
for _, box in ipairs(boxes) do
if box.isActive then
pass:setColor(0x00ff00)
else
pass:setColor(0xffffff)
end
pass:box(box.position, box.dimensions, quat(), 'line')
end
end
And lastly, let's implement the isPointInsideBox
function:
function isPointInsideBox(point, box)
local relativePoint = point - box.position + (box.dimensions / 2)
local width, height, depth = box.dimensions:unpack()
return (
relativePoint.x > 0 and relativePoint.x < width
and relativePoint.y > 0 and relativePoint.y < height
and relativePoint.z > 0 and relativePoint.z < depth
)
end
We're adding box.dimensions / 2
because box.position
marks the center of the drawn box, not its corner.
Whole code
local box1 = {
position = lovr.math.newVec3(0.5, 1.5, -0.5),
dimensions = lovr.math.newVec3(0.3, 0.3, 0.3),
isActive = false,
}
local box2 = {
position = lovr.math.newVec3(-0.5, 1.5, -0.5),
dimensions = lovr.math.newVec3(0.5, 0.5, 0.5),
isActive = false,
}
local boxes = { box1, box2 }
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local handPosition = vec3(lovr.headset.getPosition(hand))
local wasPressed = lovr.headset.wasPressed(hand, 'trigger')
for _, box in ipairs(boxes) do
if wasPressed and isPointInsideBox(handPosition, box) then
box.isActive = not box.isActive
end
end
pass:setColor(0xffffff)
pass:sphere(handPosition, .1)
end
for _, box in ipairs(boxes) do
if box.isActive then
pass:setColor(0x00ff00)
else
pass:setColor(0xffffff)
end
pass:box(box.position, box.dimensions, quat(), 'line')
end
end
function isPointInsideBox(point, box)
local relativePoint = point - box.position + (box.dimensions / 2)
local width, height, depth = box.dimensions:unpack()
return (
relativePoint.x > 0 and relativePoint.x < width
and relativePoint.y > 0 and relativePoint.y < height
and relativePoint.z > 0 and relativePoint.z < depth
)
end
If you run your code now, you should see the boxes change its color to green when you pinch them, and then back to white when you do it again.
Grabbing
Now that we're able to detect whether the user grabbed something, let's make it follow our hand.
We need to keep track of which hand is grabbing which box in order to move the boxes correctly. We'll store which hand is grabbing which box together with the offsets:
local box1 = {
-- ...
}
local box2 = {
-- ...
}
local boxes = { box1, box2 }
local grabbedBoxes = {
["hand/left"] = nil,
["hand/right"] = nil,
}
"hand/left" and "hand/right" are the hand identifiers used by LÖVR.
On each trigger event we'll iterate over each box and store it in grabbedBoxes
if it was grabbed, alongside the offset...
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local handPosition = vec3(lovr.headset.getPosition(hand))
local wasPressed = lovr.headset.wasPressed(hand, 'trigger')
if wasPressed then
for _, box in ipairs(boxes) do
if isPointInsideBox(handPosition, box) then
grabbedBoxes[hand] = {
box = box,
offset = lovr.math.newVec3(handPosition - box.position),
}
end
end
end
-- ...
end
-- ...
end
...or remove it from grabbedBoxes
if it was released...
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local handPosition = vec3(lovr.headset.getPosition(hand))
local wasPressed = lovr.headset.wasPressed(hand, 'trigger')
if wasPressed then
-- ...
end
local wasReleased = lovr.headset.wasReleased(hand, 'trigger')
if wasReleased then
grabbedBoxes[hand] = nil
end
-- ...
end
-- ...
end
...and lastly move the grabbed box:
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local handPosition = vec3(lovr.headset.getPosition(hand))
local wasPressed = lovr.headset.wasPressed(hand, 'trigger')
if wasPressed then
-- ...
end
local wasReleased = lovr.headset.wasReleased(hand, 'trigger')
if wasReleased then
grabbedBoxes[hand] = nil
end
local grabbedBox = grabbedBoxes[hand]
if grabbedBox ~= nil then
grabbedBox.box.position:set(handPosition - grabbedBox.offset)
end
pass:sphere(handPosition, .1)
end
-- ...
end
To highlight a grabbed box, we'll change its color:
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
-- ...
end
for _, box in ipairs(boxes) do
local isGrabbed = (
(grabbedBoxes.left and grabbedBoxes.left.box == box)
or (grabbedBoxes.right and grabbedBoxes.right.box == box)
)
if isGrabbed then
pass:setColor(0x00ff00)
else
pass:setColor(0xffffff)
end
pass:box(box.position, box.dimensions, quat(), 'line')
end
end
Whole code
local box1 = {
position = lovr.math.newVec3(0.5, 1, -0.5),
dimensions = lovr.math.newVec3(0.3, 0.3, 0.3),
}
local box2 = {
position = lovr.math.newVec3(-0.5, 1, -0.5),
dimensions = lovr.math.newVec3(0.5, 0.5, 0.5),
}
local boxes = { box1, box2 }
local grabbedBoxes = {
["hand/left"] = nil,
["hand/right"] = nil,
}
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local handPosition = vec3(lovr.headset.getPosition(hand))
local wasPressed = lovr.headset.wasPressed(hand, 'trigger')
if wasPressed then
for _, box in ipairs(boxes) do
if isPointInsideBox(handPosition, box) then
grabbedBoxes[hand] = {
box = box,
offset = lovr.math.newVec3(handPosition - box.position),
}
end
end
end
local wasReleased = lovr.headset.wasReleased(hand, 'trigger')
if wasReleased then
grabbedBoxes[hand] = nil
end
local grabbedBox = grabbedBoxes[hand]
if grabbedBox ~= nil then
grabbedBox.box.position:set(handPosition - grabbedBox.offset)
end
pass:sphere(handPosition, .1)
end
for _, box in ipairs(boxes) do
local isGrabbed = (
(grabbedBoxes["hand/left"] ~= nil and grabbedBoxes["hand/left"].box == box)
or (grabbedBoxes["hand/right"] ~= nil and grabbedBoxes["hand/right"].box == box)
)
if isGrabbed then
pass:setColor(0x00ff00)
else
pass:setColor(0xffffff)
end
pass:box(box.position, box.dimensions, quat(), 'line')
end
end
function isPointInsideBox(point, box)
local relativePoint = point - box.position + (box.dimensions / 2)
local width, height, depth = box.dimensions:unpack()
return (
relativePoint.x > 0 and relativePoint.x < width
and relativePoint.y > 0 and relativePoint.y < height
and relativePoint.z > 0 and relativePoint.z < depth
)
end
Now you should have a working drag'n' drop feature. But we can extend it a bit.
Rotating boxes
Rotation works analogously to translation – we need to store the "offset" of orientations at the moment of grabbing in order to apply it in each frame relative to hands current orientation.
First, store the orientation of each box:
local box1 = {
-- ...
orientation = lovr.math.newQuat(),
}
local box2 = {
-- ...
orientation = lovr.math.newQuat(),
}
Fetch hand orientation:
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local handPosition = vec3(lovr.headset.getPosition(hand))
local handOrientation = quat(lovr.headset.getOrientation(hand))
-- ...
end
-- ...
end
We'll modify entries into grabbedBoxes
:
positionOffset = quat(handOrientation):conjugate() * (handPosition - box.position)
orientationOffset = quat(handOrientation):conjugate() * box.orientation
grabbedBoxes[hand] = {
box = box,
positionOffset = lovr.math.newVec3(positionOffset),
orientationOffset = lovr.math.newQuat(orientationOffset),
}
When multiplying a quaternion's conjugate by another quaternion (box.orientation
) or by a vector (box.position
), you get an offset relative to the first quaternion's frame of reference – in our case, the frame of reference of the hand at the moment of grabbing.
We can then multiply those offsets by the current orientation of the hand:
if grabbedBox ~= nil then
local newPosition = handPosition - handOrientation * grabbedBox.positionOffset
local newOrientation = handOrientation * grabbedBox.orientationOffset
grabbedBox.box.position:set(newPosition)
grabbedBox.box.orientation:set(newOrientation)
end
Notice how the position and orientation stay the same, if the hand didn't move:
newPosition = handPosition - handOrientation * grabbedBox.positionOffset
newPosition = handPosition - handOrientation * quat(handOrientation_0):conjugate() * (handPosition_0 - box.position_0)
newPosition = handPosition - identityQuat * (handPosition_0 - box.position_0)
newPosition = handPosition - (handPosition_0 - box.position_0)
newPosition = handPosition - handPosition_0 + box.position_0
newPosition = zeroVector + box.position_0
newPosition = box.position_0
newOrientation = handOrientation * grabbedBox.orientationOffset
newOrientation = handOrientation * quat(handOrientation_0):conjugate() * box.orientation_0
newOrientation = identityQuat * box.orientation_0
newOrientation = box.orientation_0
Whole code
local box1 = {
position = lovr.math.newVec3(0.5, 1, -0.5),
dimensions = lovr.math.newVec3(0.3, 0.3, 0.3),
orientation = lovr.math.newQuat(),
}
local box2 = {
position = lovr.math.newVec3(-0.5, 1, -0.5),
dimensions = lovr.math.newVec3(0.5, 0.5, 0.5),
orientation = lovr.math.newQuat(),
}
local boxes = { box1, box2 }
local grabbedBoxes = {
["hand/left"] = nil,
["hand/right"] = nil,
}
function lovr.draw(pass)
for i, hand in ipairs(lovr.headset.getHands()) do
local handPosition = vec3(lovr.headset.getPosition(hand))
local handOrientation = quat(lovr.headset.getOrientation(hand))
local wasPressed = lovr.headset.wasPressed(hand, 'trigger')
if wasPressed then
for _, box in ipairs(boxes) do
if isPointInsideBox(handPosition, box) then
local positionOffset = quat(handOrientation):conjugate() *
(handPosition - box.position)
local orientationOffset = quat(handOrientation):conjugate() * box.orientation
grabbedBoxes[hand] = {
box = box,
positionOffset = lovr.math.newVec3(positionOffset),
orientationOffset = lovr.math.newQuat(orientationOffset),
}
end
end
end
local wasReleased = lovr.headset.wasReleased(hand, 'trigger')
if wasReleased then
grabbedBoxes[hand] = nil
end
local grabbedBox = grabbedBoxes[hand]
if grabbedBox ~= nil then
local newPosition = handPosition - handOrientation * grabbedBox.positionOffset
local newOrientation = handOrientation * grabbedBox.orientationOffset
grabbedBox.box.position:set(newPosition)
grabbedBox.box.orientation:set(newOrientation)
end
pass:sphere(handPosition, .1)
end
for _, box in ipairs(boxes) do
local isGrabbed = (
(grabbedBoxes["hand/left"] ~= nil and grabbedBoxes["hand/left"].box == box)
or (grabbedBoxes["hand/right"] ~= nil and grabbedBoxes["hand/right"].box == box)
)
if isGrabbed then
pass:setColor(0x00ff00)
else
pass:setColor(0xffffff)
end
pass:box(box.position, box.dimensions, box.orientation, 'line')
end
end
function isPointInsideBox(point, box)
local relativePoint = point - box.position + (box.dimensions / 2)
local width, height, depth = box.dimensions:unpack()
return (
relativePoint.x > 0 and relativePoint.x < width
and relativePoint.y > 0 and relativePoint.y < height
and relativePoint.z > 0 and relativePoint.z < depth
)
end
Now you should have a working drag and drop with rotation!