Files
DramaticShapeVoxelMod/lib/SpriteBillboards.lua
T
DramaticShape 702d8049f0 initial commit
2026-07-26 16:43:53 -04:00

201 lines
7.2 KiB
Lua

-- Voxel world mode: characters as forward-facing sprite billboards WITH
-- relief.
--
-- Each entity renders as its CURRENT 2D sprite frame voxelized into a
-- contoured slab: opaque pixels become geometry (the sheets carry real
-- alpha, so transparency is exact), and each ROW's thickness comes from
-- the sheet's own SIDE-VIEW silhouette at that row -- the same
-- match-the-pixel-profile idea the tree lathe uses, applied along depth.
-- The cap brim stays thin, the head rounds back deep, shoulders step out
-- past the neck, feet taper: viewed at tilt angles the top rims trace the
-- character's real profile instead of a uniform one-voxel wafer. Rows are
-- centered on the sprite plane, so the front face relief is symmetric
-- with the back.
--
-- Meshes are built per (sheet, frame) and cached; right-facing and the
-- alternating walk step are matrix mirrors, not extra meshes. UVs point
-- into the live sheet image, so RED++ OBP bakes, SGB palette bakes and
-- sprite-replacing mods all texture the slab with no rebuild.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local Voxel3D = V.require("Voxel3D")
local SpriteBillboards = {}
-- depth = side-view span * SCALE, clamped -- full span would make heads
-- as deep as the sprite is wide, which reads bulbous at these sizes
SpriteBillboards.DEPTH_SCALE = 0.5
SpriteBillboards.MIN_DEPTH = 2
SpriteBillboards.MAX_DEPTH = 8
local meshes = {}
local function build(def, frame)
local ok, id = pcall(Assets.imageData, def.image)
if not (ok and id and id.getPixel) then return nil end
local iw, ih = id:getDimensions()
local frames = math.floor(ih / 16)
local fy = frame * 16
if fy + 16 > ih then fy = 0 end
local function opaqueAt(fbase, x, y)
if x < 0 or x > 15 or y < 0 or y > 15 then return false end
local _, _, _, a = id:getPixel(x, fbase + y)
return a > 0
end
local function opaque(x, y) return opaqueAt(fy, x, y) end
-- Per-row depth from the sheet's side view: the LEFT-facing frame that
-- matches this frame's pose (stand 2 / walk 5, data/sprites/facings.asm
-- layout). Sheets without one (items, boulders) stay uniform.
local depth = {}
do
local sideFrame = nil
if frames >= 6 then
sideFrame = frame >= 3 and 5 or 2
elseif frames >= 3 then
sideFrame = 2
end
local sy = sideFrame and sideFrame * 16
for iy = 0, 15 do
local d = 3
if sy then
local lo, hi = nil, nil
for ix = 0, 15 do
if opaqueAt(sy, ix, iy) then
lo = lo or ix
hi = ix
end
end
if lo then
d = (hi - lo + 1) * SpriteBillboards.DEPTH_SCALE
d = math.max(SpriteBillboards.MIN_DEPTH,
math.min(SpriteBillboards.MAX_DEPTH, d))
end
end
depth[iy] = d
end
end
local verts, indices = {}, {}
local function quad(c, uv, shade)
for i = 1, 4 do
verts[#verts + 1] = { c[i][1], c[i][2], c[i][3],
uv[i][1], uv[i][2], shade }
end
Voxel3D.pushQuad(indices, #verts / 4 - 1)
end
local function uvx(x) return x / iw end
local function uvy(y) return (fy + y) / ih end
-- horizontal runs of opaque pixels; each row is a box slice as deep as
-- its side profile, centered on the sprite plane (z = 0)
for iy = 0, 15 do
local yT, yB = 16 - iy, 15 - iy
local zF, zB = depth[iy] / 2, -depth[iy] / 2
local ix = 0
while ix < 16 do
if opaque(ix, iy) then
local ix2 = ix
while ix2 + 1 < 16 and opaque(ix2 + 1, iy) do ix2 = ix2 + 1 end
local u0, u1 = uvx(ix + 0.02), uvx(ix2 + 0.98)
local v0, v1 = uvy(iy + 0.02), uvy(iy + 0.98)
quad({ { ix, yB, zF }, { ix2 + 1, yB, zF },
{ ix2 + 1, yT, zF }, { ix, yT, zF } },
{ { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, 1)
quad({ { ix2 + 1, yB, zB }, { ix, yB, zB },
{ ix, yT, zB }, { ix2 + 1, yT, zB } },
{ { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } }, 0.7)
-- rims: exposed where the neighbouring row lacks the pixel OR is
-- shallower than this row (the step of the depth profile); a full
-- row-depth quad is safe either way -- any overlap is sandwiched
-- inside the thicker slice and never visible
for px = ix, ix2 do
local pu0, pu1 = uvx(px + 0.1), uvx(px + 0.9)
local pv = uvy(iy + 0.5)
if not opaque(px, iy - 1) or depth[iy - 1 >= 0 and iy - 1 or 0]
< depth[iy] then
quad({ { px, yT, zB }, { px + 1, yT, zB },
{ px + 1, yT, zF }, { px, yT, zF } },
{ { pu0, pv }, { pu1, pv }, { pu1, pv }, { pu0, pv } }, 0.9)
end
if yB > 0 and (not opaque(px, iy + 1)
or depth[iy + 1 <= 15 and iy + 1 or 15]
< depth[iy]) then
quad({ { px + 1, yB, zB }, { px, yB, zB },
{ px, yB, zF }, { px + 1, yB, zF } },
{ { pu1, pv }, { pu0, pv }, { pu0, pv }, { pu1, pv } }, 0.55)
end
end
local lv = { uvx(ix + 0.5), uvy(iy + 0.5) }
if not opaque(ix - 1, iy) then
quad({ { ix, yB, zB }, { ix, yB, zF },
{ ix, yT, zF }, { ix, yT, zB } },
{ lv, lv, lv, lv }, 0.78)
end
if not opaque(ix2 + 1, iy) then
quad({ { ix2 + 1, yB, zF }, { ix2 + 1, yB, zB },
{ ix2 + 1, yT, zB }, { ix2 + 1, yT, zF } },
{ lv, lv, lv, lv }, 0.78)
end
ix = ix2 + 1
else
ix = ix + 1
end
end
end
return Voxel3D.newMesh(verts, indices)
end
-- The slab mesh for one (sprite def, frame index), or nil (headless / no
-- pixel access), cached like every other derived GPU object.
function SpriteBillboards.mesh(def, frame)
local key = def.image .. "#" .. frame
if meshes[key] == nil then
local ok, m = pcall(build, def, frame)
meshes[key] = (ok and m) or false
end
return meshes[key] or nil
end
-- One flat 16x16 quad UV-mapped to a whole frame, for the drop-shadow
-- pass: the shader's alpha discard cuts the sprite's silhouette out of it,
-- so a single quad casts the exact outline with no self-overlap to
-- double-darken. Needs only the sheet dimensions, not pixel access.
local function buildShadowQuad(def, frame)
local ok, img = pcall(Assets.image, def.image)
if not (ok and img) then return nil end
local iw, ih = img:getDimensions()
local fy = frame * 16
if fy + 16 > ih then fy = 0 end
local u0, u1 = 0.02 / iw, (16 - 0.02) / iw
local v0, v1 = (fy + 0.05) / ih, (fy + 15.95) / ih
local verts = {
{ 0, 0, 0, u0, v1, 1 }, { 16, 0, 0, u1, v1, 1 },
{ 16, 16, 0, u1, v0, 1 }, { 0, 16, 0, u0, v0, 1 },
}
local indices = {}
Voxel3D.pushQuad(indices, 0)
return Voxel3D.newMesh(verts, indices)
end
function SpriteBillboards.shadowQuad(def, frame)
local key = def.image .. "#s" .. frame
if meshes[key] == nil then
local ok, m = pcall(buildShadowQuad, def, frame)
meshes[key] = (ok and m) or false
end
return meshes[key] or nil
end
function SpriteBillboards.invalidate()
meshes = {}
end
Assets.register(SpriteBillboards.invalidate)
return SpriteBillboards