diff --git a/CHANGELOG.md b/CHANGELOG.md index 1654426..349753a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,31 @@ (`save.modData.DRAMATIC_SHAPE`), and read back when a save is opened. A save with no clock in it starts at day. +- **Window glass.** The panes in the overworld art -- the framed squares on + building fronts, the small lights in doors -- are found by SHAPE in the + tileset image (a black border row, four or five black-flanked glass rows, + a closing border), at pixel granularity because the door's pane straddles + a 2x2 tile block. No tile ids are hardcoded: a conversion that draws its + own windows in the same idiom gets glass for free. The scan yields a mask + texture aligned to the tileset atlas, which the scene shader samples with + the same coordinates the terrain does -- so the effect lands on any wall, + at any angle, in free-roam and staged battles alike, with no geometry + work. + + By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES: the + sweep's phase is fed by the camera's own travel and its strength fades out + within a beat of standing still -- a reflection is something the viewpoint + does, so still camera means still glass. It lifts the texels toward + sky-white and leaves the diagonal shine art visible through it. The mask + is consulted only by meshes textured from the tileset atlas + (Voxel3D.glass), never by sprite sheets, whose coordinates would land on + the panes' atlas positions by accident. + After dark the panes are LIT: the texel's own pattern carried into a warm + lamp colour, replacing the shaded answer entirely -- a lit window ignores + the sun, every shadow and the hour's tint, exactly as a window with a lamp + behind it does. The lamps follow the clock (DayNight.windowLight): on + through dusk, full all night, mostly out by dawn, and never lit indoors. + - **A fade out of a battle, where there used to be a hard cut.** The engine wipes INTO a fight with one of the original's eight transitions and cuts straight out of it: `BattleState:finish` pops itself and the map is simply @@ -166,15 +191,32 @@ against its own texels, and that same forgiveness lit the first `slack` of every cast shadow -- so the shadow started a bias-width away from the feet, further the lower the sun reached (the classic peter-panning, invisible at - the old fixed 45 degrees and plain at a day/night golden hour). Sprite - cards -- characters, authored figures, flowers, battle mons -- are now - drawn into the shadow map sunk exactly `slack` down the sun ray - (`ShadowMap.sink`), which cancels the bias for the shadow they throw and - nothing else: no terrain moved, so the acne margin is untouched, and the - shadow root lands back under the feet at every hour. + the old fixed 45 degrees and plain at a day/night golden hour or under the + moon). Sprite cards -- characters, authored figures, flowers, battle mons + -- are now drawn into the shadow map snugged TOWARD the sun along their + own ray (`ShadowMap.snug`): moving along the ray changes nothing about + where a shadow falls, but storing the card shallower takes three quarters + of the forgiveness back for the shadow it throws -- and for nothing else: + no terrain moved, so the acne margin is untouched where it matters, and + the quarter left keeps the card off the float-equality knife edge against + its own stored depth. The shadow root lands back under the feet at every + hour. ### Changed +- **Under `VOID FILL: TREES` the border wall is modelled trees or nothing.** + Only the first block past the map body gets carved into round trunks and + canopies; the two blocks past that were too far out to be worth the quads, + so they fell through to the mesher's plain box and came out as a flat-topped + slab of tree ART sitting beside the modelled forest -- a painted-on plateau, + and the more obvious the lower the camera got. Rather than pay to carve + hulls nobody walks near, the wall now simply STOPS where the carving does: + `Structures` does not build the ring past that distance (the same "nothing + out there" `BLACK` already produces), and the mesher drops any cell inside + it the 2x2 canopy grouping could not claim, so no strip of boxes survives at + a corner. `WATER` and every indoor border are untouched -- a flat sheet of + water is what water looks like from above anyway. + - **The two HP boxes snap to the window's edges during a staged battle.** The battle screen is 160x144 in the middle of the window and the world is the whole of it, which left both HUD blocks huddled together in the middle of the diff --git a/README.md b/README.md index e81bc3e..b5c23f9 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,19 @@ through a soft shadowless gap; moonlight presses at about two-thirds the sun's weight. The scene shader multiplies every surface by the hour's tint — neutral at noon, warm at the twilights, dim blue under the moon. +The **windows are glass**. The panes in the overworld art — the framed +squares on building fronts and the small lights in doors — are found by +*shape* in the tileset image itself (a black border row, black-flanked glass +rows, a closing border; no tile ids anywhere), and marked on a mask texture +the scene shader samples alongside the atlas. By day a thin glint crosses +them **while you move** — the sweep is fed by the camera's own travel and +dies within a beat of standing still, because a reflection is something the +viewpoint does — with the art still visible through it. After dark they are +**lit from inside**: +the pane's own shine pattern carried into a warm lamp colour, exempt from +the sun, the shadow map and the hour's tint. The lamps come on through dusk, +burn all night, and are mostly out again by dawn. + **Outdoors only.** Indoors keeps the noon rig, the neutral tint and no sky: a cave at midnight is exactly as dark as a cave at noon. A battle staged on an outdoor map fights under the hour — night void behind the arena, tinted diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index ee85fde..ce9b46b 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -1,4 +1,4 @@ --- Overworld battles: one frame of the arena, as geometry. +-- Overworld battles: one frame of the arena, as geometry. -- -- The same world the free-roam mode draws, from a placed camera instead of -- the orbit, at the WINDOW's own pixel resolution -- not the GB's. The @@ -237,13 +237,13 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, for i, nb in ipairs(neighbors) do ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end - -- thin cards are sunk along the ray (ShadowMap.sink) so their shadows + -- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows -- keep contact with their bases instead of starting a bias-width away ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), - ShadowMap.sink(nil)) + ShadowMap.snug(nil)) for _, nb in ipairs(neighbors) do ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - ShadowMap.sink(Mat4.translate(nb.ox, 0, nb.oy))) + ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end -- the mons themselves, as the same cards the camera will see. Their alpha @@ -251,7 +251,7 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, -- Pokemon rather than a blob standing in for one. for _, card in ipairs(cards or {}) do ShadowMap.draw(BattleBillboard.mesh(), card.tex, - ShadowMap.sink(card.model)) + ShadowMap.snug(card.model)) end ShadowMap.finish(sig) @@ -310,10 +310,17 @@ function BattleScene.render(state, arena, textures, token) -- the hour's light reaches the arena exactly as it reaches free-roam: the -- shared rig follows the clock on an outdoor floor and stays at noon on an - -- indoor one, and the same tint multiplies the staged shot + -- indoor one, and the same tint multiplies the staged shot -- with the + -- same window glass on whatever buildings stand in the background local outdoor = host.def and Map.isOutdoor(host.def) or false DayNight.applyRig(outdoor) Voxel3D.tint = DayNight.tint(outdoor) + local GlassMask = V.require("GlassMask") + Voxel3D.glassMask = outdoor and GlassMask.texture(host.tileset) or nil + Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0 + -- no glint in the arena: the drift is the shot breathing, not the player + -- moving, and a shimmer on background windows would fight the mons + Voxel3D.glassGlint = 0 -- shares the free-roam mode's request/evict bookkeeping, so a battle warms -- exactly the meshes walking around would have and nothing extra @@ -403,10 +410,14 @@ function BattleScene.render(state, arena, textures, token) -- mon's card is one quad wearing the battle screen (see -- BattleBillboard), so it is off the grid and has no seams to draw. Voxel3D.seams(false) + -- and no glass either: the cards wear the battle screen, not the + -- tileset atlas, so the mask's coordinates mean nothing on them + Voxel3D.glass(false) for _, card in ipairs(monCards(arena, groundY, textures)) do Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, BattleBillboard.PULL) end + Voxel3D.glass(true) Voxel3D.seams(true) if flashing then Voxel3D.flatten(nil) end -- grass and flowers ride the same camera-ward pull the free-roam pass diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 394fca5..d8b2310 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -427,6 +427,18 @@ local function runGeometry(map, bodyOnly, masks, sink) s = nil end + -- Under the TREES fill the border wall is MODELLED or it is not there + -- (see Structures' hullRingOnly): a ring cell nothing claimed would + -- be a flat-topped box standing beside carved trunks, which reads as + -- a painted-on plateau rather than forest. Structures already stops + -- the ring at the carve distance; this catches the odd cell inside it + -- that the 2x2 grouping could not take -- a canopy whose partners + -- fall outside the shortened ring is left unclaimed, and one strip of + -- boxes along an edge is the whole artefact this avoids. + if not inBody and S.hideBareRing and not S.skip[k] then + s = nil + end + if s and S.skip[k] then -- an object stands here; paint its synthesized ground and let the -- prebuilt prism quads (appended below) carry the art diff --git a/lib/DayNight.lua b/lib/DayNight.lua index b327eaa..086f3ff 100644 --- a/lib/DayNight.lua +++ b/lib/DayNight.lua @@ -393,6 +393,32 @@ function DayNight.body(t) } end +-- Maps under a CANOPY: not outdoor -- there is no sky to paint and no sun +-- or moon to see, so the shadow rig stays the mod's fixed noon light, +-- which is all that ever filtered through the leaves -- but not a sealed +-- room either: night still FALLS in them. Of everything the clock does, +-- exactly one thing reaches a canopy map: the hour's tint. +DayNight.CANOPY = { VIRIDIAN_FOREST = true } + +function DayNight.isCanopy(map) + return (map and map.id and DayNight.CANOPY[map.id]) and true or false +end + +-- How lit the WINDOWS are, 0..1 -- the lamps behind the glass, not the sky. +-- They come on through dusk (a lit window against a sunset is half the point +-- of having either), burn all night, and are mostly out again by dawn: +-- people wake before it is bright, they do not read at sunrise. +local LAMPS = { night = 1, violet = 1, dusk = 0.7, dawn = 0.25 } + +function DayNight.windowLight(t) + local mix = DayNight.mix(t or DayNight.time()) + local lit = 0 + for name, w in pairs(mix) do + lit = lit + (LAMPS[name] or 0) * w + end + return lit +end + -- The period name for the engine's world.tod hook (map.palette ctx.tod, -- music.select): the dominant phase, in the vocabulary day/night mods use. local TOD = { day = "DAY", golden = "DAY", night = "NIGHT", diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index d7fea7c..4606d5a 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -362,30 +362,39 @@ local function fit(cx, cy, vw, vh) ShadowMap.bias = ShadowMap.slack / math.max(1, far - near) end --- A CASTER sunk slightly down the sun ray before it is drawn into the map. +-- How much of the compare's forgiveness a snugged caster takes back, 0..1. +-- Short of 1 on purpose: at exactly 1 the card's own fragments compare +-- against their own stored depth on a float-equality knife edge and can +-- speckle. The tenth left over is dozens of times the packed depth's +-- quantization -- ample for that -- and leaves the contact gap around a +-- quarter of a world pixel at any sun, which no zoom resolves. +ShadowMap.SNUG = 0.9 + +-- A CASTER snugged up the sun ray -- moved TOWARD the light -- before it is +-- drawn into the map. -- -- The depth compare forgives `slack` world pixels (BIAS + the SLOPE term) -- so lit surfaces do not acne against their own texels -- but that same -- forgiveness is what lets the ground right next to a standing figure read --- as lit: the first `slack` of its shadow falls inside the compare's --- tolerance, so on screen the shadow starts that far from the feet, and the --- gap grows as the sun drops (the same bias reaches further horizontally --- the more oblique the ray). Peter-panning, in the literature; it went --- unseen while the sun hung at a fixed 45 degrees and shows plainly at a --- day/night golden hour. +-- as lit: a receiver within `slack` of its blocker along the ray passes the +-- test, so the first stretch of every shadow is forgiven away and on screen +-- it starts that far from the feet, further the lower the sun. The classic +-- peter-panning; unseen while the sun hung at a fixed 45 degrees, plain at +-- a day/night golden hour or under the moon. -- --- Storing the card that much DEEPER along the ray cancels the bias for the --- shadow it throws -- the root lands back under the feet -- while touching --- nothing else: the card's own lookup now reads a deeper stored value and --- is more confidently lit, no terrain moved, and the acne margin the slack --- exists for is intact. For sprite cards and other thin stand-ins only; --- sinking the TERRAIN would cancel the bias everywhere, which is the acne. +-- Moving the card ALONG ITS OWN RAY changes nothing about where its shadow +-- falls -- every point stays on the same light ray -- but moving it toward +-- the sun stores it SHALLOWER, so a ground point right at the foot is +-- already `slack` deeper than the stored blocker and fails the lit test: +-- the root lands back under the feet. Nothing else is touched -- no +-- terrain moved, so the acne margin the slack exists for is intact where +-- it matters. For sprite cards and other thin stand-ins only. -- -- Valid between begin() and finish(), which is when `slack` matches the -- frustum the map is being drawn with. -function ShadowMap.sink(model) +function ShadowMap.snug(model) local f = sunDir() - local s = ShadowMap.slack + local s = -ShadowMap.slack * ShadowMap.SNUG return Mat4.mul(Mat4.translate(f[1] * s, f[2] * s, f[3] * s), model or IDENTITY) end diff --git a/lib/Structures.lua b/lib/Structures.lua index bcf93e4..0b5262a 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -55,6 +55,14 @@ local Structures = {} -- must match ChunkMesher's ring (3 border blocks, in tiles) local RING = 12 +-- how far past the map body cells still get the hull. A route's ring is +-- nearly as big as its body; modelling all of it costs hundreds of +-- thousands of quads of border trees nobody walks near. Beyond this, +-- pinned cells simply are not claimed and fall through to the mesher's +-- plain box -- cheap distant scenery. (Declared up here rather than +-- beside buildCylinders because forMap's grid resolve reads it too.) +local ROUND_RING = 4 + -- object-mode gates local OBJECT_MAX_ROWS = 6 -- a prop is at most 48px of drawing local OBJECT_MAX_QUADS = 4096 -- safety cap per cluster @@ -154,12 +162,34 @@ function Structures.forMap(map) local TileRenderer = require("src.render.TileRenderer") local borderId = TileRenderer.borderBlockFor(map) local borderBlk = borderId and tileset.blocks[borderId + 1] or nil + -- TREES fill stops at ROUND_RING instead of running the full RING. + -- Only that far out does a tree cell get carved into a hull; past it + -- the cells fall through to the mesher's plain box, and a slab of + -- flat-topped boxes beside the modelled wall reads as a painted-on + -- plateau -- the wall looking like it was cut off with scissors. So + -- the far ring is simply not built: beyond ROUND_RING tileLookup + -- answers nil, which is the same "nothing out there" BLACK already + -- produces and every pass below already copes with. The cut lands on + -- the carve boundary exactly -- the 2x2-cell canopy scan starts at + -- floor(-RING/2) and RING, ROUND_RING and the body are all multiples + -- of 4 tiles, so no group is left half-resolved at the edge. + -- + -- WATER and the other tilesets' own borders keep the full ring: a flat + -- sheet of water is what water looks like from above anyway, and an + -- interior's border is black already. + local hullRingOnly = borderBlk and def.tileset == "OVERWORLD" + and (TileRenderer.voidFill or "trees") == "trees" local tw2, th2 = tw, th local function tileLookup(tx, ty) if tx >= 0 and ty >= 0 and tx < tw2 and ty < th2 then return map:tileAt(tx, ty) end if not borderBlk then return nil end + if hullRingOnly and (tx < -ROUND_RING or ty < -ROUND_RING + or tx >= tw2 + ROUND_RING + or ty >= th2 + ROUND_RING) then + return nil + end return borderBlk[(ty % 4) * 4 + (tx % 4) + 1] or 0 end local shapeAt, tileAt = {}, {} @@ -192,6 +222,7 @@ function Structures.forMap(map) -- still overdraws a walker's feet even though characters stamp over -- terrain.) S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def), + hideBareRing = hullRingOnly or nil, runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {}, grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} } Buildings.build(S, map, pixels(tileset), perRow) @@ -888,13 +919,6 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) return quads, bg end --- how far past the map body cells still get the hull. A route's ring is --- nearly as big as its body; modelling all of it costs hundreds of --- thousands of quads of border trees nobody walks near. Beyond this, --- pinned cells simply are not claimed and fall through to the mesher's --- plain box -- cheap distant scenery. -local ROUND_RING = 4 - -- Hull templates dedupe GLOBALLY per (tileset, four tiles, ground set): -- the same four-tile tree repeats for hundreds of cells on a map and -- across every route of its tileset, so the carve runs once per distinct diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 3ec50e4..ef7889b 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -32,6 +32,7 @@ local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") local Sky = V.require("Sky") local DayNight = V.require("DayNight") +local GlassMask = V.require("GlassMask") local Voxel3D = {} @@ -203,6 +204,11 @@ local SHADER = [[ uniform vec3 ghostColor; // the flat silhouette colour uniform float ghost; // 0 = shade normally, 1 = flatten to it uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon + uniform Image glassMask; // opaque where the atlas texel is window glass + uniform float glassNight; // 0 = daylight .. 1 = the lamps are on + uniform float glassPhase; // the glint's phase: advances with TRAVEL + uniform float glassGlint; // and its strength: 0 while standing still + uniform float glassOn; // 0 for sprite-sheet draws (see Voxel3D.glass) vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec4 p = Texel(tex, tc); @@ -218,6 +224,30 @@ local SHADER = [[ // dark grass and one across a white roof each stay in their own palette rgb *= 1.0 - gridDark * voxelSeam(vGrid); #endif + // WINDOW GLASS, marked per atlas texel by the mask (see GlassMask). + // By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES + // -- the phase is fed by the camera's own travel and the strength dies + // within a beat of standing still, because a reflection is something + // the viewpoint does: still camera, still glass. It lifts the texel + // toward sky-white and leaves the art visible through it. After dark + // the pane is LIT: the texel's own shine pattern carried into a warm + // lamp colour, replacing the shaded answer above -- so a lit window + // ignores the sun, every shadow and the hour's tint, exactly as a + // window with a lamp behind it does. + // glassOn gates the whole thing per DRAW: the mask is shaped like the + // tileset atlas, and only meshes textured FROM that atlas may consult + // it -- a character samples its own sprite sheet, whose coordinates + // land on the mask's pane rectangles by accident and would stripe the + // cast with lamplight at night. + float glass = Texel(glassMask, tc).a * glassOn; + if (glass > 0.0) { + float sweep = sin((sc.x + sc.y) * 0.04 - glassPhase); + float glint = pow(max(sweep, 0.0), 20.0) * 0.55 * glassGlint; + vec3 pane = mix(rgb, vec3(0.93, 0.97, 1.0), glint * glass); + float shine = dot(p.rgb, vec3(0.299, 0.587, 0.114)); + vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine); + rgb = mix(pane, lamp, glassNight * glass); + } // The hidden player is a SHAPE, not a dimmed picture of itself. Tinting // through `color` could only multiply the sprite's own pixels, which // darkens each one by its own amount and keeps the character's internal @@ -440,6 +470,19 @@ end -- answers it, so a caller that never does draws exactly what it always drew. Voxel3D.tint = { 1, 1, 1 } +-- The window-glass pass, set the same way and for the same reason: the +-- MASK belongs to the map's tileset (GlassMask.texture) and how lit the +-- panes are belongs to the hour and to being outdoors at all +-- (DayNight.windowLight). nil / 0 -- the defaults -- draw no glass effect. +Voxel3D.glassMask = nil +Voxel3D.glassNight = 0 + +-- the glint, fed by the camera's TRAVEL rather than by a clock (see +-- VoxelScene.glintStep): the phase is radians already wrapped to 2pi, and +-- the strength is 0 whenever the view has been still for a beat +Voxel3D.glassPhase = 0 +Voxel3D.glassGlint = 0 + -- The sun or moon disc's place on this camera's canvas, or nil when the -- body is set, on the southern half of the sky, or behind the camera. -- @@ -560,6 +603,16 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR) -- the hour's light, as the caller last set it (see Voxel3D.tint) pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 }) + -- the window glass: the tileset's mask (or the blank -- the sampler is + -- declared either way, and unbound is a driver-dependent crash), how lit + -- the panes are, and the movement-fed glint as the caller last set it + local mask = Voxel3D.glassMask or GlassMask.blank() + if mask then pcall(sh.send, sh, "glassMask", mask) end + pcall(sh.send, sh, "glassNight", Voxel3D.glassNight or 0) + pcall(sh.send, sh, "glassPhase", Voxel3D.glassPhase or 0) + pcall(sh.send, sh, "glassGlint", Voxel3D.glassGlint or 0) + -- on until a sprite pass says otherwise, reset per frame like `ghost` + pcall(sh.send, sh, "glassOn", 1) -- the curved world bends about the camera's focus, so the horizon keeps -- a fixed distance ahead of the player rather than sitting on the map. -- A placed camera may decline it outright (Voxel3D.camera.curve = 0). @@ -681,6 +734,19 @@ function Voxel3D.seams(on) on and VoxelGrid.DARK or 0) end +-- Whether what is drawn next may consult the glass mask. false for the +-- length of a sprite-sheet pass, true to put it back. +-- +-- Same shape as seams(), for the same reason: the mask means "this ATLAS +-- texel is window glass", so it is only an answer for meshes textured from +-- the tileset atlas. A sprite sheet's coordinates land wherever they land +-- on it, and at night that painted lamplight stripes down whoever was +-- standing in the wrong part of their own sheet. +function Voxel3D.glass(on) + if not (active and activeShader) then return end + pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0) +end + function Voxel3D.endGhost() if not active then return end pcall(love.graphics.setDepthMode, "lequal", true) @@ -866,6 +932,8 @@ function Voxel3D.invalidate() ShadowMap.invalidate() -- the sky is part of this pass and holds a shader of its own Sky.invalidate() + -- and the glass masks are textures of this context too + GlassMask.invalidate() end return Voxel3D diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index a6898b4..6a5294d 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -1,4 +1,4 @@ --- Voxel world mode: assemble and draw one frame of the 3D scene. +-- Voxel world mode: assemble and draw one frame of the 3D scene. -- -- World space is world pixels and shares its origin with the 2D paths, so -- the terrain mesh needs no transform at all and a connected map just @@ -457,6 +457,34 @@ local function posesOf(state, spriteColors) return posed, me end +-- ------- the glint's drive +-- +-- A reflection is something the VIEWPOINT does, so the window glint is fed +-- by the camera's own travel rather than by a clock: its phase advances +-- with distance covered and its strength fades in over a few steps of +-- walking and back out within a beat of standing still. Stand still and +-- the glass is still; move and the light crosses it. +VoxelScene.GLINT_RATE = 0.22 -- radians of sweep per world pixel travelled +VoxelScene.GLINT_IN = 0.12 -- strength gained per moving frame +VoxelScene.GLINT_OUT = 0.08 -- and lost per resting frame + +function VoxelScene.glintStep(g, cx, cy) + local dist = 0 + if g.x then + dist = math.abs(cx - g.x) + math.abs(cy - g.y) + end + g.x, g.y = cx, cy + g.phase = ((g.phase or 0) + dist * VoxelScene.GLINT_RATE) % (2 * math.pi) + if dist > 0.05 then + g.amp = math.min(1, (g.amp or 0) + VoxelScene.GLINT_IN) + else + g.amp = math.max(0, (g.amp or 0) - VoxelScene.GLINT_OUT) + end + return g +end + +local glint = {} + -- A stamp of everything the sun pass depends on. Nothing in it moving -- means the shadow map it produced last frame is still exactly right, and -- redrawing the whole world from the sun would buy nothing -- which is @@ -521,23 +549,23 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, -- flower billboards live outside the terrain mesh (they draw after the -- characters, pulled -- see render), but the sun still sees them: a -- handful of cutouts per meadow, unlike the grass left out below. - -- Every thin card from here down is SUNK slightly along the ray - -- (ShadowMap.sink) so its shadow keeps contact with its feet instead of - -- starting a bias-width away. + -- Every thin card from here down is SNUGGED toward the sun along its own + -- ray (ShadowMap.snug) so its shadow keeps contact with its feet instead + -- of starting a bias-width away. ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), - ShadowMap.sink(nil)) + ShadowMap.snug(nil)) for _, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - ShadowMap.sink(Mat4.translate(nb.ox, 0, nb.oy))) + ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end -- authored figures cast too, for the same reason the flowers do: a -- handful of cards per map, and a person with no shadow reads as pasted on eachFigure(state.map, 0, 0, function(mesh, _, caster) - ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.sink(caster)) + ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.snug(caster)) end) for _, nb in ipairs(state.neighbors or {}) do eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster) - ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.sink(caster)) + ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster)) end) end for _, p in ipairs(posed) do @@ -546,7 +574,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, local mesh = SpriteBillboards.shadowQuad(def, frame) if mesh then ShadowMap.draw(mesh, p.sprite:resolveImage(), - ShadowMap.sink( + ShadowMap.snug( Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0), mirror))) end @@ -573,6 +601,15 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) local outdoor = state.map.def and Map.isOutdoor(state.map.def) or false DayNight.applyRig(outdoor) Voxel3D.tint = DayNight.tint(outdoor) + -- and the window glass: the tileset's own panes (found in its art -- + -- GlassMask), lit after dark. Outdoors only, like everything the clock + -- touches, which also keeps any pane-shaped art in an interior tileset + -- from picking up a glint. + local GlassMask = V.require("GlassMask") + Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil + Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0 + local g = VoxelScene.glintStep(glint, cx, cy) + Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp local function atlasFor(map) return TerrainAtlas.forMap(map, modeColors(paletteFor, map)) @@ -614,6 +651,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) Voxel3D.endShadows() end + -- Sprite sheets from here to the figure pass: their texture coordinates + -- mean nothing to the tileset-shaped glass mask, so the glass is off or + -- the panes' atlas positions stripe the cast with lamplight at night + Voxel3D.glass(false) + -- The player's silhouette goes down BEFORE the characters, so the only -- thing it can meet in the depth buffer is the WORLD -- terrain, buildings, -- trees. Drawn after the solid pass it would meet the player's own card @@ -645,6 +687,9 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, p.colors, p.lift) end + -- back on for everything textured from the atlas again -- figures, grass + -- and flowers all sample it, where the mask's coordinates are honest + Voxel3D.glass(true) -- Authored figures, alongside the characters and with the same lean and -- the same camera-ward pull -- they ARE characters as far as the artwork -- is concerned, just ones the tileset draws instead of a sprite sheet. diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 3890873..c05eab1 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -2111,23 +2111,101 @@ end -- The depth compare forgives `slack` world pixels so lit ground does not -- acne, and that forgiveness detaches a standing card's shadow from its -- feet by the same amount -- worse the lower the sun. Cards are drawn into --- the map sunk exactly `slack` down the ray, which cancels the bias for the --- shadow they throw and nothing else. +-- the map snugged TOWARD the sun along their own ray -- which moves their +-- stored depth and nothing about where their shadow falls -- taking most of +-- the forgiveness back for the shadow they throw and for nothing else. do local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") local Mat4 = run.loader.exports.DRAMATIC_SHAPE.lib.require("Mat4") local dir = ShadowMap.sunDir() -local m = ShadowMap.sink(nil) -T.check(math.abs(m[4] - dir[1] * ShadowMap.slack) < 1e-9 - and math.abs(m[8] - dir[2] * ShadowMap.slack) < 1e-9 - and math.abs(m[12] - dir[3] * ShadowMap.slack) < 1e-9, - "sink() moves a caster exactly `slack` down the sun ray") -local sunk = ShadowMap.sink(Mat4.translate(10, 0, 6)) -T.check(math.abs(sunk[4] - (10 + dir[1] * ShadowMap.slack)) < 1e-9 - and math.abs(sunk[12] - (6 + dir[3] * ShadowMap.slack)) < 1e-9, +local s = -ShadowMap.slack * ShadowMap.SNUG +local m = ShadowMap.snug(nil) +T.check(math.abs(m[4] - dir[1] * s) < 1e-9 + and math.abs(m[8] - dir[2] * s) < 1e-9 + and math.abs(m[12] - dir[3] * s) < 1e-9, + "snug() moves a caster toward the sun -- AGAINST the light's travel") +T.check(m[8] > 0, "which is upward: the sun is above the world it lights") +T.check(ShadowMap.SNUG < 1, + "and takes back less than the whole forgiveness, so a card cannot land " + .. "on the float-equality knife edge against its own stored depth") +local snugged = ShadowMap.snug(Mat4.translate(10, 0, 6)) +T.check(math.abs(snugged[4] - (10 + dir[1] * s)) < 1e-9 + and math.abs(snugged[12] - (6 + dir[3] * s)) < 1e-9, "and composes over the caster's own transform, not instead of it") end +-- ------- the glass in the windows +-- +-- Panes are found by SHAPE in the tileset art -- a black border row, four +-- or five black-flanked glass rows, a closing border -- at pixel +-- granularity, because the door's pane straddles a 2x2 tile block and the +-- building's sits a row down inside its tile. The scan takes a pure reader, +-- so the geometry is checked here without an image in sight. +do +local GlassMask = run.loader.exports.DRAMATIC_SHAPE.lib.require("GlassMask") +local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") + +local W, H = 32, 16 +local blackAt = {} +local function paint(x, y) blackAt[y * W + x] = true end +local function pane(x0, y0, rows, hole) + for c = 1, 6 do paint(x0 + c, y0); paint(x0 + c, y0 + rows + 1) end + for r = 1, rows do + paint(x0, y0 + r); paint(x0 + 7, y0 + r) + if hole and r == 2 then paint(x0 + 3, y0 + r) end + end +end +pane(8, 1, 5) -- a building pane, one row down inside its tile +pane(20, 3, 4) -- a door pane, straddling a tile row boundary +pane(0, 8, 5, true) -- a near-miss: one black texel inside the glass + +local function getPixel(x, y) + if blackAt[y * W + x] then return 0, 0, 0 end + return 0.66, 0.66, 0.66 +end + +local rects = GlassMask.scan(getPixel, W, H) +T.eq(#rects, 2, "the scan finds the two real panes and rejects the near-miss") +T.check(rects[1].x == 9 and rects[1].y == 2 + and rects[1].w == 6 and rects[1].h == 5, + "the building pane's glass is the 6x5 interior, border excluded") +T.check(rects[2].x == 21 and rects[2].y == 4 + and rects[2].w == 6 and rects[2].h == 4, + "the door pane's glass is the 6x4 interior, wherever it sits in the grid") + +-- black is the BORDER black, not the art's dark grey rung +T.check(GlassMask._isBlack(0, 0, 0), "true black is border") +T.check(not GlassMask._isBlack(85 / 255, 85 / 255, 85 / 255), + "the dark grey shade is not") + +-- the lamps behind the glass follow the clock, not the sky's own light +T.eq(DayNight.windowLight(300), 0, "no lamps at noon") +T.eq(DayNight.windowLight(900), 1, "all of them at mid-night") +T.check(math.abs(DayNight.windowLight(600) - 0.7) < 1e-9, + "they come on through dusk -- lit windows against the sunset") +T.check(DayNight.windowLight(645) > 0.9, + "and are fully on by the fall of night") +T.check(math.abs(DayNight.windowLight(0) - 0.25) < 1e-9, + "mostly out again by dawn") + +-- the pass defaults to no glass at all until a scene says otherwise +T.eq(Voxel3D.glassMask, nil, "no mask bound by default") +T.eq(Voxel3D.glassNight, 0, "and the lamps off") + +-- the glint is fed by TRAVEL, not by a clock: still camera, still glass +local g = {} +VoxelScene.glintStep(g, 100, 100) +T.eq(g.amp, 0, "the first frame establishes position and shows no sheen") +for i = 1, 12 do VoxelScene.glintStep(g, 100 + i, 100) end +T.eq(g.amp, 1, "a dozen frames of walking fades the glint fully in") +local held = g.phase +T.check(held > 0 and held < 2 * math.pi, "with the phase advanced by the travel") +for _ = 1, 20 do VoxelScene.glintStep(g, 112, 100) end +T.eq(g.amp, 0, "standing still fades it back out within a beat") +T.eq(g.phase, held, "and the phase does not move while the camera does not") +end + Pipelines.reset() run.release()