mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 08:01:09 +02:00
added player silhouette when passing in front of buildings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user