From 6d27d914110bc7397433b31775cd6a9a977066da Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Wed, 29 Jul 2026 22:43:20 -0400 Subject: [PATCH 1/2] fix glinting on windows and striping --- CHANGELOG.md | 31 +++++++++++++++++++------------ lib/BattleScene.lua | 10 +++++++--- lib/ShadowMap.lua | 12 ++++++++++-- lib/Voxel3D.lua | 15 +++++++++++++-- lib/VoxelScene.lua | 28 +++++++++++++++++++--------- 5 files changed, 68 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8056582..74eda1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,14 +130,18 @@ 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. + By day a thin 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. The sweep pattern lives in the pane's + OWN texels, not the screen's: a screen-anchored pattern has the world + sliding through it at zoom speed whenever the camera pans, which strobed + (worst walking against the sweep); anchored to the glass, panning moves + nothing and a step advances the glint a fraction of a texel, the same in + every direction. It lifts the texels toward sky-white and leaves the + 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 @@ -202,10 +206,13 @@ 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. + no terrain moved, so the acne margin is untouched where it matters. The + obligation that comes with it: every snugged caster's LIT draw hands the + same snugged transform to its own shadow lookup (Voxel3D.draw's + `sunModel`), so stored and lookup agree exactly and the compare keeps its + full margin -- read un-snugged, the missing nine tenths showed up as + diagonal moire bands crawling across every sprite. The shadow root lands + back under the feet at every hour. ### Changed diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 6a64bf9..edb5f67 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -416,8 +416,10 @@ function BattleScene.render(state, arena, textures, token) -- tileset atlas, so the mask's coordinates mean nothing on them Voxel3D.glass(false) for _, card in ipairs(monCards(arena, groundY, textures)) do + -- the sun stored this card snugged (castShadows), so its own shadow + -- lookup must read the same snugged transform -- see ShadowMap.snug Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, - BattleBillboard.PULL) + BattleBillboard.PULL, ShadowMap.snug(card.model)) end Voxel3D.glass(true) Voxel3D.seams(true) @@ -433,10 +435,12 @@ function BattleScene.render(state, arena, textures, token) Mat4.translate(nb.ox, 0, nb.oy), pull) end local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05))) - Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull) + Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull, + ShadowMap.snug(nil)) for _, nb in ipairs(neighbors) do Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), fpull) + Mat4.translate(nb.ox, 0, nb.oy), fpull, + ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end local canvas = Voxel3D.endScene() if not canvas then return end diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index 4606d5a..9798462 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -390,8 +390,16 @@ ShadowMap.SNUG = 0.9 -- 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. +-- ONE OBLIGATION comes with it: the caster's LIT draw must hand this same +-- snugged transform to its shadow lookup (Voxel3D.draw's `sunModel`). +-- Stored and lookup then agree exactly, as they did before snugging, and +-- the compare keeps its full acne margin. A caster stored snugged but read +-- un-snugged is 0.9 of the margin short, and the loss shows up as diagonal +-- moire bands crawling across the card. +-- +-- Valid between begin() and the next begin(): `slack` and the sun hold +-- still between redraws of the map, so a lit frame that reuses last +-- frame's map computes the same displacement it was stored with. function ShadowMap.snug(model) local f = sunDir() local s = -ShadowMap.slack * ShadowMap.SNUG diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index ef7889b..62e98be 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -205,6 +205,7 @@ local SHADER = [[ 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 vec2 glassSize; // the mask's dimensions: tc -> atlas texels 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 @@ -241,7 +242,13 @@ local SHADER = [[ // 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); + // the sweep lives in the PANE's own space (atlas texels), not the + // screen's: a pattern anchored to the screen has the world sliding + // through it at zoom speed whenever the camera pans, which strobed -- + // worst where the pan and the phase ran opposite ways. Anchored to + // the glass, panning moves nothing; only the phase does, a fraction + // of a texel per step, the same in every walking direction. + float sweep = sin(tc.x * glassSize.x * 0.8 - 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)); @@ -607,7 +614,11 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- 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 + if mask then + pcall(sh.send, sh, "glassMask", mask) + local ok, mw, mh = pcall(mask.getDimensions, mask) + pcall(sh.send, sh, "glassSize", { ok and mw or 1, ok and mh or 1 }) + 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) diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 4d401ea..01cfa0f 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -307,12 +307,13 @@ local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors, -- drift): lets the leaned-back head win against the wall it leans -- OVER while a character genuinely BEHIND a building is dozens of -- pixels deeper and still loses, so real occlusion works. - -- the same card UNLEANED is what the sun saw (castShadows draws - -- exactly this mesh), so that is where each vertex asks whether the - -- light reached it -- see Voxel3D.draw + -- the same card UNLEANED -- and SNUGGED, exactly as the sun stored it + -- (castShadows draws this mesh through ShadowMap.snug) -- is where each + -- vertex asks whether the light reached it; see ShadowMap.snug for why + -- the lookup must match the stored transform to the letter Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror), billboardPull(), - Voxel3D.casterMatrix(px, py, y, mirror)) + ShadowMap.snug(Voxel3D.casterMatrix(px, py, y, mirror))) return true end @@ -464,7 +465,11 @@ end -- 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 +-- The rate is slow on purpose: the sweep pattern lives in the pane's own +-- texels (see the scene shader), so this is a FRACTION of a texel per world +-- pixel walked -- one full pass of the glint across a pane per eight or so +-- cells of travel, with no frame ever jumping it far enough to strobe. +VoxelScene.GLINT_RATE = 0.05 -- 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 @@ -699,11 +704,13 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- wins the overlap, which is the order the flat game draws them in. local figPull = billboardPull() eachFigure(state.map, 0, 0, function(mesh, model, caster) - Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, caster) + Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, + ShadowMap.snug(caster)) end) for _, nb in ipairs(state.neighbors or {}) do eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster) - Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, caster) + Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, + ShadowMap.snug(caster)) end) end -- and the seams are back on for the terrain art that follows: grass and @@ -733,11 +740,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- ON, while the nearest flower of the cell south (+20) stays in front -- and keeps overdrawing their feet. local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05))) + -- flowers are snugged casters too, so they read their own shadowing + -- through the same snugged transform the sun stored them with Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil, - fpull) + fpull, ShadowMap.snug(nil)) for _, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), fpull) + Mat4.translate(nb.ox, 0, nb.oy), fpull, + ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end return Voxel3D.endScene() From 284172db7b7a1dcd1b53ee34b2e8baef5d1d6a9c Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Wed, 29 Jul 2026 22:50:54 -0400 Subject: [PATCH 2/2] add clock sync option --- CHANGELOG.md | 17 ++++++++------ README.md | 14 +++++++----- lib/DayNight.lua | 36 +++++++++++++++++++++++++----- main.lua | 5 +++-- tests/dramatic_shape_test.lua | 42 ++++++++++++++++++++++++++++++----- 5 files changed, 89 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74eda1e..0ab9c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,13 +57,16 @@ horizon above the frame, so the arena keeps exactly the flat sky it had. - **A day/night cycle**, on a new **DAYTIME** options row: `DAY`, `NIGHT`, - `DUSK`, `DAWN`, `CYCLE`. One twenty-minute clock underneath all five -- ten - minutes of sun, ten of moon -- where the four named settings are PINS on - that dial (noon, mid-night, sunset, sunrise) and `CYCLE` lets it run, - picking up from whichever pin the player was just looking at. Everything is - a pure function of the clock, so the pinned DUSK is exactly the running - cycle stopped at sunset. Setting **VOXEL** to `FULL` switches DAYTIME to - `CYCLE` along with the rest of the preset. + `DUSK`, `DAWN`, `SYNC`, `CYCLE`. One twenty-minute clock underneath all of + them -- ten minutes of sun, ten of moon -- where the four named settings + are PINS on that dial (noon, mid-night, sunset, sunrise), `CYCLE` lets it + run, picking up from whichever pin or SYNC sky the player was just looking + at, and `SYNC` -- the DEFAULT -- lays the machine's own clock onto the + dial: local noon is the DAY pin, midnight is NIGHT, six and eighteen the + twilights, an hour of the real day is fifty seconds of dial. Everything is a pure function of + the clock, so the pinned DUSK is exactly the running cycle stopped at + sunset. Setting **VOXEL** to `FULL` switches DAYTIME to `CYCLE` along with + the rest of the preset. **The sun and the moon are in the sky**, and their positions are honest: the disc is the light's own direction projected through the same matrix the diff --git a/README.md b/README.md index aa0e410..2b15712 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ menu. | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | -| the **DAYTIME** options row | DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors | +| the **DAYTIME** options row | DAY / NIGHT / DUSK / DAWN / SYNC / CYCLE — what time it is outdoors | **3D-BTL** is on by default and is independent of **VOXEL**: battles draw on the world whether or not the free-roam camera is pitched over. @@ -71,10 +71,14 @@ to the clock: The **DAYTIME** row is one twenty-minute clock — ten minutes of sun, ten of moon. `DAY`, `NIGHT`, `DUSK` and `DAWN` are pins on that dial (noon, -mid-night, sunset, sunrise) and `CYCLE` lets it run, picking up from -whichever pin was showing. Everything is a pure function of the clock, so -the pinned DUSK is exactly the running cycle stopped at sunset. Setting -**VOXEL** to `FULL` switches DAYTIME to `CYCLE` with the rest of the preset. +mid-night, sunset, sunrise), `CYCLE` lets it run, picking up from whichever +pin (or SYNC sky) was showing, and `SYNC` — the default — lays your +machine's own clock onto the dial: local noon is the DAY pin, midnight is +NIGHT, six and eighteen the twilights, so out of the box Kanto's evening +falls when yours does. +Everything is a pure function of the clock, so the pinned DUSK is exactly +the running cycle stopped at sunset. Setting **VOXEL** to `FULL` switches +DAYTIME to `CYCLE` with the rest of the preset. The **sun and moon hang in the sky**, projected through the same matrix the geometry is drawn with, so each stands over the point on the horizon its diff --git a/lib/DayNight.lua b/lib/DayNight.lua index 086f3ff..f561c57 100644 --- a/lib/DayNight.lua +++ b/lib/DayNight.lua @@ -63,11 +63,14 @@ DayNight.T = { dawn = 0, day = 300, dusk = 600, night = 900 } DayNight.KEY = "daytime" DayNight.LABEL = "DAYTIME" --- "day" first: an unset or unreadable value is DAY, per the row's contract --- (ModSetting values[1] is the default) +-- "sync" first: an unset or unreadable value follows the machine's own +-- clock, per the row's contract (ModSetting values[1] is the default). +-- "cycle" stays LAST: the FULL preset reaches for it by position. DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL, - { "day", "night", "dusk", "dawn", "cycle" }, - { "DAY", "NIGHT", "DUSK", "DAWN", "CYCLE" }) + { "sync", "day", "night", "dusk", + "dawn", "cycle" }, + { "SYNC", "DAY", "NIGHT", "DUSK", + "DAWN", "CYCLE" }) DayNight.clock = DayNight.T.day -- the running cycle's own position @@ -311,10 +314,28 @@ local function mode() return DayNight.setting:get() or "day" end --- The effective time: the pin, or the running clock under CYCLE. +-- Where SYNC reads the real clock: local hours, 0..24 with the minutes as +-- fraction. A named seam rather than a bare os.date call, so the suite can +-- hand it a fixed hour. +function DayNight.hours() + local d = os.date("*t") + return d.hour + d.min / 60 + d.sec / 3600 +end + +-- SYNC: the machine's own time of day laid onto the dial. Local noon is +-- the DAY pin, midnight the NIGHT pin, six and eighteen the twilights -- +-- an hour of the real day is fifty seconds of dial, and Kanto's evening +-- falls when the player's does. +function DayNight.syncTime() + return ((DayNight.hours() - 6) * (DayNight.CYCLE / 24)) % DayNight.CYCLE +end + +-- The effective time: the pin, the running clock under CYCLE, or the wall +-- clock under SYNC. function DayNight.time() local m = mode() if m == "cycle" then return DayNight.clock end + if m == "sync" then return DayNight.syncTime() end return DayNight.T[m] or DayNight.T.day end @@ -326,8 +347,11 @@ end function DayNight.update(dt) local m = mode() if m ~= lastMode then - if m == "cycle" and DayNight.T[lastMode] then + if m == "cycle" then + -- from a pin, its time; from SYNC, wherever the real sky already was DayNight.clock = DayNight.T[lastMode] + or (lastMode == "sync" and DayNight.syncTime()) + or DayNight.clock end lastMode = m end diff --git a/main.lua b/main.lua index dd28deb..301fc5a 100644 --- a/main.lua +++ b/main.lua @@ -321,8 +321,9 @@ local SETTINGS = { .. "shot over the shoulder with a slow parallax drift." }, { DayNight.setting, "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " - .. "or let CYCLE run it -- ten minutes of sun, ten of moon, with the " - .. "shadows, the sky and the light following." }, + .. "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." }, } local schema = {} diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index e4a0c28..f3caeb0 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -297,8 +297,8 @@ T.eq(#hookedRows, 5, "the options hook added a row per setting") local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4] local daytime = hookedRows[5] T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") -T.eq(daytime.value(), "DAY", - "and starts pinned to DAY -- no time set means it is day") +T.eq(daytime.value(), "SYNC", + "and defaults to SYNC -- no value set follows the clock on the wall") T.eq(grid.label, "V-GRID", "the grid row carries its label") T.eq(grid.value(), "OFF", "the grid starts off") T.eq(curve.label, "V-CURVE", "the curve row carries its label") @@ -1055,6 +1055,11 @@ local skyFor = VoxelScene._skyFor local skyStrength = VoxelScene._skyStrength T.check(type(skyFor) == "function", "the scene exposes its sky resolve") +-- pinned to DAY for every sky assertion below: the row ships defaulting to +-- SYNC -- the machine's own clock -- which would hand these tests whatever +-- palette the hour of the test run happened to be +run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight").setting:sync("day") + local outside = { def = { id = "PALLET_TOWN", tileset = "OVERWORLD" } } local inside = { def = { id = "REDS_HOUSE_1F", tileset = "HOUSE" } } local TOP = math.rad(Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1]) @@ -1928,7 +1933,10 @@ local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") local Voxel = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") -- the dial and its pins -T.eq(DayNight.setting:get(), "day", "no time set means DAY: the default pin") +T.eq(DayNight.setting.values[1], "sync", + "no value set means SYNC: values[1] is the row's contract default, and " + .. "it follows the machine's clock") +DayNight.setting:sync("day") T.eq(DayNight.time(), 300, "and DAY is noon on the dial") local PINS = { day = 300, night = 900, dusk = 600, dawn = 0 } for name, t in pairs(PINS) do @@ -2087,6 +2095,30 @@ modApi.save:set(DayNight.SAVE_KEY, nil) DayNight.restore() T.eq(DayNight.clock, 300, "a save with no clock in it starts at day") +-- SYNC lays the machine's own clock onto the dial: local noon is the DAY +-- pin, midnight is NIGHT, six and eighteen the twilights +local hoursWas = DayNight.hours +DayNight.setting:sync("sync") +DayNight.hours = function() return 12 end +T.eq(DayNight.time(), 300, "local noon is the DAY pin") +DayNight.hours = function() return 18 end +T.eq(DayNight.time(), 600, "six in the evening is DUSK") +DayNight.hours = function() return 0 end +T.eq(DayNight.time(), 900, "midnight is mid-night") +DayNight.hours = function() return 6.5 end +T.check(math.abs(DayNight.time() - 25) < 1e-9, + "half past six in the morning is just after dawn") +-- and stepping from SYNC onto CYCLE picks up from the sky already showing +DayNight.update(0) +DayNight.setting:sync("cycle") +DayNight.update(0) +T.check(math.abs(DayNight.clock - 25) < 1e-9, + "CYCLE picks up from wherever SYNC's sky already was") +DayNight.hours = hoursWas +T.eq(DayNight.setting.values[#DayNight.setting.values], "cycle", + "cycle stays the ladder's last rung -- the FULL preset reaches for it " + .. "by position") + -- arriving at FULL sets the clock going local Game = require("src.core.Game") local hadSave = Game.save @@ -2098,8 +2130,8 @@ T.eq(DayNight.setting:get(), "cycle", "FULL switches DAYTIME to CYCLE -- the diorama gets its running sky") Game.save = hadSave --- put the room back the way it was found -DayNight.setting:sync("day") +-- put the room back the way it was found (SYNC is the shipped default) +DayNight.setting:sync("sync") DayNight.clock = 300 DayNight.applyRig(false) Voxel3D.tint = { 1, 1, 1 }