Skip to content

Instantly share code, notes, and snippets.

@jmiskovic
Last active May 20, 2025 14:25
Show Gist options
  • Select an option

  • Save jmiskovic/0665e7113206858e4593588968b8101a to your computer and use it in GitHub Desktop.

Select an option

Save jmiskovic/0665e7113206858e4593588968b8101a to your computer and use it in GitHub Desktop.
Point light shadow mapping
--[[ Point light shaddow mapping (VSM Fixed)
-- usage snippets
local point_light = require('point_light')
point_light.load(1024, vec3(0, 1, 0)) -- depth texture resolution & light position
-- in lovr.update(dt), or at start of lovr.draw(pass)
local pass = point_light.getPass()
-- ... draw the scene using this pass ...
lovr.graphics.submit(pass)
-- if using within draw(), submit it together with main pass
-- in lovr.draw(pass)
pass:sphere(mat4(point_light.position):scale(0.05)) -- Optional: draw light gizmo
point_light.setShader(pass)
-- ... draw the scene again ...
-- change light parameters
point_light.strength = 0.9 -- How dark the shadows are (1.0 = black, 0.0 = no shadows)
point_light.position:set(1, 2, -1)
point_light.falloff = 0.01 -- Quadratic falloff factor (higher = faster falloff)
--]]
local m = {}
m.position = lovr.math.newVec3(0, 1, 0)
m.strength = 0.5
m.max_distance = 500.0
m.falloff = 0.0025 -- influence quadratic falloff with distance
-- Writes normalized linear depth and depth^2 to an rg32f texture
local depthwritter = lovr.graphics.newShader('unlit', [[
uniform float ShadowFarPlane;
uniform vec3 ShadowLightWorld;
vec4 lovrmain() {
float dist = distance(ShadowLightWorld, PositionWorld);
float depthNormalized = dist / ShadowFarPlane;
depthNormalized = clamp(depthNormalized, 0.0, 1.0);
float depthSquared = depthNormalized * depthNormalized;
return vec4(depthNormalized, depthSquared, 1., 1.); // Store in R and G channels
}
]])
-- Samples the cube map and calculates shadowing
m.fragment = [[
layout(set = 2, binding = 0) uniform textureCube ShadowDepthBuffer;
uniform vec3 ShadowLightWorld;
uniform float ShadowQuadraticFalloff;
uniform float ShadowStrength;
uniform float ShadowFarPlane;
float calculateVSMShadow(vec2 moments, float currentDepth) {
float bias = 0.0001;
currentDepth -= bias;
if (currentDepth <= moments.x) // fully lit
return 1.0;
float variance = moments.y - (moments.x * moments.x);
variance = max(variance, 1e-6);
float d = currentDepth - moments.x;
// Chebyshev's inequality: P(x >= currentDepth) <= variance / (variance + d^2)
// This gives an upper bound on the probability that the occluder depth is
// greater than or equal to the current depth, i.e., the probability of *not* being shadowed.
float p_max = variance / (variance + d * d);
return smoothstep(0.0, 1.0, p_max); // smoothstep to reduce light bleeding
}
void addShadow(inout vec4 color) {
vec3 lightVec = PositionWorld - ShadowLightWorld;
float distSq = dot(lightVec, lightVec);
float dist = sqrt(distSq);
float currentDepthNormalized = dist / ShadowFarPlane;
vec3 lookupDir = normalize(vec3(-lightVec.x, lightVec.y, lightVec.z));
vec2 moments = getPixel(ShadowDepthBuffer, lookupDir).rg;
float lit = calculateVSMShadow(moments, currentDepthNormalized);
float shadowFactor = mix(1.0 - ShadowStrength, 1.0, lit);
shadowFactor = clamp(shadowFactor, 0.0, 1.0);
float attenuation = 1.0 / (1.0 + ShadowQuadraticFalloff * distSq);
color.rgb *= shadowFactor * attenuation;
}
]]
-- a testing shader that has only the shadowing
m.shader = lovr.graphics.newShader('unlit', m.fragment .. [[
vec4 lovrmain() {
vec4 color = DefaultColor;
addShadow(color);
return color;
}
]])
local perspective = lovr.math.newMat4():perspective(math.pi / 2, 1, 0.01, m.max_distance)
local transforms = {
Mat4():lookAt(vec3(), vec3( 1, 0, 0), vec3(0, 1, 0)),
Mat4():lookAt(vec3(), vec3(-1, 0, 0), vec3(0, 1, 0)),
Mat4():lookAt(vec3(), vec3( 0, 1, 0), vec3(0, 0,-1)),
Mat4():lookAt(vec3(), vec3( 0,-1, 0), vec3(0, 0, 1)),
Mat4():lookAt(vec3(), vec3( 0, 0, 1), vec3(0, 1, 0)),
Mat4():lookAt(vec3(), vec3( 0, 0,-1), vec3(0, 1, 0))
}
function m.load(resolution, position)
m.position:set(position or m.position)
m.resolution = resolution or 256 * 4
m.texture = lovr.graphics.newTexture(m.resolution, m.resolution, 6,
{ type='cube', format='rg32f', linear=true, mipmaps=false, usage = {'render', 'sample', 'transfer'} })
m.pass = lovr.graphics.newPass{ m.texture }
end
function m.getPass()
m.pass:reset()
-- texture uses RG channels for result, still uses depth-testing with depth channel
m.pass:setClear({ 1,1,1,1, depth = 1 })
local pos = m.position
for i, transform in ipairs(transforms) do
transform[13], transform[14], transform[15] = m.position:unpack()
m.pass:setViewPose(i, transform)
m.pass:setProjection(i, perspective)
end
m.pass:setDepthTest('<=')
m.pass:setCullMode('back')
m.pass:setShader(depthwritter)
m.pass:send('ShadowLightWorld', m.position)
m.pass:send('ShadowFarPlane', m.max_distance)
return m.pass -- pass is prepared for receiving geometry; must be submitted by user
end
function m.send(pass)
pass:send('ShadowDepthBuffer', m.texture)
pass:send('ShadowLightWorld', m.position)
pass:send('ShadowStrength', m.strength)
pass:send('ShadowFarPlane', m.max_distance)
pass:send('ShadowQuadraticFalloff', m.falloff)
end
---[[ testing scene as standalone Lua file: `lovr point_light.lua`
if arg and arg[0] and string.find(arg[0], 'point_light') then
print('running the test scene for point light variance shadow mapping')
function drawScene(pass)
pass:setColor(0.431, 0.722, 0.659)
for x = -20, 20, 2 do
for z = -20, 20, 2 do
pass:cube(x, -1, z, 1.6)
end
end
pass:setColor(0.165, 0.345, 0.310)
for height = 0, 0.8, 0.4 do
pass:torus(0, height, 0, 1.5, 0.15, math.pi / 2, 1,0,0)
end
pass:setColor(0.455, 0.639, 0.247)
pass:sphere(0, -500 - 1, 0, 500)
end
function lovr.load()
m.load(1024, vec3(1, 1, 0.5)) -- depth texture resolution & position
end
function lovr.update(dt)
-- if not lovr.system.isKeyDown('space') then return end
if lovr.system.isKeyDown('space') then
m.position:set(lovr.headset.getPosition('left'))
end
local pass = m.getPass()
drawScene(pass)
lovr.graphics.submit(pass)
end
function lovr.draw(pass)
pass:setColor(1,1,0)
pass:sphere(m.position, 0.05)
pass:setShader(m.shader)
m.send(pass)
drawScene(pass)
end
end
return m
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment