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.
This commit is contained in:
josue 2026-06-12 18:09:56 +02:00
parent 9157d07afc
commit ce249f4ef0
5 changed files with 121 additions and 4 deletions

View file

@ -683,7 +683,7 @@ void CMiniMapTexture::UpdateAndUploadEntities(
ICmpMinimap* cmpMinimap = static_cast<ICmpMinimap*>(it->second);
if (cmpMinimap->GetRenderData(v.r, v.g, v.b, posX, posZ))
{
LosVisibility vis = cmpRangeManager->GetLosVisibility(it->first, m_Simulation.GetSimContext().GetCurrentDisplayedPlayer());
LosVisibility vis = cmpRangeManager->GetCachedLosVisibility(it->first, m_Simulation.GetSimContext().GetCurrentDisplayedPlayer());
if (vis != LosVisibility::HIDDEN)
{
v.a = 255;

View file

@ -1884,6 +1884,41 @@ public:
return GetLosVisibility(handle, player);
}
LosVisibility GetCachedLosVisibility(CEntityHandle ent, player_id_t player) const override
{
entity_id_t entId = ent.GetId();
// Entities not with positions in the world are never visible
if (entId == INVALID_ENTITY)
return LosVisibility::HIDDEN;
CmpPtr<ICmpPosition> cmpPosition(ent);
if (!cmpPosition || !cmpPosition->IsInWorld())
return LosVisibility::HIDDEN;
// Gaia and observers do not have a visibility cache
if (player <= 0)
return ComputeLosVisibility(ent, player);
// The cache of entities that changed since the last visibility update
// is still stale (this notably keeps newly created entities, e.g.
// corpses, from being hidden until the next turn).
if (m_ModifiedEntitiesSet.find(entId) != m_ModifiedEntitiesSet.end())
return ComputeLosVisibility(ent, player);
EntityMap<EntityData>::const_iterator it = m_EntityData.find(entId);
if (it == m_EntityData.end())
return ComputeLosVisibility(ent, player);
return static_cast<LosVisibility>(GetPlayerVisibility(it->second.visibilities, player));
}
LosVisibility GetCachedLosVisibility(entity_id_t ent, player_id_t player) const override
{
CEntityHandle handle = GetSimContext().GetComponentManager().LookupEntityHandle(ent);
return GetCachedLosVisibility(handle, player);
}
LosVisibility GetLosVisibilityPosition(entity_pos_t x, entity_pos_t z, player_id_t player) const override
{
int i = (x / LOS_TILE_SIZE).ToInt_RoundToNearest();

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games.
/* 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
@ -461,7 +461,7 @@ void CCmpUnitRenderer::UpdateVisibility(SUnit& unit) const
else
{
CmpPtr<ICmpRangeManager> cmpRangeManager(GetSystemEntity());
unit.visibility = cmpRangeManager->GetLosVisibility(unit.entity,
unit.visibility = cmpRangeManager->GetCachedLosVisibility(unit.entity,
GetSimContext().GetCurrentDisplayedPlayer());
}
}

View file

@ -297,6 +297,21 @@ public:
virtual LosVisibility GetLosVisibility(CEntityHandle ent, player_id_t player) const = 0;
virtual LosVisibility GetLosVisibility(entity_id_t ent, player_id_t player) const = 0;
/**
* Returns the visibility status of the given entity as of the last per-turn
* visibility update, with respect to the given player.
* Unlike GetLosVisibility, this does not recompute the visibility of
* entities in LOS regions that changed since the last update, so it never
* calls into scripts for them. The result may thus be one turn stale; it is
* consistent with the VisibilityChanged messages. Meant for rendering and
* other non-simulation callers (see #5876 and #8327).
* Entities whose own state changed since the last update (e.g. created or
* moved entities, see RequestVisibilityUpdate) are still recomputed, as are
* queries for players without a visibility cache (gaia and observers).
*/
virtual LosVisibility GetCachedLosVisibility(CEntityHandle ent, player_id_t player) const = 0;
virtual LosVisibility GetCachedLosVisibility(entity_id_t ent, player_id_t player) const = 0;
/**
* Returns the visibility status of the given position, with respect to the given player.
* This respects the GetLosRevealWholeMap flag.

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games.
/* 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
@ -290,6 +290,73 @@ public:
}
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);