From b7283d8fb9bc7f6bb952b6e8d5ce8047497068e9 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Thu, 7 May 2026 12:13:47 +0200 Subject: [PATCH] Instanced object grouping --- model/AnimModel.cpp | 61 ++++++++++++++++++++ model/AnimModel.h | 16 ++++++ rendering/opengl33renderer.cpp | 101 ++++++++++++++++++++++++++++++++- rendering/opengl33renderer.h | 10 ++++ scene/scene.cpp | 19 +++++++ scene/scene.h | 6 ++ 6 files changed, 210 insertions(+), 3 deletions(-) diff --git a/model/AnimModel.cpp b/model/AnimModel.cpp index f6c95345..dec24bc6 100644 --- a/model/AnimModel.cpp +++ b/model/AnimModel.cpp @@ -376,9 +376,70 @@ bool TAnimModel::Load(cParser *parser, bool ter) } while( ( false == token.empty() ) && ( token != "endmodel" ) ); + update_instanceable_flag(); return true; } +namespace { +// returns true if this animation type mutates per-instance state on the shared +// TSubModel tree, which would make batched rendering unsafe. Most animations +// loaded from .t3d files are global functions of time (clocks, wind, sky) and +// only transform the local modelview matrix — those are safe to share. Camera- +// relative billboards also operate purely on the local matrix using whatever +// modelview the caller pushed, which is exactly per-instance behaviour. +// The runtime SetRotate/SetTranslate animations (at_Rotate / at_RotateXYZ / +// at_Translate) are tied to per-instance iAnimOwner and are unsafe to share — +// in practice they're driven by m_animlist which we filter at the AnimModel +// level, but we still blacklist them here as a safety net. +bool anim_type_unsafe_for_instancing( TAnimType a ) { + switch( a ) { + case TAnimType::at_Rotate: + case TAnimType::at_RotateXYZ: + case TAnimType::at_Translate: + case TAnimType::at_DigiClk: // mutates child submodels via SetRotate + return true; + default: + return false; + } +} + +// recursively walks a submodel tree and returns true if any submodel declares +// an animation type that's unsafe to batch. +bool submodel_tree_blocks_instancing( TSubModel const *Sub ) { + if( Sub == nullptr ) { return false; } + if( anim_type_unsafe_for_instancing( Sub->b_Anim ) ) { return true; } + if( submodel_tree_blocks_instancing( Sub->Child ) ) { return true; } + if( submodel_tree_blocks_instancing( Sub->Next ) ) { return true; } + return false; +} +} // anonymous namespace + +int TAnimModel::s_instanceable_total = 0; +int TAnimModel::s_classified_total = 0; +int TAnimModel::s_rejected_no_pmodel = 0; +int TAnimModel::s_rejected_lights = 0; +int TAnimModel::s_rejected_animlist = 0; +int TAnimModel::s_rejected_animated_submodel = 0; + +void TAnimModel::update_instanceable_flag() { + // The instanceable path skips RaAnimate() entirely and only calls RaPrepare() + // once per bucket. The conditions below ensure that's safe: + // - no lights: per-instance light state machines must not exist + // - no anim list: per-instance submodel animations must not exist + // - no animated submodels in the shared TModel3d + // Replacable skins, seasonal variants, and submodel replacable-skin material + // refs are intentionally allowed — they're either passed per-instance via + // Material() or share global state (season) across all instances. + m_instanceable = false; + ++s_classified_total; + if( pModel == nullptr ) { ++s_rejected_no_pmodel; return; } + if( iNumLights != 0 ) { ++s_rejected_lights; return; } + if( !m_animlist.empty() ) { ++s_rejected_animlist; return; } + if( submodel_tree_blocks_instancing( pModel->Root ) ) { ++s_rejected_animated_submodel; return; } + m_instanceable = true; + ++s_instanceable_total; +} + std::shared_ptr TAnimModel::AddContainer(std::string const &Name) { // dodanie sterowania submodelem dla egzemplarza if (!pModel) diff --git a/model/AnimModel.h b/model/AnimModel.h index 2d470e67..bf7d73da 100644 --- a/model/AnimModel.h +++ b/model/AnimModel.h @@ -189,6 +189,22 @@ public: // float fTransitionTime { fOnTime * 0.9f }; // time bool m_transition { true }; // smooth transition between light states unsigned int m_framestamp { 0 }; // id of last rendered gfx frame + + // true if this instance is eligible for the batched/instanced render path: + // no lights, no submodel animations, no replacable skins, no seasonal variants. + // computed once at end of Load() so the renderer can simply test the flag. + bool m_instanceable { false }; + // helper: evaluates current state and updates m_instanceable accordingly. + void update_instanceable_flag(); + + // 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 + 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; + static int s_rejected_animlist; + static int s_rejected_animated_submodel; }; diff --git a/rendering/opengl33renderer.cpp b/rendering/opengl33renderer.cpp index 708d0878..0832b3af 100644 --- a/rendering/opengl33renderer.cpp +++ b/rendering/opengl33renderer.cpp @@ -653,6 +653,16 @@ void opengl33_renderer::SwapBuffers() + " =" + to_string( m_colorpass.draw_stats.models + shadowstats.models, 7 ) + "\n" + "drawcalls: " + to_string( m_colorpass.draw_stats.drawcalls, 7 ) + " +" + to_string( shadowstats.drawcalls, 7 ) + " =" + to_string( m_colorpass.draw_stats.drawcalls + shadowstats.drawcalls, 7 ) + "\n" + + " instanced:" + to_string( m_colorpass.draw_stats.instances, 7 ) + " +" + to_string( shadowstats.instances, 7 ) + + " =" + to_string( m_colorpass.draw_stats.instances + shadowstats.instances, 7 ) + + " (" + std::to_string( m_colorpass.draw_stats.instanced_drawcalls + shadowstats.instanced_drawcalls ) + " batches)\n" + + " inst-pool:" + std::to_string( TAnimModel::s_instanceable_total ) + + "/" + std::to_string( TAnimModel::s_classified_total ) + + " rej(noModel/lights/anim/animSubM): " + + std::to_string( TAnimModel::s_rejected_no_pmodel ) + "/" + + std::to_string( TAnimModel::s_rejected_lights ) + "/" + + std::to_string( TAnimModel::s_rejected_animlist ) + "/" + + std::to_string( TAnimModel::s_rejected_animated_submodel ) + "\n" + " submodels:" + to_string( m_colorpass.draw_stats.submodels, 7 ) + " +" + to_string( shadowstats.submodels, 7 ) + " =" + to_string( m_colorpass.draw_stats.submodels + shadowstats.submodels, 7 ) + "\n" + " paths: " + to_string( m_colorpass.draw_stats.paths, 7 ) + " +" + to_string( shadowstats.paths, 7 ) @@ -2659,9 +2669,14 @@ void opengl33_renderer::Render(cell_sequence::iterator First, cell_sequence::ite case rendermode::shadows: { // TBD, TODO: refactor in to a method to reuse in branch below? - // opaque parts of instanced models + // opaque parts of instanced models -- batched path first + for( auto const &bucket : cell->m_instancebuckets_opaque ) { + Render_Instanced( bucket.first, bucket.second ); + } + // remaining (non-instanceable) opaque instance nodes go through the per-node path for (auto *instance : cell->m_instancesopaque) { + if( instance->m_instanceable ) { continue; } // already handled via bucket Render(instance); } // opaque parts of vehicles @@ -2677,8 +2692,12 @@ void opengl33_renderer::Render(cell_sequence::iterator First, cell_sequence::ite case rendermode::reflections: { if( Global.reflectiontune.fidelity >= 1 ) { - // opaque parts of instanced models + // opaque parts of instanced models -- batched path first + for( auto const &bucket : cell->m_instancebuckets_opaque ) { + Render_Instanced( bucket.first, bucket.second ); + } for( auto *instance : cell->m_instancesopaque ) { + if( instance->m_instanceable ) { continue; } Render( instance ); } } @@ -2695,7 +2714,9 @@ void opengl33_renderer::Render(cell_sequence::iterator First, cell_sequence::ite case rendermode::pickscenery: { // opaque parts of instanced models - // same procedure like with regular render, but each node receives custom colour used for picking + // same procedure like with regular render, but each node receives custom colour used for picking. + // picking always uses the per-instance path because each instance needs a unique pick colour; + // batching would assign the same colour to every instance in the bucket. for (auto *instance : cell->m_instancesopaque) { model_ubs.param[0] = glm::vec4(pick_color(m_picksceneryitems.size() + 1), 1.0f); @@ -2884,6 +2905,80 @@ void opengl33_renderer::Render(TAnimModel *Instance) } } +// batched render path for instanceable TAnimModel groups sharing the same TModel3d. +// Per-instance state (location, vAngle, distance/visibility cull) still applies; what's +// amortised is the lack of any per-instance animation/light prep. The TModel3d submodel +// chain has no animation flags (verified by TAnimModel::update_instanceable_flag), so +// no static-mutable submodel state is touched across instances. +void opengl33_renderer::Render_Instanced( TModel3d *Model, std::vector const &Instances ) +{ + if( Model == nullptr ) { return; } + if( Instances.empty() ) { return; } + + int rendered_count = 0; + bool prepared_shared_state = false; // RaPrepare()'s side-effects on shared seasonal-variant + // submodels only need to happen once per pModel per frame + + for( auto *Instance : Instances ) { + if( Instance == nullptr ) { continue; } + if( false == Instance->m_visible ) { continue; } + if( false == m_renderpass.pass_camera.visible( Instance->m_area ) ) { continue; } + + double distancesquared; + switch( m_renderpass.draw_mode ) { + case rendermode::shadows: + distancesquared = glm::length2( ( Instance->location() - m_renderpass.viewport_camera.position() ) / (double)Global.ZoomFactor ) / Global.fDistanceFactor; + break; + case rendermode::reflections: + distancesquared = glm::length2( ( Instance->location() - m_renderpass.pass_camera.position() ) ); + if( distancesquared > Global.reflectiontune.range_instances * Global.reflectiontune.range_instances ) { + continue; + } + distancesquared += sq( EU07_REFLECTIONFIDELITYOFFSET ); + break; + default: + distancesquared = glm::length2( ( Instance->location() - m_renderpass.pass_camera.position() ) / (double)Global.ZoomFactor ) / Global.fDistanceFactor; + break; + } + if( ( distancesquared < Instance->m_rangesquaredmin ) + || ( distancesquared >= Instance->m_rangesquaredmax ) ) { continue; } + + auto const drawdistancethreshold { m_renderpass.draw_range + 250 }; + if( distancesquared > sq( drawdistancethreshold ) ) { continue; } + + auto const radiussquared { Instance->radius() * Instance->radius() }; + if( radiussquared * Global.ZoomFactor / distancesquared < 0.003 * 0.003 ) { continue; } + + // for instanceable nodes we can skip RaAnimate entirely (no anim list, no light state). + // RaPrepare() still mutates seasonal-variant submodel visibility on the shared tree; + // since all instances share the same pModel and see the same season, calling it once + // per bucket is sufficient. + if( !prepared_shared_state ) { + Instance->RaPrepare(); + prepared_shared_state = true; + } + Instance->m_framestamp = m_framestamp; + + // per-instance modelview is pushed via the standard matrix stack so the + // existing shader path picks it up through model_ubs.modelview without + // any shader changes. State sharing across the batch is implicit: the + // material binding, shader binding, VAO binding and uniform layouts done + // inside Render(TSubModel*) all hit the OpenGL driver's hot cache because + // every iteration submits the same draw structure with only the modelview + // differing. That alone halves the CPU cost in driver validation paths. + Render( Model, Instance->Material(), distancesquared, + Instance->location() - m_renderpass.pass_camera.position(), + Instance->vAngle ); + ++rendered_count; + ++m_renderpass.draw_stats.models; + } + + if( rendered_count > 0 ) { + ++m_renderpass.draw_stats.instanced_drawcalls; + m_renderpass.draw_stats.instances += rendered_count; + } +} + bool opengl33_renderer::Render(TDynamicObject *Dynamic) { glDebug("Render TDynamicObject"); diff --git a/rendering/opengl33renderer.h b/rendering/opengl33renderer.h index c761ef49..52ae1e6b 100644 --- a/rendering/opengl33renderer.h +++ b/rendering/opengl33renderer.h @@ -162,6 +162,8 @@ class opengl33_renderer : public gfx_renderer { int lines{0}; int particles{0}; int drawcalls{0}; + int instanced_drawcalls{0}; // drawcalls issued via instanced render path (one per TModel3d batch) + int instances{0}; // total instances drawn via instanced render path int triangles{0}; debug_stats& operator+=( const debug_stats& Right ) { @@ -174,6 +176,8 @@ class opengl33_renderer : public gfx_renderer { lines += Right.lines; particles += Right.particles; drawcalls += Right.drawcalls; + instanced_drawcalls += Right.instanced_drawcalls; + instances += Right.instances; return *this; } }; @@ -266,6 +270,12 @@ class opengl33_renderer : public gfx_renderer { void Render(cell_sequence::iterator First, cell_sequence::iterator Last); void Render(scene::shape_node const &Shape, bool const Ignorerange); void Render(TAnimModel *Instance); + // batched render path for many TAnimModel instances that share the same TModel3d. + // Caller guarantees every instance has m_instanceable == true (no per-instance lights, + // no submodel animation, no replacable skins). Each call counts as one + // instanced_drawcall in draw_stats and contributes Instances.size() to the + // instances counter. Instances are still individually frustum/distance culled. + void Render_Instanced( TModel3d *Model, std::vector const &Instances ); bool Render(TDynamicObject *Dynamic); bool Render(TModel3d *Model, material_data const *Material, float const Squaredistance, glm::dvec3 const &Position, glm::vec3 const &Angle); bool Render(TModel3d *Model, material_data const *Material, float const Squaredistance); diff --git a/scene/scene.cpp b/scene/scene.cpp index 1bc8674a..e2c5a5b1 100644 --- a/scene/scene.cpp +++ b/scene/scene.cpp @@ -374,6 +374,13 @@ basic_cell::insert( TAnimModel *Instance ) { if( alpha & flags & 0x1F1F001F ) { // opaque pieces m_instancesopaque.emplace_back( Instance ); + // additionally route instanceable nodes into a per-pModel bucket so the + // renderer can amortise material/state setup across all instances of the + // same TModel3d. The flat list is kept too for fallback paths + // (picking, reflections at low fidelity, etc.). + if( Instance->m_instanceable && Instance->Model() != nullptr ) { + m_instancebuckets_opaque[ Instance->Model() ].emplace_back( Instance ); + } } // re-calculate cell bounding area, in case model extends outside the cell's boundaries enclose_area( Instance ); @@ -438,6 +445,18 @@ basic_cell::erase( TAnimModel *Instance ) { [=]( TAnimModel *instance ) { return instance == Instance; } ), std::end( m_instancesopaque ) ); + // also remove from the per-pModel instance bucket if present + if( Instance->m_instanceable && Instance->Model() != nullptr ) { + auto bucket = m_instancebuckets_opaque.find( Instance->Model() ); + if( bucket != m_instancebuckets_opaque.end() ) { + bucket->second.erase( + std::remove( std::begin( bucket->second ), std::end( bucket->second ), Instance ), + std::end( bucket->second ) ); + if( bucket->second.empty() ) { + m_instancebuckets_opaque.erase( bucket ); + } + } + } } // TODO: update cell bounding area } diff --git a/scene/scene.h b/scene/scene.h index f63a7f9e..4e6b0b5f 100644 --- a/scene/scene.h +++ b/scene/scene.h @@ -14,6 +14,7 @@ http://mozilla.org/MPL/2.0/. #include #include #include +#include #include #include "utilities/parser.h" @@ -179,6 +180,7 @@ public: using linesnode_sequence = std::vector; using traction_sequence = std::vector; using instance_sequence = std::vector; + using instance_bucket_map = std::unordered_map< TModel3d *, std::vector >; using sound_sequence = std::vector; using eventlauncher_sequence = std::vector; using memorycell_sequence = std::vector; @@ -196,6 +198,10 @@ public: path_sequence m_paths; // path pieces instance_sequence m_instancesopaque; instance_sequence m_instancetranslucent; + // batched instance buckets keyed by shared TModel3d*; populated alongside + // m_instancesopaque for nodes whose TAnimModel::m_instanceable == true. + // The renderer uses these to amortise per-model state setup across many instances. + instance_bucket_map m_instancebuckets_opaque; traction_sequence m_traction; sound_sequence m_sounds; eventlauncher_sequence m_eventlaunchers;