diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index d1b43cd..b18188d 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -77,7 +77,44 @@ ShadowMap.HEIGHT = 160 -- 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 +-- +-- It cannot be ONE number, because what the comparison has to forgive is +-- not fixed: the map stores one depth for a whole texel, so a lit surface +-- reads its own depth wrong by however far it RAMPS across that texel -- +-- the texel's world size times the surface's slope in the light's frame. +-- The texel swings from a third of a world pixel at the closest zoom to +-- well over one at a maximised window on the widest, so a constant bias is +-- generous at one end of the ladder and short at the other. Short shows up +-- as diagonal bands of acne across big lit surfaces -- diagonal because +-- the moire runs along neither the world grid nor the screen's, but along +-- the depth ramp in the sun's own frame, and the sun sits southeast. +-- +-- So: a floor for what does not scale (the packed depth's quantisation, +-- and the two passes reaching the same world point by different matrices), +-- plus a term in texels for what does. +ShadowMap.BIAS = 0.5 + +-- World pixels of slack per world pixel of texel, for the steepest LIT +-- surface here: a roof pitched 45 degrees and turned away from the sun, +-- whose depth ramps about 3.1 world pixels per texel crossed on EITHER of +-- the light frame's two axes (a vertical wall, by comparison, manages 1.7, +-- flat ground 0.7, and anything steeper than that roof has its back to the +-- sun and never reads the map at all). The 2x2 filter's taps sit half a +-- texel out on both axes at once, so the worst a tap can disagree by is +-- half the ramp along each -- which is where the halving that turns 6.2 +-- into 3.1 comes from, and why it is the SUM of the two components rather +-- than their magnitude. +-- +-- Measured against the artefact rather than trusted: the probe +-- (tests/voxel_acne_probe.lua) counts isolated shadowed pixels on lit +-- surfaces, and the banding stops at slack ~2.4 world px on the widest +-- rung -- where this lands 3.1 * 0.83 + 0.5. +ShadowMap.SLOPE = 3.1 + +-- The slack `fit` last worked out, in world pixels -- BIAS + SLOPE*texel. +-- Read by probes; `ShadowMap.bias` is the same number as the [0,1] depth +-- the map actually stores. +ShadowMap.slack = ShadowMap.BIAS local SHADER = [[ varying float vDepth; @@ -316,9 +353,13 @@ local function fit(cx, cy, vw, vh) -- 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 slack the comparison needs, against the coarser of the two texel + -- axes (the box is asymmetric, and one number has to cover both) + ShadowMap.slack = ShadowMap.BIAS + + ShadowMap.SLOPE * math.max(w, h) / res -- 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) + ShadowMap.bias = ShadowMap.slack / math.max(1, far - near) end -- Whether the map has to be redrawn for `sig` -- a caller-built stamp of diff --git a/lib/Sky.lua b/lib/Sky.lua new file mode 100644 index 0000000..600a51a --- /dev/null +++ b/lib/Sky.lua @@ -0,0 +1,299 @@ +-- The sky, generated rather than shipped. +-- +-- The overworld's, on every VOXEL rung. Wherever the diorama is drawn the void +-- behind it is sky rather than a black plate: at 75 degrees the horizon is +-- genuinely in frame and the bands run down to meet it, and at the steeper rungs +-- the void that shows is the ground running out past the map edge, which gets +-- the same sky above the same haze. A battle's placed camera keeps the flat fill +-- it has always had -- its horizon is above the frame and its look is not this +-- rung's to change. +-- +-- THE RECIPE is the 8-bit skybox one: a short palette of blues painted as flat +-- horizontal bands, deepest overhead, with a CHECKERBOARD of the next band +-- dithered into the bottom of each one. Alternating two colours on a pixel grid +-- is how a machine with four colours to a palette got a fifth, sixth and seventh +-- out of them, and it is what keeps four bands reading as a gradient rather than +-- as four stripes. No clouds, nothing moving. +-- +-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is +-- no baked 160x144 picture scaled up to the window, no downsized buffer blown +-- back up, no texture of any kind: one full-region rectangle through a shader +-- that answers every pixel from its own canvas coordinate. A pixel of sky is +-- computed at the size it is displayed at, so there is nothing for a filter to +-- soften and nothing to go stale when the window or the zoom changes. +-- +-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells +-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed +-- in fresh every frame -- so a chunky sky at 4x is a chunky sky at 12x, band +-- edges land on the same grid the world's own texels do, and a ZOOM keypress is +-- reflected in the frame that follows it rather than whenever something else +-- happened to rebuild. +-- +-- PALETTE ORDER, which is easy to get wrong. Stored LIGHTEST FIRST, because that +-- is shade order: a display mode transforms a four-colour palette by replacing it +-- outright (PaletteFX.effectiveColors hands back GRAYS or CLASSIC), and those are +-- written light to dark. So the sky reads the list backwards -- shade 4 overhead, +-- shade 1 at the horizon -- and GRAY gets four greys the right way up for +-- nothing. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local PaletteFX = require("src.render.PaletteFX") + +local unpack = table.unpack or unpack + +local Sky = {} + +-- Lightest first. Every channel is a multiple of 8, which is where a five-bit +-- GBC channel lands: these are colours the hardware could actually have shown, +-- not blues picked off a 24-bit colour wheel. +Sky.PALETTE = { + { 144, 192, 248 }, -- shade 1: pale, at the horizon + { 104, 160, 240 }, + { 72, 128, 224 }, + { 48, 96, 200 }, -- shade 4: deep, overhead +} + +-- The shader carries a fixed-size array, because a GLSL uniform array is a fixed +-- size; four is also what a display mode has to give (it substitutes a palette), +-- so this is the ceiling on the palette above rather than an arbitrary cap. +Sky.MAX_BANDS = 4 + +-- The checkerboard between bands. DITHER_START is how far down a band it begins, +-- as a fraction of that band: lower is a wider blend, and 1 switches it off. 0.6 +-- leaves the top of each band flat -- a band dithered all the way through reads +-- as one averaged colour instead of as a step with a soft bottom edge. +Sky.DITHER = true +Sky.DITHER_START = 0.6 + +-- How much of the frame the bands cover when the horizon is NOT in it, as a +-- fraction of the canvas height. +-- +-- At the steeper rungs the camera looks down far enough that the ground plane's +-- vanishing line is above the top edge -- there is no horizon to hang the pale +-- end on, but there is still void up there where the map runs out, and it should +-- read as sky. So the bands take the same slice of the frame the top rung's own +-- horizon gives them, which keeps the sky looking like one sky across the whole +-- ladder instead of changing character rung by rung. +Sky.SPAN = 0.23 + +-- ------- the bands +-- +-- Top first, each a { r, g, b } in 0..1, as the display mode has them. +-- +-- Memoised, because this runs once a frame and the answer only moves when the +-- mode does. +local cache = { bands = nil, key = {} } + +function Sky.bands() + local shades = PaletteFX.effectiveColors(Sky.PALETTE) or Sky.PALETTE + local n = math.min(#shades, #Sky.PALETTE, Sky.MAX_BANDS) + local key, k = cache.key, 0 + local same = cache.bands ~= nil and #cache.bands == n + for i = 1, n do + local c = shades[i] + for ch = 1, 3 do + k = k + 1 + if key[k] ~= c[ch] then same = false end + key[k] = c[ch] + end + end + if same then return cache.bands end + + local bands = {} + for i = 1, n do + -- backwards: the palette's darkest rung is the top band + local c = shades[n - i + 1] + bands[i] = { c[1] / 255, c[2] / 255, c[3] / 255 } + end + cache.bands = bands + return bands +end + +-- Put the sky onto a flat descriptor: the bands to paint, plus the flat fill +-- replaced by the palest of them. That fill is what the caller CLEARS to, so +-- making it the bottom band's own colour means the haze below the sky and the +-- bottom of the sky are one colour -- the join has no seam, and a frame that +-- cannot paint the bands is a hazy sky rather than a wrong one. +-- +-- Mutates the descriptor, which is a fresh table per frame from its caller. +function Sky.dress(sky) + local bands = Sky.bands() + local haze = bands and bands[#bands] + if not (sky and haze) then return sky end + sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] + sky.bands = bands + return sky +end + +-- Where the sky's bottom edge goes, in canvas pixels: the camera's own horizon +-- when that is in frame, and SPAN of the frame when it is not (see SPAN). nil +-- when there is no room for any of it. +function Sky.region(h, horizonY) + if not (h and h > 0) then return nil end + local edge = horizonY + if not (edge and edge > 0) then edge = h * Sky.SPAN end + edge = math.min(edge, h) + if edge < 1 then return nil end + return edge +end + +-- ------- the pass +-- +-- One rectangle, one shader, no texture. Every pixel answers for itself from its +-- canvas coordinate, so the sky is drawn at exactly the resolution it is +-- displayed at -- there is no image being scaled and so nothing to be soft. +-- +-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged +-- by is the top of its own cell row, so a whole cell row is one colour and every +-- edge in the sky lands on the diorama's pixel grid. +local SHADER_SRC = [[ +#define MAXB %d +uniform vec3 bands[MAXB]; +uniform int count; +uniform float edge; // the sky's bottom, in canvas pixels +uniform float cell; // the diorama's pixel size, in canvas pixels +uniform float start; // where the checker begins inside a band +uniform float alpha; + +// Indexed through a loop counter, which every GLSL ES compiler accepts for a +// uniform array; a bare bands[idx] is not portable. +vec3 bandAt(int idx) { + vec3 c = bands[0]; + for (int i = 1; i < MAXB; i++) { + if (i == idx) { c = bands[i]; } + } + return c; +} + +vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + float n = float(count); + float row = floor(sc.y / cell) * cell; // top of this cell row + float pos = clamp(row / max(edge, 1.0), 0.0, 0.999999) * n; + float base = floor(pos); + int idx = int(base); + vec3 c = bandAt(idx); + if (idx < count - 1 && (pos - base) > start) { + float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0); + if (parity < 0.5) { c = bandAt(idx + 1); } + } + return vec4(c, alpha); +} +]] + +local shader = nil -- nil = untried, false = unavailable + +local function getShader() + if shader == nil then + shader = false + if love.graphics and love.graphics.newShader then + local ok, sh = pcall(love.graphics.newShader, + SHADER_SRC:format(Sky.MAX_BANDS)) + if ok and sh then + shader = sh + elseif V and V.mod and V.mod.log then + -- once, and only where it can be read: the fallback below is a sky + -- without its dither, which is easy to look at and impossible to + -- diagnose without this line + V.mod.log:warn("sky shader did not compile: %s -- the bands draw flat, " + .. "with no dither between them", tostring(sh)) + end + end + end + return shader or nil +end + +Sky._getShader = getShader -- named for the suite + +-- The flat fallback: the same bands as solid rectangles, no checker, on the same +-- quantised edges. For a driver that could not compile the shader -- which is +-- also every headless run. +local function paintFlat(w, h, bands, edge, alpha, cell) + local g = love.graphics + local n = #bands + local prev = 0 + for i = 1, n do + local cut = (i == n) and math.min(h, math.ceil(edge)) + or math.floor(i / n * edge / cell + 0.5) * cell + cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge)))) + if cut > prev then + local c = bands[i] + g.setColor(c[1], c[2], c[3], alpha) + g.rectangle("fill", 0, prev, w, cut - prev) + end + prev = cut + end +end + +-- Paint the sky into the bound canvas, filling it from the top edge down to +-- `horizonY` (or to SPAN of the frame when the horizon is out of it). +-- +-- `cell` is the diorama's pixel size in canvas pixels -- the pass's own +-- pixels-per-world-pixel, handed in every frame so a zoom lands immediately. +-- +-- Returns false when there is nothing to paint, in which case the caller's flat +-- fill is the whole sky. That fill is the palest band, so a frame that declines +-- this looks like a hazy day rather than like a bug. +function Sky.paint(w, h, sky, horizonY, cell) + local bands = sky and sky.bands + if not (bands and bands[1]) then return false end + if not (w and h and w > 0 and h > 0) then return false end + local g = love.graphics + if not (g and g.rectangle) then return false end + local edge = Sky.region(h, horizonY) + if not edge then return false end + local alpha = sky[4] or 1 + cell = math.max(1, math.floor((cell or 1) + 0.5)) + + -- State to put aside. The scene's shader is one, and the blend mode another -- + -- a pass that left "replace" behind would make the fade-in strength meaningless + -- -- but the DEPTH MODE is the one that would break the frame: a rectangle + -- drawn under the pass's own ("lequal", true) stamps itself across the depth + -- buffer at the near plane and hides the entire world behind the sky. + local prevShader = g.getShader and g.getShader() or nil + local cmp, write + if g.getDepthMode then cmp, write = g.getDepthMode() end + if g.setDepthMode then g.setDepthMode("always", false) end + local blend, blendAlpha + if g.getBlendMode then blend, blendAlpha = g.getBlendMode() end + if g.setBlendMode then g.setBlendMode("alpha") end + + local sh = getShader() + if sh then + local sent = pcall(function() + -- one send per band would be one uniform lookup per band; the array takes + -- them all at once, and it must be the LAST argument or Lua truncates the + -- unpack to a single value + sh:send("bands", unpack(bands)) + sh:send("count", #bands) + sh:send("edge", edge) + sh:send("cell", cell) + sh:send("start", Sky.DITHER and Sky.DITHER_START or 2) + sh:send("alpha", alpha) + end) + if sent then + g.setShader(sh) + g.setColor(1, 1, 1, 1) + g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge))) + g.setShader() + else + sh = nil + end + end + if not sh then paintFlat(w, h, bands, edge, alpha, cell) end + g.setColor(1, 1, 1, 1) + + if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end + if g.setDepthMode then g.setDepthMode(cmp or "always", write or false) end + if prevShader and g.setShader then g.setShader(prevShader) end + return true +end + +-- Drop the compiled shader (window resize, hot reload), so a re-created graphics +-- context builds a new one instead of drawing with a handle from the old. +function Sky.invalidate() + shader = nil +end + +return Sky diff --git a/tests/voxel_acne_probe.lua b/tests/voxel_acne_probe.lua new file mode 100644 index 0000000..31ae673 --- /dev/null +++ b/tests/voxel_acne_probe.lua @@ -0,0 +1,175 @@ +-- Driver: isolate the diagonal banding on flat lit geometry. +-- +-- Shoots the SAME stand point four ways in ONE launch, so palette, camera +-- and framing are identical and the only variable is the sun pass: +-- +-- _on shadows as shipped +-- _off SHADOW_ALPHA = 0 -- the sun pass still runs, nothing reads it +-- _flatN a CONSTANT bias of N world px, i.e. SLOPE switched off +-- +-- and prints, from the frustum the run actually fitted, how much depth +-- slack each face orientation NEEDS against the slack it is given. A face +-- whose need exceeds the slack shadows itself in a moire -- acne -- which +-- reads as diagonal banding, since the ramp it aliases against runs along +-- the light's frame and the sun sits southeast. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_acne_probe.lua lovec . +-- +-- knobs (env): +-- ACNE_MAP map id (default VIRIDIAN_CITY) +-- ACNE_SPOT "x,y[,facing]" (default 32,9,up -- the gym wall) +-- ACNE_LEVELS comma list of voxel levels (default "3") +-- ACNE_FLAT comma list of constant-bias values to compare against +-- (default "1" -- what shipped before SLOPE existed) +-- SHOT_DIR output directory, must exist (default "shots") +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local SPEED = math.max(1, + math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1)) + local function wait(n) U.wait(n * SPEED) end + + local DIR = os.getenv("SHOT_DIR") or "shots" + local mapId = os.getenv("ACNE_MAP") or "VIRIDIAN_CITY" + local sx, sy, facing = (os.getenv("ACNE_SPOT") or "32,9,up") + :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)") + facing = (facing ~= "" and facing) or "up" + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + local V = assert(handle and handle.lib, "DRAMATIC_SHAPE exports missing") + local ShadowMap = V.require("ShadowMap") + local Voxel3D = V.require("Voxel3D") + local Mat4 = V.require("Mat4") + + local levels = {} + for n in (os.getenv("ACNE_LEVELS") or "3"):gmatch("%d+") do + levels[#levels + 1] = tonumber(n) + end + local flats = {} + for n in (os.getenv("ACNE_FLAT") or "1"):gmatch("[%d.]+") do + flats[#flats + 1] = tonumber(n) + end + + -- ---- what the frustum this run fitted asks of the bias, per face + local FACES = { + { "top +Y", 0, 1, 0 }, + { "south +Z", 0, 0, 1 }, + { "east +X", 1, 0, 0 }, + { "west -X", -1, 0, 0 }, + { "north -Z", 0, 0, -1 }, + { "roof 45 N", 0, 0.7071, -0.7071 }, + { "roof 45 S", 0, 0.7071, 0.7071 }, + } + + local function report(tag) + local e = ShadowMap.extent + if not e then print("[acne] " .. tag .. ": no frustum yet") return end + local res = ShadowMap.res + local texX, texY, depth = e[1] / res, e[2] / res, e[3] + local d = ShadowMap.sunDir() + local view = Mat4.lookAt({ 0, 0, 0 }, d, { 0, 0, -1 }) + local R = { view[1], view[2], view[3] } + local Up = { view[5], view[6], view[7] } + local F = { -view[9], -view[10], -view[11] } + print(("[acne] %s: res %d frustum %.0fx%.0f deep %.0f" + .. " texel %.2fx%.2f world px slack %.2f px (stored %.6f)") + :format(tag, res, e[1], e[2], depth, texX, texY, + ShadowMap.slack, ShadowMap.bias)) + for _, fc in ipairs(FACES) do + local n = { fc[2], fc[3], fc[4] } + local nr = n[1]*R[1] + n[2]*R[2] + n[3]*R[3] + local nu = n[1]*Up[1] + n[2]*Up[2] + n[3]*Up[3] + local nf = n[1]*F[1] + n[2]*F[2] + n[3]*F[3] + -- a face the sun cannot see never samples its own depth, so it + -- cannot alias -- FACE_SHADE darkens those and the map is moot + if nf < 0 then + -- the plane's depth gradient in light space, times the half-texel + -- the 2x2 filter reaches out on each axis + local need = 0.5 * (math.abs(nr / nf) * texX + + math.abs(nu / nf) * texY) + print(("[acne] %s cos %.3f slope %.2f needs %.2f px %s") + :format(fc[1], math.abs(nf), + math.sqrt((nr/nf)^2 + (nu/nf)^2), need, + need > ShadowMap.slack and "<-- ACNE" or "ok")) + else + print(("[acne] %s unlit (sun behind it)"):format(fc[1])) + end + end + end + + local Zoom = require("src.render.Zoom") + Zoom.reset() + local steps = math.floor(tonumber(os.getenv("ACNE_ZOOM")) or 0) + for _ = 1, math.abs(steps) do + Zoom.step(steps > 0 and 1 or -1, game.renderer and game.renderer:fitScale()) + end + + Pipelines.setLevel("tiltshift", 0) + U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing) + wait(20) + do + local vw, vh = game.renderer:worldViewSize() + print(("[acne] map %s at (%s,%s,%s) world view %dx%d px zoom %d") + :format(mapId, sx, sy, facing, vw, vh, Zoom.offset)) + end + + local shipped = Voxel3D.SHADOW_ALPHA + local shippedBias, shippedSlope = ShadowMap.BIAS, ShadowMap.SLOPE + + -- ACNE_MATRIX=1: the four corners of (shadows on/off) x (voxel grid + -- on/off), so a band can be attributed to one of them rather than + -- guessed at. The grid is the other thing in this pass that draws + -- regular lines, and at a grazing angle it moires. + local VoxelGrid = V.require("VoxelGrid") + local matrix = os.getenv("ACNE_MATRIX") == "1" + + local function shoot(name) + game.capturePath = ("%s/acne_%s.png"):format(DIR, name) + wait(4) + end + + for _, level in ipairs(levels) do + Pipelines.setLevel("voxel", level) + wait(30) -- outlast the camera tween + local label = Pipelines.levelLabel("voxel", level) or level + print(("[acne] --- level %s shadowsActive=%s") + :format(label, tostring(Voxel3D.shadowsActive()))) + report("as shipped") + shoot(("%s_v%s_on"):format(mapId, label)) + + if matrix then + for _, grid in ipairs({ true, false }) do + VoxelGrid.sync(grid) + for _, sun in ipairs({ true, false }) do + Voxel3D.SHADOW_ALPHA = sun and shipped or 0 + wait(8) + shoot(("%s_v%s_grid%s_sun%s"):format(mapId, label, + grid and "1" or "0", sun and "1" or "0")) + end + end + Voxel3D.SHADOW_ALPHA = shipped + end + + -- the sun pass still runs; the main pass simply stops reading it + Voxel3D.SHADOW_ALPHA = 0 + wait(6) + shoot(("%s_v%s_off"):format(mapId, label)) + Voxel3D.SHADOW_ALPHA = shipped + + for _, b in ipairs(flats) do + ShadowMap.BIAS, ShadowMap.SLOPE = b, 0 + ShadowMap.invalidate() -- force fit() to recompute ShadowMap.bias + wait(8) + report(("flat %.1f"):format(b)) + shoot(("%s_v%s_flat%s"):format(mapId, label, tostring(b):gsub("%.", "p"))) + end + ShadowMap.BIAS, ShadowMap.SLOPE = shippedBias, shippedSlope + ShadowMap.invalidate() + wait(6) + end + + Pipelines.setLevel("voxel", 0) + wait(5) + print("[acne] done") +end