Browse Source

lighting: fix long-standing issue with invisible objects (#7901)

* lighting: fix long-standing issue with invisible objects

This fixes an issue with lighting, where objects in a straight line of
sight parallel to the X or Y coordinate lines become invisible. Issue
 #6641 perfectly illustrates the bug (see video attached to the bug).

What's worse, the objects are invisible to the observer (player)
regardless of the distance to that objects. The main requirement of a
bug reproduction is line of sight parallel to the X or Y coordinate
lines.

The actual bug lies in the visibility checks of adjacent tiles of a
point, hit by the cast ray. We've cast an approximated ray on an
integer 2D grid, so we need to check if a ray can pass through the
diagonally adjacent tiles. For example, consider this case:

       #?
      ↗ #
    x

The ray is cast from the observer 'x', and reaches the '?', but
diagonally adjacent tiles '#' do not pass the light, so the '?' should
not be visible for the 2D observer.

The trick is to perform two additional visibility checks for the
diagonally adjacent tiles, but only for the rays that are not parallel
to the X or Y coordinate lines. Parallel rays, which have a 0 in one
of their coordinate components, do not require any additional adjacent
visibility checks, and the tile, hit by the ray, is always considered
visible.

For the rays that parallel to the X or Y coordinate lines, the adjacent
visibility check always degenerated to the actual ray point visibility
check, which is considered invisible if it does not allow light to
pass through, and this is the actual bug.

To fix the issue, ensure the tile is always considered visible if the
ray that hits it is parallel to the X or Y coordinate lines.

To better demonstrate the problem, here's a straightforward simulation
written in Python:

   https://gist.github.com/rouming/25c555720f93735442c2053426e73bf5

The code simulates lighting from the DevilitionX implementation, by
placing the observer 'x' in the center of the grid. The observer is
surrounded by walls and 5 random obstacles, '.' are marked as visible,
were hit by the cast rays. The first matrix output shows the bug: no
walls and obstacles are visible in the line of sight parallel to the X
and Y coordinate lines. In contrast, the second matrix output (with
the fix applied) does not exhibit this problem. Also, note the box
corners are not visible due to the adjacent visibility checks, which
are functioning correctly.

Fixes: #6641
Signed-off-by: Roman Penyaev <r.peniaev@gmail.com>

* lighting: rename variables and add explicit comments for clarity

This patch improves clarity and readability without affecting
functionality:

1. Renames `VisionCrawlTable` to `VisionRays` and `crawl` to
`rayPoint` for better clarity on the purpose of these structures.

2. Renames `factors` to `quadrants` to reflect the actual purpose of
the mirror operation along the X or Y coordinate lines.

3. Adds more explicit comments to simplify the understanding of the
ray casting algorithm.

Signed-off-by: Roman Penyaev <r.peniaev@gmail.com>

* test/WarriorLevel1to2: update timedemo

Recent visibility fix impacts game state and causes the timedemo to
behave completely differently, resulting in a butterfly effect:
https://youtu.be/nhpuuHSKGgk. This patch updates the timedemo, which
was recorded with the visibility fixes applied, ensuring the tests
pass successfully. Here's the latest timedemo video for the future
generations: https://youtu.be/udGcWmarYNI

Signed-off-by: Roman Penyaev <r.peniaev@gmail.com>

---------

Signed-off-by: Roman Penyaev <r.peniaev@gmail.com>
pull/7918/head
Roman Penyaev 11 months ago committed by GitHub
parent
commit
88d0cb749f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 88
      Source/lighting.cpp
  2. BIN
      test/fixtures/timedemo/WarriorLevel1to2/demo_0.dmo
  3. BIN
      test/fixtures/timedemo/WarriorLevel1to2/demo_0_reference_spawn_0.sv
  4. BIN
      test/fixtures/timedemo/WarriorLevel1to2/spawn_0.sv

88
Source/lighting.cpp

@ -41,10 +41,15 @@ bool UpdateLighting;
namespace {
/*
* X- Y-coordinate offsets of lighting visions.
* The last entry-pair is only for alignment.
* XY points of vision rays are cast to trace the visibility of the
* surrounding environment. The table represents N rays of M points in
* one quadrant (0°-90°) of a circle, so rays for other quadrants will
* be created by mirroring. Zero points at the end will be trimmed and
* ignored. A similar table can be recreated using Bresenham's line
* drawing algorithm, which is suitable for integer arithmetic:
* https://en.wikipedia.org/wiki/Bresenham's_line_algorithm
*/
const DisplacementOf<int8_t> VisionCrawlTable[23][15] = {
static const DisplacementOf<int8_t> VisionRays[23][15] = {
// clang-format off
{ { 1, 0 }, { 2, 0 }, { 3, 0 }, { 4, 0 }, { 5, 0 }, { 6, 0 }, { 7, 0 }, { 8, 0 }, { 9, 0 }, { 10, 0 }, { 11, 0 }, { 12, 0 }, { 13, 0 }, { 14, 0 }, { 15, 0 } },
{ { 1, 0 }, { 2, 0 }, { 3, 0 }, { 4, 0 }, { 5, 0 }, { 6, 0 }, { 7, 0 }, { 8, 1 }, { 9, 1 }, { 10, 1 }, { 11, 1 }, { 12, 1 }, { 13, 1 }, { 14, 1 }, { 15, 1 } },
@ -80,9 +85,6 @@ bool UpdateVision;
/** interpolations of a 32x32 (16x16 mirrored) light circle moving between tiles in steps of 1/8 of a tile */
uint8_t LightConeInterpolations[8][8][16][16];
/** RadiusAdj maps from VisionCrawlTable index to lighting vision radius adjustment. */
const uint8_t RadiusAdj[23] = { 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 4, 3, 2, 2, 2, 1, 1, 1, 0, 0, 0, 0 };
void RotateRadius(DisplacementOf<int8_t> &offset, DisplacementOf<int8_t> &dist, DisplacementOf<int8_t> &light, DisplacementOf<int8_t> &block)
{
dist = { static_cast<int8_t>(7 - dist.deltaY), dist.deltaX };
@ -232,31 +234,67 @@ void DoVision(Point position, uint8_t radius, MapExplorationType doAutomap, bool
{
DoVisionFlags(position, doAutomap, visible);
static const Displacement factors[] = { { 1, 1 }, { -1, 1 }, { 1, -1 }, { -1, -1 } };
for (auto factor : factors) {
for (int j = 0; j < 23; j++) {
int lineLen = radius - RadiusAdj[j];
for (int k = 0; k < lineLen; k++) {
Point crawl = position + VisionCrawlTable[j][k] * factor;
if (!InDungeonBounds(crawl))
// Adjustment to a ray length to ensure all rays lie on an
// accurate circle
static const uint8_t rayLenAdj[23] = { 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 4, 3, 2, 2, 2, 1, 1, 1, 0, 0, 0, 0 };
static_assert(std::size(rayLenAdj) == std::size(VisionRays));
// Four quadrants on a circle
static const Displacement quadrants[] = { { 1, 1 }, { -1, 1 }, { 1, -1 }, { -1, -1 } };
// Loop over quadrants and mirror rays for each one
for (const auto &quadrant : quadrants) {
// Cast a ray for a quadrant
for (unsigned int j = 0; j < std::size(VisionRays); j++) {
int rayLen = radius - rayLenAdj[j];
for (int k = 0; k < rayLen; k++) {
const auto &relRayPoint = VisionRays[j][k];
// Calculate the next point on a ray in the quadrant
Point rayPoint = position + relRayPoint * quadrant;
if (!InDungeonBounds(rayPoint))
break;
bool blockerFlag = TileHasAny(crawl, TileProperties::BlockLight);
bool tileOK = !blockerFlag;
if (VisionCrawlTable[j][k].deltaX > 0 && VisionCrawlTable[j][k].deltaY > 0) {
tileOK = tileOK || TileAllowsLight(crawl + Displacement { -factor.deltaX, 0 });
tileOK = tileOK || TileAllowsLight(crawl + Displacement { 0, -factor.deltaY });
bool visible = true;
//
// We've cast an approximated ray on an integer 2D
// grid, so we need to check if a ray can pass through
// the diagonally adjacent tiles. For example, consider
// this case:
//
// #?
// ↗ #
// x
//
// The ray is cast from the observer 'x', and reaches
// the '?', but diagonally adjacent tiles '#' do not
// pass the light, so the '?' should not be visible
// for the 2D observer.
//
// The trick is to perform two additional visibility
// checks for the diagonally adjacent tiles, but only
// for the rays that are not parallel to the X or Y
// coordinate lines. Parallel rays, which have a 0 in
// one of their coordinate components, do not require
// any additional adjacent visibility checks, and the
// tile, hit by the ray, is always considered visible.
//
if (relRayPoint.deltaX > 0 && relRayPoint.deltaY > 0) {
Displacement adjacent1 = { -quadrant.deltaX, 0 };
Displacement adjacent2 = { 0, -quadrant.deltaY };
visible = (TileAllowsLight(rayPoint + adjacent1) || TileAllowsLight(rayPoint + adjacent2));
}
if (visible)
DoVisionFlags(rayPoint, doAutomap, visible);
if (!tileOK)
break;
DoVisionFlags(crawl, doAutomap, visible);
if (blockerFlag)
bool passesLight = TileAllowsLight(rayPoint);
if (!passesLight)
// Tile does not pass the light further, we are
// done with this ray
break;
int8_t trans = dTransVal[crawl.x][crawl.y];
int8_t trans = dTransVal[rayPoint.x][rayPoint.y];
if (trans != 0)
TransList[trans] = true;
}

BIN
test/fixtures/timedemo/WarriorLevel1to2/demo_0.dmo vendored

Binary file not shown.

BIN
test/fixtures/timedemo/WarriorLevel1to2/demo_0_reference_spawn_0.sv vendored

Binary file not shown.

BIN
test/fixtures/timedemo/WarriorLevel1to2/spawn_0.sv vendored

Binary file not shown.
Loading…
Cancel
Save