Files
DramaticShapeVoxelMod/tests/aa_shots.lua
DramaticShape 47363b8d23 iterate version
2026-08-01 16:41:03 -04:00

196 lines
7.2 KiB
Lua

-- Driver: one scene, once per rung of the AA row.
--
-- The AA row is the one setting in this mod whose whole effect is a pixel
-- wide, so it is also the one that cannot be judged from a description. This
-- renders the SAME frame at each rung and writes one PNG per rung; put two of
-- them side by side, magnified, and the row is either doing something or it
-- is not.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/aa_shots.lua \
-- SHOT_DIR=<dir> lovec.exe .
--
-- knobs (env):
-- SHOT_DIR output directory (created if missing) (default "shots/aa")
-- AA_MAP map id (default VIRIDIAN_CITY)
-- AA_SPOT "x,y[,facing]" (default 20,26,up)
-- AA_RUNG the voxel camera rung (default 5, the 75 one)
--
-- The scene defaults to a town at the LOW camera on purpose: roof ridges, the
-- diagonal of a fence and a tree's silhouette against the sky are the edges
-- that stair-step, and 75 degrees is the rung that puts the most of them at an
-- angle to the pixel grid.
--
-- Determinism matters here for the same reason it does in voxel_shots_ab: the
-- three shots differ ONLY by the row under test, or comparing them means
-- nothing. The clock is pinned, the animated tile slots are frozen, the
-- townsfolk are stopped where they stand, and the tilt-shift is held at zero
-- (a gaussian over the frame would smear away the very edges being looked at).
--
-- Nothing here writes the player's options: the row is moved with
-- ModSetting:sync, which moves the cached index and persists nothing.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local OverworldState = require("src.world.OverworldController")
local ROOT = os.getenv("SHOT_DIR") or "shots/aa"
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local AntiAlias = V.require("AntiAlias")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
local MAP = os.getenv("AA_MAP") or "VIRIDIAN_CITY"
local SPOT = os.getenv("AA_SPOT") or "20,26,up"
local RUNG = math.floor(tonumber(os.getenv("AA_RUNG")) or 5)
local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
if sf == "" then sf = "up" end
OverworldState.rollEncounter = function() return nil end
local NPC = require("src.world.NPC")
if not NPC.dramaticShapeAaFreeze then
local inner = NPC.update
function NPC:update(...)
self.frozen = true
return inner(self, ...)
end
NPC.dramaticShapeAaFreeze = true
end
pcall(love.math.setRandomSeed, 20260801)
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function cameraStill()
local o = game.overworld
local c = o and o.camera
if not c then return true end
local lx, ly, held = nil, nil, 0
for _ = 1, 300 do
if c.x == lx and c.y == ly then
held = held + 1
if held >= 10 then return true end
else
held = 0
lx, ly = c.x, c.y
end
U.wait(1)
end
return false
end
-- The camera PITCH, which is the one that caught this driver out. The tween
-- runs on wall-clock dt and Voxel.t reaching 1 is not the same instant the
-- angle stops moving, so the first shot of a run came out at 67 degrees
-- while the two after it were at 75 -- three frames that differ by the
-- camera, in a comparison whose entire subject is a pixel.
local function angleStill()
local last, held = nil, 0
for _ = 1, 600 do
if Voxel.angle == last then
held = held + 1
if held >= 10 then return true end
else
held = 0
last = Voxel.angle
end
U.wait(1)
end
return false
end
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
angleStill()
cameraStill()
-- the sun map is only redrawn when its inputs move, and the AA row is not
-- one of them -- so force one pass at the settled camera rather than
-- comparing a frame against a map fitted a few hundredths of a pixel ago
if ShadowMap.forget then ShadowMap.forget() end
U.wait(20)
end
DayNight.setting:sync("day")
U.teleport(game, MAP, sx, sy, sf)
Pipelines.setLevel("voxel", RUNG)
Pipelines.setLevel("tiltshift", 0)
-- Warm up before the FIRST shot, not just between them.
--
-- Neighbour maps are requested from inside the render itself
-- (VoxelScene.prefetch), so an empty build queue right after a teleport
-- means "nothing has been asked for yet", not "everything is here". The
-- first capture of a run came out with the map beyond Viridian missing --
-- a whole tree line absent from one frame of a three-way comparison, which
-- looks exactly like the row under test doing something enormous. Settling
-- twice lets the first render request the neighbourhood and the second
-- drain it.
settle()
settle()
local shots, missed = 0, 0
for _, samples in ipairs({ 0, 2, 4 }) do
AntiAlias.setting:sync(samples)
settle()
-- AA_TRACE=1 prints the state each shot was taken in. When two shots of a
-- run disagree by more than the row could account for, this is what says
-- which input moved -- it is how the camera-tween and the neighbour-mesh
-- settles above were both found.
if os.getenv("AA_TRACE") == "1" then
local Voxel3D = V.require("Voxel3D")
local o = game.overworld
local cw, chh = Voxel3D.size()
print(("[aa] trace samples=%d angle=%.6f fov=%.6f cell=%.4f canvas=%dx%d cam=(%.3f,%.3f) eye=(%.2f,%.2f,%.2f) factor=%.4f")
:format(samples, Voxel.angle or -1, Voxel3D.fovY or -1,
Voxel3D.cell or -1, cw or 0, chh or 0,
o and o.camera and o.camera.x or -1,
o and o.camera and o.camera.y or -1,
(Voxel3D.eye or {})[1] or 0, (Voxel3D.eye or {})[2] or 0,
(Voxel3D.eye or {})[3] or 0, AntiAlias.factor()))
end
local path = ("%s/aa_%d.png"):format(ROOT, samples)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then
f:close()
shots = shots + 1
print(("[aa] %s samples=%d"):format(path, samples))
else
missed = missed + 1
print("[aa] capture did not reach disk: " .. path)
end
end
-- left where it was found, so a run cannot leak a rung into the next one
AntiAlias.setting:sync(0)
print(("[aa] %d shots into %s (%d failed to reach disk)")
:format(shots, ROOT, missed))
end