DramaticShapeVoxelMod archival mirror · read-only
9c263f0937cfd72921d6578d96556b4a20c53bb7

glass masking

DramaticShape · 2026-07-29 · parent 7e3245a9ef

glass masking

Changed files

lib/BattleScene.lua |   4 +-
 lib/GlassMask.lua   | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/VoxelScene.lua  |   6 ++-
 3 files changed, 181 insertions(+), 3 deletions(-)

Diff

diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index ce9b46b..6a64bf9 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -314,7 +314,9 @@ function BattleScene.render(state, arena, textures, token)
   -- 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)
+  -- a canopy floor (Viridian Forest) fights under the hour's tint too,
+  -- with the rig and the void exactly as they were
+  Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(host))
   local GlassMask = V.require("GlassMask")
   Voxel3D.glassMask = outdoor and GlassMask.texture(host.tileset) or nil
   Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
diff --git a/lib/GlassMask.lua b/lib/GlassMask.lua
new file mode 100644
index 0000000..92f3b95
--- /dev/null
+++ b/lib/GlassMask.lua
@@ -0,0 +1,174 @@
+-- Voxel world mode: the glass in the windows, found rather than listed.
+--
+-- Buildings and doors in the overworld art carry small panes -- six texels
+-- wide, framed in black, with a diagonal shine drawn in. This module finds
+-- them by SHAPE in the tileset image itself: a border row of six black
+-- texels, four or five rows of black-flanked non-black glass under it, and
+-- a closing border row. No tile ids are hardcoded, so a total conversion
+-- that draws its own windows in the same idiom gets glass for free, and art
+-- with no windows gets an empty mask and costs nothing.
+--
+-- The scan slides at PIXEL granularity because the art does: the building
+-- window sits a row down inside its tile, and the door's pane straddles a
+-- 2x2 tile block entirely -- a per-tile matcher finds neither.
+--
+-- What the scan yields is a MASK TEXTURE the same size as the tileset
+-- atlas: opaque white on glass texels, transparent everywhere else. Terrain
+-- meshes sample the atlas by normalized coordinates (ChunkMesher.uvRect),
+-- so the scene shader can sample this mask with the SAME coordinates and
+-- know, per fragment, whether it is drawing glass -- on any wall, at any
+-- angle, in free-roam or a staged battle, with no geometry work anywhere.
+-- The recoloured atlases (display modes, RED++) keep the tileset's layout,
+-- so the alignment holds under every palette.
+--
+-- What the shader does with the answer (Voxel3D): by day a thin glint
+-- sweeps across the panes -- a pseudo reflection, view-anchored, preserving
+-- the art under it -- and after dark the panes are LIT: the texel's own
+-- shine pattern, warmed and brightened, exempt from the sun, the shadow
+-- map and the hour's tint, as a window with a lamp behind it is.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Assets = require("src.render.Assets")
+
+local GlassMask = {}
+
+-- pane geometry the scan accepts: six glass texels across, and this many
+-- rows of them between the two border rows
+GlassMask.GLASS_W = 6
+GlassMask.ROWS = { 4, 5 }        -- door pane, building pane
+
+-- Whether a channel triple is the border black. The raw tileset art is the
+-- four DMG greys, so black is genuinely zero; the threshold forgives a
+-- rescaled asset without accepting the dark grey rung (85/255 = 0.33).
+local function isBlack(r, g, b)
+  return r < 0.12 and g < 0.12 and b < 0.12
+end
+
+GlassMask._isBlack = isBlack     -- named for the suite
+
+-- Find every pane in an image, through a pure reader so the geometry is
+-- testable headless: `getPixel(x, y)` returns r, g, b in 0..1 for 0-based
+-- coordinates. Returns { {x=, y=, w=, h=}, ... } rects of GLASS texels
+-- (the border is the detector's evidence, not part of the answer).
+function GlassMask.scan(getPixel, w, h)
+  local function black(x, y)
+    return isBlack(getPixel(x, y))
+  end
+  local function borderRow(x, y)
+    for c = 1, 6 do
+      if not black(x + c, y) then return false end
+    end
+    return true
+  end
+  local function glassRow(x, y)
+    if not (black(x, y) and black(x + 7, y)) then return false end
+    for c = 1, 6 do
+      if black(x + c, y) then return false end
+    end
+    return true
+  end
+  local want = {}
+  for _, n in ipairs(GlassMask.ROWS) do want[n] = true end
+
+  local rects = {}
+  for y = 0, h - 1 do
+    for x = 0, w - 8 do
+      if borderRow(x, y) then
+        local n = 0
+        while y + 1 + n < h and glassRow(x, y + 1 + n) do
+          n = n + 1
+        end
+        if want[n] and y + 1 + n < h and borderRow(x, y + 1 + n) then
+          rects[#rects + 1] = { x = x + 1, y = y + 1,
+                                w = GlassMask.GLASS_W, h = n }
+        end
+      end
+    end
+  end
+  return rects
+end
+
+-- ------- the runtime cache, one entry per tileset image
+
+local cache = {}       -- image path -> { rects, texture (or false) }
+
+local function entry(tileset)
+  local path = tileset and tileset.image
+  if not path then return nil end
+  local hit = cache[path]
+  if hit then return hit end
+  local ok, data = pcall(Assets.imageData, path)
+  if not (ok and data) then
+    -- unreadable art is a verdict for the session, not a retry loop
+    cache[path] = { rects = {}, texture = false }
+    return cache[path]
+  end
+  local w, h = data:getDimensions()
+  local rects = GlassMask.scan(function(x, y)
+    return data:getPixel(x, y)
+  end, w, h)
+  local texture = false
+  if #rects > 0 and love.image and love.image.newImageData
+     and love.graphics and love.graphics.newImage then
+    local built = pcall(function()
+      local mask = love.image.newImageData(w, h)
+      for _, r in ipairs(rects) do
+        for yy = r.y, r.y + r.h - 1 do
+          for xx = r.x, r.x + r.w - 1 do
+            mask:setPixel(xx, yy, 1, 1, 1, 1)
+          end
+        end
+      end
+      texture = love.graphics.newImage(mask)
+      texture:setFilter("nearest", "nearest")
+    end)
+    if not built then texture = false end
+  end
+  cache[path] = { rects = rects, texture = texture }
+  return cache[path]
+end
+
+-- The panes found in a tileset's art, as glass rects in atlas pixels.
+function GlassMask.rects(tileset)
+  local e = entry(tileset)
+  return e and e.rects or {}
+end
+
+-- The mask texture for a tileset, or nil when it has no panes (or the art
+-- is unreadable, or there is no GPU) -- callers bind the blank instead.
+function GlassMask.texture(tileset)
+  local e = entry(tileset)
+  return (e and e.texture) or nil
+end
+
+-- A 1x1 transparent stand-in, for the frames (and drivers) with no mask:
+-- the scene shader always declares the sampler, and an unbound sampler is
+-- a driver-dependent crash rather than a fallback.
+local blank = nil
+
+function GlassMask.blank()
+  if blank == nil then
+    local ok, img = pcall(function()
+      local data = love.image.newImageData(1, 1)
+      data:setPixel(0, 0, 0, 0, 0, 0)
+      return love.graphics.newImage(data)
+    end)
+    blank = (ok and img) or false
+  end
+  return blank or nil
+end
+
+-- Drop the GPU objects (window resize, hot reload). The rects survive --
+-- they are a fact about the art -- but textures are rebuilt on demand.
+function GlassMask.invalidate()
+  for _, e in pairs(cache) do
+    if e.texture and e.texture.release then pcall(e.texture.release, e.texture) end
+    e.texture = false
+  end
+  cache = {}
+  blank = nil
+end
+
+return GlassMask
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 6a5294d..4d401ea 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -597,10 +597,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
   -- the hour's light, before anything is cast or drawn: point the shared
   -- rig at the clock (or at noon, indoors -- a cave at midnight is exactly
   -- as dark as a cave at noon) and set the tint the scene shader multiplies
-  -- every surface by
+  -- every surface by. A CANOPY map (Viridian Forest) is the case between:
+  -- the rig stays at noon and no sky is painted, but the hour's tint still
+  -- falls through the leaves -- night reaches a forest floor.
   local outdoor = state.map.def and Map.isOutdoor(state.map.def) or false
   DayNight.applyRig(outdoor)
-  Voxel3D.tint = DayNight.tint(outdoor)
+  Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(state.map))
   -- 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