added player silhouette when passing in front of buildings
DramaticShape · 2026-07-26 ·
parent cec3d5e1ef
added player silhouette when passing in front of buildings
Changed files
CHANGELOG.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++
lib/Voxel3D.lua | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
lib/VoxelScene.lua | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
main.lua | 2 +-
manifest.json | 2 +-
5 files changed, 206 insertions(+), 15 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dea4d22..53b4294 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index edff87f..f6e9836 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -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
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 577165f..c4d0742 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -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
diff --git a/main.lua b/main.lua
index 88f775e..94f7fd8 100644
--- a/main.lua
+++ b/main.lua
@@ -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
diff --git a/manifest.json b/manifest.json
index f10771e..be6dca3 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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",