DramaticShapeVoxelMod archival mirror · read-only
251bf85ba2a939bfc84da9a3758f4d1963828363

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",