-- Some notes about the models used here:
-- * They are MIT licensed, originally from here:
-- https://github.com/immersive-web/webxr-input-profiles/tree/main/packages/assets/profiles/generic-hand
-- * This particular model has all of the bones parented to a
-- single root Armature node. Other approaches will need
-- to be used for models that are rigged differently, like if
-- the nodes are parented to each other in a hierarchy.
-- * This model was rigged for the Quest. It works on other
-- hand tracking systems, but differences in metacarpal bones
-- might cause the wrist vertices to get scrunched up a bit.
-- Rigging Process
------------------
-- The wrist joint/node is used as a shared reference point.
-- There are 4 coordinate spaces:
-- 1) world space (normal headset coordinate space)
-- 2) grip space (relative to hand device pose)
-- 3) wrist space (relative to wrist joint from getSkeleton)
-- 4) model space (relative to the model root node)
-- The steps to compute the local pose of a node in the model:
-- * Start with the world-space joint from getSkeleton
-- * Compute its pose relative to the wrist joint (wristFromWorld)
-- * We need a pose relative to the root Armature, not relative
-- to the wrist. To get this, apply the pose of the wrist node
-- in the model (modelFromWrist).
-- * So the final transform is (modelFromWrist * wristFromWorld)
local function animateHand(device, skeleton, model, map)
model:resetNodeTransforms()
if not skeleton then return end
-- Get offset of wrist node in the model
local modelFromWrist = mat4(model:getNodeTransform(map[2]))
local wristFromModel = mat4(modelFromWrist):invert()
-- Get offset of wrist joint in the world
local x, y, z, _, angle, ax, ay, az = unpack(skeleton[2])
local worldFromWrist = mat4(x, y, z, angle, ax, ay, az)
local wristFromWorld = mat4(worldFromWrist):invert()
-- Combine the two into a matrix that will transform the
-- world-space hand joints into local node poses for the model
local modelFromWorld = modelFromWrist * wristFromWorld
-- Transform the nodes
for index, node in pairs(map) do
local x, y, z, _, angle, ax, ay, az = unpack(skeleton[index])
local jointWorld = mat4(x, y, z, angle, ax, ay, az)
local jointModel = modelFromWorld * jointWorld
model:setNodeTransform(node, jointModel)
end
-- This offsets the root node so the wrist poses line up when the
-- model is drawn at the hand pose. Instead of doing this, you
-- could just draw the model at worldFromWrist * wristFromModel
local worldFromGrip = mat4(lovr.headset.getPose(device))
local gripFromWorld = mat4(worldFromGrip):invert()
model:setNodeTransform(model:getRootNode(), gripFromWorld * worldFromWrist * wristFromModel)
end
function lovr.load()
hands = {}
for i, hand in ipairs({ 'left', 'right' }) do
hands[hand] = {
model = lovr.graphics.newModel(hand .. '.glb'),
skeleton = nil
}
end
-- Maps skeleton joint index to node names in the model
map = {
[2] = 'wrist',
[3] = 'thumb-metacarpal',
[4] = 'thumb-phalanx-proximal',
[5] = 'thumb-phalanx-distal',
[7] = 'index-finger-metacarpal',
[8] = 'index-finger-phalanx-proximal',
[9] = 'index-finger-phalanx-intermediate',
[10] = 'index-finger-phalanx-distal',
[12] = 'middle-finger-metacarpal',
[13] = 'middle-finger-phalanx-proximal',
[14] = 'middle-finger-phalanx-intermediate',
[15] = 'middle-finger-phalanx-distal',
[17] = 'ring-finger-metacarpal',
[18] = 'ring-finger-phalanx-proximal',
[19] = 'ring-finger-phalanx-intermediate',
[20] = 'ring-finger-phalanx-distal',
[22] = 'pinky-finger-metacarpal',
[23] = 'pinky-finger-phalanx-proximal',
[24] = 'pinky-finger-phalanx-intermediate',
[25] = 'pinky-finger-phalanx-distal'
}
end
function lovr.update(dt)
for device, hand in pairs(hands) do
hand.skeleton = lovr.headset.getSkeleton(device)
animateHand(device, hand.skeleton, hand.model, map)
end
end
function lovr.draw(pass)
lovr.graphics.setBackgroundColor(0x202224)
if not hands.left.skeleton and not hands.right.skeleton then
pass:text('No skelly :(', 0, 1, -1, .1)
return
end
for device, hand in pairs(hands) do
if hand.skeleton then
-- Debug dots for joints
pass:setColor(0x8000ff)
pass:setDepthWrite(false)
for i = 1, #hand.skeleton do
local x, y, z, _, angle, ax, ay, az = unpack(hand.skeleton[i])
pass:sphere(mat4(x, y, z, angle, ax, ay, az):scale(.003))
end
pass:setDepthWrite(true)
-- Draw the (procedurally animated) wireframe hand model
local worldFromGrip = mat4(lovr.headset.getPose(device))
pass:setColor(0xffffff)
pass:setWireframe(true)
pass:draw(hand.model, worldFromGrip)
pass:setWireframe(false)
end
end
end