mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:00:51 +02:00
initial commit
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
-- Voxel world mode: the sun's own pass -- a real shadow map.
|
||||
--
|
||||
-- The old drop shadows were decals: each character's sprite frame squashed
|
||||
-- flat onto the ground plane it stood on. That can only ever paint the
|
||||
-- FLOOR, so a shadow stopped dead at the foot of a wall, and nothing but a
|
||||
-- character cast one at all -- buildings, trees, signs and ledges threw
|
||||
-- nothing.
|
||||
--
|
||||
-- So render the scene once from the sun instead. An orthographic camera
|
||||
-- pointed down the sun line stores, per texel, how far the light travelled
|
||||
-- before it hit something; the main pass transforms each fragment into that
|
||||
-- same space and asks whether anything got there first. What the sun cannot
|
||||
-- see is in shadow, whatever surface it happens to be -- so a shadow climbs
|
||||
-- a wall, drapes over a roof and slides across a passing NPC without a
|
||||
-- single case in the code, and every caster is simply whatever the pass
|
||||
-- draws: the terrain mesh (buildings, trees, ledges, props -- all of it)
|
||||
-- plus one upright card per character.
|
||||
--
|
||||
-- Depth is stored in an ORDINARY color canvas, packed into two 8-bit
|
||||
-- channels (~16 bits over the frustum, well under a tenth of a world
|
||||
-- pixel). A readable depth texture would be tidier, but depth sampling is
|
||||
-- the least portable corner of the graphics API and this mod's whole
|
||||
-- contract is that an unsupported driver falls back rather than errors --
|
||||
-- everything here is pcall-guarded and `available()` reports the result,
|
||||
-- with VoxelScene dropping back to the flat decal shadows when it says no.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
local ShadowMap = {}
|
||||
|
||||
-- The sun, as the shear a shadow takes: a point `y` world-pixels above the
|
||||
-- ground drops its shadow (KX*y, KZ*y) away from the point under it. Both
|
||||
-- negative hangs the sun in the SOUTHEAST, so every shadow falls northwest
|
||||
-- -- up and to the left on screen, since the camera puts north at the top
|
||||
-- and east to the right at every tilt.
|
||||
--
|
||||
-- Their MAGNITUDE is how low the sun sits: hypot(KX, KZ) = 1.01 puts it
|
||||
-- about 45 degrees up, so a 16px character throws a shadow about as long
|
||||
-- as it is tall -- against the 62-degree noon these started at, which read
|
||||
-- as a smudge under everybody's feet.
|
||||
--
|
||||
-- Their RATIO is the compass bearing, and it leans WEST of northwest on
|
||||
-- purpose. A character is drawn as a slab leaning back away from the
|
||||
-- camera, which covers the ground directly north of its feet -- so a
|
||||
-- shadow thrown due north lands entirely underneath the figure casting it
|
||||
-- and is never seen. At 0.85 west the shadow reaches 13px out against the
|
||||
-- sprite's own 8px half-width, so it clears the slab and reads, while 0.55
|
||||
-- north still puts it a clear 23 degrees up from horizontal on screen.
|
||||
ShadowMap.KX = -0.85 -- west drift per pixel of height
|
||||
ShadowMap.KZ = -0.55 -- north drift per pixel of height
|
||||
|
||||
-- Shadow map edge, in texels -- chosen per frame from this ladder, because
|
||||
-- the light frustum is sized to the WORLD VIEW and that swings by 3x
|
||||
-- between the closest zoom and a maximised window at the widest. A fixed
|
||||
-- edge is either wasteful at one end or a blur at the other.
|
||||
--
|
||||
-- TARGET is the world pixels per texel worth paying for: at a third of a
|
||||
-- pixel a shadow edge lands inside the pixel grid this whole mode exists
|
||||
-- to keep crisp, and finer buys nothing the grid can show. The smallest
|
||||
-- size that meets it wins, and the ladder tops out at 2048 (16 MB, and the
|
||||
-- depth buffer behind it matches) rather than chasing the target forever.
|
||||
ShadowMap.SIZES = { 1024, 1536, 2048 }
|
||||
ShadowMap.TARGET = 0.45
|
||||
ShadowMap.res = 1024 -- the rung in use; read by the main pass's filter
|
||||
|
||||
-- The tallest geometry the pass covers: gabled buildings and border forest
|
||||
-- run well under this, and the margin it buys costs only resolution --
|
||||
-- with the sun this low the frustum has to widen by most of HEIGHT again
|
||||
-- on every side to catch what casts in from off-screen.
|
||||
ShadowMap.HEIGHT = 160
|
||||
|
||||
-- Depth slack at the comparison, in world pixels. Too little and a lit
|
||||
-- surface shadows itself in a moire of acne; too much and a shadow detaches
|
||||
-- from the foot of what casts it. The frustum is ~400 world pixels deep and
|
||||
-- the packed depth resolves under 0.01 of one, so there is room.
|
||||
ShadowMap.BIAS = 1.0
|
||||
|
||||
local SHADER = [[
|
||||
varying float vDepth;
|
||||
#ifdef VERTEX
|
||||
uniform mat4 lightVP;
|
||||
uniform mat4 model;
|
||||
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
||||
vec4 c = lightVP * (model * vertex_position);
|
||||
// the projection is orthographic, so w is 1 and clip z IS the depth,
|
||||
// linear in world units along the sun line
|
||||
vDepth = c.z * 0.5 + 0.5;
|
||||
return c;
|
||||
}
|
||||
#endif
|
||||
#ifdef PIXEL
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
// the same alpha discard the main pass uses: a sprite card casts its
|
||||
// silhouette, not its 16x16 bounding box
|
||||
if (Texel(tex, tc).a < 0.5) discard;
|
||||
// pack into two channels: the high byte in red, the low in green
|
||||
float d = clamp(vDepth, 0.0, 1.0) * 255.0;
|
||||
return vec4(floor(d) / 255.0, fract(d), 0.0, 1.0);
|
||||
}
|
||||
#endif
|
||||
]]
|
||||
|
||||
local shader = nil -- nil = untried, false = unavailable
|
||||
local canvas = nil -- nil = untried, false = unavailable
|
||||
local canvasRes = 0 -- the edge `canvas` was made at
|
||||
local blank = nil -- 1x1 stand-in so the sampler is never unbound
|
||||
local drawing = false
|
||||
local ready = false
|
||||
local lastSig = nil
|
||||
local prevBlend, prevAlphaMode = nil, nil
|
||||
|
||||
local IDENTITY = Mat4.identity()
|
||||
|
||||
-- world -> [0,1] cube, applied on top of the clip matrix: the main pass
|
||||
-- samples the map with the xy and compares against the z
|
||||
local TO_UNIT = { 0.5, 0, 0, 0.5,
|
||||
0, 0.5, 0, 0.5,
|
||||
0, 0, 0.5, 0.5,
|
||||
0, 0, 0, 1 }
|
||||
|
||||
-- world -> light clip space, for the pass that FILLS the map
|
||||
ShadowMap.clipVP = IDENTITY
|
||||
-- world -> the unit cube, for the pass that READS it
|
||||
ShadowMap.uvVP = IDENTITY
|
||||
-- ShadowMap.BIAS expressed in the [0,1] depth the map stores
|
||||
ShadowMap.bias = 0
|
||||
|
||||
local function getShader()
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER)
|
||||
shader = (ok and sh) or false
|
||||
end
|
||||
return shader or nil
|
||||
end
|
||||
|
||||
-- The map canvas at edge `res`, rebuilt when the rung changes (a zoom
|
||||
-- step, a window resize). `false` is sticky: a driver that could not make
|
||||
-- one at all is not asked again every frame.
|
||||
local function getCanvas(res)
|
||||
if canvas == false then return nil end
|
||||
if canvas and canvasRes == res then return canvas end
|
||||
local ok, c = pcall(love.graphics.newCanvas, res, res)
|
||||
if not (ok and c) then
|
||||
canvas = false
|
||||
return nil
|
||||
end
|
||||
-- nearest: the 2x2 filter in the main pass wants raw texels, and a
|
||||
-- linearly blended PACKED depth is not a depth at all
|
||||
c:setFilter("nearest", "nearest")
|
||||
pcall(c.setWrap, c, "clamp", "clamp")
|
||||
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||
canvas, canvasRes = c, res
|
||||
ready = false
|
||||
return canvas
|
||||
end
|
||||
|
||||
-- A 1x1 opaque white image. The main pass's shader always declares the
|
||||
-- shadow sampler, so something has to be bound even on the frames (and the
|
||||
-- drivers) where there is no map -- unpacked it reads as depth 1 + 1/255,
|
||||
-- which is beyond the far plane and therefore "nothing occludes anything".
|
||||
local function getBlank()
|
||||
if blank == nil then
|
||||
local ok, img = pcall(function()
|
||||
local data = love.image.newImageData(1, 1)
|
||||
data:setPixel(0, 0, 1, 1, 1, 1)
|
||||
return love.graphics.newImage(data)
|
||||
end)
|
||||
blank = (ok and img) or false
|
||||
end
|
||||
return blank or nil
|
||||
end
|
||||
|
||||
-- Whether the sun pass can run at all. False headless, without shaders, or
|
||||
-- where the canvas cannot be made -- VoxelScene then keeps the flat decal
|
||||
-- shadows, which need nothing but a quad.
|
||||
function ShadowMap.available()
|
||||
if not (love.graphics and love.graphics.newCanvas
|
||||
and love.graphics.setDepthMode) then
|
||||
return false
|
||||
end
|
||||
-- the smallest rung is enough to answer the question; fit() picks the
|
||||
-- one this frame actually wants
|
||||
return getShader() ~= nil and getCanvas(ShadowMap.SIZES[1]) ~= nil
|
||||
end
|
||||
|
||||
-- The map to sample, or the blank stand-in. Never nil once the main pass
|
||||
-- has a shader at all, because an unbound sampler is a driver-dependent
|
||||
-- crash rather than a driver-dependent fallback.
|
||||
function ShadowMap.texture()
|
||||
if ready and canvas then return canvas end
|
||||
return getBlank()
|
||||
end
|
||||
|
||||
-- True while the map holds a frame the main pass can read.
|
||||
function ShadowMap.active()
|
||||
return ready and canvas ~= nil and canvas ~= false
|
||||
end
|
||||
|
||||
-- The direction the light TRAVELS, normalized. The shear is the shadow a
|
||||
-- unit of height throws, so the displacement from a point to where its
|
||||
-- shadow lands is (KX, -1, KZ) -- which is the ray.
|
||||
local function sunDir()
|
||||
local x, y, z = ShadowMap.KX, -1, ShadowMap.KZ
|
||||
local l = math.sqrt(x * x + y * y + z * z)
|
||||
return { x / l, y / l, z / l }
|
||||
end
|
||||
|
||||
ShadowMap.sunDir = sunDir
|
||||
|
||||
-- How far NORTH of the view centre the camera can still see ground, in
|
||||
-- world pixels: the top edge of the view frustum dropped onto the ground
|
||||
-- plane. The lower the camera the further that reaches, and past about 64
|
||||
-- degrees the ray clears the horizon and the honest answer is "forever" --
|
||||
-- hence the cap. Ground beyond it compresses into a few pixels near the
|
||||
-- skyline, and its shadows with it, so the border fade in the main pass
|
||||
-- eases them out rather than the frustum ending on a hard line.
|
||||
ShadowMap.FAR_CAP = 2.5 -- multiples of the view height
|
||||
|
||||
local function groundReach(vh)
|
||||
local a = Voxel.angle or 0
|
||||
local cap = ShadowMap.FAR_CAP * vh
|
||||
-- half the vertical field of view: the same FOCAL the camera projects
|
||||
-- with, so the two frusta agree about what is on screen
|
||||
local half = math.atan(1 / (2 * Voxel.FOCAL))
|
||||
local below = (math.pi / 2 - a) - half -- top ray, below horizontal
|
||||
if below <= 0.02 then return cap end
|
||||
local dist = Voxel.FOCAL * vh
|
||||
local horizon = dist * math.cos(a) / math.tan(below)
|
||||
return math.max(vh / 2, math.min(cap, horizon - dist * math.sin(a)))
|
||||
end
|
||||
|
||||
-- Fit the light frustum to the ground the camera can see, plus the margin
|
||||
-- the casters for it stand in.
|
||||
--
|
||||
-- Both are ASYMMETRIC, and for opposite reasons. The camera sits south of
|
||||
-- its focus and looks north, so the ground it sees runs far north and
|
||||
-- barely south. The sun sits southeast, so the things whose shadows land
|
||||
-- on that ground stand south and east of it -- which means the caster
|
||||
-- margin is only ever needed on two of the four sides. Paying for it on
|
||||
-- all four (and for a view-sized box at every pitch) is what the first cut
|
||||
-- did, and at 75 degrees it covered about a third of what was on screen.
|
||||
--
|
||||
-- The box is snapped to whole texels. Without that, a frustum that slides
|
||||
-- continuously with the camera reprojects every shadow edge a fraction of a
|
||||
-- texel every frame and the whole world's shadows crawl and shimmer while
|
||||
-- you walk.
|
||||
local function fit(cx, cy, vw, vh)
|
||||
local f = sunDir()
|
||||
local view = Mat4.lookAt({ 0, 0, 0 }, f, { 0, 0, -1 })
|
||||
|
||||
local reach = ShadowMap.HEIGHT
|
||||
* math.max(math.abs(ShadowMap.KX), math.abs(ShadowMap.KZ)) + 24
|
||||
local north = groundReach(vh)
|
||||
-- the view widens with distance, so the far ground spans more than the
|
||||
-- near ground does; half the depth is a serviceable stand-in for the
|
||||
-- frustum's true spread and costs a good deal less resolution
|
||||
local spread = north * 0.5
|
||||
local xs = { cx - vw / 2 - spread, cx + vw / 2 + spread + reach }
|
||||
local ys = { -32, ShadowMap.HEIGHT } -- -32 covers recessed water
|
||||
local zs = { cy - north, cy + vh / 2 + reach }
|
||||
|
||||
local l, r, b, t, zn, zf
|
||||
for _, x in ipairs(xs) do
|
||||
for _, y in ipairs(ys) do
|
||||
for _, z in ipairs(zs) do
|
||||
local px = view[1] * x + view[2] * y + view[3] * z + view[4]
|
||||
local py = view[5] * x + view[6] * y + view[7] * z + view[8]
|
||||
local pz = view[9] * x + view[10] * y + view[11] * z + view[12]
|
||||
l = l and math.min(l, px) or px
|
||||
r = r and math.max(r, px) or px
|
||||
b = b and math.min(b, py) or py
|
||||
t = t and math.max(t, py) or py
|
||||
zn = zn and math.min(zn, pz) or pz
|
||||
zf = zf and math.max(zf, pz) or pz
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local w, h = r - l, t - b
|
||||
|
||||
-- pick the resolution rung: the smallest that resolves TARGET world
|
||||
-- pixels per texel across the wider side, else the largest there is
|
||||
local res = ShadowMap.SIZES[#ShadowMap.SIZES]
|
||||
for _, size in ipairs(ShadowMap.SIZES) do
|
||||
if math.max(w, h) / size <= ShadowMap.TARGET then
|
||||
res = size
|
||||
break
|
||||
end
|
||||
end
|
||||
ShadowMap.res = res
|
||||
|
||||
-- the box's SIZE is fixed (the sun and the view size are), so snapping
|
||||
-- its corner to a texel multiple moves it in whole texels only
|
||||
local tx, ty = w / res, h / res
|
||||
l = math.floor(l / tx) * tx
|
||||
b = math.floor(b / ty) * ty
|
||||
r, t = l + w, b + h
|
||||
|
||||
-- view-space z runs NEGATIVE into the scene; ortho() wants distances,
|
||||
-- and the slack keeps geometry taller than HEIGHT from being clipped
|
||||
-- clean out of the pass instead of merely casting a truncated shadow
|
||||
local near, far = -zf - 64, -zn + 64
|
||||
local proj = Mat4.ortho(l, r, b, t, near, far)
|
||||
-- flip clip-space Y for the same reason the camera does: we bypass
|
||||
-- LOVE's transform_projection, and canvas coordinates run Y DOWN, so
|
||||
-- without this the map is stored upside down relative to the uv the
|
||||
-- main pass reads it with
|
||||
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
|
||||
|
||||
ShadowMap.clipVP = Mat4.mul(proj, view)
|
||||
ShadowMap.uvVP = Mat4.mul(TO_UNIT, ShadowMap.clipVP)
|
||||
-- what the frustum ended up covering, for probes: the lateral extent in
|
||||
-- world pixels divided by RES is how fine a shadow edge can land
|
||||
ShadowMap.extent = { r - l, t - b, far - near }
|
||||
-- the stored depth spans the frustum, so a world-pixel bias is that
|
||||
-- fraction of it
|
||||
ShadowMap.bias = ShadowMap.BIAS / math.max(1, far - near)
|
||||
end
|
||||
|
||||
-- Whether the map has to be redrawn for `sig` -- a caller-built stamp of
|
||||
-- everything the pass depends on (camera, terrain meshes, every pose). A
|
||||
-- frame that changes none of it reuses the map it already has, which is
|
||||
-- most of a dialog, a menu or any moment standing still.
|
||||
function ShadowMap.stale(sig)
|
||||
return not ready or sig ~= lastSig
|
||||
end
|
||||
|
||||
-- Begin the sun pass. Returns false when it could not start, in which case
|
||||
-- the caller must not draw into it or call finish.
|
||||
function ShadowMap.begin(cx, cy, vw, vh)
|
||||
local sh = getShader()
|
||||
if not sh then return false end
|
||||
-- fit first: it is what decides which resolution rung this view wants
|
||||
fit(cx, cy, vw, vh)
|
||||
local c = getCanvas(ShadowMap.res)
|
||||
if not c then return false end
|
||||
local ok = pcall(love.graphics.setCanvas, { c, depth = true })
|
||||
if not ok then
|
||||
pcall(love.graphics.setCanvas)
|
||||
return false
|
||||
end
|
||||
prevBlend, prevAlphaMode = love.graphics.getBlendMode()
|
||||
-- white clears to depth 1 + 1/255, past the far plane: a texel nothing
|
||||
-- was drawn into can never shadow anything
|
||||
love.graphics.clear(1, 1, 0, 1, true, true)
|
||||
love.graphics.setDepthMode("lequal", true)
|
||||
love.graphics.setMeshCullMode("none")
|
||||
-- replace, not alpha blend: these are packed numbers, not colors
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
love.graphics.setShader(sh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
pcall(sh.send, sh, "lightVP", "row", ShadowMap.clipVP)
|
||||
drawing = true
|
||||
ready = false
|
||||
return true
|
||||
end
|
||||
|
||||
-- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward
|
||||
-- pull, which is a trick for the VIEW's depth buffer and would drag a
|
||||
-- shadow off whatever throws it.
|
||||
function ShadowMap.draw(mesh, texture, model)
|
||||
if not (drawing and mesh) then return end
|
||||
local sh = getShader()
|
||||
if texture then mesh:setTexture(texture) end
|
||||
pcall(sh.send, sh, "model", "row", model or IDENTITY)
|
||||
love.graphics.draw(mesh)
|
||||
end
|
||||
|
||||
-- Close the pass and stamp it with the signature it was drawn for.
|
||||
function ShadowMap.finish(sig)
|
||||
if not drawing then return end
|
||||
drawing = false
|
||||
love.graphics.setShader()
|
||||
love.graphics.setDepthMode()
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlphaMode)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
lastSig = sig
|
||||
ready = true
|
||||
end
|
||||
|
||||
-- Drop the GPU objects (window resize, hot reload).
|
||||
function ShadowMap.invalidate()
|
||||
canvas, canvasRes, blank = nil, 0, nil
|
||||
drawing, ready, lastSig = false, false, nil
|
||||
end
|
||||
|
||||
return ShadowMap
|
||||
Reference in New Issue
Block a user