Fog lit by rays: shadows cut through tiles, and walls stay visible
ci / verify (push) Successful in 52s
ci / verify (push) Successful in 52s
The fog asked one question per tile -- can I see its centre? -- and drew or skipped the whole tile on the answer. Every shadow was a staircase, tiles snapped in and out as you walked, and a wall beside you vanished whenever its centre happened to sit behind its neighbour. Visibility casts rays from the eye instead: a fixed sweep, plus two aimed either side of every wall corner, which is what makes each shadow edge a straight line rather than a smear between rays. The view now draws terrain and actors first and then darkness over whatever the rays did not reach, so a shadow runs straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets are a child node and are still drawn over the dark; telegraphs are drawn after it, as they must be. "A bit less strict" is three specific things. A wall face you can see is lit 26 units in, measured perpendicular to the face; shadows end in a short fade rather than a line; and sight fades over its last 64 units rather than stopping at a circle. Still hard fog -- nothing remembered. The first version measured that depth along the ray, and a screenshot caught what no test had: a wall you look along got a sliver, so the wall beside you in a tunnel looked missing -- the old fog's own complaint. Perpendicular depth fixes it, and light stops the instant a ray steps back into the open, so it never rounds a corner, never reaches the far side of a one-tile wall, and never leaves a stepped wall through its other face. Two tiles touching only at a corner are closed. About half a millisecond a frame. Measured against the old fog in the same scene: hub 68 fps against 62, dungeon 240 against 240. The hub's own low rate predates this. MapGrid gains a revision counter so the corner list is rebuilt only when the map changes or the eye crosses a tile. screenshot.tscn now captures a dungeon, a tunnel and the tunnel with F1's overlay, which draws where each ray stopped -- and makes SHOT_DIR rather than failing every capture when it does not exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
83c3b920fb
commit
855669a665
12 files changed
+871
-45
No files matched your search
@@ -0,0 +1,305 @@
|
||||
extends GutTest
|
||||
## The fog's geometry. Nothing here can look at the screen -- screenshot.tscn is
|
||||
## for that -- but every property that makes the fog fair or unfair is a
|
||||
## question about which points are lit, and those can be asked directly.
|
||||
|
||||
const T := MapGrid.TILE
|
||||
const VIEW := 460.0
|
||||
|
||||
var sight: Visibility
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
sight = Visibility.new()
|
||||
|
||||
|
||||
## An open floor, walled at the border, with the origin at a tile corner so
|
||||
## world positions are easy to reason about.
|
||||
func _room(w: int = 40, h: int = 40) -> MapGrid:
|
||||
var g := MapGrid.new(w, h, MapGrid.Kind.WALL)
|
||||
g.fill_rect(Rect2i(1, 1, w - 2, h - 2), MapGrid.Kind.FLOOR)
|
||||
return g
|
||||
|
||||
|
||||
func _centre(tx: int, ty: int) -> Vector2:
|
||||
return Vector2(float(tx) + 0.5, float(ty) + 0.5) * T
|
||||
|
||||
|
||||
func test_open_ground_is_lit_and_beyond_sight_is_not() -> void:
|
||||
var g := _room()
|
||||
var eye := _centre(20, 20)
|
||||
sight.compute(g, eye, VIEW)
|
||||
assert_eq(sight.light_at(eye + Vector2(100.0, 0.0)), 1.0)
|
||||
assert_eq(sight.light_at(eye + Vector2(0.0, -(VIEW - Visibility.FADE - 1.0))), 1.0,
|
||||
"lit all the way to where the fade begins")
|
||||
assert_eq(sight.light_at(eye + Vector2(VIEW + 5.0, 0.0)), 0.0,
|
||||
"nothing past the radius")
|
||||
var mid := sight.light_at(eye + Vector2(VIEW - Visibility.FADE * 0.5, 0.0))
|
||||
assert_between(mid, 0.2, 0.8, "the edge of sight fades instead of cutting")
|
||||
|
||||
|
||||
func test_a_wall_hides_what_is_behind_it() -> void:
|
||||
var g := _room()
|
||||
for ty in range(1, 39):
|
||||
g.set_tile(24, ty, MapGrid.Kind.WALL)
|
||||
var eye := _centre(20, 20)
|
||||
sight.compute(g, eye, VIEW)
|
||||
assert_eq(sight.light_at(_centre(28, 20)), 0.0)
|
||||
assert_eq(sight.light_at(_centre(22, 20)), 1.0, "and not what is in front")
|
||||
|
||||
|
||||
## The feature asked for: a shadow edge that runs THROUGH a tile, so part of it
|
||||
## is lit and part is not. The old fog could only light or hide a tile whole.
|
||||
func test_one_tile_can_be_part_lit() -> void:
|
||||
var g := _room()
|
||||
g.set_tile(22, 20, MapGrid.Kind.PILLAR)
|
||||
var eye := _centre(20, 20) + Vector2(0.0, 7.0)
|
||||
sight.compute(g, eye, VIEW)
|
||||
# The pillar's shadow is the wedge between its top-left and bottom-left
|
||||
# corners as seen from the eye: -25.6 to +10.6 degrees. Tile (25, 21) spans
|
||||
# about +2.9 to +15.9, so the lower edge of the wedge cuts straight across
|
||||
# it. Sample a grid inside it and require both answers.
|
||||
var lit := 0
|
||||
var dark := 0
|
||||
for sy in 8:
|
||||
for sx in 8:
|
||||
var p := Vector2(25.0 * T + 2.0 + float(sx) * 4.0, 21.0 * T + 2.0 + float(sy) * 4.0)
|
||||
var l := sight.light_at(p)
|
||||
if l >= 0.99:
|
||||
lit += 1
|
||||
elif l <= 0.01:
|
||||
dark += 1
|
||||
assert_gt(lit, 0, "some of the tile is in the light")
|
||||
assert_gt(dark, 0, "and some of the same tile is in shadow")
|
||||
|
||||
|
||||
func test_a_shadow_leaves_a_corner_as_a_straight_line() -> void:
|
||||
var g := _room()
|
||||
g.set_tile(22, 18, MapGrid.Kind.PILLAR)
|
||||
var eye := _centre(20, 20)
|
||||
sight.compute(g, eye, VIEW)
|
||||
# The pillar's lower-right corner, and the line from the eye through it.
|
||||
# orthogonal() turns towards the pillar's other silhouette corner, which is
|
||||
# the shadowed side; its negation points away from the pillar, into light.
|
||||
var corner := Vector2(23.0, 19.0) * T
|
||||
var away := (corner - eye).normalized()
|
||||
var side := -away.orthogonal()
|
||||
for dist in [140.0, 220.0, 300.0]:
|
||||
var on_line: Vector2 = eye + away * dist
|
||||
assert_eq(sight.light_at(on_line + side * 3.0), 1.0,
|
||||
"%d units out, just on the open side of the edge" % dist)
|
||||
assert_eq(sight.light_at(on_line - side * 3.0), 0.0,
|
||||
"%d units out, just on the shadowed side" % dist)
|
||||
|
||||
|
||||
## "A bit less strict": the face of the wall in front of you is lit, rather than
|
||||
## being exactly where the dark begins and so never drawn at all.
|
||||
func test_the_wall_you_are_facing_is_visible() -> void:
|
||||
var g := _room()
|
||||
for ty in range(1, 39):
|
||||
g.set_tile(24, ty, MapGrid.Kind.WALL)
|
||||
var eye := _centre(20, 20)
|
||||
sight.compute(g, eye, VIEW)
|
||||
var face := 24.0 * T
|
||||
# A fixed depth, not one computed from the constant: a test that samples at
|
||||
# WALL_DEPTH - 2 passes with the depth at zero, sampling the floor in front.
|
||||
assert_eq(sight.light_at(Vector2(face + 12.0, eye.y)), 1.0,
|
||||
"the first stretch into the wall is fully lit")
|
||||
assert_lt(sight.light_at(Vector2(face + Visibility.WALL_DEPTH + 1.0, eye.y)),
|
||||
0.01, "and it is dark again before the far side")
|
||||
|
||||
|
||||
## The regression the first version of this shipped with, caught by a
|
||||
## screenshot: light carried a fixed distance ALONG each ray, so a wall seen at a
|
||||
## grazing angle -- the one right beside you in a tunnel -- got a sliver and
|
||||
## looked missing. Depth is measured straight in from the face now.
|
||||
func test_a_wall_you_are_looking_along_is_lit_its_whole_length() -> void:
|
||||
var g := _room()
|
||||
for ty in range(1, 39):
|
||||
g.set_tile(24, ty, MapGrid.Kind.WALL)
|
||||
var eye := Vector2(24.0 * T - 40.0, 20.5 * T) # just over a tile from the face
|
||||
sight.compute(g, eye, VIEW)
|
||||
var dim := 0
|
||||
for along in [0.0, 60.0, 120.0, 200.0, 280.0, 350.0]:
|
||||
for side in [-1.0, 1.0]:
|
||||
var p := Vector2(24.0 * T + 10.0, eye.y + along * side)
|
||||
if sight.light_at(p) < 0.99:
|
||||
dim += 1
|
||||
assert_eq(dim, 0, "ten units into the wall, all the way along it, is lit")
|
||||
|
||||
|
||||
func test_light_does_not_round_a_corner() -> void:
|
||||
# A wall ending in a corner, seen at a grazing angle. A ray that enters the
|
||||
# wall near its end would, if its light ran on regardless, come out through
|
||||
# the end face into the open space round the corner. It must stop there.
|
||||
var g := _room()
|
||||
for ty in range(1, 21):
|
||||
g.set_tile(24, ty, MapGrid.Kind.WALL) # ends at row 20
|
||||
var eye := Vector2(24.0 * T - 40.0, 6.0 * T)
|
||||
sight.compute(g, eye, VIEW)
|
||||
# Round the end of the wall: past its far face, below its last tile.
|
||||
for p in [Vector2(25.0 * T + 6.0, 21.0 * T + 6.0),
|
||||
Vector2(26.0 * T, 21.0 * T + 20.0),
|
||||
Vector2(25.0 * T + 20.0, 22.0 * T)]:
|
||||
assert_eq(sight.light_at(p), 0.0, "round the corner at %s is dark" % p)
|
||||
|
||||
|
||||
func test_light_never_reaches_the_far_side_of_a_one_tile_wall() -> void:
|
||||
assert_lt(Visibility.WALL_DEPTH, T,
|
||||
"the lit band inside a wall must stop inside a single tile of it")
|
||||
var g := _room()
|
||||
for ty in range(1, 39):
|
||||
g.set_tile(24, ty, MapGrid.Kind.WALL)
|
||||
var eye := _centre(20, 20)
|
||||
sight.compute(g, eye, VIEW)
|
||||
# Just past the wall, at every height the eye can see the wall at -- steep
|
||||
# and grazing alike.
|
||||
var leaks := 0
|
||||
for ty in range(2, 38):
|
||||
for fx in [0.5, 4.0, 12.0]:
|
||||
if sight.light_at(Vector2(25.0 * T + fx, float(ty) * T + 16.0)) > 0.0:
|
||||
leaks += 1
|
||||
assert_eq(leaks, 0, "nothing beyond a one-tile wall may be lit")
|
||||
|
||||
|
||||
func test_a_staircase_wall_hides_its_far_side() -> void:
|
||||
# A wall one tile thick that runs diagonally, as steps. A band of light
|
||||
# measured straight in from the face it entered could leave a step through
|
||||
# its other side -- which is the far side of the wall.
|
||||
var g := _room()
|
||||
for i in 14:
|
||||
g.set_tile(18 + i, 6 + i, MapGrid.Kind.WALL)
|
||||
g.set_tile(19 + i, 6 + i, MapGrid.Kind.WALL)
|
||||
var eye := _centre(16, 16) # below-left of the stairs
|
||||
sight.compute(g, eye, VIEW)
|
||||
var leaks := 0
|
||||
for i in range(1, 12):
|
||||
# The tile immediately above-right of each step: the far side.
|
||||
var far := _centre(20 + i, 5 + i)
|
||||
if g.at(20 + i, 5 + i) == MapGrid.Kind.FLOOR and sight.light_at(far) > 0.0:
|
||||
leaks += 1
|
||||
assert_eq(leaks, 0, "the far side of a stepped wall stays dark")
|
||||
|
||||
|
||||
func test_two_tiles_touching_at_a_corner_leave_no_gap_to_see_through() -> void:
|
||||
var g := _room()
|
||||
g.set_tile(22, 21, MapGrid.Kind.PILLAR)
|
||||
g.set_tile(21, 22, MapGrid.Kind.PILLAR)
|
||||
var eye := _centre(20, 20) + Vector2(16.0, 16.0) # the shared corner's diagonal
|
||||
sight.compute(g, eye, VIEW)
|
||||
# Straight along the diagonal, through the point where the two pillars meet.
|
||||
var through := eye + Vector2(1.0, 1.0).normalized() * 120.0
|
||||
assert_eq(sight.light_at(through), 0.0,
|
||||
"two walls meeting at a point are closed, not a crack")
|
||||
# Vector2 is single precision and the cast is double, so "exactly" is to a
|
||||
# thousandth of a unit.
|
||||
assert_almost_eq(Visibility.cast(g, eye, Vector2(1.0, 1.0).normalized(), VIEW),
|
||||
Vector2(22.0 * T, 22.0 * T).distance_to(eye), 0.001,
|
||||
"the ray stops exactly at the shared corner")
|
||||
|
||||
|
||||
func test_a_ray_stops_exactly_on_the_face_it_hits() -> void:
|
||||
var g := _room()
|
||||
g.set_tile(25, 20, MapGrid.Kind.WALL)
|
||||
var eye := _centre(20, 20)
|
||||
assert_almost_eq(Visibility.cast(g, eye, Vector2.RIGHT, VIEW), 25.0 * T - eye.x, 0.0001)
|
||||
assert_eq(Visibility.cast(g, eye, Vector2.UP, 100.0), 100.0,
|
||||
"nothing within reach means the full reach")
|
||||
|
||||
|
||||
func test_only_what_blocks_sight_casts_a_shadow() -> void:
|
||||
var g := _room()
|
||||
var eye := _centre(20, 20)
|
||||
for kind in [MapGrid.Kind.PIT, MapGrid.Kind.BARRICADE]:
|
||||
g.set_tile(23, 20, kind)
|
||||
sight.compute(g, eye, VIEW)
|
||||
assert_eq(sight.light_at(_centre(26, 20)), 1.0,
|
||||
"you can see across a %s" % MapGrid.Kind.keys()[kind])
|
||||
g.set_tile(23, 20, MapGrid.Kind.PILLAR)
|
||||
sight.compute(g, eye, VIEW)
|
||||
assert_eq(sight.light_at(_centre(26, 20)), 0.0, "but not past a pillar")
|
||||
|
||||
|
||||
func test_a_streamed_wall_is_noticed() -> void:
|
||||
# The corner list is cached between frames, so a chunk arriving with a new
|
||||
# wall in it has to invalidate it. A stale list does not lose the shadow --
|
||||
# rays read the tiles live -- it loses the EDGE: the new pillar's corners get
|
||||
# no rays of their own, and its silhouette is drawn only as finely as the
|
||||
# sweep, two degrees at a time. So the check is the edge, not the shadow.
|
||||
var g := _room()
|
||||
var eye := _centre(20, 20)
|
||||
sight.compute(g, eye, VIEW)
|
||||
g.set_tile(22, 18, MapGrid.Kind.PILLAR)
|
||||
sight.compute(g, eye, VIEW)
|
||||
var corner := Vector2(23.0, 19.0) * T
|
||||
var away := (corner - eye).normalized()
|
||||
var side := -away.orthogonal()
|
||||
var on_line := eye + away * 300.0
|
||||
assert_eq(sight.light_at(on_line + side * 2.0), 1.0,
|
||||
"the new pillar's edge is as sharp as one that was always there")
|
||||
assert_eq(sight.light_at(on_line - side * 2.0), 0.0)
|
||||
|
||||
|
||||
func test_light_falls_off_monotonically_along_every_ray() -> void:
|
||||
var g := _room()
|
||||
g.set_tile(23, 18, MapGrid.Kind.PILLAR)
|
||||
g.set_tile(17, 23, MapGrid.Kind.WALL)
|
||||
var eye := _centre(20, 20)
|
||||
sight.compute(g, eye, VIEW)
|
||||
for i in 64:
|
||||
var dir := Vector2.from_angle(TAU * float(i) / 64.0)
|
||||
var last := 1.0
|
||||
var r := 4.0
|
||||
while r < VIEW + 20.0:
|
||||
var l := sight.light_at(eye + dir * r)
|
||||
assert_lte(l, last + 0.0001, "light never comes back along a ray")
|
||||
last = l
|
||||
r += 6.0
|
||||
|
||||
|
||||
func test_the_shadow_mesh_is_well_formed() -> void:
|
||||
var g := _room()
|
||||
g.set_tile(23, 18, MapGrid.Kind.PILLAR)
|
||||
sight.compute(g, _centre(20, 20), VIEW)
|
||||
var mesh := sight.shadow_mesh(VIEW + 64.0, Color(0.05, 0.05, 0.08))
|
||||
var points: PackedVector2Array = mesh["points"]
|
||||
var colors: PackedColorArray = mesh["colors"]
|
||||
var indices: PackedInt32Array = mesh["indices"]
|
||||
assert_eq(points.size(), sight.angles.size() * 3)
|
||||
assert_eq(colors.size(), points.size(), "a colour per vertex")
|
||||
assert_eq(indices.size() % 3, 0, "whole triangles")
|
||||
for k in indices:
|
||||
assert_between(k, 0, points.size() - 1)
|
||||
for c in colors:
|
||||
assert_true(c.a == 0.0 or c.a == 1.0, "clear at the fade's start, dark beyond")
|
||||
|
||||
|
||||
func test_neighbouring_rays_are_never_far_apart() -> void:
|
||||
# The outline between two rays is a straight chord. If the gap were wide, the
|
||||
# chord would cut inside the circle of sight on open ground, and a wedge of
|
||||
# darkness would appear where nothing is in the way.
|
||||
var g := _room()
|
||||
sight.compute(g, _centre(20, 20), VIEW)
|
||||
var widest := 0.0
|
||||
var n := sight.angles.size()
|
||||
for i in n:
|
||||
var gap := wrapf(sight.angles[(i + 1) % n] - sight.angles[i], 0.0, TAU)
|
||||
widest = maxf(widest, gap)
|
||||
var dip := (VIEW - Visibility.FADE) * (1.0 - cos(widest * 0.5))
|
||||
assert_lt(dip, 1.0,
|
||||
"the chord dips %.2f units into open ground; under a pixel is invisible" % dip)
|
||||
# And the behaviour that follows from it: halfway between two rays, right at
|
||||
# the start of the fade, open ground is still fully lit.
|
||||
var mid := (sight.angles[0] + sight.angles[1]) * 0.5
|
||||
assert_eq(sight.light_at(_centre(20, 20)
|
||||
+ Vector2.from_angle(mid) * (VIEW - Visibility.FADE - 1.0)), 1.0)
|
||||
|
||||
|
||||
func test_a_real_dungeon_is_cheap_enough_to_do_every_frame() -> void:
|
||||
# Not a benchmark -- the machine running the suite varies -- but a guard
|
||||
# against the ray count exploding: corners are the variable part, and a
|
||||
# map with thousands of them in view would be a regression worth knowing.
|
||||
var inst := Instance.make_dungeon(2, 4242, 1, Dungeons.STANDARD)
|
||||
sight.compute(inst.world.map, inst.world.spawn_point, VIEW)
|
||||
assert_lt(sight.angles.size(), 1200,
|
||||
"%d rays for one frame of a depth-1 dungeon" % sight.angles.size())
|
||||
@@ -0,0 +1 @@
|
||||
uid://beivpa4pxiu3f
|
||||
Reference in new issue
Block a user