mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 08:11:11 +02:00
glass masking
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
+4
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user