From 0bce6c09e1a74e1cd77a8602300201b571af3ee1 Mon Sep 17 00:00:00 2001 From: fredzio Date: Tue, 31 Aug 2021 16:25:45 +0200 Subject: [PATCH 1/8] Change projectile behaviour to be like in vanilla wrt. water plane: - enchanted arrow explode upon hit the water plane - non enchanted arrow disappear (or more accurately, they hit nothingness) - enchanted arrow shot underwater explode immediately - non enchanted arrow disappear immediately Also, solve a bug that occured previously and could theoritically still happens where we use the last tested collision position for instead of the last registered hit: Use the hit position as saved inside Projectile::hit() instead of the last position saved inside the callback. If a projectile collides with several objects (bottom of the sea and water surface for instance), the last collision tested won't necessarily be the impact position as we have no control over the order in which the tests are performed. --- apps/openmw/mwphysics/physicssystem.cpp | 6 ++--- apps/openmw/mwphysics/physicssystem.hpp | 2 +- apps/openmw/mwphysics/projectile.cpp | 24 ++----------------- apps/openmw/mwphysics/projectile.hpp | 23 +++++++++++------- .../mwphysics/projectileconvexcallback.cpp | 4 +--- apps/openmw/mwworld/projectilemanager.cpp | 13 +++++----- apps/openmw/mwworld/worldimp.cpp | 1 + 7 files changed, 29 insertions(+), 44 deletions(-) diff --git a/apps/openmw/mwphysics/physicssystem.cpp b/apps/openmw/mwphysics/physicssystem.cpp index 5b50962be9..e9ab3864fd 100644 --- a/apps/openmw/mwphysics/physicssystem.cpp +++ b/apps/openmw/mwphysics/physicssystem.cpp @@ -643,7 +643,7 @@ namespace MWPhysics mTaskScheduler->convexSweepTest(projectile->getConvexShape(), from_, to_, resultCallback); - const auto newpos = projectile->isActive() ? position : Misc::Convert::toOsg(resultCallback.m_hitPointWorld); + const auto newpos = projectile->isActive() ? position : Misc::Convert::toOsg(projectile->getHitPosition()); projectile->setPosition(newpos); mTaskScheduler->updateSingleAabb(foundProjectile->second); } @@ -713,7 +713,7 @@ namespace MWPhysics mActors.emplace(ptr, std::move(actor)); } - int PhysicsSystem::addProjectile (const MWWorld::Ptr& caster, const osg::Vec3f& position, const std::string& mesh, bool computeRadius, bool canTraverseWater) + int PhysicsSystem::addProjectile (const MWWorld::Ptr& caster, const osg::Vec3f& position, const std::string& mesh, bool computeRadius) { osg::ref_ptr shapeInstance = mShapeManager->getInstance(mesh); assert(shapeInstance); @@ -721,7 +721,7 @@ namespace MWPhysics mProjectileId++; - auto projectile = std::make_shared(caster, position, radius, canTraverseWater, mTaskScheduler.get(), this); + auto projectile = std::make_shared(caster, position, radius, mTaskScheduler.get(), this); mProjectiles.emplace(mProjectileId, std::move(projectile)); return mProjectileId; diff --git a/apps/openmw/mwphysics/physicssystem.hpp b/apps/openmw/mwphysics/physicssystem.hpp index b20c8f88e1..14e4b678c5 100644 --- a/apps/openmw/mwphysics/physicssystem.hpp +++ b/apps/openmw/mwphysics/physicssystem.hpp @@ -130,7 +130,7 @@ namespace MWPhysics void addObject (const MWWorld::Ptr& ptr, const std::string& mesh, osg::Quat rotation, int collisionType = CollisionType_World, bool skipAnimated = false); void addActor (const MWWorld::Ptr& ptr, const std::string& mesh); - int addProjectile(const MWWorld::Ptr& caster, const osg::Vec3f& position, const std::string& mesh, bool computeRadius, bool canTraverseWater); + int addProjectile(const MWWorld::Ptr& caster, const osg::Vec3f& position, const std::string& mesh, bool computeRadius); void setCaster(int projectileId, const MWWorld::Ptr& caster); void updateProjectile(const int projectileId, const osg::Vec3f &position) const; void removeProjectile(const int projectileId); diff --git a/apps/openmw/mwphysics/projectile.cpp b/apps/openmw/mwphysics/projectile.cpp index a8bb444956..4efb245149 100644 --- a/apps/openmw/mwphysics/projectile.cpp +++ b/apps/openmw/mwphysics/projectile.cpp @@ -15,12 +15,10 @@ namespace MWPhysics { -Projectile::Projectile(const MWWorld::Ptr& caster, const osg::Vec3f& position, float radius, bool canCrossWaterSurface, PhysicsTaskScheduler* scheduler, PhysicsSystem* physicssystem) - : mCanCrossWaterSurface(canCrossWaterSurface) - , mCrossedWaterSurface(false) +Projectile::Projectile(const MWWorld::Ptr& caster, const osg::Vec3f& position, float radius, PhysicsTaskScheduler* scheduler, PhysicsSystem* physicssystem) + : mHitWater(false) , mActive(true) , mHitTarget(nullptr) - , mWaterHitPosition(std::nullopt) , mPhysics(physicssystem) , mTaskScheduler(scheduler) { @@ -75,11 +73,6 @@ osg::Vec3f Projectile::getPosition() const return mPosition; } -bool Projectile::canTraverseWater() const -{ - return mCanCrossWaterSurface; -} - void Projectile::hit(const btCollisionObject* target, btVector3 pos, btVector3 normal) { bool active = true; @@ -143,17 +136,4 @@ bool Projectile::isValidTarget(const btCollisionObject* target) const [target](const btCollisionObject* actor) { return target == actor; }); } -std::optional Projectile::getWaterHitPosition() -{ - return std::exchange(mWaterHitPosition, std::nullopt); -} - -void Projectile::setWaterHitPosition(btVector3 pos) -{ - if (mCrossedWaterSurface) - return; - mCrossedWaterSurface = true; - mWaterHitPosition = pos; -} - } diff --git a/apps/openmw/mwphysics/projectile.hpp b/apps/openmw/mwphysics/projectile.hpp index dd659b6581..5e4e487c03 100644 --- a/apps/openmw/mwphysics/projectile.hpp +++ b/apps/openmw/mwphysics/projectile.hpp @@ -4,7 +4,6 @@ #include #include #include -#include #include @@ -32,7 +31,7 @@ namespace MWPhysics class Projectile final : public PtrHolder { public: - Projectile(const MWWorld::Ptr& caster, const osg::Vec3f& position, float radius, bool canCrossWaterSurface, PhysicsTaskScheduler* scheduler, PhysicsSystem* physicssystem); + Projectile(const MWWorld::Ptr& caster, const osg::Vec3f& position, float radius, PhysicsTaskScheduler* scheduler, PhysicsSystem* physicssystem); ~Projectile() override; btConvexShape* getConvexShape() const { return mConvexShape; } @@ -56,15 +55,25 @@ namespace MWPhysics return mCasterColObj; } - bool canTraverseWater() const; + void setHitWater() + { + mHitWater = true; + } + + bool getHitWater() const + { + return mHitWater; + } void hit(const btCollisionObject* target, btVector3 pos, btVector3 normal); void setValidTargets(const std::vector& targets); bool isValidTarget(const btCollisionObject* target) const; - std::optional getWaterHitPosition(); - void setWaterHitPosition(btVector3 pos); + btVector3 getHitPosition() const + { + return mHitPosition; + } private: @@ -72,13 +81,11 @@ namespace MWPhysics btConvexShape* mConvexShape; bool mTransformUpdatePending; - bool mCanCrossWaterSurface; - bool mCrossedWaterSurface; + bool mHitWater; std::atomic mActive; MWWorld::Ptr mCaster; const btCollisionObject* mCasterColObj; const btCollisionObject* mHitTarget; - std::optional mWaterHitPosition; osg::Vec3f mPosition; btVector3 mHitPosition; btVector3 mHitNormal; diff --git a/apps/openmw/mwphysics/projectileconvexcallback.cpp b/apps/openmw/mwphysics/projectileconvexcallback.cpp index 687253e1cc..6520be787d 100644 --- a/apps/openmw/mwphysics/projectileconvexcallback.cpp +++ b/apps/openmw/mwphysics/projectileconvexcallback.cpp @@ -49,9 +49,7 @@ namespace MWPhysics } case CollisionType_Water: { - mProjectile->setWaterHitPosition(m_hitPointWorld); - if (mProjectile->canTraverseWater()) - return 1.f; + mProjectile->setHitWater(); break; } } diff --git a/apps/openmw/mwworld/projectilemanager.cpp b/apps/openmw/mwworld/projectilemanager.cpp index 76c6ca7548..25e7f0c7de 100644 --- a/apps/openmw/mwworld/projectilemanager.cpp +++ b/apps/openmw/mwworld/projectilemanager.cpp @@ -317,7 +317,7 @@ namespace MWWorld // in case there are multiple effects, the model is a dummy without geometry. Use the second effect for physics shape if (state.mIdMagic.size() > 1) model = "meshes\\" + MWBase::Environment::get().getWorld()->getStore().get().find(state.mIdMagic[1])->mModel; - state.mProjectileId = mPhysics->addProjectile(caster, pos, model, true, false); + state.mProjectileId = mPhysics->addProjectile(caster, pos, model, true); state.mToDelete = false; mMagicBolts.push_back(state); } @@ -342,7 +342,7 @@ namespace MWWorld if (!ptr.getClass().getEnchantment(ptr).empty()) SceneUtil::addEnchantedGlow(state.mNode, mResourceSystem, ptr.getClass().getEnchantmentColor(ptr)); - state.mProjectileId = mPhysics->addProjectile(actor, pos, model, false, true); + state.mProjectileId = mPhysics->addProjectile(actor, pos, model, false); state.mToDelete = false; mProjectiles.push_back(state); } @@ -493,9 +493,6 @@ namespace MWWorld auto* projectile = mPhysics->getProjectile(projectileState.mProjectileId); - if (const auto hitWaterPos = projectile->getWaterHitPosition()) - mRendering->emitWaterRipple(Misc::Convert::toOsg(*hitWaterPos)); - const auto pos = projectile->getPosition(); projectileState.mNode->setPosition(pos); @@ -519,6 +516,8 @@ namespace MWWorld if (invIt != inv.end() && Misc::StringUtils::ciEqual(invIt->getCellRef().getRefId(), projectileState.mBowId)) bow = *invIt; } + if (projectile->getHitWater()) + mRendering->emitWaterRipple(pos); MWMechanics::projectileHit(caster, target, bow, projectileRef.getPtr(), pos, projectileState.mAttackStrength); projectileState.mToDelete = true; @@ -663,7 +662,7 @@ namespace MWWorld int weaponType = ptr.get()->mBase->mData.mType; state.mThrown = MWMechanics::getWeaponType(weaponType)->mWeaponClass == ESM::WeaponType::Thrown; - state.mProjectileId = mPhysics->addProjectile(state.getCaster(), osg::Vec3f(esm.mPosition), model, false, true); + state.mProjectileId = mPhysics->addProjectile(state.getCaster(), osg::Vec3f(esm.mPosition), model, false); } catch(...) { @@ -716,7 +715,7 @@ namespace MWWorld osg::Vec4 lightDiffuseColor = getMagicBoltLightDiffuseColor(state.mEffects); createModel(state, model, osg::Vec3f(esm.mPosition), osg::Quat(esm.mOrientation), true, true, lightDiffuseColor, texture); - state.mProjectileId = mPhysics->addProjectile(state.getCaster(), osg::Vec3f(esm.mPosition), model, true, false); + state.mProjectileId = mPhysics->addProjectile(state.getCaster(), osg::Vec3f(esm.mPosition), model, true); MWBase::SoundManager *sndMgr = MWBase::Environment::get().getSoundManager(); for (const std::string &soundid : state.mSoundIds) diff --git a/apps/openmw/mwworld/worldimp.cpp b/apps/openmw/mwworld/worldimp.cpp index bc28c2eb7b..3f93aa033a 100644 --- a/apps/openmw/mwworld/worldimp.cpp +++ b/apps/openmw/mwworld/worldimp.cpp @@ -3145,6 +3145,7 @@ namespace MWWorld bool underwater = MWBase::Environment::get().getWorld()->isUnderwater(MWMechanics::getPlayer().getCell(), worldPos); if (underwater) { + MWMechanics::projectileHit(actor, Ptr(), bow, projectile, worldPos, attackStrength); mRendering->emitWaterRipple(worldPos); return; } From d879e4aba5db6c13e4a6c10d36dbee1670b374d6 Mon Sep 17 00:00:00 2001 From: elsid Date: Sun, 19 Sep 2021 19:11:27 +0200 Subject: [PATCH 2/8] Use real frame number for axis x --- scripts/osg_stats.py | 51 +++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/scripts/osg_stats.py b/scripts/osg_stats.py index f0e6ce4010..8a36eb5963 100755 --- a/scripts/osg_stats.py +++ b/scripts/osg_stats.py @@ -45,20 +45,27 @@ import termtables help='Start processing from this frame.') @click.option('--end_frame', type=int, default=sys.maxsize, help='End processing at this frame.') +@click.option('--frame_number_name', type=str, default='FrameNumber', + help='Frame number metric name.') @click.argument('path', type=click.Path(), nargs=-1) def main(print_keys, timeseries, hist, hist_ratio, stdev_hist, plot, stats, timeseries_sum, stats_sum, begin_frame, end_frame, path, - commulative_timeseries, commulative_timeseries_sum): + commulative_timeseries, commulative_timeseries_sum, frame_number_name): sources = {v: list(read_data(v)) for v in path} if path else {'stdin': list(read_data(None))} keys = collect_unique_keys(sources) - frames = collect_per_frame(sources=sources, keys=keys, begin_frame=begin_frame, end_frame=end_frame) + frames, begin_frame, end_frame = collect_per_frame( + sources=sources, keys=keys, begin_frame=begin_frame, + end_frame=end_frame, frame_number_name=frame_number_name, + ) if print_keys: for v in keys: print(v) if timeseries: - draw_timeseries(sources=frames, keys=timeseries, add_sum=timeseries_sum) + draw_timeseries(sources=frames, keys=timeseries, add_sum=timeseries_sum, + begin_frame=begin_frame, end_frame=end_frame) if commulative_timeseries: - draw_commulative_timeseries(sources=frames, keys=commulative_timeseries, add_sum=commulative_timeseries_sum) + draw_commulative_timeseries(sources=frames, keys=commulative_timeseries, add_sum=commulative_timeseries_sum, + begin_frame=begin_frame, end_frame=end_frame) if hist: draw_hists(sources=frames, keys=hist) if hist_ratio: @@ -92,19 +99,26 @@ def read_data(path): frame[key] = to_number(value) -def collect_per_frame(sources, keys, begin_frame, end_frame): +def collect_per_frame(sources, keys, begin_frame, end_frame, frame_number_name): + assert begin_frame < end_frame result = collections.defaultdict(lambda: collections.defaultdict(list)) + begin_frame = max(begin_frame, min(v[0][frame_number_name] for v in sources.values())) + end_frame = min(end_frame, begin_frame + max(len(v) for v in sources.values())) + for name in sources.keys(): + for key in keys: + result[name][key] = [0] * (end_frame - begin_frame) for name, frames in sources.items(): for frame in frames: - for key in keys: - if key in frame: - result[name][key].append(frame[key]) - else: - result[name][key].append(None) - for name, sources in result.items(): - for key, values in sources.items(): - result[name][key] = numpy.array(values[begin_frame:end_frame]) - return result + number = frame[frame_number_name] + if begin_frame <= number < end_frame: + index = number - begin_frame + for key in keys: + if key in frame: + result[name][key][index] = frame[key] + for name in result.keys(): + for key in keys: + result[name][key] = numpy.array(result[name][key]) + return result, begin_frame, end_frame def collect_unique_keys(sources): @@ -116,12 +130,11 @@ def collect_unique_keys(sources): return sorted(result) -def draw_timeseries(sources, keys, add_sum): +def draw_timeseries(sources, keys, add_sum, begin_frame, end_frame): fig, ax = matplotlib.pyplot.subplots() + x = numpy.array(range(begin_frame, end_frame)) for name, frames in sources.items(): - x = numpy.array(range(max(len(v) for k, v in frames.items() if k in keys))) for key in keys: - print(key, name) ax.plot(x, frames[key], label=f'{key}:{name}') if add_sum: ax.plot(x, numpy.sum(list(frames[k] for k in keys), axis=0), label=f'sum:{name}') @@ -130,10 +143,10 @@ def draw_timeseries(sources, keys, add_sum): fig.canvas.set_window_title('timeseries') -def draw_commulative_timeseries(sources, keys, add_sum): +def draw_commulative_timeseries(sources, keys, add_sum, begin_frame, end_frame): fig, ax = matplotlib.pyplot.subplots() + x = numpy.array(range(begin_frame, end_frame)) for name, frames in sources.items(): - x = numpy.array(range(max(len(v) for k, v in frames.items() if k in keys))) for key in keys: ax.plot(x, numpy.cumsum(frames[key]), label=f'{key}:{name}') if add_sum: From e641bea606171542ab52f5c7e099b030d2ed4aac Mon Sep 17 00:00:00 2001 From: Pi03k Date: Sun, 19 Sep 2021 19:07:54 +0200 Subject: [PATCH 3/8] Toggling table columns visibility --- apps/opencs/CMakeLists.txt | 2 +- apps/opencs/view/world/scenesubview.hpp | 1 - apps/opencs/view/world/table.cpp | 3 + .../world/tableheadermouseeventhandler.cpp | 64 +++++++++++++++++++ .../world/tableheadermouseeventhandler.hpp | 25 ++++++++ 5 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 apps/opencs/view/world/tableheadermouseeventhandler.cpp create mode 100644 apps/opencs/view/world/tableheadermouseeventhandler.hpp diff --git a/apps/opencs/CMakeLists.txt b/apps/opencs/CMakeLists.txt index 0ffa3da559..952bbbdbda 100644 --- a/apps/opencs/CMakeLists.txt +++ b/apps/opencs/CMakeLists.txt @@ -71,7 +71,7 @@ opencs_units (view/world cellcreator pathgridcreator referenceablecreator startscriptcreator referencecreator scenesubview infocreator scriptedit dialoguesubview previewsubview regionmap dragrecordtable nestedtable dialoguespinbox recordbuttonbar tableeditidaction scripterrortable extendedcommandconfigurator - bodypartcreator landtexturecreator landcreator + bodypartcreator landtexturecreator landcreator tableheadermouseeventhandler ) opencs_units (view/world diff --git a/apps/opencs/view/world/scenesubview.hpp b/apps/opencs/view/world/scenesubview.hpp index aabb7ca2a7..53cd54e7ac 100644 --- a/apps/opencs/view/world/scenesubview.hpp +++ b/apps/opencs/view/world/scenesubview.hpp @@ -32,7 +32,6 @@ namespace CSVWidget namespace CSVWorld { - class Table; class TableBottomBox; class CreatorFactoryBase; diff --git a/apps/opencs/view/world/table.cpp b/apps/opencs/view/world/table.cpp index 2834159b7f..643396a057 100644 --- a/apps/opencs/view/world/table.cpp +++ b/apps/opencs/view/world/table.cpp @@ -28,6 +28,7 @@ #include "../../model/prefs/shortcut.hpp" #include "tableeditidaction.hpp" +#include "tableheadermouseeventhandler.hpp" #include "util.hpp" void CSVWorld::Table::contextMenuEvent (QContextMenuEvent *event) @@ -422,6 +423,8 @@ CSVWorld::Table::Table (const CSMWorld::UniversalId& id, connect (&CSMPrefs::State::get(), SIGNAL (settingChanged (const CSMPrefs::Setting *)), this, SLOT (settingChanged (const CSMPrefs::Setting *))); CSMPrefs::get()["ID Tables"].update(); + + new TableHeaderMouseEventHandler(this); } void CSVWorld::Table::setEditLock (bool locked) diff --git a/apps/opencs/view/world/tableheadermouseeventhandler.cpp b/apps/opencs/view/world/tableheadermouseeventhandler.cpp new file mode 100644 index 0000000000..866c6149db --- /dev/null +++ b/apps/opencs/view/world/tableheadermouseeventhandler.cpp @@ -0,0 +1,64 @@ +#include "tableheadermouseeventhandler.hpp" +#include "dragrecordtable.hpp" + +#include +#include + +namespace CSVWorld +{ + +TableHeaderMouseEventHandler::TableHeaderMouseEventHandler(DragRecordTable * parent) + : QWidget(parent) + , table(*parent) + , header(*table.horizontalHeader()) +{ + header.setContextMenuPolicy(Qt::ContextMenuPolicy::CustomContextMenu); + connect( + &header, &QHeaderView::customContextMenuRequested, [=](const QPoint & position) { showContextMenu(position); }); + + header.viewport()->installEventFilter(this); +} + +bool TableHeaderMouseEventHandler::eventFilter(QObject * tableWatched, QEvent * event) +{ + if (event->type() == QEvent::Type::MouseButtonPress) + { + auto & clickEvent = static_cast(*event); + if ((clickEvent.button() == Qt::MiddleButton)) + { + const auto & index = table.indexAt(clickEvent.pos()); + table.setColumnHidden(index.column(), true); + clickEvent.accept(); + return true; + } + } + return false; +} + +void TableHeaderMouseEventHandler::showContextMenu(const QPoint & position) +{ + auto & menu{createContextMenu()}; + menu.popup(header.viewport()->mapToGlobal(position)); +} + +QMenu & TableHeaderMouseEventHandler::createContextMenu() +{ + auto * menu = new QMenu(this); + for (int i = 0; i < table.model()->columnCount(); ++i) + { + const auto & name = table.model()->headerData(i, Qt::Horizontal, Qt::DisplayRole); + QAction * action{new QAction(name.toString(), this)}; + action->setCheckable(true); + action->setChecked(!table.isColumnHidden(i)); + menu->addAction(action); + + connect(action, &QAction::triggered, [=]() { + table.setColumnHidden(i, !action->isChecked()); + action->setChecked(!action->isChecked()); + action->toggle(); + }); + } + return *menu; +} + +} // namespace CSVWorld diff --git a/apps/opencs/view/world/tableheadermouseeventhandler.hpp b/apps/opencs/view/world/tableheadermouseeventhandler.hpp new file mode 100644 index 0000000000..934bc1dbb7 --- /dev/null +++ b/apps/opencs/view/world/tableheadermouseeventhandler.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace CSVWorld +{ +class DragRecordTable; + +class TableHeaderMouseEventHandler : public QWidget +{ +public: + explicit TableHeaderMouseEventHandler(DragRecordTable * parent); + + void showContextMenu(const QPoint &); + +private: + DragRecordTable & table; + QHeaderView & header; + + QMenu & createContextMenu(); + bool eventFilter(QObject *, QEvent *) override; + +}; // class TableHeaderMouseEventHandler +} // namespace CSVWorld From 5878b1fce6487206dbabc0dc50ed904ebe33893c Mon Sep 17 00:00:00 2001 From: elsid Date: Fri, 24 Sep 2021 22:23:55 +0200 Subject: [PATCH 4/8] Use same logic for testing cell as for loading cell Having different branches makes testing less useful. If something fails in regular executing it should fail in testing. To make it possible there should be none differences in the execution paths. --- apps/openmw/mwworld/scene.cpp | 131 ++++++++++++++++------------------ apps/openmw/mwworld/scene.hpp | 10 +-- 2 files changed, 66 insertions(+), 75 deletions(-) diff --git a/apps/openmw/mwworld/scene.cpp b/apps/openmw/mwworld/scene.cpp index 89349d329a..e52f5532b4 100644 --- a/apps/openmw/mwworld/scene.cpp +++ b/apps/openmw/mwworld/scene.cpp @@ -200,13 +200,12 @@ namespace struct InsertVisitor { MWWorld::CellStore& mCell; - Loading::Listener& mLoadingListener; + Loading::Listener* mLoadingListener; bool mOnlyObjects; - bool mTest; std::vector mToInsert; - InsertVisitor (MWWorld::CellStore& cell, Loading::Listener& loadingListener, bool onlyObjects, bool test); + InsertVisitor (MWWorld::CellStore& cell, Loading::Listener* loadingListener, bool onlyObjects); bool operator() (const MWWorld::Ptr& ptr); @@ -214,8 +213,8 @@ namespace void insert(AddObject&& addObject); }; - InsertVisitor::InsertVisitor (MWWorld::CellStore& cell, Loading::Listener& loadingListener, bool onlyObjects, bool test) - : mCell (cell), mLoadingListener (loadingListener), mOnlyObjects(onlyObjects), mTest(test) + InsertVisitor::InsertVisitor (MWWorld::CellStore& cell, Loading::Listener* loadingListener, bool onlyObjects) + : mCell(cell), mLoadingListener(loadingListener), mOnlyObjects(onlyObjects) {} bool InsertVisitor::operator() (const MWWorld::Ptr& ptr) @@ -244,8 +243,8 @@ namespace } } - if (!mTest) - mLoadingListener.increaseProgress (1); + if (mLoadingListener != nullptr) + mLoadingListener->increaseProgress(1); } } @@ -325,12 +324,12 @@ namespace MWWorld mRendering.update (duration, paused); } - void Scene::unloadInactiveCell (CellStore* cell, bool test) + void Scene::unloadInactiveCell (CellStore* cell) { assert(mActiveCells.find(cell) == mActiveCells.end()); assert(mInactiveCells.find(cell) != mInactiveCells.end()); - if (!test) - Log(Debug::Info) << "Unloading cell " << cell->getCell()->getDescription(); + + Log(Debug::Info) << "Unloading cell " << cell->getCell()->getDescription(); ListObjectsVisitor visitor; @@ -351,13 +350,13 @@ namespace MWWorld mInactiveCells.erase(cell); } - void Scene::deactivateCell(CellStore* cell, bool test) + void Scene::deactivateCell(CellStore* cell) { assert(mInactiveCells.find(cell) != mInactiveCells.end()); if (mActiveCells.find(cell) == mActiveCells.end()) return; - if (!test) - Log(Debug::Info) << "Deactivate cell " << cell->getCell()->getDescription(); + + Log(Debug::Info) << "Deactivate cell " << cell->getCell()->getDescription(); ListAndResetObjectsVisitor visitor; @@ -409,7 +408,7 @@ namespace MWWorld mActiveCells.erase(cell); } - void Scene::activateCell (CellStore *cell, Loading::Listener* loadingListener, bool respawn, bool test) + void Scene::activateCell(CellStore *cell, Loading::Listener* loadingListener, bool respawn) { using DetourNavigator::HeightfieldShape; @@ -417,17 +416,14 @@ namespace MWWorld assert(mInactiveCells.find(cell) != mInactiveCells.end()); mActiveCells.insert(cell); - if (test) - Log(Debug::Info) << "Testing cell " << cell->getCell()->getDescription(); - else - Log(Debug::Info) << "Loading cell " << cell->getCell()->getDescription(); + Log(Debug::Info) << "Loading cell " << cell->getCell()->getDescription(); const auto world = MWBase::Environment::get().getWorld(); const int cellX = cell->getCell()->getGridX(); const int cellY = cell->getCell()->getGridY(); - if (!test && cell->getCell()->isExterior()) + if (cell->getCell()->isExterior()) { if (const auto heightField = mPhysics->getHeightField(cellX, cellY)) { @@ -466,69 +462,64 @@ namespace MWWorld if (respawn) cell->respawn(); - insertCell (*cell, loadingListener, false, test); + insertCell(*cell, loadingListener, false); mRendering.addCell(cell); - if (!test) + + MWBase::Environment::get().getWindowManager()->addCell(cell); + bool waterEnabled = cell->getCell()->hasWater() || cell->isExterior(); + float waterLevel = cell->getWaterLevel(); + mRendering.setWaterEnabled(waterEnabled); + if (waterEnabled) { - MWBase::Environment::get().getWindowManager()->addCell(cell); - bool waterEnabled = cell->getCell()->hasWater() || cell->isExterior(); - float waterLevel = cell->getWaterLevel(); - mRendering.setWaterEnabled(waterEnabled); - if (waterEnabled) - { - mPhysics->enableWater(waterLevel); - mRendering.setWaterHeight(waterLevel); + mPhysics->enableWater(waterLevel); + mRendering.setWaterHeight(waterLevel); - if (cell->getCell()->isExterior()) - { - if (const auto heightField = mPhysics->getHeightField(cellX, cellY)) - { - const btTransform& transform =heightField->getCollisionObject()->getWorldTransform(); - mNavigator.addWater(osg::Vec2i(cellX, cellY), ESM::Land::REAL_SIZE, - osg::Vec3f(static_cast(transform.getOrigin().x()), - static_cast(transform.getOrigin().y()), - waterLevel)); - } - } - else + if (cell->getCell()->isExterior()) + { + if (const auto heightField = mPhysics->getHeightField(cellX, cellY)) { - mNavigator.addWater(osg::Vec2i(cellX, cellY), std::numeric_limits::max(), - osg::Vec3f(0, 0, waterLevel)); + const btTransform& transform =heightField->getCollisionObject()->getWorldTransform(); + mNavigator.addWater(osg::Vec2i(cellX, cellY), ESM::Land::REAL_SIZE, + osg::Vec3f(static_cast(transform.getOrigin().x()), + static_cast(transform.getOrigin().y()), + waterLevel)); } } else - mPhysics->disableWater(); - - const auto player = MWBase::Environment::get().getWorld()->getPlayerPtr(); - - // The player is loaded before the scene and by default it is grounded, with the scene fully loaded, we validate and correct this. - if (player.mCell == cell) // Only run once, during initial cell load. { - mPhysics->traceDown(player, player.getRefData().getPosition().asVec3(), 10.f); + mNavigator.addWater(osg::Vec2i(cellX, cellY), std::numeric_limits::max(), + osg::Vec3f(0, 0, waterLevel)); } + } + else + mPhysics->disableWater(); - mNavigator.update(player.getRefData().getPosition().asVec3()); + const auto player = MWBase::Environment::get().getWorld()->getPlayerPtr(); - if (!cell->isExterior() && !(cell->getCell()->mData.mFlags & ESM::Cell::QuasiEx)) - mRendering.configureAmbient(cell->getCell()); + // The player is loaded before the scene and by default it is grounded, with the scene fully loaded, we validate and correct this. + if (player.mCell == cell) // Only run once, during initial cell load. + { + mPhysics->traceDown(player, player.getRefData().getPosition().asVec3(), 10.f); } + mNavigator.update(player.getRefData().getPosition().asVec3()); + + if (!cell->isExterior() && !(cell->getCell()->mData.mFlags & ESM::Cell::QuasiEx)) + mRendering.configureAmbient(cell->getCell()); + mPreloader->notifyLoaded(cell); } - void Scene::loadInactiveCell (CellStore *cell, Loading::Listener* loadingListener, bool test) + void Scene::loadInactiveCell(CellStore *cell, Loading::Listener* loadingListener) { assert(mActiveCells.find(cell) == mActiveCells.end()); assert(mInactiveCells.find(cell) == mInactiveCells.end()); mInactiveCells.insert(cell); - if (test) - Log(Debug::Info) << "Testing inactive cell " << cell->getCell()->getDescription(); - else - Log(Debug::Info) << "Loading inactive cell " << cell->getCell()->getDescription(); + Log(Debug::Info) << "Loading inactive cell " << cell->getCell()->getDescription(); - if (!test && cell->getCell()->isExterior()) + if (cell->getCell()->isExterior()) { float verts = ESM::Land::LAND_SIZE; float worldsize = ESM::Land::REAL_SIZE; @@ -550,7 +541,7 @@ namespace MWWorld } } - insertCell (*cell, loadingListener, true, test); + insertCell(*cell, loadingListener, true); } void Scene::clear() @@ -746,8 +737,8 @@ namespace MWWorld loadingListener->setLabel("Testing exterior cells ("+std::to_string(i)+"/"+std::to_string(cells.getExtSize())+")..."); CellStore *cell = MWBase::Environment::get().getWorld()->getExterior(it->mData.mX, it->mData.mY); - loadInactiveCell (cell, loadingListener, true); - activateCell (cell, loadingListener, false, true); + loadInactiveCell(cell, nullptr); + activateCell(cell, nullptr, false); auto iter = mInactiveCells.begin(); while (iter != mInactiveCells.end()) @@ -755,8 +746,8 @@ namespace MWWorld if (it->isExterior() && it->mData.mX == (*iter)->getCell()->getGridX() && it->mData.mY == (*iter)->getCell()->getGridY()) { - deactivateCell(*iter, true); - unloadInactiveCell (*iter, true); + deactivateCell(*iter); + unloadInactiveCell(*iter); break; } @@ -794,8 +785,8 @@ namespace MWWorld loadingListener->setLabel("Testing interior cells ("+std::to_string(i)+"/"+std::to_string(cells.getIntSize())+")..."); CellStore *cell = MWBase::Environment::get().getWorld()->getInterior(it->mName); - loadInactiveCell (cell, loadingListener, true); - activateCell (cell, loadingListener, false, true); + loadInactiveCell(cell, nullptr); + activateCell(cell, nullptr, false); auto iter = mInactiveCells.begin(); while (iter != mInactiveCells.end()) @@ -804,8 +795,8 @@ namespace MWWorld if (it->mName == (*iter)->getCell()->mName) { - deactivateCell(*iter, true); - unloadInactiveCell (*iter, true); + deactivateCell(*iter); + unloadInactiveCell(*iter); break; } @@ -988,9 +979,9 @@ namespace MWWorld mCellChanged = false; } - void Scene::insertCell (CellStore &cell, Loading::Listener* loadingListener, bool onlyObjects, bool test) + void Scene::insertCell (CellStore &cell, Loading::Listener* loadingListener, bool onlyObjects) { - InsertVisitor insertVisitor (cell, *loadingListener, onlyObjects, test); + InsertVisitor insertVisitor(cell, loadingListener, onlyObjects); cell.forEach (insertVisitor); insertVisitor.insert([&] (const MWWorld::Ptr& ptr) { addObject(ptr, *mPhysics, mRendering, mPagedRefs, onlyObjects); }); if (!onlyObjects) diff --git a/apps/openmw/mwworld/scene.hpp b/apps/openmw/mwworld/scene.hpp index 75c070dd1e..3f5d7bf2f5 100644 --- a/apps/openmw/mwworld/scene.hpp +++ b/apps/openmw/mwworld/scene.hpp @@ -100,7 +100,7 @@ namespace MWWorld std::vector> mWorkItems; - void insertCell (CellStore &cell, Loading::Listener* loadingListener, bool onlyObjects, bool test = false); + void insertCell(CellStore &cell, Loading::Listener* loadingListener, bool onlyObjects); osg::Vec2i mCurrentGridCenter; // Load and unload cells as necessary to create a cell grid with "X" and "Y" in the center @@ -116,10 +116,10 @@ namespace MWWorld osg::Vec4i gridCenterToBounds(const osg::Vec2i ¢erCell) const; osg::Vec2i getNewGridCenter(const osg::Vec3f &pos, const osg::Vec2i *currentGridCenter = nullptr) const; - void unloadInactiveCell (CellStore* cell, bool test = false); - void deactivateCell (CellStore* cell, bool test = false); - void activateCell (CellStore *cell, Loading::Listener* loadingListener, bool respawn, bool test = false); - void loadInactiveCell (CellStore *cell, Loading::Listener* loadingListener, bool test = false); + void unloadInactiveCell(CellStore* cell); + void deactivateCell(CellStore* cell); + void activateCell(CellStore *cell, Loading::Listener* loadingListener, bool respawn); + void loadInactiveCell(CellStore *cell, Loading::Listener* loadingListener); public: From e9f253c473e2343f903e325aa26038568a2eed87 Mon Sep 17 00:00:00 2001 From: elsid Date: Sun, 19 Sep 2021 19:16:39 +0200 Subject: [PATCH 5/8] Support stacked histogram for frames with duration over given threshold --- scripts/osg_stats.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/scripts/osg_stats.py b/scripts/osg_stats.py index 8a36eb5963..037aafea00 100755 --- a/scripts/osg_stats.py +++ b/scripts/osg_stats.py @@ -38,7 +38,7 @@ import termtables @click.option('--timeseries_sum', is_flag=True, help='Add a graph to timeseries for a sum per frame of all given timeseries metrics.') @click.option('--commulative_timeseries_sum', is_flag=True, - help='Add a graph to timeseries for a sum per frame of all given commulative timeseries.') + help='Add a graph to timeseries for a sum per frame of all given commulative timeseries.') @click.option('--stats_sum', is_flag=True, help='Add a row to stats table for a sum per frame of all given stats metrics.') @click.option('--begin_frame', type=int, default=0, @@ -47,10 +47,17 @@ import termtables help='End processing at this frame.') @click.option('--frame_number_name', type=str, default='FrameNumber', help='Frame number metric name.') +@click.option('--hist_threshold', type=str, multiple=True, + help='Show a histogram for given metric only for frames with threshold_name metric over threshold_value.') +@click.option('--threshold_name', type=str, default='Frame duration', + help='Frame duration metric name.') +@click.option('--threshold_value', type=float, default=1.05/60, + help='Threshold for hist_over.') @click.argument('path', type=click.Path(), nargs=-1) def main(print_keys, timeseries, hist, hist_ratio, stdev_hist, plot, stats, timeseries_sum, stats_sum, begin_frame, end_frame, path, - commulative_timeseries, commulative_timeseries_sum, frame_number_name): + commulative_timeseries, commulative_timeseries_sum, frame_number_name, + hist_threshold, threshold_name, threshold_value): sources = {v: list(read_data(v)) for v in path} if path else {'stdin': list(read_data(None))} keys = collect_unique_keys(sources) frames, begin_frame, end_frame = collect_per_frame( @@ -76,6 +83,9 @@ def main(print_keys, timeseries, hist, hist_ratio, stdev_hist, plot, stats, draw_plots(sources=frames, plots=plot) if stats: print_stats(sources=frames, keys=stats, stats_sum=stats_sum) + if hist_threshold: + draw_hist_threshold(sources=frames, keys=hist_threshold, begin_frame=begin_frame, + threshold_name=threshold_name, threshold_value=threshold_value) matplotlib.pyplot.show() @@ -240,7 +250,6 @@ def print_stats(sources, keys, stats_sum): if stats_sum: stats.append(make_stats(source=name, key='sum', values=sum_multiple(frames, keys))) metrics = list(stats[0].keys()) - max_key_size = max(len(tuple(v.values())[0]) for v in stats) termtables.print( [list(v.values()) for v in stats], header=metrics, @@ -248,6 +257,27 @@ def print_stats(sources, keys, stats_sum): ) +def draw_hist_threshold(sources, keys, begin_frame, threshold_name, threshold_value): + for name, frames in sources.items(): + indices = [n for n, v in enumerate(frames[threshold_name]) if v > threshold_value] + numbers = [v + begin_frame for v in indices] + x = [v for v in range(0, len(indices))] + fig, ax = matplotlib.pyplot.subplots() + ax.set_title(f'Frames with "{threshold_name}" > {threshold_value} ({len(indices)})') + ax.bar(x, [frames[threshold_name][v] for v in indices], label=threshold_name, color='black', alpha=0.2) + prev = 0 + for key in keys: + values = [frames[key][v] for v in indices] + ax.bar(x, values, bottom=prev, label=key) + prev = values + ax.hlines(threshold_value, x[0] - 1, x[-1] + 1, color='black', label='threshold', linestyles='dashed') + ax.xaxis.set_major_locator(matplotlib.pyplot.FixedLocator(x)) + ax.xaxis.set_major_formatter(matplotlib.pyplot.FixedFormatter(numbers)) + ax.grid(True) + ax.legend() + fig.canvas.set_window_title(f'hist_threshold:{name}') + + def filter_not_none(values): return [v for v in values if v is not None] @@ -282,5 +312,6 @@ def to_number(value): except ValueError: return float(value) + if __name__ == '__main__': main() From 83e0d9aeefd70413d4a04882eed0a8bda03cd19d Mon Sep 17 00:00:00 2001 From: Petr Mikheev Date: Sun, 26 Sep 2021 17:28:51 +0200 Subject: [PATCH 6/8] Minor refactoring: use Misc::normalizeAngle in worldimp.cpp --- apps/openmw/mwworld/worldimp.cpp | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/apps/openmw/mwworld/worldimp.cpp b/apps/openmw/mwworld/worldimp.cpp index bc28c2eb7b..9d52d7ee77 100644 --- a/apps/openmw/mwworld/worldimp.cpp +++ b/apps/openmw/mwworld/worldimp.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -76,21 +77,6 @@ #include "contentloader.hpp" #include "esmloader.hpp" -namespace -{ - -// Wraps a value to (-PI, PI] -void wrap(float& rad) -{ - const float pi = static_cast(osg::PI); - if (rad>0) - rad = std::fmod(rad+pi, 2.0f*pi)-pi; - else - rad = std::fmod(rad-pi, 2.0f*pi)+pi; -} - -} - namespace MWWorld { struct GameContentLoader : public ContentLoader @@ -1290,8 +1276,6 @@ namespace MWWorld void World::rotateObject(const Ptr& ptr, const osg::Vec3f& rot, MWBase::RotationFlags flags) { - const float pi = static_cast(osg::PI); - ESM::Position pos = ptr.getRefData().getPosition(); float *objRot = pos.rot; if (flags & MWBase::RotationFlag_adjust) @@ -1313,13 +1297,9 @@ namespace MWWorld * currently it's done so for rotating the camera, which needs * clamping. */ - const float half_pi = pi/2.f; - - if(objRot[0] < -half_pi) objRot[0] = -half_pi; - else if(objRot[0] > half_pi) objRot[0] = half_pi; - - wrap(objRot[1]); - wrap(objRot[2]); + objRot[0] = osg::clampBetween(objRot[0], -osg::PIf / 2, osg::PIf / 2); + objRot[1] = Misc::normalizeAngle(objRot[1]); + objRot[2] = Misc::normalizeAngle(objRot[2]); } ptr.getRefData().setPosition(pos); From c6f7137ee1da615f602c18f365373d5d324481ac Mon Sep 17 00:00:00 2001 From: Bo Svensson <90132211+bosvensson1@users.noreply.github.com> Date: Mon, 27 Sep 2021 18:41:24 +0000 Subject: [PATCH 7/8] fixes bugs with share state (#3111) * optimizer.cpp merge fix * objectpaging.cpp * optimizer.hpp setSharedStateManager * optimizer.cpp shareState * scenemanager.cpp shareState * scenemanager.cpp * optimizer.cpp * optimizer.cpp * scenemanager.cpp * optimizer.cpp --- apps/openmw/mwrender/objectpaging.cpp | 2 +- components/resource/scenemanager.cpp | 12 ++++-------- components/sceneutil/optimizer.cpp | 12 +++++++++++- components/sceneutil/optimizer.hpp | 13 ++++++++++++- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/apps/openmw/mwrender/objectpaging.cpp b/apps/openmw/mwrender/objectpaging.cpp index 47eacbe1a2..88c3d4ba02 100644 --- a/apps/openmw/mwrender/objectpaging.cpp +++ b/apps/openmw/mwrender/objectpaging.cpp @@ -651,7 +651,7 @@ namespace MWRender } optimizer.setIsOperationPermissibleForObjectCallback(new CanOptimizeCallback); unsigned int options = SceneUtil::Optimizer::FLATTEN_STATIC_TRANSFORMS|SceneUtil::Optimizer::REMOVE_REDUNDANT_NODES|SceneUtil::Optimizer::MERGE_GEOMETRY; - mSceneManager->shareState(mergeGroup); + optimizer.optimize(mergeGroup, options); group->addChild(mergeGroup); diff --git a/components/resource/scenemanager.cpp b/components/resource/scenemanager.cpp index b468b430ff..cc38144794 100644 --- a/components/resource/scenemanager.cpp +++ b/components/resource/scenemanager.cpp @@ -659,22 +659,18 @@ namespace Resource osg::ref_ptr shaderVisitor (createShaderVisitor()); loaded->accept(*shaderVisitor); - // share state - // do this before optimizing so the optimizer will be able to combine nodes more aggressively - // note, because StateSets will be shared at this point, StateSets can not be modified inside the optimizer - mSharedStateMutex.lock(); - mSharedStateManager->share(loaded.get()); - mSharedStateMutex.unlock(); - if (canOptimize(normalized)) { SceneUtil::Optimizer optimizer; + optimizer.setSharedStateManager(mSharedStateManager, &mSharedStateMutex); optimizer.setIsOperationPermissibleForObjectCallback(new CanOptimizeCallback); - static const unsigned int options = getOptimizationOptions(); + static const unsigned int options = getOptimizationOptions()|SceneUtil::Optimizer::SHARE_DUPLICATE_STATE; optimizer.optimize(loaded, options); } + else + shareState(loaded); if (compile && mIncrementalCompileOperation) mIncrementalCompileOperation->add(loaded); diff --git a/components/sceneutil/optimizer.cpp b/components/sceneutil/optimizer.cpp index 5fbeb681fc..dffbe62ec7 100644 --- a/components/sceneutil/optimizer.cpp +++ b/components/sceneutil/optimizer.cpp @@ -30,6 +30,8 @@ #include #include +#include + #include #include #include @@ -84,6 +86,13 @@ void Optimizer::optimize(osg::Node* node, unsigned int options) cstv.removeTransforms(node); } + if (options & SHARE_DUPLICATE_STATE && _sharedStateManager) + { + if (_sharedStateMutex) _sharedStateMutex->lock(); + _sharedStateManager->share(node); + if (_sharedStateMutex) _sharedStateMutex->unlock(); + } + if (options & REMOVE_REDUNDANT_NODES) { OSG_INFO<<"Optimizer::optimize() doing REMOVE_REDUNDANT_NODES"<getNumChildren()==1 && transform->getChild(0)->asTransform()!=0 && transform->getChild(0)->asTransform()->asMatrixTransform()!=0 && - transform->getChild(0)->asTransform()->getDataVariance()==osg::Object::STATIC) + (!transform->getChild(0)->getStateSet() || transform->getChild(0)->getStateSet()->referenceCount()==1) && + transform->getChild(0)->getDataVariance()==osg::Object::STATIC) { // now combine with its child. osg::MatrixTransform* child = transform->getChild(0)->asTransform()->asMatrixTransform(); diff --git a/components/sceneutil/optimizer.hpp b/components/sceneutil/optimizer.hpp index 2d6293e231..7b32bafee2 100644 --- a/components/sceneutil/optimizer.hpp +++ b/components/sceneutil/optimizer.hpp @@ -25,6 +25,12 @@ //#include #include +#include + +namespace osgDB +{ + class SharedStateManager; +} //namespace osgUtil { namespace SceneUtil { @@ -65,7 +71,7 @@ class Optimizer public: - Optimizer() : _mergeAlphaBlending(false) {} + Optimizer() : _mergeAlphaBlending(false), _sharedStateManager(nullptr), _sharedStateMutex(nullptr) {} virtual ~Optimizer() {} enum OptimizationOptions @@ -121,6 +127,8 @@ class Optimizer void setMergeAlphaBlending(bool merge) { _mergeAlphaBlending = merge; } void setViewPoint(const osg::Vec3f& viewPoint) { _viewPoint = viewPoint; } + void setSharedStateManager(osgDB::SharedStateManager* sharedStateManager, std::mutex* sharedStateMutex) { _sharedStateMutex = sharedStateMutex; _sharedStateManager = sharedStateManager; } + /** Reset internal data to initial state - the getPermissibleOptionsMap is cleared.*/ void reset(); @@ -258,6 +266,9 @@ class Optimizer osg::Vec3f _viewPoint; bool _mergeAlphaBlending; + osgDB::SharedStateManager* _sharedStateManager; + mutable std::mutex* _sharedStateMutex; + public: /** Flatten Static Transform nodes by applying their transform to the From 449539c0ed66484c9837a2d752de8c9905943bb7 Mon Sep 17 00:00:00 2001 From: psi29a Date: Mon, 27 Sep 2021 19:04:38 +0000 Subject: [PATCH 8/8] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b9ea47c56..cc824b7540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,8 @@ Bug #6184: Command and Calm and Demoralize and Frenzy and Rally magic effects inconsistencies with vanilla Bug #6197: Infinite Casting Loop Bug #6273: Respawning NPCs rotation is inconsistent - Bug #6289: Keyword search in dialogues expected the text to be all ASCII characters + Bug #6289: Keyword search in dialogues expected the text to be all ASCII characters + Feature #890: OpenMW-CS: Column filtering Feature #2554: Modifying an object triggers the instances table to scroll to the corresponding record Feature #2780: A way to see current OpenMW version in the console Feature #3616: Allow Zoom levels on the World Map