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=