mitigate diagonal shadow artifacts
DramaticShape · 2026-07-29 ·
parent 8dc046f72e
mitigate diagonal shadow artifacts
Changed files
lib/ShadowMap.lua | 45 +++++++++-
lib/Sky.lua | 299 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
tests/voxel_acne_probe.lua | 175 ++++++++++++++++++++++++++++++++++++++
3 files changed, 517 insertions(+), 2 deletions(-)
Diff
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