updates to oak's lab
DramaticShape · 2026-08-01 ·
parent a140980b1d
updates to oak's lab
Changed files
CHANGELOG.md | 66 ++++++++++++++++++++++++++++++++++++
README.md | 1 +
data/voxel_heights.lua | 90 ++++++++++++++++++++++++++++++++++++++++++++++++
lib/BattleScene.lua | 14 ++++++--
lib/Buildings.lua | 36 ++++++++++++++++----
lib/TileShape.lua | 18 ++++++++++
lib/Voxel3D.lua | 2 +-
lib/VoxelGrid.lua | 13 +++++++
lib/Water.lua | 2 +-
main.lua | 36 ++++++++++++++++++--
tests/dramatic_shape_test.lua | 58 ++++++++++++++++++++++++++++++-
tools/building_voxels.py | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
12 files changed, 426 insertions(+), 25 deletions(-)
Diff
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