From 251bf85ba2a939bfc84da9a3758f4d1963828363 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 26 Jul 2026 22:57:10 -0400 Subject: [PATCH] added player silhouette when passing in front of buildings --- 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 --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",