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.
This commit is contained in:
DramaticShape
2026-07-27 10:31:04 -04:00
parent 2ff518a6d1
commit 326c595336
76 changed files with 115 additions and 17154 deletions
+42 -158
View File
@@ -1,21 +1,26 @@
-- Voxel world mode: characters as forward-facing sprite billboards WITH
-- relief.
-- Voxel world mode: characters as flat forward-facing sprite billboards.
--
-- 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.
-- 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.
--
-- 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.
-- 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 = ...
@@ -25,147 +30,12 @@ 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)
-- 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()
@@ -182,15 +52,29 @@ local function buildShadowQuad(def, frame)
return Voxel3D.newMesh(verts, indices)
end
function SpriteBillboards.shadowQuad(def, frame)
local key = def.image .. "#s" .. frame
-- 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(buildShadowQuad, def, frame)
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
+3 -3
View File
@@ -8,8 +8,8 @@
-- +Z map south (world-pixel y)
--
-- A character at rest faces +Z, i.e. toward a camera parked to the south,
-- which is what "facing down" means in the 2D game -- so a facing is just a
-- yaw and tools/build_voxels.py bakes its models in that pose.
-- which is what "facing down" means in the 2D game -- and a character card
-- is drawn in exactly that pose, leaning back rather than yawing.
--
-- The camera orbits the view centre at Voxel.angle: 0 is straight down
-- (what the flat 2D view already is) and 50 degrees leans toward the
@@ -44,7 +44,7 @@ Voxel3D.FORMAT = {
{ "VertexShade", "float", 1 },
}
-- Face shading by direction id (see tools/build_voxels.py): top faces stay
-- Face shading by direction id: top faces stay
-- full brightness, sides step down so an extruded block reads as solid
-- instead of a flat sticker, and the faces turned away from the sun are
-- darkest. The sun hangs in the SOUTHEAST (see ShadowMap), so south and
+3 -3
View File
@@ -1,13 +1,13 @@
-- Voxel world mode: the voxel wireframe, 3D Dot Game Heroes style.
--
-- Every mesh in this mode is built on a one-unit-per-voxel grid in its OWN
-- model space -- terrain in world pixels, a character slab in the sprite's
-- own pixels, a carved model in its voxels. So the seams are simply the
-- model space -- terrain in world pixels, a character card in the sprite's
-- own pixels. So the seams are simply the
-- integer planes of that space, and drawing them is a pixel-shader job:
-- measure how far the fragment is from the nearest integer plane, in
-- DISPLAY pixels, and darken the ones within half a pixel of it. Sitting in
-- model space is what keeps the wireframe glued to a thing however it is
-- posed -- a character's slab leans back by the camera's pitch and its
-- posed -- a character's card leans back by the camera's pitch and its
-- seams lean with it, instead of the world's grid sliding across the
-- sprite.
--
-147
View File
@@ -1,147 +0,0 @@
-- Voxel world mode: character models.
--
-- Loads the face lists tools/build_voxels.py carves into this mod's own
-- assets/voxels/<name>.lua and turns them into meshes. A face carries the
-- sheet pixel it samples rather than a baked color, so a model textures
-- from the LIVE sprite sheet image -- the very image SpriteRenderer would
-- have drawn. That is what makes RED++ OBJ palette recolors and mod sprite
-- replacements apply to the 3D model with no rebuild and no second copy of
-- the palette logic.
--
-- A model is geometry alone: it names no sheet, and never points into the
-- player's imported cache (MK301). The sheet comes from the spriteDef the
-- engine already handed us, so the pairing is the engine's, not ours.
--
-- The carved models ship inside this mod, but another mod may shadow one
-- by dropping overrides/voxels/<name>.lua -- the same overrides/ directory
-- and the same priority order the engine uses for art. Our own copy is the
-- fallback when nothing shadows it.
-- 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 VoxelModels = {}
-- Where a model may live, in priority order. A voxel model is this mod's
-- own file type -- the cache never holds one -- so rather than route the
-- lookup through Assets.resolve (which only ever rewrites cache paths, and
-- would mean naming a cache path we have no business naming), we walk the
-- engine's override order ourselves and read the same overrides/ directory
-- it would have read. Highest-priority mod first; our copy last.
local OVERRIDE_DIR = "/overrides/voxels/"
local OWN_DIR = V.path .. "/assets/voxels/"
-- Assets.loader is nil on a mod-free boot and in the headless harness, in
-- which case nothing can be shadowing anything and our own copy is it.
local function candidates(name)
local paths = {}
local loader = Assets.loader
if loader then
for _, other in ipairs(loader:overrideOrder()) do
paths[#paths + 1] = other.path .. OVERRIDE_DIR .. name .. ".lua"
end
end
paths[#paths + 1] = OWN_DIR .. name .. ".lua"
return paths
end
local specs = {} -- sprite name -> face-list table, or false
local meshes = {} -- "name#pose" -> mesh, or false
-- the sprite's own image path -> model name, its basename ("red"). The
-- path is whatever the engine put in the spriteDef; we only read its stem.
local function modelName(spriteDef)
local image = spriteDef and spriteDef.image
if type(image) ~= "string" then return nil end
return image:match("([^/\\]+)%.png$")
end
-- The carved face list for a sprite, or nil. Missing models are cached as
-- false: a sprite with no model falls back to a flat billboard, and must
-- not re-hit the filesystem every frame to discover that.
function VoxelModels.spec(spriteDef)
local name = modelName(spriteDef)
if not name then return nil end
if specs[name] == nil then
local spec = false
-- first candidate that yields a usable model wins. A candidate that
-- is missing OR fails to load keeps the walk going, so a broken
-- override costs that mod its model, never ours.
for _, path in ipairs(candidates(name)) do
if Assets.exists(path) then
-- love.filesystem, not loadfile: the path may live inside a mounted
-- .love archive or a mod directory, which plain io cannot reach
local ok, chunk = pcall(love.filesystem.load, path)
if ok and chunk then
local good, value = pcall(chunk)
if good and type(value) == "table" and value.poses then
spec = value
break
end
end
end
end
specs[name] = spec
end
return specs[name] or nil
end
-- Expand a pose's flat face runs into a mesh. Each face is one quad whose
-- four corners share a single texel centre, so every face is flat-shaded
-- from exactly one sheet pixel -- which is what voxel art wants, and side
-- steps filtering bleed at every face edge for free.
local function buildMesh(spec, pose)
local p = spec.poses[pose]
if not p or not p.faces then return nil end
local faces, n = p.faces, p.count or (#p.faces / 6)
local sw, sh = spec.sheetW or 16, spec.sheetH or 16
local verts, indices = {}, {}
for i = 0, n - 1 do
local b = i * 6
local x, y, z = faces[b + 1], faces[b + 2], faces[b + 3]
local dir, u, v = faces[b + 4], faces[b + 5], faces[b + 6]
local corners = Voxel3D.FACE_CORNERS[dir]
if corners then
local tu, tv = (u + 0.5) / sw, (v + 0.5) / sh
local shade = Voxel3D.FACE_SHADE[dir] or 1
for c = 1, 4 do
local o = corners[c]
verts[#verts + 1] = { x + o[1], y + o[2], z + o[3], tu, tv, shade }
end
Voxel3D.pushQuad(indices, #verts / 4 - 1)
end
end
return Voxel3D.newMesh(verts, indices)
end
-- The mesh for one (sprite, pose). `pose` is "stand" or "walk"; a sheet
-- with no walk frames falls back to its stand model, which is what a
-- 3-frame NPC does in 2D too (it turns to face but never animates).
function VoxelModels.mesh(spriteDef, pose)
local spec = VoxelModels.spec(spriteDef)
if not spec then return nil end
if not spec.poses[pose] then pose = "stand" end
local key = (spec.name or "?") .. "#" .. pose
if meshes[key] == nil then
local ok, mesh = pcall(buildMesh, spec, pose)
meshes[key] = (ok and mesh) or false
end
return meshes[key] or nil
end
function VoxelModels.size(spriteDef)
local spec = VoxelModels.spec(spriteDef)
return spec and spec.size or 16
end
function VoxelModels.invalidate()
specs = {}
meshes = {}
end
Assets.register(VoxelModels.invalidate)
return VoxelModels
+28 -63
View File
@@ -16,7 +16,6 @@ local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")
local ShadowMap = V.require("ShadowMap")
local ChunkMesher = V.require("ChunkMesher")
local VoxelModels = V.require("VoxelModels")
local SpriteBillboards = V.require("SpriteBillboards")
local TileShape = V.require("TileShape")
local TerrainAtlas = V.require("TerrainAtlas")
@@ -97,11 +96,9 @@ end
VoxelScene._skyFor = skyFor -- named for the suite
VoxelScene._skyStrength = skyStrength
-- A facing is a yaw about +Y. Models are carved facing +Z (south, "down"),
-- and Mat4.rotateY(+90 deg) maps +Z to +X, so east is +90 and west is -90.
-- Nothing is mirrored: yawing to face east shows the model's east side,
-- which carries the left-view art -- exactly the mirror the 2D game draws
-- for right-facing, arrived at by turning the model instead of flipping it.
-- A facing as a yaw about +Y, kept for callers that reason about which way
-- an entity points (the mod exports it). The character cards themselves
-- never yaw -- they face south and lean, like the flat game.
local YAW = {
down = 0,
up = math.pi,
@@ -133,27 +130,8 @@ local function groundAt(map, cellX, cellY)
return s.h > 0 and s.h or 0
end
-- Model matrix for a 16x16 character whose cell's top-left is world
-- (px, py), standing on ground height `gh`, yawed to `facing`. The yaw is
-- taken about the cell centre, not the model corner, so turning in place
-- keeps the character on its own tile.
local function characterMatrix(px, py, gh, facing, mirror)
local m = Mat4.mul(Mat4.translate(px + 8, gh, py + 8),
Mat4.rotateY(YAW[facing] or 0))
if mirror then
m = Mat4.mul(m, Mat4.scale(-1, 1, 1))
end
return Mat4.mul(m, Mat4.translate(-8, 0, -8))
end
VoxelScene.characterMatrix = characterMatrix
VoxelScene.YAW = YAW
-- Character style: "billboard" renders each entity as its current sprite
-- frame voxelized with side-view relief, leaning back toward the camera;
-- "model" is the carved visual-hull figure that turns with its facing.
VoxelScene.style = "billboard"
-- Camera-ward pull distance for billboards (and the grass rows, which
-- must keep their relative depth to feet): just enough that a leaned-back
-- slab clears the wall it leans over. The lean flattens toward top-down,
@@ -229,37 +207,25 @@ local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors,
end
local y = gh + (lift or 0)
if VoxelScene.style == "billboard" then
-- pick the very frame the 2D path would draw (same tables). The slab
-- always faces SOUTH -- the direction the 2D game implies -- and only
-- LEANS BACK, pivoting at its feet, by exactly the camera's pitch, so
-- at every tilt level the sprite reads face-on like the flat game.
-- No camera-tracking yaw: every sprite leans in parallel.
local frame, mirror = frameFor(def, facing, phase, flip)
local mesh = SpriteBillboards.mesh(def, frame)
if mesh then
-- Camera-ward pull (applied per vertex in the shader, along each
-- vertex's own eye ray, so it is a PURE depth bias with zero screen
-- drift): lets the leaned-back head win against the wall it leans
-- OVER while a character genuinely BEHIND a building is dozens of
-- pixels deeper and still loses, so real occlusion works.
-- the same slab UNLEANED is what the sun saw (castShadows draws
-- exactly this card), so that is where each vertex asks whether the
-- light reached it -- see Voxel3D.draw
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
billboardPull(),
Voxel3D.casterMatrix(px, py, y, mirror))
return true
end
-- no pixel access: fall through to the carved model
end
local mesh = VoxelModels.mesh(def, phase == 1 and "walk" or "stand")
-- pick the very frame the 2D path would draw (same tables). 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, so
-- at every tilt level the sprite reads face-on like the flat game.
-- No camera-tracking yaw: every sprite leans in parallel.
local frame, mirror = frameFor(def, facing, phase, flip)
local mesh = SpriteBillboards.mesh(def, frame)
if not mesh then return false end
-- alternate walk steps mirror the down/up frames in 2D (the GB uses an
-- OAM flip); mirroring the model about its own centre is the same idea
local mirror = flip and phase == 1 and (facing == "down" or facing == "up")
Voxel3D.draw(mesh, tex, characterMatrix(px, py, y, facing, mirror))
-- Camera-ward pull (applied per vertex in the shader, along each
-- vertex's own eye ray, so it is a PURE depth bias with zero screen
-- drift): lets the leaned-back head win against the wall it leans
-- OVER while a character genuinely BEHIND a building is dozens of
-- pixels deeper and still loses, so real occlusion works.
-- the same card UNLEANED is what the sun saw (castShadows draws
-- exactly this mesh), so that is where each vertex asks whether the
-- light reached it -- see Voxel3D.draw
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
billboardPull(),
Voxel3D.casterMatrix(px, py, y, mirror))
return true
end
@@ -268,14 +234,13 @@ VoxelScene.drawEntity = drawEntity
-- The player's silhouette, for wherever the scenery is standing in front of
-- them (Voxel3D.beginGhost inverts the depth test around this call).
--
-- A FLAT CARD, not the relief slab the solid pass draws. The slab carries
-- front and back faces and nothing is culled, so with the test inverted its
-- own back faces -- a few voxels deeper than the front ones that just won --
-- read as "behind something" and the figure repaints itself on open ground,
-- occluded or not. One quad has no self-overlap, which is the very reason
-- the shadow pass uses this same mesh, and it cannot double-blend into a
-- mottled patch either. A silhouette is an outline, so the outline is
-- exactly the right mesh for it.
-- The same flat card the solid pass and the sun pass draw. That it has no
-- self-overlap is what makes it safe here: with the depth test inverted, a
-- mesh carrying both front and back faces would read its own back faces as
-- "behind something" and repaint the figure on open ground, occluded or
-- not. One quad cannot do that, and cannot double-blend into a mottled
-- patch either. A silhouette is an outline, so an outline is the right
-- mesh for it.
local function drawGhost(p)
local def = p.sprite.def
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)