mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:00:50 +02:00
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:
+42
-158
@@ -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
@@ -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
@@ -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.
|
||||
--
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user