DramaticShapeVoxelMod archival mirror · read-only
lib/GlassMask.lua

GlassMask.lua

lib/GlassMask.lua6.1 KB10064492f3b95fd7
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 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