diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ea089..5bb313c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog +## 1.4.1 + +### Added + +- **Furniture through the building pipeline.** The band-table voxelizer + that models whole buildings from their own drawings (lib/Buildings.lua) + now reads interior furniture too, and the first four drawings are in: + + - **F01, the starter-ball table in Oak's lab** -- the tabletop's 16 + drawn rows lay flat over a 16px plot (1:1, the first template that + never cycles), the black/#555/black edge band folds into the slab's + own rim, and the base extrudes with its corner feet. Six voxels + tall, exactly the drawn elevation. + - **F03, the empty north table beside it** -- the same band table on a + grid two tiles narrower. + - **F02, the lab's computer desk** -- the first DESK-SET template: the + drawing segments into PARTS, each classified by the surface it + depicts. The monitor and the computer tower stand upright on the + desk wearing their own drawn tops as lids; the keyboards and the + mouse lie flat in front of them; the sheet of paper on the right + lies flat across the desk. Flat parts keep the drawing's own rule -- + drawn row IS depth row, the same 1:1 the tabletop is drawn with -- + so an object's height on the drawing is its position on the desk. + The Hall of Fame's recording machine is this drawing tile for tile + on the GYM atlas, and models identically for free. + - **F04, the Center PC** -- the desk-set read again: a Mac-style unit + with its screen and drive slot in relief, standing at the back of a + low white-topped desk with its keyboard lying at the front edge. + Eleven Pokemon Centers, plus the Indigo Plateau lobby, whose MART + tileset shares the atlas. + + Two measurements had to stop being assumptions for furniture to fit + the pipeline: the GROUND LINE is now read off the drawing (a building + ends on the black threshold row it stands on; a table's legs stop two + rows short of theirs, and extruding against the grid floated them in + the air), and a template may name its PLOT (`depth`) when the matched + grid runs past it onto the walkable floor the legs merely stand on. + Both are identities for every existing building. + +- **The Center couch has a backrest.** The couch is drawn from above -- + back-and-arm strip down the west side, cushions and seams on the east + -- and rendered as one seat-high box. The new `backrest` class raises + the drawn back strip to 12px over the 8px seat, in every Center and + the Celadon Hotel. The man sitting on it keeps his seat: the figure + anchor now scans under his card for the tallest authored upright (his + cushion) instead of reading the corner tile, which is the backrest + now. + +### Changed + +- **Sprites ride at the height the art actually stands.** Class heights + can now be overridden per tileset (a tileset entry's `heights`), and + DOJO's lab tables use it: they are drawn 6px tall, not the default + table's 12, so the starter balls sit exactly on the modelled tabletop + -- and the volume-built north tables drop to the same height, keeping + every table in the room level. +- The Center PC's old rendering -- a 12px table box with the unit as a + flat standee on it -- retires wherever the F04 template stamps; the + pins stay only as the degradation path when the shape profile is + absent. + ## 1.4.0 ### Added diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index cbf4fda..97f52f6 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -110,6 +110,9 @@ return { bed = 7, stool = 8, counter = 8, + -- the raised back band of low seating (the Center couch's back and + -- arm strip): half again the 8px seat it rises over + backrest = 12, table = 12, desk = 24, prop = 16, @@ -705,14 +708,25 @@ return { -- one clean band, so the drawn front panel stands up and the -- counter top stays on top; at 12 they read as wall stubs counter = { 8, 10, 24, 25, 56, 90, 91, - -- and the lounge couch with the man sitting on it. - -- Same half-cell box: its bottom row (42/43, the front - -- base) stands up as the couch's front, and the three - -- rows above it -- cushion (38/39) and the man - -- (36/37 head, 52/53 face) -- ride the top face in + -- and the lounge couch's SEAT column with the man + -- sitting on it. Same half-cell box: its bottom row + -- (43, the front base) stands up as the couch's + -- front, and the rows above it -- cushion (39) and + -- the man (37 head, 53 face) -- ride the top face in -- drawn order, each exactly once. See the note below -- on why he cannot be stood upright. - 36, 37, 38, 39, 42, 43, 52, 53 }, + 37, 39, 43, 53 }, + -- The couch's WEST tile column: the drawing's left strip is the + -- couch's back and arm running north-south (the seat's cushions + -- and seams fill the east column), so it rises over the 8px seat + -- the way a couch back does instead of lying flush in the same + -- box. Per-tile granularity puts the drawn ~6px strip plus a + -- 2px sliver of cushion on the raised band -- invisible at tile + -- scale, and the alternative is no backrest at all. The figure + -- anchor scans for the tallest authored UPRIGHT under the man + -- (see Structures.buildFigure), so he keeps sitting at seat + -- height beside it. + backrest = { 36, 38, 42, 52 }, -- standing per-pixel props, black-outline segmented: the healing -- machines' screen tops (58/59/74/75, drawn above the pinned -- bodies so they stand ON them) and the PC (66/70/82/86), which @@ -3320,6 +3334,62 @@ return { }, }, + POKECENTER = { + -- F04: the PC in every Center's northeast corner (11 + -- placements; the Indigo Plateau lobby's twin is registered + -- under MART below). The lab desk-set read again: a Mac-style + -- unit drawn face-on -- white top band (rows 0-3), bezel, screen + -- and drive slot (4-14) -- standing at the back of a low desk + -- whose front face is rows 20-23; the drawn top around the unit + -- is WHITE, which is what `lid` carries. The keyboard rows 17-19 + -- are drawn below the desk's 16px top span, so the flat part's + -- `z` puts it at the desk's front edge. The old billboard pins + -- for these tiles (66/70/82/86, desk 9/88) stay as the + -- degradation path -- the claim neutralizes them wherever this + -- template stamps. + { + id = "center_pc", + tiles = { + { 66, 70 }, + { 82, 86 }, + { 9, 88 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 3 }, + facade = { 4, 14 }, depth = 6 }, -- the unit + { kind = "flat", x = { 2, 13 }, rows = { 17, 19 }, + z = 13 }, -- keyboard + }, + }, + }, + + MART = { + -- F04 again: the Indigo Plateau lobby's PC (cell 15,7) -- the + -- MART tileset shares the POKECENTER atlas, so this is the same + -- drawing tile for tile. Same part table as the POKECENTER entry + -- above. + { + id = "center_pc", + tiles = { + { 66, 70 }, + { 82, 86 }, + { 9, 88 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 3 }, + facade = { 4, 14 }, depth = 6 }, -- the unit + { kind = "flat", x = { 2, 13 }, rows = { 17, 19 }, + z = 13 }, -- keyboard + }, + }, + }, + GYM = { -- F02 again: the Hall of Fame's recording machine is the lab's -- computer desk drawing, tile for tile, on the GYM atlas (one diff --git a/lib/AntiAlias.lua b/lib/AntiAlias.lua new file mode 100644 index 0000000..30f8761 --- /dev/null +++ b/lib/AntiAlias.lua @@ -0,0 +1,244 @@ +-- Voxel world mode: anti-aliasing, by supersampling. +-- +-- Everything else in this mod is flat art blitted at whole pixels; this one +-- pass is real geometry seen through a perspective camera, and a polygon +-- edge that lands at an angle across the pixel grid is the one place in the +-- game where a hard stair-step is not a stylistic choice. A roof ridge, a +-- ledge lip, a tree's silhouette against the sky and the leaning card of a +-- character are all cut by an edge that has no reason to line up with +-- anything, and at the shallow rungs -- where the diorama reads most like a +-- photograph of a model -- they crawl as the camera drifts. +-- +-- SUPERSAMPLING, not MSAA and not a filter over the finished frame, for two +-- reasons that both come out of what the pass already is: +-- +-- MSAA would take the water with it. The reflections read the frame's own +-- DEPTH buffer as a texture (Voxel3D.beginWater), and a multisampled depth +-- attachment is not a thing a fragment shader in this dialect can sample. +-- The row would have quietly switched the other row off. +-- +-- An edge filter (FXAA and its relatives) works from the finished colour +-- alone, and would be GUESSING where the edges are out of one sample per +-- pixel -- inventing detail it never rendered, and unable to tell a +-- geometry edge from the boundary between two texels of a tileset. +-- +-- Rendering the pass larger and folding it back down has neither problem: +-- the depth buffer stays an ordinary texture, every pass in the frame keeps +-- working in the canvas it was handed, and the fold is an average of samples +-- that were each rendered honestly. It antialiases everything at once -- +-- geometry, the alpha-cut outline of a sprite card, the wireframe, the +-- water's ray march -- because none of them know it is happening. +-- +-- Be clear about what "everything" means: the artwork softens too. A tileset +-- texel out here is not a screen pixel, it is a quad in a perspective view, +-- and its boundary crosses the pixel grid at the same arbitrary angle a roof +-- ridge does -- so the fold averages across it exactly as it averages across +-- the ridge. That is what an honest extra sample says about that pixel, and +-- it is also the trade the row IS: the diorama comes out smoother, not +-- sharper. Which is why this is a row and not something that is simply on. +-- +-- What it costs is pixels, which is the whole of why this is a row and not +-- something that is simply on: 2X is half again as many in each direction, +-- 4X is twice, and the scene pass is the most expensive thing in the frame. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") + +local AntiAlias = {} + +-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in +-- OPTIONS and the mod manager's own settings page for this mod +AntiAlias.KEY = "aa" +AntiAlias.LABEL = "AA" + +-- The ladder is SAMPLES PER DISPLAY PIXEL, which is how an AA setting reads +-- everywhere else, and the canvas scale each rung costs is its square root: +-- 2 samples is a canvas 1.41x wider and taller, 4 is one exactly twice the +-- size. OFF is the default -- this is a cost knob, and a mod should not +-- quietly spend four times the fill rate of the machine it lands on. +AntiAlias.setting = ModSetting.new(AntiAlias.KEY, AntiAlias.LABEL, + { 0, 2, 4 }, { "OFF", "2X", "4X" }) + +-- The scale the pass currently open was actually expanded by (see expand). +-- 1 while there is no supersampling in force, which is also what every +-- reader gets on a frame that never opened a pass at all. +local live = 1 + +function AntiAlias.samples() + return tonumber(AntiAlias.setting:get()) or 0 +end + +-- What the row ASKS for. The scale in force is `factor()`, which is this +-- clamped to what the driver will actually allocate. +local function wanted() + local n = AntiAlias.samples() + if n <= 1 then return 1 end + return math.sqrt(n) +end + +-- The biggest canvas this driver admits to, or nil where it will not say. +-- A 4K window at 4X asks for 7680 across, which is past the limit on plenty +-- of hardware and every phone -- and a refused canvas is not a softer +-- diorama, it is beginScene returning false and the whole mode falling back +-- to the flat 2D path. +local function textureLimit() + if not (love.graphics and love.graphics.getSystemLimits) then return nil end + local ok, limits = pcall(love.graphics.getSystemLimits) + return (ok and limits and limits.texturesize) or nil +end + +-- The size to render `w` x `h` display pixels at, and the size everything +-- inside the pass then measures itself in. +-- +-- Also where `live` is set, which is why this must be called once per pass +-- immediately before beginScene: the wireframe's line width and the FX +-- overlay's sprite scale are both quoted in DISPLAY pixels and have to be +-- multiplied up into canvas ones, and the honest multiplier is the one this +-- returned rather than the one the row asked for. +function AntiAlias.expand(w, h) + local s = wanted() + local max = textureLimit() + if max and max > 0 then + -- clamped rather than abandoned: a window too big for 4X can usually + -- still carry some of it, and half a rung of smoothing is worth more + -- than a row that silently does nothing at that size + s = math.min(s, max / math.max(1, w), max / math.max(1, h)) + end + if not (s > 1.01) then + live = 1 + return w, h + end + local ew, eh = math.floor(w * s + 0.5), math.floor(h * s + 0.5) + live = ew / math.max(1, w) + return ew, eh +end + +-- The scale the open pass was expanded by; 1 when it was not. +function AntiAlias.factor() + return live +end + +-- ------- the fold +-- +-- One target per pass (the free-roam world and the battle's arena are alive +-- at different moments but reallocating on every battle entry and exit is +-- what the scene canvas's own slots exist to avoid), reallocated only when +-- that pass's DISPLAY size changes -- a window resize, or the row itself +-- moving, which changes the source and not this. + +local targets = {} + +local function targetFor(slot, w, h) + local t = targets[slot] + if not (t and t.w == w and t.h == h) then + local ok, c = pcall(love.graphics.newCanvas, w, h) + if not (ok and c) then return nil end + -- nearest, like the canvas it stands in for: this one is composited a + -- canvas pixel to a display pixel, and the smoothing has already happened + pcall(c.setFilter, c, "nearest", "nearest") + if t and t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end + t = { canvas = c, w = w, h = h } + targets[slot] = t + end + return t.canvas +end + +-- The box filter, and the whole of why it is a shader rather than a scaled +-- draw with linear filtering on. +-- +-- The void this pass renders into is cleared to a TRANSPARENT BLACK, and at +-- the rungs below FULL a good deal of the frame is still that. Averaging a +-- straight-alpha edge against it drags the result toward black as well as +-- toward transparent, and then the engine's own composite multiplies by that +-- alpha a second time -- so every silhouette against the void would come out +-- ringed with a dark fringe, which is exactly the artefact the row is here to +-- remove. +-- +-- So the taps are premultiplied before they are averaged and divided back out +-- after, which is the arithmetic that makes an edge pixel mean "half covered +-- by this colour" instead of "covered by half of this colour". +-- +-- Four taps, half a source texel from the destination centre. At 4X those +-- land dead on the four texel centres the destination pixel covers, so it is +-- an exact 2x2 box; at 2X the source grid does not divide, and the bilinear +-- fetch under each tap widens the box a little rather than missing samples. +local SHADER = [[ + uniform vec2 tap; // half a SOURCE texel, in uv + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 a = Texel(tex, tc + vec2(-tap.x, -tap.y)); + vec4 b = Texel(tex, tc + vec2( tap.x, -tap.y)); + vec4 c = Texel(tex, tc + vec2(-tap.x, tap.y)); + vec4 d = Texel(tex, tc + vec2( tap.x, tap.y)); + float al = (a.a + b.a + c.a + d.a) * 0.25; + if (al <= 0.0) return vec4(0.0); + vec3 sum = a.rgb * a.a + b.rgb * b.a + c.rgb * c.a + d.rgb * d.a; + return vec4(sum * 0.25 / al, al) * color; + } +]] + +local shader = nil -- nil = untried, false = unavailable + +local function getShader() + if shader == nil then + local ok, sh = pcall(love.graphics.newShader, SHADER) + shader = (ok and sh) or false + end + return shader or nil +end + +-- Fold `canvas` down to `w` x `h` and hand back the result. +-- +-- Returns the input untouched when there is nothing to fold -- the row is +-- off, or the canvas already IS that size -- so a caller can run it +-- unconditionally, and so can a headless test run. A target that would not +-- allocate is the same answer: the pass is lost either way if this hands back +-- something the wrong size, so it hands back the input and the frame draws at +-- the size it was rendered. +function AntiAlias.resolve(canvas, w, h, slot) + if not canvas then return canvas end + local ok, cw, ch = pcall(canvas.getDimensions, canvas) + if not ok or (cw == w and ch == h) then return canvas end + local target = targetFor(slot or "world", w, h) + if not target then return canvas end + + local sh = getShader() + local prevBlend, prevAlpha = love.graphics.getBlendMode() + -- the scene canvas filters nearest for its usual 1:1 blit; the taps want + -- linear, put back below so every other pass finds what it expects + pcall(canvas.setFilter, canvas, "linear", "linear") + love.graphics.setColor(1, 1, 1, 1) + -- replace, not alpha-blend: this is an image-processing copy, and the alpha + -- the shader worked out has to land as itself rather than be composited + -- against whatever the target held + love.graphics.setBlendMode("replace", "premultiplied") + if sh then + love.graphics.setShader(sh) + pcall(sh.send, sh, "tap", { 0.5 / cw, 0.5 / ch }) + end + local drew = pcall(function() + love.graphics.setCanvas(target) + love.graphics.clear(0, 0, 0, 0) + love.graphics.draw(canvas, 0, 0, 0, w / cw, h / ch) + end) + love.graphics.setCanvas() + love.graphics.setShader() + love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) + pcall(canvas.setFilter, canvas, "nearest", "nearest") + return drew and target or canvas +end + +-- Drop the GPU objects (window resize, hot reload). +function AntiAlias.invalidate() + for slot, t in pairs(targets) do + if t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end + targets[slot] = nil + end +end + +function AntiAlias.row() + return AntiAlias.setting:row() +end + +return AntiAlias diff --git a/lib/Buildings.lua b/lib/Buildings.lua index d378ec7..b07c913 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -378,9 +378,10 @@ local function deskSetModel(sp, pr, t) for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end end end + local field = t.desk.lid == "white" and WHITE or GREY for sx = 0, W - 1 do for z = 0, D - 1 do - local shade = GREY + local shade = field if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then shade = BLACK elseif sx == 1 or z == 1 then @@ -395,10 +396,16 @@ local function deskSetModel(sp, pr, t) Budget.tick() local x0, x1 = p.x[1], p.x[2] if p.kind == "flat" then - for sy = p.rows[1], p.rows[2] do - if sy >= 0 and sy < D then + -- drawn row = depth row by default; `z` renames the origin when + -- the flat sits below the desk's own drawn top span (the Center + -- PC's keyboard) + local r0 = p.rows[1] + local z0 = p.z or r0 + for sy = r0, p.rows[2] do + local z = z0 + (sy - r0) + if z >= 0 and z < D then for sx = x0, x1 do - if inside[sy * W + sx] then put(sx, plane, sy, sy * W + sx) end + if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end end end end diff --git a/lib/Structures.lua b/lib/Structures.lua index 0b5262a..2e3e70c 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -2253,14 +2253,22 @@ local function buildFigure(S, map, fig, tx, ty, perRow) -- He stands ON the furniture he was drawn into -- the same lift a pinned -- prop above a pinned box takes (see buildObject), and gated the same -- way: a thing set down on furniture occupies a BLOCKED cell, while a - -- seat you merely walk up to is in a walkable one. + -- seat you merely walk up to is in a walkable one. The row under his + -- card is SCANNED for the tallest authored upright rather than read at + -- its west corner: the corner tile can be furniture that is not his + -- seat (the couch's raised backrest column stands there, `top` art and + -- taller than the cushion he actually sits on). local baseY = 0 - local bs = S.shapeAt[keyOf(tx, ty + fig.h)] local blocked = not map:isWalkableCell(math.floor(tx / 2), math.floor((ty + fig.h - 1) / 2)) - if blocked and bs and bs.authored and bs.art == "upright" - and (bs.h or 0) > 0 then - baseY = bs.h + if blocked then + for dx = 0, fig.w - 1 do + local bs = S.shapeAt[keyOf(tx + dx, ty + fig.h)] + if bs and bs.authored and bs.art == "upright" + and (bs.h or 0) > baseY then + baseY = bs.h + end + end end local atlasW = map.tileset.imageWidth or 128 diff --git a/lib/TileShape.lua b/lib/TileShape.lua index f36f87d..31e5916 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -83,6 +83,10 @@ local FALLBACK_HEIGHTS = { bed = 7, stool = 8, counter = 8, + -- the raised back band of low seating: the Center couch's west strip + -- is drawn from above like the rest of the couch, but depicts the + -- back and arm rising over the 8px seat + backrest = 12, table = 12, desk = 24, prop = 16, @@ -145,6 +149,9 @@ local ART = { -- profile archetype Structures builds real steps for -- rising flights -- for stairs leading up, sunken stairwells for stairs leading down bed = "top", + -- a backrest's art is the couch seen from above, so like the bed it + -- rides the top face of its taller box + backrest = "top", stool = "billboard", -- half-cell furniture: a service counter, a low couch. One 8px band, -- so exactly the drawing's bottom row stands up as the front and diff --git a/main.lua b/main.lua index 6ca5f8a..01aaf0f 100644 --- a/main.lua +++ b/main.lua @@ -890,7 +890,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.4.0" +mod.exports.version = "1.4.1" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 157448b..b2382d4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.4.0", + "version": "1.4.1", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/tests/aa_shots.lua b/tests/aa_shots.lua new file mode 100644 index 0000000..dcd484a --- /dev/null +++ b/tests/aa_shots.lua @@ -0,0 +1,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= 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 diff --git a/tools/building_voxels.py b/tools/building_voxels.py index 6cb00b7..1ca5fd2 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -614,6 +614,31 @@ TEMPLATES = { roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, ), + # F04: the Pokemon Center's PC -- every Center's northeast corner + # (11 placements) plus the Indigo Plateau lobby, whose MART tileset + # shares this atlas. The lab desk-set read again: a + # Mac-style unit drawn face-on -- white top band (rows 0-3), bezel, + # screen and drive slot (4-14) -- standing at the back of a low + # desk whose front face is rows 20-23 and whose drawn top (the + # white sliver of row 15 and the margins beside the unit) names the + # lid shade. The keyboard rows 17-19 lie BELOW the desk's 16px top + # span, so the flat part carries an authored z origin: it lies at + # the desk's front edge, in front of the unit. + "center_pc": dict( + tiles=[ + [66, 70], + [82, 86], + [9, 88], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=2, + desk=dict(fascia=(20, 21), base=(22, 23), lid="white"), + parts=[ + dict(kind="upright", x=(2, 13), top=(0, 3), facade=(4, 14), + depth=6), # the unit + dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard + ], + ), # B23: the Victory Road entrance on Route 23: a rock face with two # barred doors. The roof band is the pale cliff top seen from above. "victory_road_gate": dict( @@ -815,6 +840,7 @@ def build_desk_set(sp, pr, t): for sx in range(W): for z in range(D): put(sx, y, z, sx, sy) + field = WHITE if t["desk"].get("lid") == "white" else GREY for sx in range(W): for z in range(D): if sx in (0, W - 1) or z in (0, D - 1): @@ -822,7 +848,7 @@ def build_desk_set(sp, pr, t): elif sx == 1 or z == 1: shade = WHITE else: - shade = GREY + shade = field px = shade_px.get(shade) or shade_px[BLACK] put(sx, plane - 1, z, px[0], px[1]) @@ -831,11 +857,15 @@ def build_desk_set(sp, pr, t): if p["kind"] == "flat": r0, r1 = p["rows"] for sy in range(r0, r1 + 1): - if not 0 <= sy < D: + # drawn row = depth row by default; `z` renames the + # origin when the flat sits below the desk's own drawn + # top span (the Center PC's keyboard) + z = p.get("z", r0) + (sy - r0) + if not 0 <= z < D: continue for sx in range(x0, x1 + 1): if inside(sx, sy): - put(sx, plane, sy, sx, sy) + put(sx, plane, z, sx, sy) continue tr0, tr1 = p["top"] fr0, fr1 = p["facade"] @@ -1048,8 +1078,9 @@ def verify_desk_set(vox, pr, t): x0, x1 = p["x"] if p["kind"] == "flat": r0, r1 = p["rows"] + z0 = p.get("z", r0) for x in range(x0, x1 + 1): - for z in range(max(r0, 0), min(r1, D - 1) + 1): + for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1): tops[(x, z)] = max(tops.get((x, z), 0), plane) else: fr0, fr1 = p["facade"]