Last active
May 20, 2025 14:25
-
-
Save jmiskovic/0665e7113206858e4593588968b8101a to your computer and use it in GitHub Desktop.
Point light shadow mapping
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --[[ 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