From 55ba7a9f41e463dcb94f2adaf6859d8563df316c Mon Sep 17 00:00:00 2001
From: pawelw
Date: Sat, 7 Nov 2026 12:35:55 +0100
Subject: [PATCH] Instancing: skip the buckets of a cell lying wholly beyond
the range of their instances (#225)
* perf(instancing): skip a cell's buckets lying wholly beyond the range of their instances
* perf(instancing): go only through the instances drawn one by one in the per-node pass
* perf(instancing): leave out of the color pass the cells holding only instances too far to be drawn
* refactor(instancing): keep the caches of the instances of a cell without mutable fields, set by the renderer when the cell is queued
---
model/AnimModel.cpp | 3 ++
model/AnimModel.h | 3 ++
rendering/opengl33renderer.cpp | 93 +++++++++++++++++++++++++++++++++-
rendering/opengl33renderer.h | 6 +++
scene/scene.cpp | 3 ++
scene/scene.h | 13 +++++
6 files changed, 119 insertions(+), 2 deletions(-)
diff --git a/model/AnimModel.cpp b/model/AnimModel.cpp
index 770790c1e..cafb3a93e 100644
--- a/model/AnimModel.cpp
+++ b/model/AnimModel.cpp
@@ -514,6 +514,7 @@ bool submodel_tree_blocks_instancing( TSubModel const *Sub ) {
} // anonymous namespace
int TAnimModel::s_instanceable_total = 0;
+unsigned int TAnimModel::s_instanceable_revoked = 0;
int TAnimModel::s_classified_total = 0;
int TAnimModel::s_rejected_no_pmodel = 0;
int TAnimModel::s_rejected_lights = 0;
@@ -551,6 +552,7 @@ std::shared_ptr TAnimModel::AddContainer(std::string const &Name
m_animlist.push_back(tmp);
// containers are added by events after the instance was loaded and bucketed;
// the instanced path skips RaAnimate(), so this instance has to be drawn on its own
+ if( m_instanceable ) { ++s_instanceable_revoked; }
m_instanceable = false;
return tmp;
}
@@ -780,6 +782,7 @@ void TAnimModel::SkinSet( int const Index, material_handle const Material ) {
m_materialdata.replacable_skins[ std::clamp( Index, 1, 4 ) ] = Material;
// the instance stays in the bucket of its old skin set, where it would get the textures of the batch
+ if( m_instanceable ) { ++s_instanceable_revoked; }
m_instanceable = false;
}
diff --git a/model/AnimModel.h b/model/AnimModel.h
index 0087418df..2cf1003bb 100644
--- a/model/AnimModel.h
+++ b/model/AnimModel.h
@@ -237,6 +237,9 @@ public:
// diagnostic counters (process-wide). Updated inside update_instanceable_flag()
// so the renderer can surface load-time classification stats in the debug overlay.
static int s_instanceable_total; // count of TAnimModel*'s classified instanceable
+ // bumped whenever an instance loses its instanceable status after it was put in a scene cell (an event adds
+ // an animation container, or changes a skin); the cells rebuild their lists of instances drawn one by one then
+ static unsigned int s_instanceable_revoked;
static int s_classified_total; // count of TAnimModel*'s that ran through the classifier
static int s_rejected_no_pmodel;
static int s_rejected_lights;
diff --git a/rendering/opengl33renderer.cpp b/rendering/opengl33renderer.cpp
index 5c64988b4..33346fad2 100644
--- a/rendering/opengl33renderer.cpp
+++ b/rendering/opengl33renderer.cpp
@@ -2666,6 +2666,12 @@ void opengl33_renderer::Render(section_sequence::iterator First, section_sequenc
{
if ((true == cell.m_active) && (m_renderpass.pass_camera.visible(cell.m_area)))
{
+ // a cell with nothing but instances too far to be drawn is left out of the color pass altogether
+ if( ( true == Prepare_Instances( cell ) )
+ && ( m_renderpass.draw_mode == rendermode::color )
+ && ( true == Cell_Out_Of_Range( cell ) ) ) {
+ continue;
+ }
// store visible cells with content as well as their current distance, for sorting later
m_cellqueue.emplace_back(glm::length2(m_renderpass.pass_camera.position() - cell.m_area.center), &cell);
}
@@ -2680,6 +2686,7 @@ void opengl33_renderer::Render(section_sequence::iterator First, section_sequenc
{
if ((true == cell.m_active) && (m_renderpass.pass_camera.visible(cell.m_area)))
{
+ Prepare_Instances( cell );
// store visible cells with content as well as their current distance, for sorting later
m_cellqueue.emplace_back(glm::length2(m_renderpass.pass_camera.position() - cell.m_area.center), &cell);
}
@@ -2871,8 +2878,10 @@ void opengl33_renderer::Render(cell_sequence::iterator First, cell_sequence::ite
Queue_Instances( cell );
}
}
- // remaining (non-instanceable) opaque instance nodes go through the per-node path
- for (auto *instance : cell->m_instancesopaque)
+ // remaining (non-instanceable) opaque instance nodes go through the per-node path. the list of them kept for
+ // the cell spares going through every instance of it only to skip the instanceable ones - with many of these
+ // (vegetation) that took more time than drawing everything else
+ for (auto *instance : ( Instance_Cache_Valid( *cell ) ? cell->m_instancecache.single : cell->m_instancesopaque ))
{
if( instance->m_instanceable ) { continue; } // already handled via bucket
Render(instance);
@@ -3178,6 +3187,11 @@ void opengl33_renderer::Queue_Instances( scene::basic_cell const *Cell )
auto const distancelimit { slack * ( reflections ? sq( static_cast( Global.reflectiontune.range_instances ) ) : sq( static_cast( m_renderpass.draw_range ) + 250.0 ) ) };
// up close the rounding of the copies is too large a part of the distance for the slack to cover it
auto const distancetested { sq( 64.0 ) };
+ // the nearest point of the bounding sphere of the cell, which holds the locations of all its instances. no instance
+ // of the cell is closer, so where this distance fails the range test below for the largest range in a bucket,
+ // it fails it for each instance of that bucket, and the bucket can be skipped without going through them
+ auto const cellnearestdistance { std::max( 0.0, glm::length( Cell->m_area.center - cameraposition ) - Cell->m_area.radius ) };
+ auto const cellnearestdistancesquared { cellnearestdistance * cellnearestdistance };
for( auto const &bucket : Cell->m_instancebuckets_opaque ) {
auto const &instances { bucket.second.instances };
@@ -3190,6 +3204,11 @@ void opengl33_renderer::Queue_Instances( scene::basic_cell const *Cell )
|| ( bucket.second.bounds.size() != instances.size() ) ) {
Update_Instance_Bounds( bucket.first.pModel, bucket.second );
}
+ else if( ( bucket.second.rangeversion == m_instanceboundsversion )
+ && ( cellnearestdistancesquared > distancetested )
+ && ( cellnearestdistancesquared * distancescale + distanceoffset >= slack * bucket.second.rangesquaredmax ) ) {
+ continue;
+ }
auto const &bounds { bucket.second.bounds };
std::vector *dest { nullptr }; // looked up when there's something to put in it
for( std::size_t idx = 0; idx < bounds.size(); ++idx ) {
@@ -3210,6 +3229,76 @@ void opengl33_renderer::Queue_Instances( scene::basic_cell const *Cell )
}
}
+// brings up to date what the renderer keeps of the instances of the cell: the copies of the bounds of the instances
+// and the largest range in each bucket, the largest range in the cell, and the list of its instances drawn one by one.
+// rebuilt after the content of the cell changed, or any instance lost its instanceable status (an event added an
+// animation container, or changed a skin). returns: true if the cache can be used; it can't in the editor, which
+// moves and resizes the instances in place, as with the copies of their bounds
+bool opengl33_renderer::Prepare_Instances( scene::basic_cell &Cell )
+{
+ if( EditorModeFlag ) { return false; }
+ if( Instance_Cache_Valid( Cell ) ) { return true; }
+
+ auto &cache { Cell.m_instancecache };
+ cache.rangesquaredmax = 0.f;
+ for( auto &[ key, bucket ] : Cell.m_instancebuckets_opaque ) {
+ if( ( bucket.boundsversion != m_instanceboundsversion )
+ || ( bucket.bounds.size() != bucket.instances.size() ) ) {
+ Update_Instance_Bounds( key.pModel, bucket );
+ }
+ auto rangesquaredmax { 0.f };
+ for( auto const &instancebounds : bucket.bounds ) {
+ rangesquaredmax = std::max( rangesquaredmax, instancebounds.rangesquaredmax );
+ }
+ bucket.rangesquaredmax = rangesquaredmax;
+ bucket.rangeversion = m_instanceboundsversion;
+ cache.rangesquaredmax = std::max( cache.rangesquaredmax, rangesquaredmax );
+ }
+ cache.single.clear();
+ for( auto *instance : Cell.m_instancesopaque ) {
+ if( false == instance->m_instanceable ) {
+ cache.single.emplace_back( instance );
+ }
+ }
+ cache.instancedonly =
+ ( false == Cell.m_instancebuckets_opaque.empty() )
+ && Cell.m_paths.empty()
+ && Cell.m_shapesopaque.empty()
+ && Cell.m_shapestranslucent.empty()
+ && Cell.m_lines.empty()
+ && Cell.m_traction.empty()
+ && Cell.m_instancetranslucent.empty()
+ && cache.single.empty();
+ cache.boundsversion = m_instanceboundsversion;
+ cache.revoked = TAnimModel::s_instanceable_revoked;
+ return true;
+}
+
+bool opengl33_renderer::Instance_Cache_Valid( scene::basic_cell const &Cell ) const
+{
+ return ( false == EditorModeFlag )
+ && ( Cell.m_instancecache.boundsversion == m_instanceboundsversion )
+ && ( Cell.m_instancecache.revoked == TAnimModel::s_instanceable_revoked );
+}
+
+// true if nothing of the cell would be drawn by the color pass: it holds only instances drawn with the instanced path,
+// and the nearest point of the cell is past the largest range of them. the test is the one made for each bucket in
+// Queue_Instances(), done before the cell is put in the cell queue: a scenery with many small instances of a short
+// range has them in nearly every cell up to the draw range, and queueing, sorting and visiting these cells only
+// to skip all their buckets took a good part of the frame
+bool opengl33_renderer::Cell_Out_Of_Range( scene::basic_cell const &Cell ) const
+{
+ if( ( false == Instance_Cache_Valid( Cell ) ) || ( false == Cell.m_instancecache.instancedonly ) ) { return false; }
+ // same measures as in Queue_Instances() for the color pass
+ auto const distancescale { 1.0 / ( static_cast( Global.ZoomFactor ) * static_cast( Global.ZoomFactor ) * static_cast( Global.fDistanceFactor ) ) };
+ auto const slack { 1.001 };
+ auto const distancetested { sq( 64.0 ) };
+ auto const cellnearestdistance { std::max( 0.0, glm::length( Cell.m_area.center - m_renderpass.pass_camera.position() ) - Cell.m_area.radius ) };
+ auto const cellnearestdistancesquared { cellnearestdistance * cellnearestdistance };
+ return ( cellnearestdistancesquared > distancetested )
+ && ( cellnearestdistancesquared * distancescale >= slack * Cell.m_instancecache.rangesquaredmax );
+}
+
// makes the copies of the bounds of the instances held by a bucket of a scene cell
void opengl33_renderer::Update_Instance_Bounds( TModel3d const *Model, scene::basic_cell::instance_bucket const &Bucket )
{
diff --git a/rendering/opengl33renderer.h b/rendering/opengl33renderer.h
index d48055f9f..b472849bc 100644
--- a/rendering/opengl33renderer.h
+++ b/rendering/opengl33renderer.h
@@ -302,6 +302,12 @@ class opengl33_renderer : public gfx_renderer {
// adds the bucketed instances of a scene cell to the frame-level buckets drawn by Render_Instanced(),
// less the ones the copies of their bounds show to be out of sight in the current render pass
void Queue_Instances( scene::basic_cell const *Cell );
+ // brings up to date what the renderer keeps of the instances of the cell; returns: true if the cache can be used
+ bool Prepare_Instances( scene::basic_cell &Cell );
+ // true if the cache of the cell is up to date
+ bool Instance_Cache_Valid( scene::basic_cell const &Cell ) const;
+ // true if nothing of the cell would be drawn by the color pass from the camera of the pass
+ bool Cell_Out_Of_Range( scene::basic_cell const &Cell ) const;
// makes the copies of the bounds of the instances held by a bucket of a scene cell
void Update_Instance_Bounds( TModel3d const *Model, scene::basic_cell::instance_bucket const &Bucket );
bool Render(TDynamicObject *Dynamic);
diff --git a/scene/scene.cpp b/scene/scene.cpp
index 7b0e29940..312765c4d 100644
--- a/scene/scene.cpp
+++ b/scene/scene.cpp
@@ -352,6 +352,7 @@ void
basic_cell::insert( TAnimModel *Instance ) {
m_active = true;
+ m_instancecache.boundsversion = 0; // the renderer builds it anew
auto const flags = Instance->Flags();
auto alpha =
@@ -454,6 +455,8 @@ basic_cell::unregister_end( TTrack *Path ) {
void
basic_cell::erase( TAnimModel *Instance ) {
+ m_instancecache.boundsversion = 0; // the renderer builds it anew
+
auto const flags = Instance->Flags();
auto alpha =
Instance->Material() != nullptr ? Instance->Material()->textures_alpha : 0x30300030;
diff --git a/scene/scene.h b/scene/scene.h
index 086e2e830..0f52fcdbd 100644
--- a/scene/scene.h
+++ b/scene/scene.h
@@ -252,6 +252,18 @@ public:
// caches owned by the renderer; emptied whenever the content of the bucket changes
mutable std::vector bounds; // copies for the instances, in the same order
mutable unsigned int boundsversion { 0 };
+ // the largest rangesquaredmax of the copies - past it no instance of the bucket is drawn - and the version
+ // of the bounds it was taken with. set by the renderer before the cell is drawn, see Prepare_Instances()
+ float rangesquaredmax { 0.f };
+ unsigned int rangeversion { 0 };
+ };
+ // what the renderer keeps of the instances of the cell, set by it before the cell is drawn
+ struct instance_cache {
+ instance_sequence single; // the opaque instances drawn one by one, as they aren't instanceable
+ float rangesquaredmax { 0.f }; // the largest rangesquaredmax of the instance buckets
+ bool instancedonly { false }; // the cell holds nothing to draw but instances drawn with the instanced path
+ unsigned int boundsversion { 0 }; // version of the bounds of the renderer it was built with, 0: not built
+ unsigned int revoked { 0 }; // TAnimModel::s_instanceable_revoked at the time of the build
};
using instance_bucketdata_map = std::unordered_map< instance_bucket_key, instance_bucket, instance_bucket_key_hash >;
using sound_sequence = std::vector;
@@ -275,6 +287,7 @@ public:
// m_instancesopaque for nodes whose TAnimModel::m_instanceable == true.
// The renderer uses these to amortise per-model state setup across many instances.
instance_bucketdata_map m_instancebuckets_opaque;
+ instance_cache m_instancecache;
traction_sequence m_traction;
sound_sequence m_sounds;
eventlauncher_sequence m_eventlaunchers;