Files
DramaticShapeVoxelMod/lib/SpriteBillboards.lua
T
DramaticShape 326c595336 characters are flat sprite cards; drop the voxelized sprite path
Every figure is now its current 2D frame on one flat quad, the shader's
alpha discard cutting the silhouette. It still faces south and leans by
the camera's pitch, so it reads face-on at every tilt as before.

Removes the contoured slab and the carved visual-hull models --
lib/VoxelModels.lua, tools/build_voxels.py and ~70 generated files under
assets/voxels/ (2 MB). A sprite is a drawing, not an object seen from one
side: Gen 1's overworld figures are 16x16 icons with a fixed front-on
reading, and solidifying one invents a body the artist never drew. Those
files were also the one place this mod carried a description of the ROM
art, since a carve records a sprite's silhouette pixel for pixel.

The card needs no pixel access, and the solid draw, the sun pass and the
player's occlusion silhouette now share one mesh -- which the inverted
depth test the silhouette uses requires, since self-overlap would repaint
the figure on open ground. overrides/voxels/<name>.lua still wins where a
mod ships one.
2026-07-27 10:31:22 -04:00

85 lines
3.4 KiB
Lua

-- Voxel world mode: characters as flat forward-facing sprite billboards.
--
-- Every character -- the player, NPCs, the ghosts standing on a neighbour
-- map -- is its CURRENT 2D sprite frame on a single flat quad. The sheets
-- carry real alpha and the shader discards it, so the quad cuts the
-- sprite's exact silhouette out of itself; no geometry is built from the
-- pixels and nothing about a sprite is voxelized.
--
-- That is deliberate. A sprite is a DRAWING, not an object seen from one
-- side: Gen 1's overworld figures are 16x16 icons with a fixed front-on
-- reading, and turning one into a solid -- whether a contoured slab or a
-- carved visual hull -- reconstructs a body the artist never drew and the
-- game never implied. It also had the mod ship a description of the ROM
-- art. One quad wearing the real frame is both more faithful and cheaper:
-- it needs no pixel access at all, only the sheet's dimensions.
--
-- The card always faces SOUTH -- the direction the 2D game implies -- and
-- only LEANS BACK, pivoting at its feet, by exactly the camera's pitch
-- (VoxelScene's billboardMatrix), so at every tilt level it reads face-on
-- like the flat game. 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 it 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 = {}
local meshes = {}
-- One flat 16x16 quad UV-mapped to a whole frame. A hair of inset keeps
-- the sampler inside this frame rather than picking up the neighbouring
-- one along the shared edge.
local function buildCard(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
-- The card for one (sprite def, frame index), or nil (headless / no
-- image), cached like every other derived GPU object.
--
-- The solid draw, the sun pass and the player's occlusion silhouette all
-- take THIS mesh. That the three agree is load-bearing, not tidiness: the
-- silhouette is drawn with the depth test INVERTED, so any self-overlap in
-- the mesh would read as "behind something" and repaint the figure on open
-- ground whether or not anything hides it; and the sun must see the same
-- outline the camera does, or a shadow stops matching what casts it.
function SpriteBillboards.mesh(def, frame)
local key = def.image .. "#" .. frame
if meshes[key] == nil then
local ok, m = pcall(buildCard, def, frame)
meshes[key] = (ok and m) or false
end
return meshes[key] or nil
end
-- Kept as its own name because the shadow and ghost passes read as their
-- own thing at the call sites; it once carried a different mesh from the
-- solid draw, and now deliberately does not.
SpriteBillboards.shadowQuad = SpriteBillboards.mesh
function SpriteBillboards.invalidate()
meshes = {}
end
Assets.register(SpriteBillboards.invalidate)
return SpriteBillboards