Fog lit by rays: shadows cut through tiles, and walls stay visible
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:
claudeandClaude Opus 5.5 committed 2026-10-05 10:07:33 +02:00
1 parent 83c3b920fb
commit 855669a665
12 files changed
+871 -45

No files matched your search

+305
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
uid://beivpa4pxiu3f