DramaticShapeVoxelMod archival mirror · read-only
tests/aa_shots.lua

aa_shots.lua

tests/aa_shots.lua7.2 KB100644dcd484aae7
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195
-- 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