From 20f536f05e68acb9e3a9fe1140cbd310b29cded2 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Thu, 1 Oct 2026 11:51:41 +0200 Subject: [PATCH] fix(instancing): keep batches correct after load-time classification --- model/AnimModel.cpp | 5 + rendering/opengl33geometrybank.cpp | 35 +++++-- rendering/opengl33renderer.cpp | 156 ++++++++++++++++++++--------- rendering/opengl33renderer.h | 7 +- scene/scene.cpp | 26 ++--- 5 files changed, 158 insertions(+), 71 deletions(-) diff --git a/model/AnimModel.cpp b/model/AnimModel.cpp index bc2d6e84..d5d856e0 100644 --- a/model/AnimModel.cpp +++ b/model/AnimModel.cpp @@ -478,6 +478,9 @@ std::shared_ptr TAnimModel::AddContainer(std::string const &Name auto tmp = std::make_shared(); tmp->Init(tsb); 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 + m_instanceable = false; return tmp; } return nullptr; @@ -688,6 +691,8 @@ std::optional> > TAnimModel::L 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 + m_instanceable = false; } void TAnimModel::AnimUpdate(double dt) diff --git a/rendering/opengl33geometrybank.cpp b/rendering/opengl33geometrybank.cpp index 91c9a8dd..506d22f1 100644 --- a/rendering/opengl33geometrybank.cpp +++ b/rendering/opengl33geometrybank.cpp @@ -215,13 +215,34 @@ opengl33_vaogeometrybank::draw_instanced_( gfx::geometry_handle const &Geometry, GLsizei const inst_count = static_cast(InstanceCount); if( chunkrecord.index_count > 0 ) { - m_vao->bind(); - ::glDrawElementsInstancedBaseVertex( - chunk.type, - chunkrecord.index_count, GL_UNSIGNED_INT, - reinterpret_cast( chunkrecord.index_offset * sizeof( gfx::basic_index ) ), - inst_count, - chunkrecord.vertex_offset ); + // GLES 3.0 context doesn't provide the base vertex variant, same fallbacks as in draw_() + if( glDrawElementsInstancedBaseVertex ) { + m_vao->bind(); + ::glDrawElementsInstancedBaseVertex( + chunk.type, + chunkrecord.index_count, GL_UNSIGNED_INT, + reinterpret_cast( chunkrecord.index_offset * sizeof( gfx::basic_index ) ), + inst_count, + chunkrecord.vertex_offset ); + } + else if( glDrawElementsInstancedBaseVertexOES ) { + m_vao->bind(); + ::glDrawElementsInstancedBaseVertexOES( + chunk.type, + chunkrecord.index_count, GL_UNSIGNED_INT, + reinterpret_cast( chunkrecord.index_offset * sizeof( gfx::basic_index ) ), + inst_count, + chunkrecord.vertex_offset ); + } + else { + setup_attrib( chunkrecord.vertex_offset ); + m_vao->bind(); + ::glDrawElementsInstanced( + chunk.type, + chunkrecord.index_count, GL_UNSIGNED_INT, + reinterpret_cast( chunkrecord.index_offset * sizeof( gfx::basic_index ) ), + inst_count ); + } } else { m_vao->bind(); diff --git a/rendering/opengl33renderer.cpp b/rendering/opengl33renderer.cpp index b9937816..352d9632 100644 --- a/rendering/opengl33renderer.cpp +++ b/rendering/opengl33renderer.cpp @@ -2382,8 +2382,11 @@ void opengl33_renderer::Render(scene::basic_region *Region) m_sectionqueue.clear(); m_cellqueue.clear(); - // discard last pass's accumulated instance buckets before this pass starts - m_frame_instance_buckets.clear(); + // discard last pass's accumulated instance buckets before this pass starts. + // the vectors themselves are kept, so their allocations get reused by the following passes + for( auto &bucket : m_frame_instance_buckets ) { + bucket.second.clear(); + } // build a list of region sections to render glm::vec3 const cameraposition{m_renderpass.pass_camera.position()}; auto const camerax = static_cast(std::floor(cameraposition.x / scene::EU07_SECTIONSIZE + scene::EU07_REGIONSIDESECTIONCOUNT / 2)); @@ -2802,10 +2805,11 @@ void opengl33_renderer::Render(cell_sequence::iterator First, cell_sequence::ite // still happens inside Render_Instanced(), so correctness is unchanged -- only // the number of calls and glBufferSubData round-trips drops sharply. // (For pickscenery/pickcontrols modes the map stays empty, so this is a no-op.) - for( auto const &bucket : m_frame_instance_buckets ) { + for( auto &bucket : m_frame_instance_buckets ) { + if( bucket.second.empty() ) { continue; } Render_Instanced( bucket.first.pModel, bucket.second ); + bucket.second.clear(); } - m_frame_instance_buckets.clear(); } void opengl33_renderer::Draw_Geometry(std::vector::iterator begin, std::vector::iterator end) @@ -3024,13 +3028,13 @@ void opengl33_renderer::Render_Instanced( TModel3d *Model, std::vector::max(); + float farthest_distancesquared = 0.f; material_data const *batch_material { nullptr }; for( auto *Instance : Instances ) { if( Instance == nullptr ) { continue; } + // instances which lost their instanceable status after being bucketed (animation or skin + // assigned by an event) are drawn by the per-node path + if( false == Instance->m_instanceable ) { continue; } if( false == Instance->m_visible ) { continue; } if( false == m_renderpass.pass_camera.visible( Instance->m_area ) ) { continue; } @@ -3077,6 +3085,7 @@ void opengl33_renderer::Render_Instanced( TModel3d *Model, std::vectorm_framestamp = m_framestamp; closest_distancesquared = std::min( closest_distancesquared, static_cast(distancesquared) ); + farthest_distancesquared = std::max( farthest_distancesquared, static_cast(distancesquared) ); // Build the camera-relative root modelview for this instance: // mv = view * translate(instance_pos - camera_pos) * rotate(instance_angles) * scale(m_scale) @@ -3095,55 +3104,106 @@ void opengl33_renderer::Render_Instanced( TModel3d *Model, std::vector( distancesquared ), mv ); } - if( m_instance_modelviews.empty() ) { return; } + if( m_instance_survivors.empty() ) { return; } + + // LOD: a submodel is drawn only while fSquareDist lies inside its [fSquareMinDist, fSquareMaxDist) + // range, so the distinct range bounds split distance into bands with constant LOD. The bucket + // collects instances from every visible cell, so a single fSquareDist for all of them would draw + // far instances at the LOD of the nearest one. When some bound falls inside the distance span of + // the survivors they're sorted near-to-far and drawn one band at a time, like Render_Sleepers(). + m_instance_lodbounds.clear(); + m_instance_lodpending.clear(); + m_instance_lodpending.push_back( Model->Root ); + while( false == m_instance_lodpending.empty() ) { + auto const *submodel = m_instance_lodpending.back(); + m_instance_lodpending.pop_back(); + if( submodel == nullptr ) { continue; } + if( ( submodel->fSquareMinDist > closest_distancesquared ) && ( submodel->fSquareMinDist <= farthest_distancesquared ) ) { + m_instance_lodbounds.emplace_back( submodel->fSquareMinDist ); + } + if( ( submodel->fSquareMaxDist > closest_distancesquared ) && ( submodel->fSquareMaxDist <= farthest_distancesquared ) ) { + m_instance_lodbounds.emplace_back( submodel->fSquareMaxDist ); + } + m_instance_lodpending.push_back( submodel->Child ); + m_instance_lodpending.push_back( submodel->Next ); + } + if( false == m_instance_lodbounds.empty() ) { + std::sort( m_instance_lodbounds.begin(), m_instance_lodbounds.end() ); + m_instance_lodbounds.erase( std::unique( m_instance_lodbounds.begin(), m_instance_lodbounds.end() ), m_instance_lodbounds.end() ); + std::sort( m_instance_survivors.begin(), m_instance_survivors.end(), + []( std::pair const &Left, std::pair const &Right ) { + return Left.first < Right.first; } ); + } + // contiguous copy of the (sorted) modelview matrices, for the UBO upload + m_instance_modelviews.clear(); + m_instance_modelviews.reserve( m_instance_survivors.size() ); + for( auto const &survivor : m_instance_survivors ) { + m_instance_modelviews.emplace_back( survivor.second ); + } + + auto alpha = ( batch_material != nullptr ? batch_material->textures_alpha : 0x30300030 ); + alpha ^= 0x0F0F000F; // 2. Walk the submodel tree once per sub-batch. The submodel-local matrix // stack starts at identity; the per-instance camera transform comes from // instance_modelview[gl_InstanceID] in the shader. std::size_t const total = m_instance_modelviews.size(); - std::size_t offset_idx = 0; - while( offset_idx < total ) { - std::size_t const this_batch = std::min( total - offset_idx, gl::MAX_INSTANCES_PER_BATCH ); - - // 2a. Upload N modelviews to instance_ubo[0..N-1]. - instance_ubo->update( - reinterpret_cast( m_instance_modelviews.data() + offset_idx ), - 0, - static_cast( this_batch * sizeof( glm::mat4 ) ) ); - - // 2b. Push identity onto the matrix stack so submodel transforms - // accumulate from identity, not from camera/instance space. - ::glPushMatrix(); - ::glLoadIdentity(); - - // 2c. Configure the existing TModel3d/TSubModel render path. Setting - // m_current_instance_count routes draw(handle) calls to draw_instanced. - m_current_instance_count = this_batch; - - Model->Root->fSquareDist = closest_distancesquared; // shared global, used by submodel LOD - auto alpha = ( batch_material != nullptr ? batch_material->textures_alpha : 0x30300030 ); - alpha ^= 0x0F0F000F; - Model->Root->ReplacableSet( ( batch_material != nullptr ? batch_material->replacable_skins : nullptr ), alpha ); - Model->Root->pRoot = Model; - - Render( Model->Root ); - - m_current_instance_count = 0; - - ::glPopMatrix(); - - // 2d. Restore instance_modelview[0] to identity so subsequent - // non-instanced draws continue to compute identity * modelview. - { - glm::mat4 const identity( 1.0f ); - instance_ubo->update( reinterpret_cast( &identity ), 0, sizeof( identity ) ); + std::size_t band_start = 0; + while( band_start < total ) { + // the band ends at the first instance distance which reaches the next lod bound + // (without bounds inside the distance span the whole set forms a single band) + auto const upperbound = std::upper_bound( m_instance_lodbounds.begin(), m_instance_lodbounds.end(), m_instance_survivors[ band_start ].first ); + float const band_limit = ( upperbound == m_instance_lodbounds.end() ? std::numeric_limits::max() : *upperbound ); + std::size_t band_end = band_start; + while( ( band_end < total ) && ( m_instance_survivors[ band_end ].first < band_limit ) ) { + ++band_end; } + // every distance in the band selects the same lod; the nearest one represents the band + float const band_distancesquared = ( m_instance_lodbounds.empty() ? closest_distancesquared : m_instance_survivors[ band_start ].first ); - offset_idx += this_batch; - ++m_renderpass.draw_stats.instanced_drawcalls; + std::size_t offset_idx = band_start; + while( offset_idx < band_end ) { + std::size_t const this_batch = std::min( band_end - offset_idx, gl::MAX_INSTANCES_PER_BATCH ); + + // 2a. Upload N modelviews to instance_ubo[0..N-1]. + instance_ubo->update( + reinterpret_cast( m_instance_modelviews.data() + offset_idx ), + 0, + static_cast( this_batch * sizeof( glm::mat4 ) ) ); + + // 2b. Push identity onto the matrix stack so submodel transforms + // accumulate from identity, not from camera/instance space. + ::glPushMatrix(); + ::glLoadIdentity(); + + // 2c. Configure the existing TModel3d/TSubModel render path. Setting + // m_current_instance_count routes draw(handle) calls to draw_instanced. + m_current_instance_count = this_batch; + + Model->Root->fSquareDist = band_distancesquared; // shared global, used by submodel LOD + Model->Root->ReplacableSet( ( batch_material != nullptr ? batch_material->replacable_skins : nullptr ), alpha ); + Model->Root->pRoot = Model; + + Render( Model->Root ); + + m_current_instance_count = 0; + + ::glPopMatrix(); + + // 2d. Restore instance_modelview[0] to identity so subsequent + // non-instanced draws continue to compute identity * modelview. + { + glm::mat4 const identity( 1.0f ); + instance_ubo->update( reinterpret_cast( &identity ), 0, sizeof( identity ) ); + } + + offset_idx += this_batch; + ++m_renderpass.draw_stats.instanced_drawcalls; + } + band_start = band_end; } m_renderpass.draw_stats.instances += static_cast( total ); diff --git a/rendering/opengl33renderer.h b/rendering/opengl33renderer.h index 6fda37c3..8e5c0b14 100644 --- a/rendering/opengl33renderer.h +++ b/rendering/opengl33renderer.h @@ -376,7 +376,7 @@ class opengl33_renderer : public gfx_renderer { // cell's buckets are merged here keyed by (TModel3d*, skins), so that // Render_Instanced() runs once per unique model across the whole pass instead // of once per cell -- collapsing many tiny instanced draws into a few large - // batches. Reused every pass; cleared at the top of Render(scene::basic_region*). + // batches. Reused every pass; the vectors are emptied (not freed) at the top of Render(scene::basic_region*). scene::basic_cell::instance_bucket_map m_frame_instance_buckets; renderpass_config m_colorpass; // parametrs of most recent color pass std::array m_shadowpass; // parametrs of most recent shadowmap pass for each of csm stages @@ -426,6 +426,11 @@ class opengl33_renderer : public gfx_renderer { // reused across calls -- clear() retains capacity, so once it has grown to // the largest batch seen, steady-state frames perform no allocation here. std::vector m_instance_modelviews; + // further persistent scratch buffers for Render_Instanced(): culled instances paired with their + // squared distance, and the lod bounds of the drawn model which split them into lod bands + std::vector> m_instance_survivors; + std::vector m_instance_lodbounds; + std::vector m_instance_lodpending; gl::scene_ubs scene_ubs; gl::model_ubs model_ubs; gl::light_ubs light_ubs; diff --git a/scene/scene.cpp b/scene/scene.cpp index 14825705..8ebff031 100644 --- a/scene/scene.cpp +++ b/scene/scene.cpp @@ -442,22 +442,18 @@ basic_cell::erase( TAnimModel *Instance ) { [=]( TAnimModel *instance ) { return instance == Instance; } ), std::end( m_instancesopaque ) ); - // also remove from the per-(pModel, skins) instance bucket if present - if( Instance->m_instanceable && Instance->Model() != nullptr ) { - instance_bucket_key key; - key.pModel = Instance->Model(); - auto const *mat = Instance->Material(); - if( mat != nullptr ) { - for( int i = 0; i < 5; ++i ) { key.skins[i] = mat->replacable_skins[i]; } + // also remove from the per-(pModel, skins) instance bucket if present. + // NOTE: searched by pointer rather than by key: the instance may have lost its instanceable + // status or changed its skins since it was inserted, which would leave a dangling pointer behind + for( auto bucket = m_instancebuckets_opaque.begin(); 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() ) { + bucket = m_instancebuckets_opaque.erase( bucket ); } - auto bucket = m_instancebuckets_opaque.find( key ); - 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 ); - } + else { + ++bucket; } } }