LÖVR v0.18.0, codename Dream Eater, was released on February 14th, 2025. This version has been 490 days in the making, with 899 commits from 8 authors.
The main highlight of this release is a brand new physics engine, Jolt Physics! In addition to tons of new physics features, this version also added:
Grab a snack and get comfortable, hopefully the rest of this doesn't put you to sleep!
This version LÖVR switched from using the venerable ODE to Jolt Physics. Jolt is faster, more
stable, multithreaded, and deterministic. There were also lots of new features and improvements
added to the lovr.physics
API as part of the switch.
Shapecasts are similar to raycasts, except instead of detecting collision along a line they sweep a whole shape through the scene and find anything it touches. They're very helpful for player locomotion and projectile hit tests.
Normally the physics engine will compute a new position for a collider, and then detect and resolve any collisions at the new location. This usually works fine, but fast-moving objects can pass through walls if they move fully through through them during one physics step:
CCD (continuous collision detection) is a technique that solves this problem by checking for
collisions along the object's whole path, instead of just checking the final position. It's great
for fast moving projectiles. Just call Collider:setContinuous
on any colliders that need CCD.
It's now possible to lock translation and rotation of a Collider on specific axes with
Collider:setDegreesOfFreedom
. Previously this required hacks like setting very high damping.
It's nice for keeping objects upright, or for an object that only needs to move in one direction,
like elevators.
There is a new World:setCallbacks
function that lets you set callbacks when various collision
events occur. There are 4 different callbacks:
filter
lets you filter collisions with Lua code. It can be used when the tag system isn't
powerful enough to express when objects should collide.enter
is fired when 2 objects start touching, useful for playing sounds or spawning particles.exit
is fired when 2 objects stop touching.contact
is fired every frame while 2 objects are touching. This can be useful to adjust the
friction dynamically or break contact if some condition is met.The enter
and contact
callbacks receive a Contact
object which has lots of useful information
about the collision, including the exact Shapes that are touching, how much they're overlapping, the
contact points, and the contact normal.
These callbacks replace the older method that used a callback in World:update
, along with
World:overlaps
and World:collide
. World:getContacts
is also superseded by the more powerful
Contact
object.
MeshShape has received a lot of improvements. First, it's been split into 2 shapes! Now,
MeshShape
is a kinematic-only shape like TerrainShape
, intended to be used for static level
geometry.
For dynamic objects with arbitrary shapes, ConvexShape
is a new shape that represents a "convex
hull" of a triangle mesh. ConvexShape can be created from all the same things as MeshShape can, but
it won't tank the performance of the simulation like MeshShape used to do.
Also, it's possible to create copies of MeshShape
and ConvexShape
. The copies will reuse the
mesh data from the original shape, and they can have a different scale. This makes it possible to
stamp out copies of objects with complex collision meshes without any frame hitches.
World:raycast
now returns the triangle that was hit for MeshShapes. This can be used to look up
other vertex data like UVs, which lets you do things like paint on a Model!
All physics queries like raycasts and shapecasts now take a list of tags to filter by. Narrowing down the set of objects that the physics engine needs to check like this can give a big speedup when doing lots of collision queries on a dense scene!
-- Only check if we hit an enemy or a wall, skip everything else
world:raycast(from, to, 'enemy wall')
-- Ignore hits on sensors
world:raycast(from, to, '~sensor')
There are 2 new types of joints:
WeldJoint
glues two colliders together, restricting all movement between them.ConeJoint
allows two colliders to rotate a fixed angle away from each other, good for ropes.Breakable joints are now supported! Joint:getForce
and Joint:getTorque
return the amount of
force/torque the physics engine is using to enforce the joint's constraint, allowing the joint to be
disabled or destroyed when the force gets too high.
DistanceJoint
, HingeJoint
, and SliderJoint
now have :setSpring
methods, which make their
limits "soft" and bouncy. Hinge and slider joints also have :setFriction
which causes them to
require some extra force to move.
Hinge and slider joints have motors now. The motor can be used to move the joint back to a desired position or rotation, or to get it to move at a target speed. The motor has a maximum amount of force it can apply, and has its own springiness. Motors are useful when designing robots or other physics-based animation rigs.
A new World:interpolate
function makes it much easier to implement fixed-timestep physics
simulation, where the physics loop is run at a different rate than the normal frame loop.
Above, both simulations are running at 15Hz, but the version on the right uses World:interpolate
to smooth out the collider poses between the last 2 physics steps.
The headset module now supports composition layers, exposed as a new Layer
object. Layers are 2D
"panels" placed in 3D space. This might seem redundant, since you can already draw a texture in 3D
space with Pass:draw
, but layers have several important benefits:
Layers can also be curved. Pictured below is the lovr-neovim
library that renders a neovim editor on a curved panel in front of you:
In addition to the Layer
object, there is also a new lovr.headset.setBackground
function, which
uses the same techniques but for a skybox (cubemap or equirectangular). For static backgrounds this
can reduce rendering costs significantly, since the main headset pass doesn't need to render the
background at all, which uses a significant amount of time on mobile GPUs.
Fixed foveated rendering is an optimization that renders at a lower resolution around the edges of
the display. The drop in quality is often imperceptible due to lens distortion, but the GPU savings
can be as high as 40% for scenes with heavy pixel shading! It's really easy to enable, just call
lovr.headset.setFoveation
:
lovr.headset.setFoveation('medium', true)
The second parameter defaults to true
and specifies whether the system is allowed to dynamically
lower the foveation level based on GPU load.
There is a new stylus
device for pens like the Logitech MX Ink. The device exposes a 3D pose,
velocity, a pressure-sensitive nib
axis, and the 3 buttons on the pen.
In addition to TTF fonts, LÖVR now supports BMFont fonts. BMFont files contain an atlas of glyph
images, along with a small text file containing metadata. Compared to the existing MSDF fonts,
BMFonts look better and are more efficient for 2D text, making them a great fit for rendering text
on the new Layer
objects. Since the image atlas can contain anything, they can also be used for
icons/emoji. However, they don't support scaling like MSDF fonts do, so they can get blurry when
rendering text in 3D space.
LÖVR has built in filesystem monitoring now! Simply add the --watch
command line argument:
lovr --watch .
When watching is enabled, the lovr.filechanged
callback will be fired whenever a file changes.
The default implementation restarts the project with lovr.event.restart
, but you can override it
to ignore certain files or perform more granular asset reloading without performing a full restart.
Under the hood this uses efficient native APIs for filesystem events, so it's more lightweight and
responsive than polling file modification times with lovr.filesystem.getLastModified
.
There have been a ton of tiny improvements to the shader syntax. These are all optional, so existing shaders will continue to work.
First, uniform variables no longer require the set
and binding
decorations:
// Old
layout(set = 2, binding = 0) uniform texture2D sparkles;
// New
uniform texture2D sparkles;
Vertex attributes no longer require location
decorations:
// Old
layout(location = 0) in vec3 displacement;
// New
in vec3 displacement;
Instead of a Constants
block, regular uniform
variables are supported. This makes it easier to
port code from other shaders.
// Old
Constants {
vec3 direction;
float radius;
vec2 size;
};
// New
uniform vec3 direction;
uniform float radius;
uniform vec2 size;
Uniform and storage buffers support a scalar
layout, which removes confusing padding requirements
and doesn't require Buffer
formats to specify a std140
or std430
layout:
layout(scalar) uniform Light {
vec3 direction;
vec3 color;
};
// newBuffer({{ 'direction', 'vec3' }, { 'color', 'vec3' }}) works!
// No need for { layout = 'std140' }
There is also a new raw
flag in lovr.graphics.newShader
which will create a completely raw
shader without any of the LÖVR helpers:
lovr.graphics.newShader([[
in vec3 position;
void main() {
gl_Position = vec4(position, 1);
}
]], [[
out vec4 pixel;
void main() {
pixel = vec4(1, 0, 1, 1);
}
]], { raw = true })
The lovr.headset.isMounted
getter and lovr.mount
callback are back! These tell you whether the
headset is currently on someone's head, using the proximity sensor.
There's a new File
object which can be used to do multiple file operations on a file without
having to reopen it every time, or for partial reads/writes.
Quat
has new methods for working with euler angles: Quat:getEuler
and Quat:setEuler
.
Channel:push
supports Lua tables now!
Cubemap arrays are supported: cube
textures can be created with any layer count as long as it's a
multiple of 6.
MSAA textures can now use sample counts of 2, 8, and 16 (instead of just 1 and 4), and they can be
correctly sent to multisample shader variables like texture2DMSAA
. Additionally, Pass:setCanvas
accepts a set of resolve
attachments that can be used to do custom MSAA resolves.
Texture
, Shader
, and Pass
can be created with debug labels, which will show in graphics
debuggers like RenderDoc.
The lovr
executable is now shipped as a zip archive on all platforms, with the nogame screen and
command line parser packaged like a regular fused project. This makes it easy to customize the
nogame screen, or extend the CLI with new options. It's even possible to put Lua libraries in the
zip, which will be globally accessible to all projects run with that copy of LÖVR.
lovr test
in the repository.CHANGES.md
in the repository.x86_64
Android CI builds for Magic Leap 2.--watch
CLI flag, lovr.filechanged
event, and lovr.filesystem.watch/unwatch
.File
object and lovr.filesystem.newFile
.lovr.filesystem.getBundlePath
(for internal boot code).lovr.filesystem.setSource
(for internal boot code).Pass:polygon
.Shader:hasVariable
.Font
and Rasterizer
.uniform
variables in shader code.sn10x3
DataType
.border
WrapMode
.d24
texture format.SampleID
, SampleMaskIn
, SampleMask
, and SamplePosition
in pixel shaders.layout(scalar)
buffers and packedBuffers
graphics feature.raw
flag to lovr.graphics.newShader
.Texture:getLabel
, Shader:getLabel
, and Pass:getLabel
.Model:resetBlendShapes
.Layer
object, lovr.headset.newLayer
, and lovr.headset.get/setLayers
.lovr.headset.setBackground
.stylus
Device, nib
DeviceButton, and nib
DeviceAxis.lovr.headset.get/setFoveation
.t.headset.controllerskeleton
to control how controllers return hand tracking data.controller
field to the table returned by lovr.headset.getSkeleton
.t.headset.mask
.lovr.headset.isMounted
and the lovr.mount
callback.lovr.headset.stop
, lovr.headset.isActive
, and t.headset.start
.lovr.headset.getFeatures
.t.headset.debug
to enable additional messages from the VR runtime.--simulator
CLI flag to force use of simulator headset driver.lovr.headset.getHandles
.Quat:get/setEuler
.lovr.physics.newWorld
that takes a table of settings.World:interpolate
.World:get/setCallbacks
and Contact
object.World:getColliderCount
.World:getJointCount
and World:getJoints
.World:shapecast
.World:overlapShape
.Collider:get/setGravityScale
.Collider:is/setContinuous
.Collider:get/setDegreesOfFreedom
.Collider:applyLinearImpulse
and Collider:applyAngularImpulse
.Collider:moveKinematic
.Collider:getShape
.Collider:is/setSensor
(replaces Shape:is/setSensor
).Collider:get/setInertia
.Collider:get/setCenterOfMass
.Collider:get/setAutomaticMass
.Collider:resetMassData
.Collider:is/setEnabled
.ConvexShape
.WeldJoint
.ConeJoint
.Joint:getForce
and Joint:getTorque
.Joint:get/setPriority
.Joint:isDestroyed
.DistanceJoint:get/setLimits
.:get/setSpring
to DistanceJoint
, HingeJoint
, and SliderJoint
.HingeJoint:get/setFriction
and SliderJoint:get/setFriction
.SliderJoint:getAnchors
.Shape:raycast
and Shape:containsPoint
.Shape:get/setDensity
.Shape:getMass/Volume/Inertia/CenterOfMass
.Shape:get/setOffset
.HingeJoint
and SliderJoint
.MeshShape
from a ModelData
.lovr.system.isWindowVisible
and lovr.system.isWindowFocused
.lovr.system.wasMousePressed
and lovr.system.wasMouseReleased
.lovr.system.get/setClipboardText
.lovr.system.openConsole
(for internal Lua code).KeyCode
s for numpad keys.Channel:push
.lovr.thread.newChannel
.t.thread.workers
to configure number of worker threads.Mesh:setMaterial
to also take a Texture
.Texture:getFormat
to also return whether the texture is linear or sRGB.Texture:setPixels
to allow copying between textures with different formats.Readback:getImage
and Texture:getPixels
to return sRGB images when the source Texture is sRGB.lovr.graphics.newTexture
to use a layer count of 6 when type is cube
and only width/height is given.lovr.graphics.newTexture
to default mipmap count to 1 when an Image is given with a non-blittable format.lovr.graphics.newTexture
to error if mipmaps are requested and an Image is given with a non-blittable format.TextureFeature
to merge sample
with filter
, render
with blend
, and blitsrc
/blitdst
into blit
.t.headset.supersample
.lovr.graphics.compileShader
to take/return multiple stages.TerrainShape
to require square dimensions.MeshShape
constructors to accept a MeshShape
to reuse data from.MeshShape
constructors to take an optional scale to apply to the vertices.Buffer:setData
to use more consistent rules to read data from tables.World:queryBox/querySphere
to perform coarse AABB collision detection (use World:overlapShape
for an exact test).World:queryBox/querySphere
to take an optional set of tags to include/exclude.World:queryBox/querySphere
to return the first collider detected, when the callback is nil.World:queryBox/querySphere
to return nil when a callback is given.World:raycast
to take a set of tags to allow/ignore.World:raycast
callback to be optional (if nil, the closest hit will be returned).World:raycast
to also return the triangle that was hit on MeshShapes.tostring
on objects to also include their pointers.desktop
HeadsetDriver to be named simulator
.--graphics-debug
CLI flag to be named --debug
.--version
and lovr.getVersion
to also return the git commit hash.Pass:setViewport/Scissor
to be per-draw state instead of per-pass.Image:get/set/mapPixel
to support r16f
, rg16f
, and rgba16f
.Image:getPixel
to return 1 for alpha when the format doesn't have an alpha component.state
stack (used with Pass:push/pop
) from 4 to 8.lovr.focus
and lovr.visible
to also get called for window events.lovr.focus
and lovr.visible
to have an extra parameter for the display type.t.headset.submitdepth
to actually submit depth.Texture:getType
when used with texture views.dt
in lovr.update when restarting with the simulator.Collider
/Shape
/Joint
userdata wouldn't get garbage collected.Blob:getName
.Model:getMesh
.Curve:slice
when curve has more than 4 points.hand/*/pinch
and hand/*/poke
device poses.KHR_texture_transform
extension.World:get/setTightness
(use stabilization
option when creating World).World:get/setLinearDamping
(use Collider:get/setLinearDamping
).World:get/setAngularDamping
(use Collider:get/setAngularDamping
).World:is/setSleepingAllowed
(use allowSleep
option when creating World).World:get/setStepCount
(use positionSteps
/velocitySteps
option when creating World).Collider:is/setGravityIgnored
(use Collider:get/setGravityScale
).lovr.headset.getOriginType
(use lovr.headset.isSeated
).lovr.headset.getDisplayFrequency
(renamed to lovr.headset.getRefreshRate
).lovr.headset.getDisplayFrequencies
(renamed to lovr.headset.getRefreshRates
).lovr.headset.setDisplayFrequency
(renamed to lovr.headset.setRefreshRate
).lovr.graphics.getBuffer
(use lovr.graphics.newBuffer
).lovr.graphics.newBuffer
that takes length first (format is first now).location
key for Buffer fields (use name
).Buffer:isTemporary
.Buffer:getPointer
(renamed to Buffer:mapData
).lovr.graphics.getPass
(use lovr.graphics.newPass
).Pass:getType
(passes support both render and compute now).Pass:getTarget
(renamed to Pass:getCanvas
).Pass:getSampleCount
(Pass:getCanvas
returns sample count).Pass:send
that takes a binding number instead of a variable name.Texture:newView
(renamed to lovr.graphics.newTextureView
).Texture:isView
and Texture:getParent
.Shape:setPosition
, Shape:setOrientation
, and Shape:setPose
(use Shape:setOffset
).Shape:is/setSensor
(use Collider:is/setSensor
).World:overlaps/computeOverlaps/collide/getContacts
(use World:setCallbacks
).BallJoint:setAnchor
.DistanceJoint:setAnchors
.HingeJoint:setAnchor
.BallJoint:get/setResponseTime
and BallJoint:get/setTightness
.DistanceJoint:get/setResponseTime
and DistanceJoint:get/setTightness
(use :setSpring
).DistanceJoint:get/setDistance
(renamed to :get/setLimits
).HingeJoint:get/setUpper/LowerLimit
(use :get/setLimits
).SliderJoint:get/setUpper/LowerLimit
(use :get/setLimits
).Collider:getLocalCenter
(renamed to Collider:getCenterOfMass
).Shape:getMassData
(split into separate getters).shaderConstantSize
limit from lovr.graphics.getLimits
.Pass:getViewport
and Pass:getScissor
.