diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ab6e1..f9ea089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,72 @@ ever lands half-way between two pixels and changing a wavelength moves the speed with it. +- **AA, a new options row: OFF / 2X / 4X.** Everything else in this game is + flat art blitted at whole pixels. This mode's world 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 where a hard stair-step is not a + stylistic choice -- a roof ridge, a ledge lip, a tree's silhouette against + the sky, the leaning card of a character. At the shallow rungs, where the + diorama reads most like a photograph of a model, they crawl as the camera + drifts. + + The row is SUPERSAMPLING: the whole pass renders into a canvas larger than + the window and is folded back down at the end. The ladder is samples per + display pixel, so 2X is a canvas root-two wider and taller and 4X one + exactly twice the size -- an honest 2x2 box. + + Two alternatives were tried against what this pass already is, and both + lost: + + - **MSAA** would have taken the water with it. The reflections read the + frame's own depth buffer as a texture, and a multisampled depth + attachment is not something a fragment shader in this dialect can sample. + The row would have quietly switched the WATER row off. + + - **An edge filter** (FXAA and its relatives) works from the finished + colour alone, so it 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 larger has neither problem, and nothing in the frame had to be + taught about it: every pass already measures itself in the canvas it was + handed, so the sky's dither, the water's ray march, the shadow lookups and + the camera itself come out the same picture at a higher sample rate. It + antialiases the geometry, the alpha-cut outline of a sprite card, the + wireframe and the reflections at once, because none of them know it is + happening. + + And it softens the ARTWORK with them, which is worth saying plainly. 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 it is a row and not something that is simply on. + + Two things are quoted in DISPLAY pixels rather than canvas ones and are + multiplied up to match: the voxel wireframe's line width -- left alone it + would fold down to half a line, so turning the smoothing up would appear to + fade the grid out -- and the scale the overworld's FX closures draw at. + + The fold is a shader rather than a scaled draw, because the void this pass + renders into is a transparent BLACK: averaging a straight-alpha edge against + it drags the colour toward black as well as toward transparent, and the + engine's composite then multiplies by that alpha a second time. Every + silhouette against the sky would have come out ringed with a dark fringe -- + the exact artefact the row exists to remove. So the taps are premultiplied + before they are averaged and divided back out after. + + The staged battle gets it too, on its own canvas: the arena is folded back + to the window's pixel size before the depth-of-field pass and the HUDs go + on, so the world is smoothed and the pics, panels and text box stay the + chunky GB art they are. + + OFF by default, and **FULL neither sets it nor takes the row away** -- it + is the one row that is not a knob on the look but on what the look COSTS, + and only the player knows what their machine can carry. No hotkey, for the + same reason: it is set once, not flicked while walking. + ### Changed diff --git a/README.md b/README.md index db18394..14e2cb8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ menu. | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | +| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | **3D-BTL** is on by default and is independent of **VOXEL**: battles draw diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 4079a53..7ee4004 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -579,6 +579,12 @@ return { -- auto-extracting into standing prisms, and the ball/Pokedex -- sprites ride the table's authored height. table = { 41, 42, 57, 59, 78, 79 }, + -- These tables are drawn 6px tall (3px slab edge over 3px base -- + -- see the lab_table entry under `buildings`), not the default + -- table's 12: the override keeps the volume-built north tables + -- and the band-built starter table one height, and stands the + -- ball/Pokedex sprites exactly on the top face of both. + heights = { table = 6 }, }, -- Red's room and the Copycat's room (one tileset). The detector reads @@ -3231,5 +3237,89 @@ return { slab = 4, frontEave = 4, ledge = nil, }, }, + + DOJO = { + -- F01: the starter-ball table in Oak's lab (one placement in the + -- game: OAKS_LAB cell 6,3) -- the first FURNITURE through the + -- band pipeline, and the first drawing whose plot is smaller than + -- its grid. Its 24 rows read: 0-15 the tabletop seen from above + -- (black rim, white highlight course, grey field); 16-18 the top + -- slab's own front edge, black/#555/black -- which is exactly + -- what the rim treatment paints, so slab = 3 and those rows fold + -- into the roof band instead of extruding under it; 19-21 the + -- base band, corner feet and the inset dark panel between them. + -- + -- The legs stand on open FLOOR: the measured ground line lands + -- two rows short of the grid (see Buildings measure), and `depth` + -- keeps the plot to the blocked cell row -- the grid's third row + -- is the walkable cell the player faces the table from, matched + -- so the flat leg art is claimed off the floor, not so the model + -- stands on it. 16 top rows onto a 16px plot map 1:1: roofBack + -- covers the whole depth, nothing cycles, and roofCycle is + -- unreachable behind it. The Poke Ball sprites ride the `table` + -- pin's height (VoxelScene.groundAt reads the collision tile, not + -- this model), so the tileset entry above overrides that height + -- to the 6px this drawing actually stands. + { + id = "lab_table", + tiles = { + { 41, 59, 59, 59, 59, 42 }, + { 78, 57, 57, 57, 57, 79 }, + { 88, 89, 89, 89, 89, 90 }, + }, + roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 }, + slab = 3, frontEave = 0, ledge = nil, depth = 2, + }, + -- F02: the computer bank on the lab's west table (OAKS_LAB cell + -- 0,1) -- the one FACADE-ONLY template: rows 0-14 are machines + -- drawn face-on (monitor and keys, the centre console, the right + -- cabinet) over the lab-table fascia and base bands, and nothing + -- is drawn from above. roofRows = 0, so the whole drawing + -- extrudes -- the house facade treatment with no roof band; the + -- roof fields are inert and the recess pass sinks every framed + -- pane (screens, keys, the cabinet's inset face) into relief on + -- its own. Same drawing, same grid, stands in the Hall of Fame + -- on the GYM atlas -- registered there below. + { + id = "lab_computers", + tiles = { + { 91, 92, 93, 94 }, + { 54, 55, 85, 95 }, + { 88, 89, 89, 90 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + }, + -- F03: the empty north table beside it (OAKS_LAB cell 2,1): the + -- starter table's band table verbatim on a grid two tiles + -- narrower. + { + id = "lab_table_small", + tiles = { + { 41, 59, 59, 42 }, + { 78, 57, 57, 79 }, + { 88, 89, 89, 90 }, + }, + roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 }, + slab = 3, frontEave = 0, ledge = nil, depth = 2, + }, + }, + + GYM = { + -- F02 again: the Hall of Fame's recording machine is the lab's + -- computer bank drawing, tile for tile, on the GYM atlas (one + -- placement: HALL_OF_FAME cell 4,1). Same band table as the DOJO + -- entry above. + { + id = "lab_computers", + tiles = { + { 91, 92, 93, 94 }, + { 54, 55, 85, 95 }, + { 88, 89, 89, 90 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + }, + }, }, } diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 170caac..3938f89 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -42,6 +42,7 @@ local BattleCam = V.require("BattleCam") local BattleBillboard = V.require("BattleBillboard") local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") +local AntiAlias = V.require("AntiAlias") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -427,7 +428,16 @@ function BattleScene.render(state, arena, textures, token) -- its own canvas slot: this renders at the window's pixel size and the -- free-roam pass does too, but the two are alive at different moments -- and a shared slot would reallocate on every battle entry and exit - if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then + -- + -- AA, if the row asks for it, renders it larger still and folds it back + -- to pw x ph below (see AntiAlias). The framing is untouched by that: + -- the lens was widened by the window's RATIO to the letterbox and the + -- rig solved in the GB's own frame, so a bigger canvas is more samples + -- of the identical shot -- which is why the pins below still measure in + -- pw and ph, and why the HUDs and the depth of field, drawn onto the + -- folded canvas afterwards, stay the chunky GB art they are. + local rw, rh = AntiAlias.expand(pw, ph) + if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then return end Voxel3D.draw(terrain, atlasFor(host), nil) @@ -500,7 +510,7 @@ function BattleScene.render(state, arena, textures, token) Mat4.translate(nb.ox, 0, nb.oy), fpull, ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end - local canvas = Voxel3D.endScene() + local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle") if not canvas then return end local vp = Voxel3D.vp diff --git a/lib/Buildings.lua b/lib/Buildings.lua index 7bfc6e0..644cb09 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -190,7 +190,24 @@ local function measure(sp, t) top[x] = r end - local wallH = H - roofRows + -- The drawing's own ground line: the row after the last drawn one. A + -- building ends on the black threshold row it stands on (ground == H), + -- but furniture is drawn standing on open floor -- the lab table's + -- legs stop two rows short of its grid -- and extruding against H + -- would float it that far above its own plot. + local ground = roofRows + for sy = H - 1, roofRows, -1 do + local drawn = false + for sx = 0, W - 1 do + if sp.inside[sy * W + sx] then drawn = true break end + end + if drawn then + ground = sy + 1 + break + end + end + + local wallH = ground - roofRows local ytop = wallH - 1 + t.slab -- Side faces must not come out as slabs of outline black: where the @@ -279,7 +296,12 @@ local function measure(sp, t) -- sprite taller than its footprint -- the tower's 16-row drawing -- stands on the 8 rows of it that are actually on the map, and D = H -- would have pushed its body 64px south into the town plaza. - return { top = top, ytop = ytop, D = #t.tiles * 8, + -- `depth` (in tile rows) names the plot when the grid runs PAST it + -- onto ground the drawing merely stands its legs on: the lab table's + -- third row is the walkable cell the player faces it from, and the + -- full-grid depth would stand the model in their path. + return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8, + ground = ground, recess = recess, interior = interior, shadeTexel = shadeTexel } end @@ -292,7 +314,7 @@ end local function model(sp, pr, t) local W, H, D = sp.W, sp.H, pr.D local slab, roofRows = t.slab, t.roofRows - local top, ytop = pr.top, pr.ytop + local top, ytop, ground = pr.top, pr.ytop, pr.ground -- The roof's drawn span. A sprite inset from its box (B03) leaves outer -- columns undrawn in the roof band; they carry no roof at all, and the @@ -367,16 +389,18 @@ local function model(sp, pr, t) -- the awning: the band juts two voxels past the walls, front and back if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then - local sy = H - 1 - y + local sy = ground - 1 - y if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then return sy * W + x end return nil end - -- the facade, extruded straight back over the footprint + -- the facade, extruded straight back over the footprint. Rows map + -- against the measured ground line, not the grid's last row: the two + -- differ only for furniture standing on open floor (see measure). if z < 0 or z >= D then return nil end - local sy = H - 1 - y + local sy = ground - 1 - y local i = sy * W + x if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then -- the drawing's last row is the ground the building stands on, so diff --git a/lib/TileShape.lua b/lib/TileShape.lua index 6cb65ce..f36f87d 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -295,6 +295,24 @@ function TileShape.forMap(map) if cache[id] then return cache[id] end local heights = TileShape.heights() + -- Per-tileset height overrides (a tileset entry's `heights`): the class + -- vocabulary is global but the drawings are not -- the DOJO lab tables + -- are drawn 6px tall where the default `table` is 12 -- and the height + -- a sprite RIDES at (VoxelScene.groundAt) must be the height the art + -- actually stands, or the starter balls float over their own table. + -- Same gate as the global list: known classes, numbers only. + do + local s = load() + local entry = s and s.tilesets and s.tilesets[id] + local over = entry and entry.heights + if type(over) == "table" then + for class, h in pairs(over) do + if type(h) == "number" and FALLBACK_HEIGHTS[class] then + heights[class] = h + end + end + end + end local authored = authoredGroups(id, heights) local count = math.floor((tileset.imageWidth or 128) / 8) * math.floor((tileset.imageHeight or 48) / 8) diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 471a688..54ef847 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -715,7 +715,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "sunTexel", { texel, texel }) if grid then pcall(sh.send, sh, "gridDark", VoxelGrid.DARK) - pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH) + pcall(sh.send, sh, "gridWidth", VoxelGrid.width()) end -- ordinary shading until the silhouette pass asks for otherwise. Sent -- every frame rather than once, because a scene that opened mid-ghost -- diff --git a/lib/VoxelGrid.lua b/lib/VoxelGrid.lua index 1fb7ce7..b585072 100644 --- a/lib/VoxelGrid.lua +++ b/lib/VoxelGrid.lua @@ -44,6 +44,19 @@ VoxelGrid.DARK = 0.45 -- 1.0 here is the one-pixel wireframe. VoxelGrid.WIDTH = 1.0 +-- The same width in the CANVAS pixels the shader measures in, which is what +-- every sender of it actually wants. +-- +-- The two are the same number until AA renders the pass larger than the +-- window (see AntiAlias): there a canvas pixel is a fraction of a display +-- one, and a width left at 1.0 would come out a half or a quarter of a line +-- after the fold -- the wireframe fading as the smoothing goes up, which +-- reads as one row breaking the other. Scaled, it stays a one-pixel seam and +-- simply gains the antialiasing everything else in the frame just gained. +function VoxelGrid.width() + return VoxelGrid.WIDTH * V.require("AntiAlias").factor() +end + -- where it persists and the rows that cycle it (see ModSetting) VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL, { false, true }, { "OFF", "ON" }) diff --git a/lib/Water.lua b/lib/Water.lua index 9b7956c..db1db8e 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -1192,7 +1192,7 @@ function Water.begin(ctx) if ctx.grid then local VoxelGrid = V.require("VoxelGrid") send("gridDark", VoxelGrid.DARK) - send("gridWidth", VoxelGrid.WIDTH) + send("gridWidth", VoxelGrid.width()) end Water.sendSky(sh, ctx) diff --git a/main.lua b/main.lua index 95ca933..6ca5f8a 100644 --- a/main.lua +++ b/main.lua @@ -82,6 +82,7 @@ local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") +local AntiAlias = V.require("AntiAlias") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -203,20 +204,34 @@ mod.content.render_pipelines:register("voxel", { -- a magnified low-res image, while the FX closures keep drawing in -- world-pixel units. local sw, sh = sceneSize(ctx) - local canvas = VoxelScene.render(ctx.state, sw, sh, + -- With AA on, the whole pass runs into a canvas BIGGER than the window + -- and is folded back down at the end (see AntiAlias). Nothing between + -- these two lines knows: every pass in the frame measures itself in the + -- canvas it was handed, so the sky's dither, the water's march and the + -- camera itself all come out the same picture at a higher sample rate. + local rw, rh = AntiAlias.expand(sw, sh) + local canvas = VoxelScene.render(ctx.state, rw, rh, ctx.vw, ctx.vh, ctx.paletteFor) if not canvas then return nil end -- fall back to the 2D path if Voxel3D.beginOverlay() then + -- the FX closures are ordinary 2D draws sized in DISPLAY pixels, and + -- they are drawing into the supersampled canvas alongside everything + -- else -- so the scale goes up with it, or the "!" bubble lands the + -- right place at half the size. project() already answers in canvas + -- pixels, so only the scale needs saying. ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end, - ctx.scale) + ctx.scale * AntiAlias.factor()) Voxel3D.endOverlay() end - return canvas + -- and back to the window's own size, which is what the engine composites + -- one canvas pixel to one display pixel. A pass-through when AA is off. + return AntiAlias.resolve(canvas, sw, sh, "world") end, invalidate = function() Voxel3D.invalidate() OverworldBattle.invalidate() + AntiAlias.invalidate() ChunkMesher.invalidate() -- no map id = every cached mesh end, }) @@ -361,6 +376,21 @@ local SETTINGS = { .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " .. "shadows, the sky and the light following -- or SYNC it to the " .. "clock on the wall, so Kanto's evening falls when yours does." }, + -- Marked `full` for the opposite reason the battle rows are: this is not a + -- knob on the look at all, it is what the look COSTS. FULL is a preset for + -- the diorama, not a licence to spend four times the fill rate on the + -- machine it happens to be running on, so it neither sets this nor takes + -- the row away -- the player decides what their hardware can carry, from + -- inside FULL like anywhere else. + { AntiAlias.setting, + "Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge " + .. "lips, a tree against the sky -- by rendering the diorama larger than " + .. "the window and folding it back down. Every edge in the picture " + .. "softens with them, the tileset's own texels included, so the diorama " + .. "reads smoother rather than sharper. 2X costs half again as many " + .. "pixels in each direction and 4X twice, which makes this the most " + .. "expensive row in the mod.", + full = true }, } local schema = {} diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 70b37fc..0ca9283 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -145,6 +145,9 @@ T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME") -- back sprite (or no staged fights at all) can still say so from inside FULL T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL") T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it") +-- and AA, for the opposite reason: it is not a knob on the look at all, it is +-- what the look COSTS, and only the player knows what their machine can carry +T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns") -- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot -- be reached while FULL owns it, so a value changed underneath (the mod @@ -379,9 +382,11 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 7, "the options hook added a row per setting") +T.eq(#hookedRows, 8, "the options hook added a row per setting") local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] +-- the AA row is hookedRows[8]; it is read in its own block below, because +-- this chunk is one main function and has 200 local slots to spend T.eq(water.label, "WATER", "the water row carries its label") T.eq(water.value(), "FULL", "and defaults to FULL -- reflections are the point of having the row") @@ -459,6 +464,57 @@ curve.step(settingGame, 1) curve.step(settingGame, 1) T.eq(curve.value(), "OFF", "the curve is left off for the rows below") +-- ------- AA renders the pass larger and folds it back down +-- +-- The ladder is SAMPLES per display pixel, so the canvas scale each rung asks +-- for is its square root -- and the scale in force has to be readable after +-- the fact, because two things are quoted in DISPLAY pixels and have to be +-- multiplied up into the canvas the pass actually opened: the wireframe's +-- line width and the FX overlay's sprite scale. +do +local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") +local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") +local aaGame = { save = { options = {} }, mods = { modOptions = {} } } +local aa = hookedRows[8] +T.eq(aa.label, "AA", "the anti-aliasing row carries its label") +T.eq(aa.value(), "OFF", + "and starts off -- supersampling is a cost knob, and a mod must not spend " + .. "four times the fill rate of the machine it lands on unasked") + +T.eq(AntiAlias.samples(), 0, "AA is off by default") +local w, h = AntiAlias.expand(320, 200) +T.eq(w, 320, "an OFF row renders at the window's own width") +T.eq(h, 200, "and its height") +T.eq(AntiAlias.factor(), 1, "with nothing to multiply display pixels by") +T.eq(VoxelGrid.width(), VoxelGrid.WIDTH, + "so the wireframe is the one-display-pixel line it has always been") + +aa.step(aaGame, 1) +T.eq(aa.value(), "2X", "stepping the row climbs to two samples a pixel") +T.eq(aaGame.save.options.modOptions.DRAMATIC_SHAPE.aa, 2, + "the sample count persists beside the other settings, not over them") +w, h = AntiAlias.expand(320, 200) +T.eq(w, 453, "two samples a pixel is a canvas root-two wider") +T.eq(h, 283, "and root-two taller") +T.check(math.abs(AntiAlias.factor() - 453 / 320) < 1e-9, + "and the factor is what it MEASURED, not what the row asked for") + +aa.step(aaGame, 1) +T.eq(aa.value(), "4X", "and again to four") +w, h = AntiAlias.expand(320, 200) +T.eq(w, 640, "four samples a pixel is a canvas exactly twice the size") +T.eq(h, 400, "in each direction, which is the 2x2 box the fold reads") +T.eq(AntiAlias.factor(), 2, "with everything in display pixels doubled") +T.eq(VoxelGrid.width(), VoxelGrid.WIDTH * 2, + "the wireframe among them -- a seam left at 1.0 would fold down to half a " + .. "line, so the smoothing row would appear to fade the grid row out") + +aa.step(aaGame, 1) +T.eq(aa.value(), "OFF", "and the ladder wraps back to off") +AntiAlias.expand(320, 200) +T.eq(AntiAlias.factor(), 1, "leaving nothing behind for the next pass") +end + -- ------- the animated terrain atlas survives an engine without its seams -- -- Regression: cycling palette modes with voxel mode on eventually killed diff --git a/tools/building_voxels.py b/tools/building_voxels.py index 0fb1b12..a38f8c5 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -547,6 +547,57 @@ TEMPLATES = { roof_rows=17, roof_back=5, roof_front=3, roof_cycle=(5, 9), slab=4, front_eave=4, ledge=None, tileset="forest", ), + # F01: the starter-ball table in Oak's lab -- the first FURNITURE + # through the pipeline, and the first drawing whose plot is smaller + # than its grid. Rows 0-15 are the tabletop seen from above; 16-18 + # are the top slab's own front edge (black/#555/black -- exactly + # what the rim treatment paints, so slab=3 and those rows fold into + # the roof band instead of extruding); 19-21 are the base: corner + # feet and the inset dark panel between them. The legs stand on + # open FLOOR, so the measured ground line lands two rows short of + # the grid; `depth` keeps the plot to the blocked cell row -- the + # legs row of the grid is the walkable cell the player faces the + # table from, and D = len(tiles) would stand the model in their + # path. 16 top rows onto a 16px plot map 1:1: no cycling, and + # roof_cycle is unreachable behind roof_back=16. + "lab_table": dict( + tiles=[ + [41, 59, 59, 59, 59, 42], + [78, 57, 57, 57, 57, 79], + [88, 89, 89, 89, 89, 90], + ], + roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), + slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, + ), + # F02: the computer bank on Oak's lab's west table -- and the Hall + # of Fame's recording machine, the same drawing on the GYM atlas + # (one placement each; both registered in voxel_heights). The one + # FACADE-ONLY template: rows 0-14 are machines drawn face-on over + # the lab-table fascia and base bands, nothing is drawn from above, + # so roof_rows=0 and the whole drawing extrudes -- the house facade + # treatment with no roof band. The recess pass turns every framed + # pane (the monitor, its keys, the right cabinet's inset face) into + # relief on its own. + "lab_computers": dict( + tiles=[ + [91, 92, 93, 94], + [54, 55, 85, 95], + [88, 89, 89, 90], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="gym", depth=2, + ), + # F03: the empty north table beside it -- the starter table's band + # table verbatim on a grid two tiles narrower (one placement). + "lab_table_small": dict( + tiles=[ + [41, 59, 59, 42], + [78, 57, 57, 79], + [88, 89, 89, 90], + ], + roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), + slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, + ), # 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( @@ -642,7 +693,14 @@ def profile(sp, t): r = next((y for y in range(H) if inside(x, y)), t["roof_rows"]) top.append(min(r, t["roof_rows"])) - wall_h = H - t["roof_rows"] + # The drawing's own ground line: the row after the last drawn one. A + # building ends on the black threshold row it stands on (ground == H), + # but furniture is drawn standing on open floor -- the lab table's + # legs stop two rows short of its grid -- and extruding against H + # would float it that far above its own plot. + ground = max((y for y in range(H) for x in range(W) if inside(x, y)), + default=H - 1) + 1 + wall_h = ground - t["roof_rows"] ytop = wall_h - 1 + t["slab"] # Recesses: the panes the art seals behind a black frame. Non-black @@ -674,8 +732,12 @@ def profile(sp, t): if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX: recess.update(cells) + # Depth is the PLOT. For a whole-drawing building that is the grid + # itself; `depth` (in tile rows) names it when the grid runs past the + # plot onto ground the drawing merely stands its legs on. return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess, - inside=inside, D=H, W=W, H=H) + inside=inside, D=t.get("depth", len(t["tiles"])) * 8, + ground=ground, W=W, H=H) # --------------------------------------------------------------- stage 3 -- @@ -720,7 +782,9 @@ def build(sp, pr, t): # ---- walls: the facade rows extruded straight back, trimmed under the roof for sy in range(t["roof_rows"], H): - y = H - 1 - sy + y = pr["ground"] - 1 - sy # rows below the ground line are floor + if y < 0: + continue for sx in range(W): if not inside(sx, sy) or trimmed(sx, y): continue @@ -742,7 +806,7 @@ def build(sp, pr, t): if t["ledge"]: l0, l1 = t["ledge"] for sy in range(l0, l1 + 1): - y = H - 1 - sy + y = pr["ground"] - 1 - sy for sx in range(W): if inside(sx, sy) and not trimmed(sx, y): for z in (-2, -1, D, D + 1): @@ -750,7 +814,7 @@ def build(sp, pr, t): # ---- recesses: the front voxel of every pane sinks, its frame stays proud for sx, sy in pr["recess"]: - vox.pop((sx, H - 1 - sy, D - 1), None) + vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None) # ---- roof: flat top over the plateau, stepped diagonal ends z0, z1 = 0, D - 1 + t["front_eave"] @@ -805,6 +869,41 @@ def verify(vox, sp, pr, t): for (x, y, z), _ in vox.items(): assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}" + # A facade-only template (roof_rows == 0: the lab computer bank) has + # no roof band, so there is no surface to hold level or slope and no + # coverage to demand of it; the poke-through assert above and the + # facade asserts below are what it answers to. Everything with a roof + # band answers the full set. + if t["roof_rows"] == 0: + # its one geometric intent: a straight extrusion, so each column + # of the drawing is solid from its lowest voxel to its top. NOT + # from the ground -- the drawing's base band is inset like every + # lab table's, and the body's outer columns legitimately + # overhang it -- and not on the recess layer, which sinks panes. + lo, hi = {}, {} + for (x, y, z) in vox: + lo[(x, z)] = min(lo.get((x, z), y), y) + hi[(x, z)] = max(hi.get((x, z), y), y) + for (x, z), h in hi.items(): + if z == D - 1: + continue + assert all((x, y, z) in vox for y in range(lo[(x, z)], h + 1)), \ + f"facade column has a hole at {x},{z}" + else: + verify_roof(vox, pr, t) + + shell = [k for k in vox if not all( + (k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox + for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), + (0, -1, 0), (0, 0, 1), (0, 0, -1)))] + return shell + + +def verify_roof(vox, pr, t): + W, H, D = pr["W"], pr["H"], pr["D"] + ytop, top, slab = pr["ytop"], pr["top"], t["slab"] + T = lambda x: ytop - top[max(0, min(W - 1, x))] + # The roof surface reads over the columns the drawing actually paints: # a sprite inset from its box says nothing about the rest. roofed = [x for x in range(W) if top[x] < t["roof_rows"]] @@ -845,12 +944,6 @@ def verify(vox, sp, pr, t): elif any((x, y, z) in vox for y in range(0, T(x) - slab + 1)): assert (x, T(x), z) in vox, f"wall uncovered at {x},{z}" - shell = [k for k in vox if not all( - (k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox - for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), - (0, -1, 0), (0, 0, 1), (0, 0, -1)))] - return shell - def preview(shell, vox, sp, name, out, flip, canvas=(1700, 900), pad=20): # Mirroring depth swings the camera round to the other side. Mirror