DramaticShapeVoxelMod archival mirror · read-only
e14953c78af7f935033cdd9a08d872191d768c43

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