0ad/source/simulation2/components/tests/test_RangeManager.h
josue ce249f4ef0 Don't recompute the LOS visibility of unchanged entities when rendering
The per-turn visibility update of the range manager runs on MT_Update,
which is broadcast before unit motion and before scripted components
act. LOS changes later in the turn therefore leave regions flagged
dirty during all the frames rendered until the next turn, so the
renderer's once-per-turn GetLosVisibility query fell back to
ComputeLosVisibility for every entity in such regions. That calls into
the scripted Visibility component of every corpse near any fight once
per turn from the render path (#8327), and shows end-of-turn visibility
while positions are still interpolating across the turn (#5876).

Add GetCachedLosVisibility, which trusts the visibility cache for dirty
regions but still recomputes entities whose own state changed since the
last update - so newly spawned entities (corpses, mirages) don't render
hidden until the next turn - and use it for unit rendering and the
minimap. The cached result is consistent with the VisibilityChanged
messages. Simulation and UI callers keep the existing behaviour.

Limiting the scripted visibility calls to the per-turn update was
suggested by Itms and Vantha in #8327.
2026-06-12 18:09:56 +02:00

401 lines
18 KiB
C++

/* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D.
*
* 0 A.D. is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* 0 A.D. is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
*/
#include "lib/self_test.h"
#include "maths/Fixed.h"
#include "maths/FixedVector2D.h"
#include "maths/FixedVector3D.h"
#include "maths/Matrix3D.h"
#include "ps/CStr.h"
#include "ps/XML/Xeromyces.h"
#include "scriptinterface/ScriptInterface.h"
#include "simulation2/MessageTypes.h"
#include "simulation2/components/ICmpObstruction.h"
#include "simulation2/components/ICmpObstructionManager.h"
#include "simulation2/components/ICmpPosition.h"
#include "simulation2/components/ICmpRangeManager.h"
#include "simulation2/components/ICmpVision.h"
#include "simulation2/helpers/Player.h"
#include "simulation2/helpers/Position.h"
#include "simulation2/system/Component.h"
#include "simulation2/system/ComponentTest.h"
#include "simulation2/system/Entity.h"
#include <cstddef>
#include <cstdint>
#include <memory>
#include <optional>
#include <random>
#include <string>
#include <vector>
class MockVisionRgm : public ICmpVision
{
public:
DEFAULT_MOCK_COMPONENT()
entity_pos_t GetRange() const override { return entity_pos_t::FromInt(66); }
bool GetRevealShore() const override { return false; }
};
class MockPositionRgm : public ICmpPosition
{
public:
DEFAULT_MOCK_COMPONENT()
void SetTurretParent(entity_id_t /*id*/, const CFixedVector3D& /*pos*/) override {}
entity_id_t GetTurretParent() const override {return INVALID_ENTITY;}
void UpdateTurretPosition() override {}
std::set<entity_id_t>* GetTurrets() override { return nullptr; }
bool IsInWorld() const override { return true; }
void MoveOutOfWorld() override { }
void MoveTo(entity_pos_t /*x*/, entity_pos_t /*z*/) override { }
void MoveAndTurnTo(entity_pos_t /*x*/, entity_pos_t /*z*/, entity_angle_t /*a*/) override { }
void JumpTo(entity_pos_t /*x*/, entity_pos_t /*z*/) override { }
void SetHeightOffset(entity_pos_t /*dy*/) override { }
entity_pos_t GetHeightOffset() const override { return entity_pos_t::Zero(); }
void SetHeightFixed(entity_pos_t /*y*/) override { }
entity_pos_t GetHeightFixed() const override { return entity_pos_t::Zero(); }
entity_pos_t GetHeightAtFixed(entity_pos_t, entity_pos_t) const override { return entity_pos_t::Zero(); }
bool IsHeightRelative() const override { return true; }
void SetHeightRelative(bool /*relative*/) override { }
bool CanFloat() const override { return false; }
void SetFloating(bool /*flag*/) override { }
void SetActorFloating(bool /*flag*/) override { }
void SetActorAnchor(const CStr& /*anchor*/) override { }
void SetConstructionProgress(fixed /*progress*/) override { }
CFixedVector3D GetPosition() const override { return m_Pos; }
CFixedVector2D GetPosition2D() const override { return CFixedVector2D(m_Pos.X, m_Pos.Z); }
CFixedVector3D GetPreviousPosition() const override { return CFixedVector3D(); }
CFixedVector2D GetPreviousPosition2D() const override { return CFixedVector2D(); }
fixed GetTurnRate() const override { return fixed::Zero(); }
void TurnTo(entity_angle_t /*y*/) override { }
void SetYRotation(entity_angle_t /*y*/) override { }
void SetXZRotation(entity_angle_t /*x*/, entity_angle_t /*z*/) override { }
CFixedVector3D GetRotation() const override { return CFixedVector3D(); }
fixed GetDistanceTravelled() const override { return fixed::Zero(); }
void GetInterpolatedPosition2D(float /*frameOffset*/, float& x, float& z, float& rotY) const override { x = z = rotY = 0; }
CMatrix3D GetInterpolatedTransform(float /*frameOffset*/) const override { return CMatrix3D(); }
CFixedVector3D m_Pos;
};
class MockObstructionRgm : public ICmpObstruction
{
public:
DEFAULT_MOCK_COMPONENT();
MockObstructionRgm(entity_pos_t s) : m_Size(s) {};
ICmpObstructionManager::tag_t GetObstruction() const override { return {}; };
bool GetObstructionSquare(ICmpObstructionManager::ObstructionSquare&) const override { return false; };
bool GetPreviousObstructionSquare(ICmpObstructionManager::ObstructionSquare&) const override { return false; };
entity_pos_t GetSize() const override { return m_Size; };
CFixedVector2D GetStaticSize() const override { return {}; };
EObstructionType GetObstructionType() const override { return {}; };
void SetUnitClearance(const entity_pos_t&) override {};
bool IsControlPersistent() const override { return {}; };
bool CheckShorePlacement() const override { return {}; };
EFoundationCheck CheckFoundation(const std::string&) const override { return {}; };
EFoundationCheck CheckFoundation(const std::string& , bool) const override { return {}; };
std::string CheckFoundation_wrapper(const std::string&, bool) const override { return {}; };
bool CheckDuplicateFoundation() const override { return {}; };
std::vector<entity_id_t> GetEntitiesByFlags(ICmpObstructionManager::flags_t) const override { return {}; };
std::vector<entity_id_t> GetEntitiesBlockingMovement() const override { return {}; };
std::vector<entity_id_t> GetEntitiesBlockingConstruction() const override { return {}; };
std::vector<entity_id_t> GetEntitiesDeletedUponConstruction() const override { return {}; };
void ResolveFoundationCollisions() const override {};
void SetActive(bool) override {};
void SetMovingFlag(bool) override {};
void SetDisableBlockMovementPathfinding(bool, bool, int32_t) override {};
bool GetBlockMovementFlag(bool) const override { return {}; };
void SetControlGroup(entity_id_t) override {};
entity_id_t GetControlGroup() const override { return {}; };
void SetControlGroup2(entity_id_t) override {};
entity_id_t GetControlGroup2() const override { return {}; };
private:
entity_pos_t m_Size;
};
class TestCmpRangeManager : public CxxTest::TestSuite
{
std::optional<CXeromycesEngine> xeromycesEngine;
public:
void setUp()
{
xeromycesEngine.emplace();
}
void tearDown()
{
xeromycesEngine.reset();
}
// TODO It would be nice to call Verify() with the shore revealing system
// but that means testing on an actual map, with water and land.
void test_basic()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* cmp = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
MockVisionRgm vision;
test.AddMock(100, IID_Vision, vision);
MockPositionRgm position;
test.AddMock(100, IID_Position, position);
// This tests that the incremental computation produces the correct result
// in various edge cases
cmp->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512));
cmp->Verify();
{ CMessageCreate msg(100); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessageOwnershipChanged msg(100, -1, 1); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(247), entity_pos_t::FromDouble(257.95), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(247), entity_pos_t::FromInt(253), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256)+entity_pos_t::Epsilon(), entity_pos_t::FromInt(256), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256)-entity_pos_t::Epsilon(), entity_pos_t::FromInt(256), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256)+entity_pos_t::Epsilon(), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256)-entity_pos_t::Epsilon(), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(383), entity_pos_t::FromInt(84), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(348), entity_pos_t::FromInt(83), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
std::mt19937 rng;
for (size_t i = 0; i < 1024; ++i)
{
double x = std::uniform_real_distribution<double>(0.0, 512.0)(rng);
double z = std::uniform_real_distribution<double>(0.0, 512.0)(rng);
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromDouble(x), entity_pos_t::FromDouble(z), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
}
// Test OwnershipChange, GetEntitiesByPlayer, GetNonGaiaEntities
{
player_id_t previousOwner = -1;
for (player_id_t newOwner = 0; newOwner < 8; ++newOwner)
{
CMessageOwnershipChanged msg(100, previousOwner, newOwner);
cmp->HandleMessage(msg, false);
for (player_id_t i = 0; i < 8; ++i)
TS_ASSERT_EQUALS(cmp->GetEntitiesByPlayer(i).size(), i == newOwner ? 1 : 0);
TS_ASSERT_EQUALS(cmp->GetNonGaiaEntities().size(), newOwner > 0 ? 1 : 0);
previousOwner = newOwner;
}
}
}
void test_queries()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* cmp = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
MockVisionRgm vision, vision2;
MockPositionRgm position, position2;
MockObstructionRgm obs(fixed::FromInt(2)), obs2(fixed::Zero());
test.AddMock(100, IID_Vision, vision);
test.AddMock(100, IID_Position, position);
test.AddMock(100, IID_Obstruction, obs);
test.AddMock(101, IID_Vision, vision2);
test.AddMock(101, IID_Position, position2);
test.AddMock(101, IID_Obstruction, obs2);
cmp->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512));
cmp->Verify();
{ CMessageCreate msg(100); cmp->HandleMessage(msg, false); }
{ CMessageCreate msg(101); cmp->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(100, -1, 1); cmp->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(101, -1, 1); cmp->HandleMessage(msg, false); }
auto move = [&cmp](entity_id_t ent, MockPositionRgm& pos, fixed x, fixed z) {
pos.m_Pos = CFixedVector3D(x, fixed::Zero(), z);
{ CMessagePositionChanged msg(ent, true, x, z, entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
};
move(100, position, fixed::FromInt(10), fixed::FromInt(10));
move(101, position2, fixed::FromInt(10), fixed::FromInt(20));
std::vector<entity_id_t> nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
move(101, position2, fixed::FromInt(10), fixed::FromInt(10));
nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
move(101, position2, fixed::FromInt(10), fixed::FromInt(13));
nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
move(101, position2, fixed::FromInt(10), fixed::FromInt(15));
// In range thanks to self obstruction size.
nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
// In range thanks to target obstruction size.
nearby = cmp->ExecuteQuery(101, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{100});
// Trickier: min-range is closest-to-closest, but rotation may change the real distance.
nearby = cmp->ExecuteQuery(100, fixed::FromInt(2), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(5), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(6), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
nearby = cmp->ExecuteQuery(101, fixed::FromInt(5), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{100});
nearby = cmp->ExecuteQuery(101, fixed::FromInt(6), fixed::FromInt(50), {1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
}
void test_cached_los_visibility()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* cmp = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
// A viewer unit of player 1, and a static entity of player 2 watched by it.
MockVisionRgm vision;
MockPositionRgm viewerPosition, watchedPosition;
test.AddMock(100, IID_Vision, vision);
test.AddMock(100, IID_Position, viewerPosition);
test.AddMock(101, IID_Position, watchedPosition);
cmp->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512));
cmp->SetSharedLos(1, { 1 });
cmp->SetSharedLos(2, { 2 });
{ CMessageCreate msg(100); cmp->HandleMessage(msg, false); }
{ CMessageCreate msg(101); cmp->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(100, -1, 1); cmp->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(101, -1, 2); cmp->HandleMessage(msg, false); }
auto move = [&cmp](entity_id_t ent, MockPositionRgm& pos, int x, int z) {
pos.m_Pos = CFixedVector3D(fixed::FromInt(x), fixed::Zero(), fixed::FromInt(z));
CMessagePositionChanged msg(ent, true, fixed::FromInt(x), fixed::FromInt(z), entity_angle_t::Zero());
cmp->HandleMessage(msg, false);
};
auto update = [&cmp] {
CMessageUpdate msg(fixed::FromInt(1));
cmp->HandleMessage(msg, false);
};
move(100, viewerPosition, 10, 10);
move(101, watchedPosition, 20, 10);
// Both entities changed since the last visibility update, so the
// cached query recomputes them and agrees with the uncached one.
TS_ASSERT_EQUALS(cmp->GetCachedLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::VISIBLE);
TS_ASSERT_EQUALS(cmp->GetLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::VISIBLE);
// The per-turn update refreshes the cache.
update();
TS_ASSERT_EQUALS(cmp->GetCachedLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::VISIBLE);
TS_ASSERT_EQUALS(cmp->GetLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::VISIBLE);
// The viewer moves away: the watched entity leaves player 1's vision.
// Its LOS region is now dirty, so the uncached query recomputes (the
// entity is in explored-but-not-visible territory and doesn't retain
// in fog, hence hidden), while the cached query still returns the
// result of the last per-turn update.
move(100, viewerPosition, 400, 400);
TS_ASSERT_EQUALS(cmp->GetLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::HIDDEN);
TS_ASSERT_EQUALS(cmp->GetCachedLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::VISIBLE);
// After the next per-turn update both agree again.
update();
TS_ASSERT_EQUALS(cmp->GetLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::HIDDEN);
TS_ASSERT_EQUALS(cmp->GetCachedLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::HIDDEN);
// An entity whose own state changed since the last update (here: it
// moved into player 1's vision) is recomputed even by the cached
// query, so e.g. newly spawned corpses don't stay hidden until the
// next turn.
move(101, watchedPosition, 401, 400);
TS_ASSERT_EQUALS(cmp->GetLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::VISIBLE);
TS_ASSERT_EQUALS(cmp->GetCachedLosVisibility(static_cast<entity_id_t>(101), 1), LosVisibility::VISIBLE);
}
void test_IsInTargetParabolicRange()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* cmp = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
const entity_id_t source = 200;
const entity_id_t target = 201;
entity_pos_t range = fixed::FromInt(-3);
entity_pos_t yOrigin = fixed::FromInt(-20);
// Invalid range.
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), range);
// No source ICmpPosition.
range = fixed::FromInt(10);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
// No target ICmpPosition.
MockPositionRgm cmpSourcePosition;
test.AddMock(source, IID_Position, cmpSourcePosition);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
// Too much height difference.
MockPositionRgm cmpTargetPosition;
test.AddMock(target, IID_Position, cmpTargetPosition);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
// If no offset we get the range.
range = fixed::FromInt(20);
yOrigin = fixed::Zero();
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), range);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, fixed::Zero(), yOrigin), fixed::Zero());
// Normal case.
yOrigin = fixed::FromInt(5);
range = fixed::FromInt(10);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), fixed::FromFloat(14.142136f));
// Big range.
range = fixed::FromInt(260);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), fixed::FromFloat(264.952820f));
}
};