From 1435ef89d7396a57c7fdaf0727377e492d66b99f Mon Sep 17 00:00:00 2001 From: bruno-dasilva Date: Wed, 29 Jul 2026 02:25:56 -0700 Subject: [PATCH 01/10] refactor: extract ghost drawing to DrawGhostedBuildings (#3107) Ghost drawing in GLSL was inline in DrawAlphaUnit. Ghost drawing in GL4 was further inlined into DrawAlphaObjects. Both needed flags to pass around which makes the whole thing kinda awkward. This was ok in current world but the follow up PRs add more params so it's best to split them ahead of time. We pull the ghost drawing impl out into its own DrawGhostedBuildings function in both paths, then call them from DrawAlphaObjects. Simplifies the code and also makes the GLSL/GL4 paths more symmetric. Pure refactor, no behavior change. Co-authored-by: Bruno Da Silva --- rts/Rendering/Units/UnitDrawer.cpp | 115 +++++++++++++++-------------- rts/Rendering/Units/UnitDrawer.h | 4 +- 2 files changed, 61 insertions(+), 58 deletions(-) diff --git a/rts/Rendering/Units/UnitDrawer.cpp b/rts/Rendering/Units/UnitDrawer.cpp index a439038dabe..346cdff97d9 100644 --- a/rts/Rendering/Units/UnitDrawer.cpp +++ b/rts/Rendering/Units/UnitDrawer.cpp @@ -822,7 +822,7 @@ void CUnitDrawerGLSL::DrawAlphaObjects(int modelType, bool drawReflection, bool CModelDrawerHelper::BindModelTypeTexture(modelType, mdlRenderer.GetObjectBinKey(i)); for (auto* o : mdlRenderer.GetObjectBin(i)) { - DrawAlphaUnit(o, modelType, thisPassMask, false); + DrawAlphaUnit(o, thisPassMask); } } @@ -869,66 +869,33 @@ void CUnitDrawerGLSL::DrawGhostedBuildings(int modelType) const glColor4f(0.6f, 0.6f, 0.6f, IModelDrawerState::alphaValues.y); // buildings that died while ghosted - for (GhostSolidObject* dgb : deadGhostedBuildings) { - if (camera->InView(dgb->pos, dgb->GetModel()->GetDrawRadius())) { - glPushMatrix(); - glTranslatef3(dgb->pos); - glRotatef(dgb->facing * 90.0f, 0, 1, 0); + for (const GhostSolidObject* dgb : deadGhostedBuildings) { + const S3DModel* model = dgb->GetModel(); + if (!camera->InView(dgb->pos, model->GetDrawRadius())) + continue; - CModelDrawerHelper::BindModelTypeTexture(modelType, dgb->GetModel()->textureType); - SetTeamColor(dgb->team, IModelDrawerState::alphaValues.y); + glPushMatrix(); + glTranslatef3(dgb->pos); + glRotatef(dgb->facing * 90.0f, 0, 1, 0); - dgb->GetModel()->DrawStatic(); - glPopMatrix(); - } - } + CModelDrawerHelper::BindModelTypeTexture(modelType, model->textureType); + SetTeamColor(dgb->team, IModelDrawerState::alphaValues.y); - for (CUnit* lgb : liveGhostedBuildings) { - DrawAlphaUnit(lgb, modelType, DrawFlags::SO_ALPHAF_FLAG, true); + model->DrawStatic(); + glPopMatrix(); } -} - -void CUnitDrawerGLSL::DrawOpaqueUnit(CUnit* unit, uint8_t thisPassMask) const -{ - RECOIL_DETAILED_TRACY_ZONE; - if (!ShouldDrawOpaqueUnit(unit, thisPassMask)) - return; - - // draw the unit with the default (non-Lua) material - SetTeamColor(unit->team); - DrawUnitTrans(unit, 0, 0, false, false); -} -void CUnitDrawerGLSL::DrawUnitShadow(CUnit* unit) const -{ - RECOIL_DETAILED_TRACY_ZONE; - if (ShouldDrawUnitShadow(unit)) - DrawUnitTrans(unit, 0, 0, false, false); -} - -void CUnitDrawerGLSL::DrawAlphaUnit(CUnit* unit, int modelType, uint8_t thisPassMask, bool drawGhostBuildingsPass) const -{ - RECOIL_DETAILED_TRACY_ZONE; - if (!drawGhostBuildingsPass && !ShouldDrawAlphaUnit(unit, thisPassMask)) - return; - - const unsigned short losStatus = unit->losStatus[gu->myAllyTeam]; - - if (drawGhostBuildingsPass) { + // buildings that left LOS but are still alive + for (CUnit* unit : liveGhostedBuildings) { // check for decoy models const UnitDef* decoyDef = unit->unitDef->decoyDef; - const S3DModel* model = nullptr; - - if (decoyDef == nullptr) { - model = unit->model; - } - else { - model = decoyDef->LoadModel(); - } + const S3DModel* model = (decoyDef == nullptr) ? unit->model : decoyDef->LoadModel(); // FIXME: needs a second pass if (model->type != modelType) - return; + continue; + + const unsigned short losStatus = unit->losStatus[gu->myAllyTeam]; // ghosted enemy units if (losStatus & LOS_CONTRADAR) { @@ -948,17 +915,44 @@ void CUnitDrawerGLSL::DrawAlphaUnit(CUnit* unit, int modelType, uint8_t thisPass // not actually cloaked CModelDrawerHelper::BindModelTypeTexture(modelType, model->textureType); - SetTeamColor(unit->team, (losStatus & LOS_CONTRADAR) ? IModelDrawerState::alphaValues.z : IModelDrawerState::alphaValues.y); + const float ghostAlpha = (losStatus & LOS_CONTRADAR) ? IModelDrawerState::alphaValues.z : IModelDrawerState::alphaValues.y; + SetTeamColor(unit->team, ghostAlpha); model->DrawStatic(); glPopMatrix(); glColor4f(1.0f, 1.0f, 1.0f, IModelDrawerState::alphaValues.x); - return; } +} + +void CUnitDrawerGLSL::DrawOpaqueUnit(CUnit* unit, uint8_t thisPassMask) const +{ + RECOIL_DETAILED_TRACY_ZONE; + if (!ShouldDrawOpaqueUnit(unit, thisPassMask)) + return; + + // draw the unit with the default (non-Lua) material + SetTeamColor(unit->team); + DrawUnitTrans(unit, 0, 0, false, false); +} + +void CUnitDrawerGLSL::DrawUnitShadow(CUnit* unit) const +{ + RECOIL_DETAILED_TRACY_ZONE; + if (ShouldDrawUnitShadow(unit)) + DrawUnitTrans(unit, 0, 0, false, false); +} + +void CUnitDrawerGLSL::DrawAlphaUnit(CUnit* unit, uint8_t thisPassMask) const +{ + RECOIL_DETAILED_TRACY_ZONE; + if (!ShouldDrawAlphaUnit(unit, thisPassMask)) + return; if (unit->GetIsIcon()) return; + const unsigned short losStatus = unit->losStatus[gu->myAllyTeam]; + if ((losStatus & LOS_INLOS) || gu->spectatingFullView) { SetTeamColor(unit->team, IModelDrawerState::alphaValues.x); DrawUnitTrans(unit, 0, 0, false, false); @@ -1738,12 +1732,21 @@ void CUnitDrawerGL4::DrawAlphaObjects(int modelType, bool drawReflection, bool d smv.Submit(GL_TRIANGLES, false); } - // void CGLUnitDrawer::DrawGhostedBuildings(int modelType) - if (gu->spectatingFullView) - return; + smv.Unbind(); + // living and dead ghosted buildings + if (!gu->spectatingFullView) + DrawGhostedBuildings(modelType); +} + +void CUnitDrawerGL4::DrawGhostedBuildings(int modelType) const +{ + RECOIL_DETAILED_TRACY_ZONE; const auto& deadGhostBuildings = modelDrawerData->GetDeadGhostBuildings(gu->myAllyTeam, modelType); + auto& smv = S3DModelVAO::GetInstance(); + smv.Bind(); + const auto oldMM = modelDrawerState->SetMatrixMode(ShaderMatrixModes::STATIC_MATMODE); // deadGhostedBuildings { diff --git a/rts/Rendering/Units/UnitDrawer.h b/rts/Rendering/Units/UnitDrawer.h index d6c6935afc6..97106632f76 100644 --- a/rts/Rendering/Units/UnitDrawer.h +++ b/rts/Rendering/Units/UnitDrawer.h @@ -167,7 +167,7 @@ class CUnitDrawerGLSL : public CUnitDrawerBase { void DrawOpaqueUnit(CUnit* unit, uint8_t thisPassMask) const; void DrawUnitShadow(CUnit* unit) const; - void DrawAlphaUnit(CUnit* unit, int modelType, uint8_t thisPassMask, bool drawGhostBuildingsPass) const; + void DrawAlphaUnit(CUnit* unit, uint8_t thisPassMask) const; void DrawOpaqueAIUnit(const CUnitDrawerData::TempDrawUnit& unit) const; void DrawAlphaAIUnit(const CUnitDrawerData::TempDrawUnit& unit) const; @@ -236,7 +236,7 @@ class CUnitDrawerGL4 final : public CUnitDrawerGLSL { void DrawOpaqueObjectsAux(int modelType) const override; void DrawOpaqueAIUnit(const CUnitDrawerData::TempDrawUnit& unit) const; - void DrawGhostedBuildings(int modelType) const override {} //implemented in-line + void DrawGhostedBuildings(int modelType) const override; void DrawUnitModelBeingBuiltShadow(const CUnit* unit, bool noLuaCall) const; void DrawUnitModelBeingBuiltOpaque(const CUnit* unit, bool noLuaCall) const; From eab16157c723715bdd42e46d288b5cd8cb1e6472 Mon Sep 17 00:00:00 2001 From: bruno-dasilva Date: Wed, 29 Jul 2026 02:25:56 -0700 Subject: [PATCH 02/10] fix(GLSL/GL4): draw ghost buildings with the palette they were last seen under (#3108) This fixes two bugs: 1. recently we added custom color palette functionality, but that was not extended to ghosts (so they are currently using just the team color). 2. Live ghosts read the color from the live CUnit at draw time, so if a unit changes team while in fog of war (eg. unit gifts), that information would be leaked. So, to fix, we now a) use paletteIndex for ghost color and b) snapshot the last seen paletteIndex for that unit. Co-authored-by: Bruno Da Silva --- rts/Rendering/Units/UnitDrawer.cpp | 41 +++++++++++++------------- rts/Rendering/Units/UnitDrawerData.cpp | 26 +++++++++++++--- rts/Rendering/Units/UnitDrawerData.h | 18 ++++++++++- 3 files changed, 59 insertions(+), 26 deletions(-) diff --git a/rts/Rendering/Units/UnitDrawer.cpp b/rts/Rendering/Units/UnitDrawer.cpp index 346cdff97d9..2ff97165935 100644 --- a/rts/Rendering/Units/UnitDrawer.cpp +++ b/rts/Rendering/Units/UnitDrawer.cpp @@ -886,7 +886,9 @@ void CUnitDrawerGLSL::DrawGhostedBuildings(int modelType) const } // buildings that left LOS but are still alive - for (CUnit* unit : liveGhostedBuildings) { + for (const auto& lgb : liveGhostedBuildings) { + const CUnit* unit = lgb.unit; + // check for decoy models const UnitDef* decoyDef = unit->unitDef->decoyDef; const S3DModel* model = (decoyDef == nullptr) ? unit->model : decoyDef->LoadModel(); @@ -915,8 +917,9 @@ void CUnitDrawerGLSL::DrawGhostedBuildings(int modelType) const // not actually cloaked CModelDrawerHelper::BindModelTypeTexture(modelType, model->textureType); + // color with the team the unit was last seen under, not the live unit's current team const float ghostAlpha = (losStatus & LOS_CONTRADAR) ? IModelDrawerState::alphaValues.z : IModelDrawerState::alphaValues.y; - SetTeamColor(unit->team, ghostAlpha); + SetTeamColor(lgb.team, ghostAlpha); model->DrawStatic(); glPopMatrix(); @@ -1772,7 +1775,7 @@ void CUnitDrawerGL4::DrawGhostedBuildings(int modelType) const } modelDrawerState->SetStaticModelMatrix(staticWorldMat); - smv.SubmitImmediately(dgb->GetModel(), static_cast(dgb->team)); //need to submit immediately every model because of static per-model matrix + smv.SubmitImmediately(dgb->GetModel(), dgb->paletteIndex); //need to submit immediately every model because of static per-model matrix } } @@ -1782,20 +1785,14 @@ void CUnitDrawerGL4::DrawGhostedBuildings(int modelType) const int prevModelType = -1; int prevTexType = -1; - for (const auto* lgb : liveGhostedBuildings) { - if (!camera->InView(lgb->pos, lgb->model->GetDrawRadius())) + for (const auto& lgb : liveGhostedBuildings) { + const CUnit* u = lgb.unit; + if (!camera->InView(u->pos, u->model->GetDrawRadius())) continue; // check for decoy models - const UnitDef* decoyDef = lgb->unitDef->decoyDef; - const S3DModel* model = nullptr; - - if (decoyDef == nullptr) { - model = lgb->model; - } - else { - model = decoyDef->LoadModel(); - } + const UnitDef* decoyDef = u->unitDef->decoyDef; + const S3DModel* model = (decoyDef == nullptr) ? u->model : decoyDef->LoadModel(); // FIXME: needs a second pass if (model->type != modelType) @@ -1804,20 +1801,21 @@ void CUnitDrawerGL4::DrawGhostedBuildings(int modelType) const static CMatrix44f staticWorldMat; staticWorldMat.LoadIdentity(); - staticWorldMat.Translate(lgb->pos); + staticWorldMat.Translate(u->pos); - staticWorldMat.RotateY(-lgb->buildFacing * math::DEG_TO_RAD * 90.0f); + staticWorldMat.RotateY(-u->buildFacing * math::DEG_TO_RAD * 90.0f); - const unsigned short losStatus = lgb->losStatus[gu->myAllyTeam]; + const unsigned short losStatus = u->losStatus[gu->myAllyTeam]; - // ghosted enemy units + // ghosted enemy units; SetTeamColor only gates the alpha here (the per-instance + // paletteIndex passed to SubmitImmediately drives the actual color) if (losStatus & LOS_CONTRADAR) { modelDrawerState->SetColorMultiplier(0.9f, 0.9f, 0.9f, IModelDrawerState::alphaValues.z); - modelDrawerState->SetTeamColor(lgb->team, IModelDrawerState::alphaValues.z); + modelDrawerState->SetTeamColor(lgb.team, IModelDrawerState::alphaValues.z); } else { modelDrawerState->SetColorMultiplier(0.6f, 0.6f, 0.6f, IModelDrawerState::alphaValues.y); - modelDrawerState->SetTeamColor(lgb->team, IModelDrawerState::alphaValues.y); + modelDrawerState->SetTeamColor(lgb.team, IModelDrawerState::alphaValues.y); } if (prevModelType != modelType || prevTexType != model->textureType) { @@ -1826,7 +1824,8 @@ void CUnitDrawerGL4::DrawGhostedBuildings(int modelType) const } modelDrawerState->SetStaticModelMatrix(staticWorldMat); - smv.SubmitImmediately(model, static_cast(lgb->team)); //need to submit immediately every model because of static per-model matrix + // draw with the palette the unit was last seen under, not the live unit's current one + smv.SubmitImmediately(model, lgb.paletteIndex); //need to submit immediately every model because of static per-model matrix } } diff --git a/rts/Rendering/Units/UnitDrawerData.cpp b/rts/Rendering/Units/UnitDrawerData.cpp index 3f98205280a..8059312f907 100644 --- a/rts/Rendering/Units/UnitDrawerData.cpp +++ b/rts/Rendering/Units/UnitDrawerData.cpp @@ -48,6 +48,7 @@ CR_REG_METADATA(GhostSolidObject, ( CR_MEMBER(facing), CR_MEMBER(team), + CR_MEMBER(paletteIndex), CR_IGNORED(currentIconIndex), CR_IGNORED(model), @@ -55,6 +56,13 @@ CR_REG_METADATA(GhostSolidObject, ( CR_POSTLOAD(PostLoad) )) +CR_BIND(CUnitDrawerData::LiveGhostBuilding, ) +CR_REG_METADATA(CUnitDrawerData::LiveGhostBuilding, ( + CR_MEMBER(unit), + CR_MEMBER(paletteIndex), + CR_MEMBER(team) +)) + CR_BIND(CUnitDrawerData::TempDrawUnit, ) CR_REG_METADATA(CUnitDrawerData::TempDrawUnit, ( CR_MEMBER(unitDefId), @@ -617,6 +625,7 @@ bool CUnitDrawerData::UpdateUnitGhosts(const CUnit* unit, const bool addNewGhost gso->facing = u->buildFacing; gso->dir = u->frontdir; gso->team = u->team; + gso->paletteIndex = u->paletteIndex; gso->radius = u->radius; gso->GetModel(); @@ -640,7 +649,8 @@ bool CUnitDrawerData::UpdateUnitGhosts(const CUnit* unit, const bool addNewGhost } - spring::VectorErase(savedData.liveGhostBuildings[allyTeam][MDL_TYPE(u)], u); + spring::VectorEraseIf(savedData.liveGhostBuildings[allyTeam][MDL_TYPE(u)], + [u](const LiveGhostBuilding& lgb) { return lgb.unit == u; }); } return addedOwnAllyTeam; } @@ -674,7 +684,8 @@ void CUnitDrawerData::UnitEnteredLos(const CUnit* unit, int allyTeam) CUnit* u = const_cast(unit); //cleanup if (unit->leavesGhost) - spring::VectorErase(savedData.liveGhostBuildings[allyTeam][MDL_TYPE(unit)], u); + spring::VectorEraseIf(savedData.liveGhostBuildings[allyTeam][MDL_TYPE(unit)], + [u](const LiveGhostBuilding& lgb) { return lgb.unit == u; }); if (allyTeam != gu->myAllyTeam) return; @@ -687,8 +698,15 @@ void CUnitDrawerData::UnitLeftLos(const CUnit* unit, int allyTeam) RECOIL_DETAILED_TRACY_ZONE; CUnit* u = const_cast(unit); //cleanup - if (unit->leavesGhost) - spring::VectorInsertUnique(savedData.liveGhostBuildings[allyTeam][MDL_TYPE(unit)], u, true); + if (unit->leavesGhost) { + // snapshot the color the unit is last seen under so a later team change (while out of LOS) + // does not recolor its ghost. keep the earliest snapshot if it re-fires without re-entering. + auto& lgbs = savedData.liveGhostBuildings[allyTeam][MDL_TYPE(unit)]; + const bool alreadyGhosted = std::any_of(lgbs.begin(), lgbs.end(), + [u](const LiveGhostBuilding& lgb) { return lgb.unit == u; }); + if (!alreadyGhosted) + lgbs.push_back({ u, u->paletteIndex, static_cast(u->team) }); + } if (allyTeam != gu->myAllyTeam) return; diff --git a/rts/Rendering/Units/UnitDrawerData.h b/rts/Rendering/Units/UnitDrawerData.h index 36630545555..a2debe2b7bb 100644 --- a/rts/Rendering/Units/UnitDrawerData.h +++ b/rts/Rendering/Units/UnitDrawerData.h @@ -34,7 +34,13 @@ class GhostSolidObject { int refCount; int facing; //FIXME replaced with dir-vector just legacy decal drawer uses this + + // color identity captured when the ghost was created; a ghost keeps the color it was last + // seen under. team drives the legacy (GLSL) team-color path, paletteIndex drives the GL4 + // per-instance palette (equal to team unless a custom Lua color palette was assigned). uint8_t team; + uint16_t paletteIndex; + size_t currentIconIndex; private: mutable const S3DModel* model; @@ -86,6 +92,16 @@ class CUnitDrawerData : public CUnitDrawerDataBase { private: mutable const UnitDef* unitDef; }; + // a still-alive building that left an observer's LOS. The unit is drawn as a ghost using the + // color it was last seen under (snapshotted here), so it does not silently recolor if the live + // unit changes team while out of LOS. team feeds the legacy (GLSL) team-color path, paletteIndex + // feeds the GL4 per-instance palette (equal to team unless a custom Lua palette was assigned). + struct LiveGhostBuilding { + CR_DECLARE_STRUCT(LiveGhostBuilding) + CUnit* unit = nullptr; + uint16_t paletteIndex = 0; + uint8_t team = 0; + }; struct SavedData { CR_DECLARE_STRUCT(SavedData) @@ -97,7 +113,7 @@ class CUnitDrawerData : public CUnitDrawerDataBase { std::vector, MODELTYPE_CNT>> deadGhostBuildings; /// buildings that left LOS but are still alive - std::vector, MODELTYPE_CNT>> liveGhostBuildings; + std::vector, MODELTYPE_CNT>> liveGhostBuildings; }; public: CUnitDrawerData(bool& mtModelDrawer_); From 223f9245f9ecaff7ce73cdbcf4cb66c50c9fab2c Mon Sep 17 00:00:00 2001 From: bruno-dasilva Date: Wed, 29 Jul 2026 02:25:57 -0700 Subject: [PATCH 03/10] refactor(GL4): extract EmplaceInstance submission primitive (#3109) Extract the build + queue impl out of AddToSubmissionImpl into a private EmplaceInstance(), so in the next PR we can add another caller to EmplaceInstance(). Results in cleaner code without passing around a bunch of flags in the follow ups. Behavior should be unchanged. Co-authored-by: Bruno Da Silva --- rts/Rendering/Models/3DModelVAO.cpp | 31 +++++++++++++++++++---------- rts/Rendering/Models/3DModelVAO.hpp | 11 ++++++++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/rts/Rendering/Models/3DModelVAO.cpp b/rts/Rendering/Models/3DModelVAO.cpp index 7be3e00733f..33025974957 100644 --- a/rts/Rendering/Models/3DModelVAO.cpp +++ b/rts/Rendering/Models/3DModelVAO.cpp @@ -286,14 +286,28 @@ void S3DModelVAO::DrawElements(GLenum prim, uint32_t vboIndxStart, uint32_t vboI glDrawElements(prim, vboIndxCount, GL_UNSIGNED_INT, indxVBO.GetPtr(vboIndxStart * sizeof(uint32_t))); } +bool S3DModelVAO::EmplaceInstance(uint32_t indexStart, uint32_t indexCount, uint32_t traIndex, uint16_t paletteIndex, uint16_t numPieces, uint32_t uniIndex, uint32_t bposeIndex) +{ + RECOIL_DETAILED_TRACY_ZONE; + if (traIndex == TransformsMemStorage::INVALID_INDEX || bposeIndex == TransformsMemStorage::INVALID_INDEX) + return false; + + modelDataToInstance[SIndexAndCount{ indexStart, indexCount }].emplace_back(SInstanceData( + traIndex, + paletteIndex, + numPieces, + uniIndex, + bposeIndex + )); + + return true; +} + template bool S3DModelVAO::AddToSubmissionImpl(const TObj* obj, uint32_t indexStart, uint32_t indexCount, uint16_t paletteIndex) { RECOIL_DETAILED_TRACY_ZONE; const auto traIndex = transformsUploader.GetElemOffset(obj); - if (traIndex == TransformsMemStorage::INVALID_INDEX) - return false; - const auto uniIndex = modelUniformsStorage.GetObjOffset(obj); //doesn't need to exist for defs and models. Don't check for validity uint16_t numPieces = 0; @@ -307,19 +321,14 @@ bool S3DModelVAO::AddToSubmissionImpl(const TObj* obj, uint32_t indexStart, uint bposeIndex = transformsUploader.GetElemOffset(obj->model); } - if (bposeIndex == TransformsMemStorage::INVALID_INDEX) - return false; - - auto& modelInstanceData = modelDataToInstance[SIndexAndCount{ indexStart, indexCount }]; - modelInstanceData.emplace_back(SInstanceData( + return EmplaceInstance( + indexStart, indexCount, static_cast(traIndex), paletteIndex, numPieces, static_cast(uniIndex), static_cast(bposeIndex) - )); - - return true; + ); } bool S3DModelVAO::AddToSubmission(const S3DModel* model, uint16_t paletteIndex) diff --git a/rts/Rendering/Models/3DModelVAO.hpp b/rts/Rendering/Models/3DModelVAO.hpp index 4a5a6f359d1..d9405818f46 100644 --- a/rts/Rendering/Models/3DModelVAO.hpp +++ b/rts/Rendering/Models/3DModelVAO.hpp @@ -102,6 +102,17 @@ class S3DModelVAO { uint32_t indexCount, uint16_t paletteIndex ); + // build one SInstanceData from already-resolved offsets and queue it for the next Submit(); + // returns false (drawing nothing) if the world transform or bind pose is unavailable. + bool EmplaceInstance( + uint32_t indexStart, + uint32_t indexCount, + uint32_t traIndex, + uint16_t paletteIndex, + uint16_t numPieces, + uint32_t uniIndex, + uint32_t bposeIndex + ); void EnableAttribs(bool inst) const; void DisableAttribs() const; private: From 1dfbd72d7bef212fef740fdc1dc5cda8eb7157e9 Mon Sep 17 00:00:00 2001 From: bruno-dasilva Date: Wed, 29 Jul 2026 02:25:57 -0700 Subject: [PATCH 04/10] perf(GL4): batch ghost-building rendering (#3110) * refactor(GL4): replace matrix modes magic number with an enum Add SetFlag("MATMODE_XXX", ...) alongside the existing GBUFFER_*_IDX flags so the GL4 model shaders can reference the matrix-mode enum value by name instead of hardcoding a literal. Intended as a precursor for the ghost draw perf fixes, which add a third mode in the shader. * refactor(GL4): add ARRAY_MATMODE static-instanced model draw path * perf(GL4): batch ghost-building rendering via ARRAY_MATMODE Ghost buildings are static (no animation, never move), so each gets a single world-transform slot in the transforms SSBO, and is drawn batched through ARRAY_MATMODE. One multidraw per (color bucket x texture type) instead of one immediate draw per ghost. --------- Co-authored-by: Bruno Da Silva --- .../shaders/GLSL/ModelVertProgGL4.glsl | 19 ++- rts/Rendering/Common/ModelDrawerState.cpp | 4 + rts/Rendering/Models/3DModelVAO.cpp | 16 +++ rts/Rendering/Models/3DModelVAO.hpp | 3 + rts/Rendering/Units/UnitDrawer.cpp | 122 +++++++++++------- rts/Rendering/Units/UnitDrawerData.cpp | 61 +++++++++ rts/Rendering/Units/UnitDrawerData.h | 17 +++ 7 files changed, 187 insertions(+), 55 deletions(-) diff --git a/cont/base/springcontent/shaders/GLSL/ModelVertProgGL4.glsl b/cont/base/springcontent/shaders/GLSL/ModelVertProgGL4.glsl index 468d850874e..aa05d764667 100644 --- a/cont/base/springcontent/shaders/GLSL/ModelVertProgGL4.glsl +++ b/cont/base/springcontent/shaders/GLSL/ModelVertProgGL4.glsl @@ -262,7 +262,7 @@ Transform Lerp(Transform t0, Transform t1, float a) { void GetModelSpaceVertex(out vec4 msPosition, out vec3 msNormal) { - bool staticModel = (matrixMode > 0); + bool staticModel = (matrixMode == MATMODE_STATIC || matrixMode == MATMODE_ARRAY); vec4 piecePos = vec4(pos, 1.0); vec4 normal4 = vec4(normal, 0.0); @@ -271,7 +271,10 @@ void GetModelSpaceVertex(out vec4 msPosition, out vec3 msNormal) Transform tx; if (staticModel) { - tx = transforms[instData.x + bID0]; + // pieces always come from the bind-pose block (instData.w). In ARRAY_MATMODE + // instData.x is the per-instance world transform, not the bind pose; for static + // model submits instData.x == instData.w anyway, so instData.w is correct for both. + tx = transforms[instData.w + bID0]; } else { // do interpolation tx = Lerp( @@ -327,16 +330,20 @@ void GetModelSpaceVertex(out vec4 msPosition, out vec3 msNormal) void main(void) { - bool staticModel = (matrixMode > 0); - vec4 modelPos; vec3 modelNormal; GetModelSpaceVertex(modelPos, modelNormal); - if (staticModel) { + if (matrixMode == MATMODE_ARRAY) { + // static instanced: per-instance world transform read from the SSBO (no interpolation) + Transform wtx = transforms[instData.x + 0u]; + worldPos = ApplyTransform(wtx, modelPos); + wtx.trSc = vec4(0, 0, 0, 1); //nullify the translation part for the normal + worldNormal = ApplyTransform(wtx, modelNormal); + } else if (matrixMode == MATMODE_STATIC) { worldPos = staticModelMatrix * modelPos; worldNormal = mat3(staticModelMatrix) * modelNormal; - } else { + } else { // MATMODE_NORMAL // do interpolation Transform tx = Lerp( transforms[instData.x + 0u], diff --git a/rts/Rendering/Common/ModelDrawerState.cpp b/rts/Rendering/Common/ModelDrawerState.cpp index b732a8d22c7..3aaa4c8c94c 100644 --- a/rts/Rendering/Common/ModelDrawerState.cpp +++ b/rts/Rendering/Common/ModelDrawerState.cpp @@ -263,6 +263,10 @@ CModelDrawerStateGL4::CModelDrawerStateGL4() modelShaders[n]->SetFlag("GBUFFER_MISCTEX_IDX", GL::GeometryBuffer::ATTACHMENT_MISCTEX); modelShaders[n]->SetFlag("GBUFFER_ZVALTEX_IDX", GL::GeometryBuffer::ATTACHMENT_ZVALTEX); + // name the matrix-mode values the shader compares against, so it reads modes by name + modelShaders[n]->SetFlag("MATMODE_STATIC", static_cast(ShaderMatrixModes::STATIC_MATMODE)); + modelShaders[n]->SetFlag("MATMODE_ARRAY", static_cast(ShaderMatrixModes::ARRAY_MATMODE)); + modelShaders[n]->Link(); modelShaders[n]->Enable(); modelShaders[n]->Disable(); diff --git a/rts/Rendering/Models/3DModelVAO.cpp b/rts/Rendering/Models/3DModelVAO.cpp index 33025974957..ae9d82d799c 100644 --- a/rts/Rendering/Models/3DModelVAO.cpp +++ b/rts/Rendering/Models/3DModelVAO.cpp @@ -372,6 +372,22 @@ bool S3DModelVAO::AddToSubmission(const UnitDef* unitDef, uint16_t paletteIndex) return AddToSubmissionImpl(unitDef, model->indxStart, model->indxCount, paletteIndex); } +bool S3DModelVAO::AddStaticInstance(const S3DModel* model, uint32_t worldTransformOffset, uint16_t paletteIndex) +{ + RECOIL_DETAILED_TRACY_ZONE; + assert(model); + + // the world transform is the caller-supplied slot; pieces are read from the model bind pose + return EmplaceInstance( + model->indxStart, model->indxCount, + worldTransformOffset, + paletteIndex, + static_cast(model->numPieces), + static_cast(modelUniformsStorage.GetObjOffset(model)), + static_cast(transformsUploader.GetElemOffset(model)) + ); +} + void S3DModelVAO::Submit(GLenum mode, bool bindUnbind) { diff --git a/rts/Rendering/Models/3DModelVAO.hpp b/rts/Rendering/Models/3DModelVAO.hpp index d9405818f46..e64f3fba517 100644 --- a/rts/Rendering/Models/3DModelVAO.hpp +++ b/rts/Rendering/Models/3DModelVAO.hpp @@ -71,6 +71,9 @@ class S3DModelVAO { bool AddToSubmission(const CFeature* feature); bool AddToSubmission(const UnitDef* unitDef, uint16_t paletteIndex); + + bool AddStaticInstance(const S3DModel* model, uint32_t worldTransformOffset, uint16_t paletteIndex); + void Submit(GLenum mode = GL_TRIANGLES, bool bindUnbind = false); bool SubmitImmediately(const S3DModel* model, uint16_t paletteIndex, GLenum mode = GL_TRIANGLES, bool bindUnbind = false); diff --git a/rts/Rendering/Units/UnitDrawer.cpp b/rts/Rendering/Units/UnitDrawer.cpp index 2ff97165935..9a99cdf6d09 100644 --- a/rts/Rendering/Units/UnitDrawer.cpp +++ b/rts/Rendering/Units/UnitDrawer.cpp @@ -2,6 +2,8 @@ #include "UnitDrawer.h" +#include + #include "Game/Camera.h" #include "Game/CameraHandler.h" #include "Game/Game.h" @@ -1745,46 +1747,75 @@ void CUnitDrawerGL4::DrawAlphaObjects(int modelType, bool drawReflection, bool d void CUnitDrawerGL4::DrawGhostedBuildings(int modelType) const { RECOIL_DETAILED_TRACY_ZONE; - const auto& deadGhostBuildings = modelDrawerData->GetDeadGhostBuildings(gu->myAllyTeam, modelType); auto& smv = S3DModelVAO::GetInstance(); smv.Bind(); - const auto oldMM = modelDrawerState->SetMatrixMode(ShaderMatrixModes::STATIC_MATMODE); - // deadGhostedBuildings - { - modelDrawerState->SetColorMultiplier(0.6f, 0.6f, 0.6f, IModelDrawerState::alphaValues.y); - modelDrawerState->SetTeamColor(0, IModelDrawerState::alphaValues.y); //teamID doesn't matter here + // Ghost buildings are static (no animation, never move), so each gets a single world-transform + // slot in the transforms SSBO and is drawn batched through ARRAY_MATMODE - one multidraw per + // (color bucket x texture type) instead of one immediate draw per ghost. + const auto oldMM = modelDrawerState->SetMatrixMode(ShaderMatrixModes::ARRAY_MATMODE); - int prevModelType = -1; - int prevTexType = -1; - for (const auto* dgb : deadGhostBuildings) { - if (!camera->InView(dgb->pos, dgb->GetModel()->GetDrawRadius())) + struct GhostInstance { + const S3DModel* model; + uint32_t worldTransformOffset; + uint16_t paletteIndex; // color the ghost was last seen under (see LiveGhostBuilding / GhostSolidObject) + }; + // bind the texture once per group, accumulate, then one Submit (=one multidraw) per texture type. + // buckets are reused across frames (see clearBuckets) so a screen full of ghosts does not realloc + // its per-texture vectors every frame; empty buckets (a texture no longer on screen) are skipped. + const auto flushGhosts = [&](const std::map>& byTex) { + for (const auto& [texType, instances] : byTex) { + if (instances.empty()) continue; + CModelDrawerHelper::BindModelTypeTexture(modelType, texType); + for (const auto& gi : instances) + smv.AddStaticInstance(gi.model, gi.worldTransformOffset, gi.paletteIndex); + smv.Submit(GL_TRIANGLES, false); + } + }; + // clear the mapped vectors (keeping their capacity) instead of clearing the map (which would free them) + const auto clearBuckets = [](std::map>& byTex) { + for (auto& [texType, instances] : byTex) + instances.clear(); + }; - static CMatrix44f staticWorldMat; - - staticWorldMat.LoadIdentity(); - staticWorldMat.Translate(dgb->pos); + // deadGhostedBuildings (single color state) + { + const auto& deadGhostBuildings = modelDrawerData->GetDeadGhostBuildings(gu->myAllyTeam, modelType); - staticWorldMat.RotateY(-dgb->facing * math::DEG_TO_RAD * 90.0f); + static std::map> byTex; + clearBuckets(byTex); + bool any = false; + for (const auto* dgb : deadGhostBuildings) { + const S3DModel* model = dgb->GetModel(); + if (!camera->InView(dgb->pos, model->GetDrawRadius())) + continue; + if (!dgb->worldTransformAlloc.Valid()) + continue; - if (prevModelType != modelType || prevTexType != dgb->GetModel()->textureType) { - prevModelType = modelType; prevTexType = dgb->GetModel()->textureType; - CModelDrawerHelper::BindModelTypeTexture(modelType, dgb->GetModel()->textureType); //inefficient rendering, but w/e - } + byTex[model->textureType].push_back({ model, static_cast(dgb->worldTransformAlloc.GetOffset()), dgb->paletteIndex }); + any = true; + } - modelDrawerState->SetStaticModelMatrix(staticWorldMat); - smv.SubmitImmediately(dgb->GetModel(), dgb->paletteIndex); //need to submit immediately every model because of static per-model matrix + if (any) { + modelDrawerState->SetColorMultiplier(0.6f, 0.6f, 0.6f, IModelDrawerState::alphaValues.y); + modelDrawerState->SetTeamColor(0, IModelDrawerState::alphaValues.y); //teamID is per-instance + flushGhosts(byTex); } } - // liveGhostedBuildings + // liveGhostedBuildings (two color states: normal and CONTRADAR) { const auto& liveGhostedBuildings = modelDrawerData->GetLiveGhostBuildings(gu->myAllyTeam, modelType); - int prevModelType = -1; - int prevTexType = -1; + static std::map> byTexNormal; + static std::map> byTexContradar; + clearBuckets(byTexNormal); + clearBuckets(byTexContradar); + bool anyNormal = false; + bool anyContradar = false; + for (const auto& lgb : liveGhostedBuildings) { const CUnit* u = lgb.unit; if (!camera->InView(u->pos, u->model->GetDrawRadius())) @@ -1798,34 +1829,27 @@ void CUnitDrawerGL4::DrawGhostedBuildings(int modelType) const if (model->type != modelType) continue; - static CMatrix44f staticWorldMat; - - staticWorldMat.LoadIdentity(); - staticWorldMat.Translate(u->pos); - - staticWorldMat.RotateY(-u->buildFacing * math::DEG_TO_RAD * 90.0f); + const size_t xfOffset = modelDrawerData->GetLiveGhostTransform(u); + if (xfOffset == TransformsMemStorage::INVALID_INDEX) + continue; const unsigned short losStatus = u->losStatus[gu->myAllyTeam]; + const bool contradar = (losStatus & LOS_CONTRADAR); + // bucket with the palette the unit was last seen under, not the live unit's current one + (contradar ? byTexContradar : byTexNormal)[model->textureType] + .push_back({ model, static_cast(xfOffset), lgb.paletteIndex }); + (contradar ? anyContradar : anyNormal) = true; + } - // ghosted enemy units; SetTeamColor only gates the alpha here (the per-instance - // paletteIndex passed to SubmitImmediately drives the actual color) - if (losStatus & LOS_CONTRADAR) { - modelDrawerState->SetColorMultiplier(0.9f, 0.9f, 0.9f, IModelDrawerState::alphaValues.z); - modelDrawerState->SetTeamColor(lgb.team, IModelDrawerState::alphaValues.z); - } - else { - modelDrawerState->SetColorMultiplier(0.6f, 0.6f, 0.6f, IModelDrawerState::alphaValues.y); - modelDrawerState->SetTeamColor(lgb.team, IModelDrawerState::alphaValues.y); - } - - if (prevModelType != modelType || prevTexType != model->textureType) { - prevModelType = modelType; prevTexType = model->textureType; - CModelDrawerHelper::BindModelTypeTexture(modelType, model->textureType); //inefficient rendering, but w/e - } - - modelDrawerState->SetStaticModelMatrix(staticWorldMat); - // draw with the palette the unit was last seen under, not the live unit's current one - smv.SubmitImmediately(model, lgb.paletteIndex); //need to submit immediately every model because of static per-model matrix + if (anyNormal) { + modelDrawerState->SetColorMultiplier(0.6f, 0.6f, 0.6f, IModelDrawerState::alphaValues.y); + modelDrawerState->SetTeamColor(0, IModelDrawerState::alphaValues.y); + flushGhosts(byTexNormal); + } + if (anyContradar) { + modelDrawerState->SetColorMultiplier(0.9f, 0.9f, 0.9f, IModelDrawerState::alphaValues.z); + modelDrawerState->SetTeamColor(0, IModelDrawerState::alphaValues.z); + flushGhosts(byTexContradar); } } diff --git a/rts/Rendering/Units/UnitDrawerData.cpp b/rts/Rendering/Units/UnitDrawerData.cpp index 8059312f907..dfd048a332a 100644 --- a/rts/Rendering/Units/UnitDrawerData.cpp +++ b/rts/Rendering/Units/UnitDrawerData.cpp @@ -29,9 +29,22 @@ #include "Map/ReadMap.h" #include "System/Misc/TracyDefs.h" +#include "System/Matrix44f.h" +#include "System/MathConstants.h" +#include "System/Transform.hpp" +#include "Rendering/Models/ModelsMemStorage.h" static FixedDynMemPoolT ghostMemPool; +// world transform of a (static) ghost building, matching the legacy staticModelMatrix construction +static Transform MakeGhostWorldTransform(const float3& pos, int facing) +{ + CMatrix44f m; + m.Translate(pos); + m.RotateY(-facing * math::HALFPI); + return Transform::FromMatrix(m); +} + /////////////////////////// CR_BIND_POOL(GhostSolidObject, ,ghostMemPool.allocMem, ghostMemPool.freeMem) @@ -51,6 +64,7 @@ CR_REG_METADATA(GhostSolidObject, ( CR_MEMBER(paletteIndex), CR_IGNORED(currentIconIndex), + CR_IGNORED(worldTransformAlloc), CR_IGNORED(model), CR_POSTLOAD(PostLoad) @@ -96,6 +110,15 @@ void GhostSolidObject::PostLoad() RECOIL_DETAILED_TRACY_ZONE; model = nullptr; GetModel(); + + // the GPU world transform slot is render-only state; re-create it from the saved pos/facing + InitWorldTransform(); +} + +void GhostSolidObject::InitWorldTransform() +{ + worldTransformAlloc = ScopedTransformMemAlloc(1); + worldTransformAlloc.UpdateForced(0, MakeGhostWorldTransform(pos, facing)); } const S3DModel* GhostSolidObject::GetModel() const @@ -165,6 +188,7 @@ CUnitDrawerData::~CUnitDrawerData() if (tmpGso->DecRef()) continue; + // worldTransformAlloc frees its slot in ~GhostSolidObject (ghostMemPool.free below) // might be the gbOwner of a decal; groundDecals is deleted after us groundDecals->GhostDestroyed(tmpGso); ghostMemPool.free(tmpGso); @@ -173,6 +197,7 @@ CUnitDrawerData::~CUnitDrawerData() lgb.clear(); } } + liveGhostTransforms.clear(); // each entry's ScopedTransformMemAlloc frees its slot on erase assert(ghostMemPool.allocs() == 0); ghostMemPool.clear(); @@ -224,6 +249,8 @@ void CUnitDrawerData::Update() updateBody(unit); } + UpdateLiveGhostTransforms(); + if ((useDistToGroundForIcons = (camHandler->GetCurrentController()).GetUseDistToGroundForIcons())) { const float3& camPos = camera->GetPos(); // use the height at the current camera position @@ -634,6 +661,8 @@ bool CUnitDrawerData::UpdateUnitGhosts(const CUnit* unit, const bool addNewGhost gso->iconRadius = u->iconRadius; + gso->InitWorldTransform(); + groundDecals->GhostCreated(u, gso); } @@ -714,6 +743,37 @@ void CUnitDrawerData::UnitLeftLos(const CUnit* unit, int allyTeam) UpdateCurrentUnitIcon(unit); } +void CUnitDrawerData::UpdateLiveGhostTransforms() +{ + RECOIL_DETAILED_TRACY_ZONE; + // Maintain one world-transform slot per live ghost building drawn for the local allyTeam. + // Ghosts are static, so each slot is filled once on first sight; entries not seen this sweep + // (units that regained LOS, died, or belong to a different allyTeam now) are freed. + const int stamp = ++liveGhostSweepStamp; + + for (int modelType = MODELTYPE_3DO; modelType < MODELTYPE_CNT; modelType++) { + for (const auto& lgb : savedData.liveGhostBuildings[gu->myAllyTeam][modelType]) { + const CUnit* u = lgb.unit; + const auto it = liveGhostTransforms.find(u); + if (it == liveGhostTransforms.end()) { + ScopedTransformMemAlloc alloc(1); + alloc.UpdateForced(0, MakeGhostWorldTransform(u->pos, u->buildFacing)); + liveGhostTransforms.emplace(u, std::make_pair(std::move(alloc), stamp)); + } + else { + it->second.second = stamp; + } + } + } + + for (auto it = liveGhostTransforms.begin(); it != liveGhostTransforms.end(); ) { + if (it->second.second != stamp) + it = liveGhostTransforms.erase(it); // ScopedTransformMemAlloc frees the slot on erase + else + ++it; + } +} + void CUnitDrawerData::UnitLeavesGhostChanged(const CUnit* unit, const bool leaveDeadGhost) { if (unit->leavesGhost) { @@ -750,6 +810,7 @@ void CUnitDrawerData::PlayerChanged(int playerID) void CUnitDrawerData::RemoveDeadGhost(GhostSolidObject* gso, std::vector& dgb, int index) { if (!gso->DecRef()) { + // worldTransformAlloc frees its slot in ~GhostSolidObject (ghostMemPool.free) groundDecals->GhostDestroyed(gso); ghostMemPool.free(gso); } diff --git a/rts/Rendering/Units/UnitDrawerData.h b/rts/Rendering/Units/UnitDrawerData.h index a2debe2b7bb..d5ae046da63 100644 --- a/rts/Rendering/Units/UnitDrawerData.h +++ b/rts/Rendering/Units/UnitDrawerData.h @@ -23,6 +23,8 @@ class GhostSolidObject { bool DecRef() { return ((refCount--) > 1); } const S3DModel* GetModel() const; void PostLoad(); + // (re)allocate and fill the batched-draw world transform slot from pos/facing + void InitWorldTransform(); public: std::string modelName; @@ -42,6 +44,8 @@ class GhostSolidObject { uint16_t paletteIndex; size_t currentIconIndex; + + ScopedTransformMemAlloc worldTransformAlloc; private: mutable const S3DModel* model; }; @@ -167,6 +171,12 @@ class CUnitDrawerData : public CUnitDrawerDataBase { return savedData.liveGhostBuildings[allyTeam][modelType]; } + // world transform slot (in transformsMemStorage) for a live ghost building; INVALID_INDEX if none + size_t GetLiveGhostTransform(const CUnit* unit) const { + const auto it = liveGhostTransforms.find(unit); + return (it != liveGhostTransforms.end()) ? it->second.first.GetOffset(false) : TransformsMemStorage::INVALID_INDEX; + } + auto* GetSavedData() { return &savedData; } const auto* GetSavedData() const { return &savedData; } protected: @@ -207,6 +217,13 @@ class CUnitDrawerData : public CUnitDrawerDataBase { S3DModel* GetUnitModel(const CUnit* unit) const; void RemoveDeadGhost(GhostSolidObject* gso, std::vector& dgb, int index); + // rebuilds the per-unit world transform slots for live ghost buildings of the local allyTeam. + // scan-based so it self-heals across savegame load, allyTeam/spectator changes and leavesGhost toggles. + void UpdateLiveGhostTransforms(); + // maps unit -> { RAII-owned world transform slot, last sweep stamp seen } + spring::unordered_map> liveGhostTransforms; + int liveGhostSweepStamp = 0; + // icons bool useDistToGroundForIcons; float sqCamDistToGroundForIcons; From e238786d07a1f4e7b5c17d7154aa5ce84966d396 Mon Sep 17 00:00:00 2001 From: Nouridin <39697812+Nouridin@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:48:43 +0300 Subject: [PATCH 05/10] Changes: (#3066) - Removed `lcstr` and `lcsub` arrays to save 64KB of BSS data per translation. - Removed unsafe inline implementation of `StrCaseStr` to eliminate multi-threaded race conditions. --- rts/Game/UI/MiniMap.cpp | 2 +- rts/System/StringUtil.h | 20 -------------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/rts/Game/UI/MiniMap.cpp b/rts/Game/UI/MiniMap.cpp index 2f202bffc18..30037a609c1 100644 --- a/rts/Game/UI/MiniMap.cpp +++ b/rts/Game/UI/MiniMap.cpp @@ -457,7 +457,7 @@ void CMiniMap::ConfigCommand(const std::string& line) const bool wantMaximized = (words.size() >= 2) ? !!atoi(words[1].c_str()) : !isMaximized; if (isMaximized != wantMaximized) - ToggleMaximized(StrCaseStr(words[0].c_str(), "maxspect") == 0); + ToggleMaximized(hashStringLower(words[0].c_str()) != hashString("maxspect")); } break; case hashString("mouseevents"): { diff --git a/rts/System/StringUtil.h b/rts/System/StringUtil.h index ea7ca4b62c6..73d2aaeef26 100644 --- a/rts/System/StringUtil.h +++ b/rts/System/StringUtil.h @@ -30,26 +30,6 @@ struct _UTIL_CONCAT(doOnce, __LINE__) { _UTIL_CONCAT(doOnce, __LINE__)() { code; } }; static _UTIL_CONCAT(doOnce, __LINE__) _UTIL_CONCAT(doOnceVar, __LINE__); -static char lcstr[32768]; -static char lcsub[32768]; -static inline const char* StrCaseStr(const char* str, const char* sub) { - const char* pos = nullptr; - - if (str == nullptr) - return nullptr; - if (sub == nullptr) - return nullptr; - - std::strncpy(lcstr, str, sizeof(lcstr) - 1); - std::strncpy(lcsub, sub, sizeof(lcsub) - 1); - std::transform(lcstr, lcstr + sizeof(lcstr), lcstr, (int (*)(int)) tolower); - std::transform(lcsub, lcsub + sizeof(lcsub), lcsub, (int (*)(int)) tolower); - - if ((pos = std::strstr(lcstr, lcsub)) == nullptr) - return nullptr; - - return (str + (pos - lcstr)); -} static inline void StringToLower(const char* in, char* out, size_t len) { From bdd7662b089d5e4f3cd18b0271bc247a27acd36b Mon Sep 17 00:00:00 2001 From: color-dev <239244564+color-dev@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:51:58 -0700 Subject: [PATCH 06/10] MouseHandler: route XButtons (Mouse4/5) as keybinds instead of mouse ownership (#2613) When bound through uikeys, Mouse4/5 (aka XButtons) try to assign ownership to gameInputReceiver. This causes either the mouse input or the X-button bind to be misrouted when pressed in combination. To prevent this, Mouse4/5 are routed like keybinds instead of sharing mouse ownership. This allows them to be pressed/released without stealing or confusing the activeReceiver. --- rts/Game/UI/MouseHandler.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/rts/Game/UI/MouseHandler.cpp b/rts/Game/UI/MouseHandler.cpp index 400cffa743f..62e155622b7 100644 --- a/rts/Game/UI/MouseHandler.cpp +++ b/rts/Game/UI/MouseHandler.cpp @@ -361,6 +361,24 @@ void CMouseHandler::MousePress(int x, int y, int button) pressedBitMask |= 1 << button; + const bool isXButton = (button == SDL_BUTTON_X1 || button == SDL_BUTTON_X2); + if (isXButton) { + + // 1. Lua first + if (luaInputReceiver->MousePress(x, y, button)) { + return; + } + + // 2. GameInputReceiver via the same path as mouse buttons + auto activeControllerReceiver = (activeController == nullptr) ? nullptr : activeController->GetInputReceiver(); + if (activeControllerReceiver && activeControllerReceiver->MousePress(x, y, button)) { + // NOTE: X‑buttons bypass ownership so they can be pressed/released without stealing or confusing activeReceiver + return; + } + return; + } + + if (activeReceiver != nullptr && activeReceiver->MousePress(x, y, button)) return; @@ -530,6 +548,22 @@ void CMouseHandler::MouseRelease(int x, int y, int button) return; } + const bool isXButton = (button == SDL_BUTTON_X1 || button == SDL_BUTTON_X2); + if (isXButton) { + + // 1. Lua first + luaInputReceiver->MouseRelease(x, y, button); + + // 2. GameInputReceiver via the same path as mouse buttons + auto activeControllerReceiver = (activeController == nullptr) ? nullptr : activeController->GetInputReceiver(); + if (activeControllerReceiver) { + activeControllerReceiver->MouseRelease(x, y, button); + } + + // 3. Skip ownership funnel + return; + } + if (activeReceiver != nullptr) { activeReceiver->MouseRelease(x, y, button); From 3d1e96185c2c289d35e78ff1aeba78aea5bf9adc Mon Sep 17 00:00:00 2001 From: lostsquirrel1 Date: Wed, 29 Jul 2026 11:05:27 +0100 Subject: [PATCH 07/10] Update changelog --- doc/site/content/changelogs/_index.markdown | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/site/content/changelogs/_index.markdown b/doc/site/content/changelogs/_index.markdown index 1e8e738da3a..0906e9055e3 100644 --- a/doc/site/content/changelogs/_index.markdown +++ b/doc/site/content/changelogs/_index.markdown @@ -57,6 +57,7 @@ This is the bleeding-edge changelog since version 2026.06, for **pre-release 202 - Added cancelcommand action - Added GetPrevFrameChecksum() to the Lua API [PR 2922](https://github.com/beyond-all-reason/RecoilEngine/pull/2922) - Fixes to Spring.SetMapShader [PR 3127](https://github.com/beyond-all-reason/RecoilEngine/pull/3127) +- MouseHandler: route XButtons (Mouse4/5) as keybinds instead of mouse ownership [PR 2613](https://github.com/beyond-all-reason/RecoilEngine/pull/2613) ## Misc - Restored lowercasing in FileSystem::GetExtension @@ -80,6 +81,7 @@ This is the bleeding-edge changelog since version 2026.06, for **pre-release 202 - Fix stale spGetActionHotKeys returns [PR 3082](https://github.com/beyond-all-reason/RecoilEngine/pull/3082) - Use a portable type cast in MemPoolTypes logging - Support alternate file extensions for replays [PR 2975](https://github.com/beyond-all-reason/RecoilEngine/pull/2975) +- Throw error and stop processing if modrules parsing fails ## Rendering - Added sorting icon names before adding to atlas so insertion order is consistent across runs. @@ -96,6 +98,8 @@ This is the bleeding-edge changelog since version 2026.06, for **pre-release 202 - Debug logs for loading splat normals - Fix SMF DNTS gating and fallback textures - Add mapoptions for blank map splats +- Performance improvements with drawing ghosted buildings [PR 3110](https://github.com/beyond-all-reason/RecoilEngine/pull/3110) +- Changed ghosted buildings are drawn based on the last team was seen with and so which team ownership doesn't show automatically on the ghost. [PR 3108](https://github.com/beyond-all-reason/RecoilEngine/pull/3108) ## Simulation - Sanitize NaNs in CHoverAirMoveType::UpdateMoveRate() @@ -106,3 +110,4 @@ This is the bleeding-edge changelog since version 2026.06, for **pre-release 202 - Fixed edge scrolling threshold [Issue 2987](https://github.com/beyond-all-reason/RecoilEngine/issues/2987) - Dump state handles resource packs - Fix units having the wrong path id after loading a save game. [PR 3120](https://github.com/beyond-all-reason/RecoilEngine/pull/3120) +- Avoid UB in float-to-short angle casts (fixes arm64/x86 desync) [PR 3075](https://github.com/beyond-all-reason/RecoilEngine/pull/3075) From 3fdeec025da39f06659076785bbfbc388b66c846 Mon Sep 17 00:00:00 2001 From: TarnishedKnight Date: Wed, 29 Jul 2026 12:31:11 +0100 Subject: [PATCH 08/10] Bar 106 fix publish workflow (#3146) --- doc/site/mise.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/site/mise.toml b/doc/site/mise.toml index 3eac83faf22..e827931ebe8 100644 --- a/doc/site/mise.toml +++ b/doc/site/mise.toml @@ -1,3 +1,6 @@ +[settings.npm] + package_manager = "npm" + [tools] # We only install tools required for each task on each task, unless all tasks use a tool here # For CI when we need to install and cache all tools, see mise.ci.toml @@ -101,7 +104,7 @@ [tasks.lua_library] description = "Generate Lua Docs" - tools."npm:lua-doc-extractor" = "{{env.LUA_DOC_EXTRACTOR_VERSION}}" + tools."npm:lua-doc-extractor" = { version = "{{env.LUA_DOC_EXTRACTOR_VERSION}}", allow_low_downloads = true } dir = "../../" run = [ "lua-doc-extractor --src \"{{vars.lua_doc_paths}}\" --dest {{vars.lua_doc_gen_dest}} --repo \"${LUA_DOC_EXTRACTOR_SOURCE_REF}\"" From e39c69ae7af257d20f3fa5df7210632520fa4424 Mon Sep 17 00:00:00 2001 From: lostsquirrel1 Date: Wed, 29 Jul 2026 12:43:00 +0100 Subject: [PATCH 09/10] fix changelog page 2026.06 --- doc/site/content/changelogs/changelog-2026-06.markdown | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/doc/site/content/changelogs/changelog-2026-06.markdown b/doc/site/content/changelogs/changelog-2026-06.markdown index 993a030f907..90a9f6ee9ec 100644 --- a/doc/site/content/changelogs/changelog-2026-06.markdown +++ b/doc/site/content/changelogs/changelog-2026-06.markdown @@ -1,8 +1,6 @@ +++ -title = "Running changelog" -[cascade] - [cascade.params] - type = "docs" +title = "Release 2026.06" +aliases = ['/changelogs/changelog-2026-06'] +++ This is the bleeding-edge changelog since version 2025.06, for **pre-release 2026.06**. From d6e9943238dc50dc7e4ebab8694ea66ac4c55835 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Wed, 29 Jul 2026 12:54:08 +0100 Subject: [PATCH 10/10] smmalloc: drop the leaked INLINE macro in a single wrapper header (#3026) smmalloc.h defines `#define INLINE inline`, a very generic token that collides with unrelated code (e.g. simdjson's layout_mode::INLINE enumerator) whenever both land in the same translation unit. Instead of undef-ing INLINE at each affected site, add System/recoil-smmalloc.h which includes smmalloc and drops the macro, and route the engine's single smmalloc include (MemPoolTypes.h) through it. The macro can no longer escape into engine code, so a future file mixing smmalloc with json/etc. won't reintroduce the collision. Surfaced by the macOS port (#2991, commit 16622a9707). The upstream smmalloc leak is worth fixing there too. Co-authored-by: Mark Kropf --- rts/System/MemPoolTypes.h | 2 +- rts/System/recoil-smmalloc.h | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 rts/System/recoil-smmalloc.h diff --git a/rts/System/MemPoolTypes.h b/rts/System/MemPoolTypes.h index 3ab106b64b9..34e0824d50a 100644 --- a/rts/System/MemPoolTypes.h +++ b/rts/System/MemPoolTypes.h @@ -13,7 +13,7 @@ #include #include -#include "smmalloc/smmalloc.h" +#include "System/recoil-smmalloc.h" #include "System/UnorderedMap.hpp" #include "System/ContainerUtil.h" diff --git a/rts/System/recoil-smmalloc.h b/rts/System/recoil-smmalloc.h new file mode 100644 index 00000000000..4ee7b9cb246 --- /dev/null +++ b/rts/System/recoil-smmalloc.h @@ -0,0 +1,15 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#pragma once + +// smmalloc.h leaks `#define INLINE inline` -- a very generic token that +// collides with unrelated code (e.g. simdjson's layout_mode::INLINE +// enumerator) whenever both end up in the same translation unit. Include +// smmalloc only through this wrapper so the macro is dropped immediately and +// never escapes into engine code. + +#include "smmalloc/smmalloc.h" + +#ifdef INLINE +#undef INLINE +#endif