added player silhouette when passing in front of buildings

This commit is contained in:
DramaticShape
2026-07-26 22:57:10 -04:00
parent cec3d5e1ef
commit 251bf85ba2
5 changed files with 206 additions and 15 deletions
+57
View File
@@ -1,5 +1,62 @@
# Changelog
## 1.0.3
### Added
- **The player shows through whatever hides them.** Occlusion in this mode is
the real thing -- walk north of Red's house and the roof is genuinely in
front of you -- but a player who cannot see their own character has lost
track of where they are standing, which the flat game never allowed. The
figure now draws a second time as a translucent silhouette wherever the
world is in front of it.
No code anywhere asks whether the player is occluded: the depth buffer
already knows, and the test is the question. The silhouette is drawn with
the depth compare INVERTED -- `greater` where the scene uses `lequal` --
so it appears exactly where the ordinary draw would have lost, and nothing
at all is drawn when nothing is in the way. LOVE hands the compare straight
to `glDepthFunc`, so the two are true complements with no seam between
them.
It goes down BEFORE the characters, so the only thing it can meet in the
depth buffer is the world -- terrain, buildings, trees. Drawn after the
solid pass it would meet the player's own card instead, and every fragment
of a figure sits behind the one that just wrote it, so it would paint over
the player permanently. Characters then draw on top as usual.
It uses the FLAT card (`SpriteBillboards.shadowQuad`), not the relief slab
the solid pass draws. The slab carries front and back faces and the mode
culls neither, 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 whether or not anything is in
front of it. One quad has no self-overlap, which is exactly why the shadow
pass already uses this mesh, and it cannot double-blend into a mottled
patch either. A silhouette is an outline, so the outline is the right mesh.
Depth writes are off: the pass is behind the scenery by definition, and
writing would file the hidden figure in front of the building hiding it,
which the grass pass at the end of the frame reads. The card carries the
same transform and the same camera-ward pull as the solid draw (both now
come from one shared `billboardMatrix`/`billboardPull`, so they cannot
drift), which is what keeps the leaning-over-a-near-wall case out of it:
pull already won that fight for the solid draw, so a character merely
standing close to a wall does not shimmer a silhouette over it.
It is drawn as ONE flat translucent grey, not as a dimmed copy of the
sprite. Tinting through the vertex colour could only MULTIPLY the sprite's
own pixels, which darkens each one by its own amount and keeps all the
character's internal detail -- a murky picture of Red rather than a shape.
So the fragment shader carries a `ghost` / `ghostColor` pair and replaces
the colour outright, last in the chain so neither the sun nor a voxel seam
can mottle it. Staying translucent is what keeps it reading as "behind
that wall" rather than as a hole punched through it.
`Voxel3D.GHOST_COLOR` and `GHOST_ALPHA` (0.5) are the knobs. Only the
player gets this -- NPCs and the ghosts standing on a neighbouring map are
left to honest occlusion, because it is only your own character you cannot
afford to lose behind a roof.
## 1.0.2
### Fixed
+76
View File
@@ -198,6 +198,9 @@ local SHADER = [[
}
#endif
uniform vec3 ghostColor; // the flat silhouette colour
uniform float ghost; // 0 = shade normally, 1 = flatten to it
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
// sprite sheets key GB OBJ color 0 to alpha 0; discarding rather than
@@ -210,6 +213,13 @@ local SHADER = [[
// dark grass and one across a white roof each stay in their own palette
rgb *= 1.0 - gridDark * voxelSeam(vGrid);
#endif
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
// through `color` could only multiply the sprite's own pixels, which
// darkens each one by its own amount and keeps the character's internal
// detail; replacing the colour outright is what makes it read as one
// solid silhouette. Last in the chain, so neither the sun nor a voxel
// seam can mottle it.
rgb = mix(rgb, ghostColor, ghost);
return vec4(rgb, 1.0) * color;
}
#endif
@@ -394,6 +404,12 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
pcall(sh.send, sh, "gridDark", VoxelGrid.DARK)
pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH)
end
-- ordinary shading until the silhouette pass asks for otherwise. Sent
-- every frame rather than once, because a scene that opened mid-ghost --
-- a driver hiccup between beginGhost and endGhost -- would otherwise
-- start out flattening everything it drew.
pcall(sh.send, sh, "ghost", 0)
pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
-- the curved world bends about the camera's focus, so the horizon keeps
-- a fixed distance ahead of the player rather than sitting on the map
Voxel3D.curveK = WorldCurve.k(vh)
@@ -418,6 +434,66 @@ function Voxel3D.depth(mode)
true)
end
-- ------------------------------------------------ the player's own ghost --
-- The silhouette's colour, and how solid it is.
--
-- ONE flat grey rather than a dimmed copy of the sprite, so the shape reads
-- at a glance instead of competing with whatever is showing through it --
-- and translucent rather than opaque, so it stays a hint of where the
-- player is rather than a hole punched in the building. The wall it is
-- seen through still shows, which is what keeps it reading as "behind
-- that" instead of "in front of it".
Voxel3D.GHOST_COLOR = { 0.26, 0.26, 0.28 }
Voxel3D.GHOST_ALPHA = 0.5
-- Draw a character AGAIN wherever the ordinary draw LOST the depth test.
--
-- Honest occlusion is the point of this mode -- walk behind the Mart and the
-- Mart is genuinely in front of you -- but a player who cannot see their own
-- character has lost track of where they are standing, which the flat game
-- never allowed. So the figure is drawn a second time with the test
-- INVERTED: "greater" passes exactly where "lequal" failed, and LOVE hands
-- the compare straight to glDepthFunc, so the two are true complements.
-- Every texel of the sprite is therefore drawn once and once only -- solid
-- where it is visible, translucent where it is not -- with no seam where
-- they meet and no double-blending anywhere.
--
-- Nothing is drawn at all when nothing is in the way, and no code here ever
-- asks whether the player is occluded: the depth buffer already knows, and
-- the test is the question.
--
-- Depth WRITES are off. This pass is behind the scenery by definition, and
-- writing would file the hidden figure's depth in front of the building
-- hiding it -- the grass pass at the end of the frame reads that buffer.
--
-- The caller redraws through the ordinary character path, so the ghost keeps
-- the same mesh, matrix and camera-ward PULL as the real draw. The pull
-- matching is what keeps the leaning-over-a-near-wall case out of here: pull
-- already won that fight for the solid draw, so this pass finds nothing left
-- to paint and a character merely standing close to a wall does not shimmer
-- a ghost over it.
function Voxel3D.beginGhost()
if not active then return end
pcall(love.graphics.setDepthMode, "greater", false)
love.graphics.setColor(1, 1, 1, Voxel3D.GHOST_ALPHA)
if activeShader then
pcall(activeShader.send, activeShader, "ghostColor", Voxel3D.GHOST_COLOR)
pcall(activeShader.send, activeShader, "ghost", 1)
end
end
function Voxel3D.endGhost()
if not active then return end
pcall(love.graphics.setDepthMode, "lequal", true)
love.graphics.setColor(1, 1, 1, 1)
-- back to ordinary shading before anything else draws; leaving it set
-- would flatten the grass pass that follows into one grey sheet
if activeShader then
pcall(activeShader.send, activeShader, "ghost", 0)
end
end
-- -------------------------------------------------------------- shadows --
-- The sun. One direction, shared by everything that needs to know where
+71 -13
View File
@@ -191,6 +191,27 @@ local function drawShadow(sprite, px, py, facing, phase, flip, gh, lift)
Voxel3D.shadowMatrix(px, py, gh, lift, mirror))
end
-- Where a billboard character's card stands: on the middle of its cell at
-- height `y`, pivoted at the feet and tipped back by exactly the camera's
-- pitch. The slab is built centred on its sprite plane (z = 0), so only the
-- x anchor shifts; the relief bulges symmetrically front and back of it.
--
-- Shared by the solid draw and the silhouette below, so the two can never
-- drift apart -- a silhouette standing anywhere but exactly behind the
-- figure would read as a second character.
local function billboardMatrix(px, py, y, mirror)
local Voxel = V.require("VoxelState")
local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8),
Mat4.rotateX(Voxel.angle - math.pi / 2))
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
end
local function billboardPull()
local Voxel = V.require("VoxelState")
return VoxelScene.pull(math.max(Voxel.angle, 0.05))
end
-- Draw one posed entity. Returns true if 3D geometry carried it, false
-- when nothing could be built and the caller should fall back.
-- `colors` is the 4-color world palette the entity stands under in the SGB
@@ -217,25 +238,17 @@ local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors,
local frame, mirror = frameFor(def, facing, phase, flip)
local mesh = SpriteBillboards.mesh(def, frame)
if mesh then
local Voxel = V.require("VoxelState")
local pitch = Voxel.angle - math.pi / 2
-- 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.
local a = math.max(Voxel.angle, 0.05)
local pull = VoxelScene.pull(a)
local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8),
Mat4.rotateX(pitch))
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
-- the slab is built centered on its sprite plane (z = 0), so only
-- the x anchor shifts; the relief bulges symmetrically front/back
m = Mat4.mul(m, Mat4.translate(-8, 0, 0))
-- 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, m, pull, Voxel3D.casterMatrix(px, py, y, mirror))
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
@@ -252,6 +265,31 @@ end
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.
local function drawGhost(p)
local def = p.sprite.def
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
local mesh = SpriteBillboards.shadowQuad(def, frame)
if not mesh then return end
local tex = p.sprite:resolveImage()
if p.colors and not def.trueColor then
tex = TerrainAtlas.forSprite(def.image, p.colors) or tex
end
local y = p.gh + (p.lift or 0)
Voxel3D.draw(mesh, tex, billboardMatrix(p.px, p.py, y, mirror),
billboardPull())
end
-- Render the world. `state` is the OverworldState; `vw`/`vh` the world view
-- size in world pixels; `w`/`h` the pixel size of the canvas to render
-- into; `paletteFor(map)` yields a map's 4-color world palette (nil in the
@@ -334,9 +372,15 @@ end
-- map. pose() returns the VISUAL y (ledge hops arc it, surfing bobs it);
-- the difference from the entity's base y becomes vertical LIFT in 3D, so
-- a hop rises off the ground instead of sliding north.
-- Returns the pose list and, separately, the PLAYER's entry in it (nil
-- during a Fly animation, which draws the player itself and is skipped
-- below). Only that one entry gets the see-through treatment: NPCs and the
-- ghosts standing on a neighbour map are left to honest occlusion, because
-- it is only your own character you cannot afford to lose behind a roof.
local function posesOf(state, spriteColors)
local colors = spriteColors(state.map)
local posed = {}
local me = nil
for _, g in ipairs(state.ghosts or {}) do
local sprite, vx, vy, facing, phase, flip = g.npc:pose()
posed[#posed + 1] = {
@@ -355,9 +399,10 @@ local function posesOf(state, spriteColors)
gh = groundAt(state.map, e.cellX, e.cellY),
lift = e.py - vy, colors = colors,
}
if e == state.player then me = posed[#posed] end
end
end
return posed
return posed, me
end
-- A stamp of everything the sun pass depends on. Nothing in it moving
@@ -451,7 +496,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
return modeColors(paletteFor, map)
end
local posed = posesOf(state, spriteColors)
local posed, me = posesOf(state, spriteColors)
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor)
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
@@ -480,6 +525,19 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
Voxel3D.endShadows()
end
-- The player's silhouette goes down BEFORE the characters, so the only
-- thing it can meet in the depth buffer is the WORLD -- terrain, buildings,
-- trees. Drawn after the solid pass it would meet the player's own card
-- instead, and every fragment of a figure sits behind the one that just
-- wrote it, so the silhouette would paint over the player at all times.
-- Every character then draws on top as usual, which leaves the silhouette
-- showing in exactly one situation: where the world hides them.
if me then
Voxel3D.beginGhost()
drawGhost(me)
Voxel3D.endGhost()
end
-- characters, normally depth-tested: the camera-ward pull inside
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
-- character genuinely behind a building is far deeper and loses the
+1 -1
View File
@@ -372,7 +372,7 @@ mod.events:on("map.reloaded", function(payload)
if mapId then ChunkMesher.invalidate(mapId) end
end)
mod.exports.version = "1.0.2"
mod.exports.version = "1.0.3"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.0.2",
"version": "1.0.3",
"api": 2,
"entry": "main.lua",
"profile": "content",