mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 12:11:03 +02:00
201 lines
7.2 KiB
Lua
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
|