Updated widescreen support for the loading screen
This commit is contained in:
parent
e06af15c2f
commit
2bab0e4576
@ -59,6 +59,8 @@ auto EventListener::ProcessEvent(
|
||||
}
|
||||
} else if (a_event->menuName == RE::LoadingMenu::MENU_NAME) {
|
||||
LoadScreenFrameFix::OnMenuOpen();
|
||||
} else if (a_event->menuName == RE::MainMenu::MENU_NAME) {
|
||||
LoadScreenFrameFix::OnMainMenuOpen();
|
||||
}
|
||||
} else {
|
||||
if (a_event->menuName == RE::DialogueMenu::MENU_NAME) {
|
||||
|
||||
@ -34,6 +34,18 @@
|
||||
// the art is centred the computed correction is ~0 and nothing more is written. It also converges
|
||||
// again by itself if the engine re-applies its own transform.
|
||||
//
|
||||
// The measurements are read from the billboard *geometry's* bound, never the attachment node's,
|
||||
// although corrections are written to the attachment node. The attachment node's worldBound
|
||||
// merges every child, and Enderal's loadscreen NIFs carry fog emitters whose bounds are
|
||||
// world-space particle bounds: empty on the first frames of a main-menu load, but alive during
|
||||
// gameplay cell transitions, and they do not follow the node's scale. Measuring the merged bound
|
||||
// therefore turned Resize into a runaway on cell transitions - shrink the node, re-measure a
|
||||
// bound the shrink did not change, shrink again - until the art collapsed onto the attachment
|
||||
// origin (which also collapses the child NIF's translation; the origin projects left of centre
|
||||
// at mid-height, exactly where the wreckage sat). Belt and braces, every correction is also
|
||||
// clamped per art instance, and a clamp hit is logged as a warning: if that line ever appears, a
|
||||
// measurement is wrong again - find out why before loosening the clamp.
|
||||
//
|
||||
// Note this leaves loadingmenu.swf and the movie's GFx viewport completely untouched. Moving the
|
||||
// matte inside the movie is not an option in any case: it fades in through a timeline cxform ramp
|
||||
// starting at alpha 0, and setting any property on a timeline instance from script (in-movie
|
||||
@ -42,36 +54,85 @@
|
||||
namespace LoadScreenFrameFix
|
||||
{
|
||||
inline bool g_enabled = false;
|
||||
|
||||
inline void Install(bool a_enabled)
|
||||
{
|
||||
g_enabled = a_enabled;
|
||||
|
||||
if (a_enabled) {
|
||||
logger::info("Loading screen frame fix armed");
|
||||
}
|
||||
}
|
||||
inline bool g_mainMenuSeen = false;
|
||||
|
||||
namespace detail
|
||||
{
|
||||
// The loading screen billboard is the only thing attached to the UI3D scene while the
|
||||
// Loading Menu is up, and the only node there with a non-empty bound - the sibling fog
|
||||
// emitter has none.
|
||||
inline RE::NiAVObject* FindArt()
|
||||
struct Art
|
||||
{
|
||||
RE::NiAVObject* root{ nullptr }; // the menuObjects attachment node - corrections are written here
|
||||
RE::NiAVObject* shape{ nullptr }; // the billboard geometry - measurements are read here
|
||||
};
|
||||
|
||||
// Everything applied to one art instance so far. Long loads swap the art NIF mid-stream
|
||||
// and the engine can re-apply its own transform underneath, so the totals are keyed on
|
||||
// the shape pointer (unique per NIF, unlike the attachment node) and reset when it
|
||||
// changes.
|
||||
struct State
|
||||
{
|
||||
const RE::NiAVObject* shape{ nullptr }; // identity only, never dereferenced
|
||||
|
||||
float scale{ 1.0f }; // cumulative resize factor, clamped to [1/3, 3]
|
||||
float shift{ 0.0f }; // cumulative X translation, clamped to +/-150 units
|
||||
|
||||
float lastRatio{ 0.0f }; // resize stability gate, see Resize
|
||||
std::uint32_t stableTicks{ 0 };
|
||||
|
||||
bool loggedResize{ false };
|
||||
bool loggedCentre{ false };
|
||||
bool warnedClamp{ false };
|
||||
};
|
||||
|
||||
inline State g_state;
|
||||
|
||||
// The largest non-particle geometry in the slot's subtree - the art billboard. Fog
|
||||
// emitters are NiParticles-derived and must not contaminate the measurement: their
|
||||
// bounds are computed in world space from the live particles, so they neither follow
|
||||
// the attachment node's scale nor hold still.
|
||||
inline RE::NiAVObject* FindShape(RE::NiAVObject* a_object)
|
||||
{
|
||||
if (!a_object) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (a_object->AsGeometry()) {
|
||||
return a_object->AsParticlesGeom() ? nullptr : a_object;
|
||||
}
|
||||
|
||||
RE::NiAVObject* best = nullptr;
|
||||
|
||||
if (const auto node = a_object->AsNode()) {
|
||||
for (const auto& child : node->GetChildren()) {
|
||||
const auto found = FindShape(child.get());
|
||||
if (found && (!best || found->worldBound.radius > best->worldBound.radius)) {
|
||||
best = found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// The loading screen art is the only billboard attached to the UI3D scene while the
|
||||
// Loading Menu is up; slots holding nothing, or only emitters, are skipped.
|
||||
inline Art FindArt()
|
||||
{
|
||||
const auto manager = RE::UI3DSceneManager::GetSingleton();
|
||||
if (!manager) {
|
||||
return nullptr;
|
||||
return {};
|
||||
}
|
||||
|
||||
for (const auto& slot : manager->menuObjects) {
|
||||
const auto object = slot.get();
|
||||
if (object && object->worldBound.radius > 0.0f) {
|
||||
return object;
|
||||
if (object) {
|
||||
const auto shape = FindShape(object);
|
||||
if (shape && shape->worldBound.radius > 0.0f) {
|
||||
return { object, shape };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
return {};
|
||||
}
|
||||
|
||||
// 1.5.97 never fits the load screen model to the viewport at all - the billboard grows with
|
||||
@ -87,42 +148,63 @@ namespace LoadScreenFrameFix
|
||||
// Verified against 1.6.1170 at 3840x1080: predicted 0.1781, measured 0.1777.
|
||||
//
|
||||
// Returns true when it changed the node, so the caller re-measures before centring.
|
||||
inline bool Resize(RE::NiAVObject* a_art, float a_projectedRadius)
|
||||
inline bool Resize(RE::NiAVObject* a_root, float a_projectedRadius, RE::GFxMovieView* a_view, float a_aspect)
|
||||
{
|
||||
if (a_projectedRadius <= 0.0f) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto view = RE::UI::GetSingleton()->GetMovieView(RE::LoadingMenu::MENU_NAME);
|
||||
if (!view || view->GetViewScaleMode() != RE::GFxMovieView::ScaleModeType::kShowAll) {
|
||||
if (a_view->GetViewScaleMode() != RE::GFxMovieView::ScaleModeType::kShowAll) {
|
||||
// Under kExactFit the stage is stretched to the whole screen, so the frame is as
|
||||
// wide as the art already is; resizing to a kShowAll target would blow it up.
|
||||
return false;
|
||||
}
|
||||
|
||||
RE::GViewport viewport;
|
||||
view->GetViewport(&viewport);
|
||||
if (viewport.height <= 0 || viewport.width <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// kShowAll fits the 1280x720 stage to the shorter axis, so above 16:9 the stage covers
|
||||
// (16/9)/aspect of the screen width, and at or below it the full width.
|
||||
const float aspect = static_cast<float>(viewport.width) / static_cast<float>(viewport.height);
|
||||
const float stageWidth = aspect > (16.0f / 9.0f) ? (16.0f / 9.0f) / aspect : 1.0f;
|
||||
// kShowAll fits the 1280x720 stage to the shorter axis; the caller guarantees the
|
||||
// viewport is wider than 16:9, so the stage covers (16/9)/aspect of the screen width.
|
||||
const float stageWidth = (16.0f / 9.0f) / a_aspect;
|
||||
const float target = 0.35617f * stageWidth;
|
||||
const float ratio = target / a_projectedRadius;
|
||||
float ratio = target / a_projectedRadius;
|
||||
|
||||
// Wide deadband: 1.6.1170 lands within 0.2%, so it never trips this
|
||||
if (ratio > 0.95f && ratio < 1.05f) {
|
||||
// Wide deadband: the arts are not all authored at the 794.7-unit width the target is
|
||||
// derived from (a correct 16:9 load measured 9% under it), while the smallest real
|
||||
// 1.5.97 defect, 21:9, still needs a ratio of 0.75.
|
||||
if (ratio > 0.85f && ratio < 1.15f) {
|
||||
g_state.stableTicks = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
a_art->local.scale *= ratio;
|
||||
// A measurement taken mid-flight looks exactly like the 1.5.97 defect for a frame or
|
||||
// two: cell transitions attach the art before the engine's aspect squeeze lands, and
|
||||
// long loads fade a swapped art in. Only act on a value that has held still for
|
||||
// several consecutive frames - the real 1.5.97 oversize is permanent, so the one
|
||||
// legitimate fix is merely delayed ~80 ms, well inside the fade.
|
||||
if (std::fabs(ratio - g_state.lastRatio) > 0.02f) {
|
||||
g_state.lastRatio = ratio;
|
||||
g_state.stableTicks = 1;
|
||||
return false;
|
||||
}
|
||||
if (++g_state.stableTicks < 5) {
|
||||
return false;
|
||||
}
|
||||
g_state.stableTicks = 0;
|
||||
|
||||
static bool logged = false;
|
||||
if (!logged) {
|
||||
logged = true;
|
||||
const float total = std::clamp(g_state.scale * ratio, 1.0f / 3.0f, 3.0f);
|
||||
ratio = total / g_state.scale;
|
||||
|
||||
if (ratio > 0.999f && ratio < 1.001f) {
|
||||
if (!g_state.warnedClamp) {
|
||||
g_state.warnedClamp = true;
|
||||
logger::warn("Loading screen art correction clamped ({:.3f}x scale, {:.1f} units of shift applied) - a measurement is off, not converging", g_state.scale, g_state.shift);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
g_state.scale = total;
|
||||
a_root->local.scale *= ratio;
|
||||
|
||||
if (!g_state.loggedResize) {
|
||||
g_state.loggedResize = true;
|
||||
logger::info("Loading screen art resized: projected radius {:.4f}, target {:.4f}, scaled by {:.3f}", a_projectedRadius, target, ratio);
|
||||
}
|
||||
|
||||
@ -130,15 +212,52 @@ namespace LoadScreenFrameFix
|
||||
}
|
||||
}
|
||||
|
||||
// Runs once per frame for as long as the Loading Menu is up, started from EventListener
|
||||
inline void Centre(std::uint32_t a_pass)
|
||||
// Runs once per frame from the LoadingMenu::AdvanceMovie hook below. Earlier versions
|
||||
// self-scheduled through AddUITask instead, seeded from the MenuOpenCloseEvent: that worked
|
||||
// for main-menu loads, but whatever pumps the SKSE UI task queue does not run during gameplay
|
||||
// cell transitions - there the loop measured exactly once, at menu-open, *before* the engine
|
||||
// applies its (16/9)/aspect squeeze, saw the art dead centre, and never ran again to see it
|
||||
// drift. AdvanceMovie is called every frame the menu animates, on every load path.
|
||||
inline void Tick()
|
||||
{
|
||||
if (!g_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The Main Menu's 3D background lives in the same menuObjects array, and on a load
|
||||
// started from the main menu it is the only billboard there until the menu is gone -
|
||||
// the art attaches later. Correcting it visibly dragged the menu background to centre
|
||||
// the moment loading began, so while the Main Menu is open (including the tail of a
|
||||
// quit-to-main-menu load) the scene is left entirely alone; the engine's own placement
|
||||
// of that background is acceptable on every runtime.
|
||||
const auto ui = RE::UI::GetSingleton();
|
||||
if (!ui || !ui->IsMenuOpen(RE::LoadingMenu::MENU_NAME)) {
|
||||
if (!ui || ui->IsMenuOpen(RE::MainMenu::MENU_NAME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Game startup runs a Loading Menu as well, and there the background is attached
|
||||
// *before* the Main Menu first registers as open, where the gate above cannot see it.
|
||||
// Nothing framed ever loads that early, so stay idle until the Main Menu has existed.
|
||||
if (!g_mainMenuSeen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// At 16:9 and narrower there is nothing to fix on any runtime - the X drift and the
|
||||
// missing 1.5.97 fit both exist only above 16:9 - while measurement noise (authoring
|
||||
// variance, mid-attach states) is still there. Stay out entirely.
|
||||
const auto view = ui->GetMovieView(RE::LoadingMenu::MENU_NAME);
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
RE::GViewport viewport;
|
||||
view->GetViewport(&viewport);
|
||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const float aspect = static_cast<float>(viewport.width) / static_cast<float>(viewport.height);
|
||||
if (aspect <= 16.0f / 9.0f + 0.005f) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -146,9 +265,14 @@ namespace LoadScreenFrameFix
|
||||
const auto camera = manager ? manager->camera.get() : nullptr;
|
||||
const auto art = detail::FindArt();
|
||||
|
||||
if (art && camera) {
|
||||
const auto& centre = art->worldBound.center;
|
||||
const float radius = art->worldBound.radius;
|
||||
if (art.root && camera) {
|
||||
if (detail::g_state.shape != art.shape) {
|
||||
detail::g_state = {};
|
||||
detail::g_state.shape = art.shape;
|
||||
}
|
||||
|
||||
const auto& centre = art.shape->worldBound.center;
|
||||
const float radius = art.shape->worldBound.radius;
|
||||
const RE::NiPoint3 probe{ centre.x + radius, centre.y, centre.z };
|
||||
|
||||
float x = 0.0f, y = 0.0f, z = 0.0f;
|
||||
@ -162,23 +286,31 @@ namespace LoadScreenFrameFix
|
||||
|
||||
if (std::fabs(perUnit) > 1e-8f) {
|
||||
// Size first - it moves the art, so centring has to measure afterwards
|
||||
if (detail::Resize(art, std::fabs(perUnit) * radius)) {
|
||||
if (detail::Resize(art.root, std::fabs(perUnit) * radius, view.get(), aspect)) {
|
||||
RE::NiUpdateData updateData{};
|
||||
art->Update(updateData);
|
||||
art.root->Update(updateData);
|
||||
} else {
|
||||
const float delta = (0.5f - x) / perUnit;
|
||||
|
||||
// ~1 px at 3840 wide; below that the loop has converged
|
||||
if (std::fabs(delta) > 0.05f) {
|
||||
art->local.translate.x += delta;
|
||||
const float total = std::clamp(detail::g_state.shift + delta, -150.0f, 150.0f);
|
||||
const float applied = total - detail::g_state.shift;
|
||||
|
||||
RE::NiUpdateData updateData{};
|
||||
art->Update(updateData);
|
||||
if (std::fabs(applied) > 0.05f) {
|
||||
detail::g_state.shift = total;
|
||||
art.root->local.translate.x += applied;
|
||||
|
||||
static bool logged = false;
|
||||
if (!logged) {
|
||||
logged = true;
|
||||
logger::info("Loading screen art centred: projected x {:.4f}, moved {:.2f} units along X", x, delta);
|
||||
RE::NiUpdateData updateData{};
|
||||
art.root->Update(updateData);
|
||||
|
||||
if (!detail::g_state.loggedCentre) {
|
||||
detail::g_state.loggedCentre = true;
|
||||
logger::info("Loading screen art centred: projected x {:.4f}, moved {:.2f} units along X", x, applied);
|
||||
}
|
||||
} else if (!detail::g_state.warnedClamp) {
|
||||
detail::g_state.warnedClamp = true;
|
||||
logger::warn("Loading screen art correction clamped ({:.3f}x scale, {:.1f} units of shift applied) - a measurement is off, not converging", detail::g_state.scale, detail::g_state.shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -186,16 +318,44 @@ namespace LoadScreenFrameFix
|
||||
}
|
||||
}
|
||||
|
||||
// Loading screens outlast any fixed budget, so this simply follows the menu; the cap is
|
||||
// only a backstop in case the menu is somehow never reported closed.
|
||||
if (a_pass < 7200) {
|
||||
SKSE::GetTaskInterface()->AddUITask([a_pass]() { Centre(a_pass + 1); });
|
||||
}
|
||||
}
|
||||
|
||||
// Called from EventListener when the Loading Menu opens
|
||||
inline void AdvanceMovieEx(RE::IMenu* a_this, float a_interval, std::uint32_t a_currentTime);
|
||||
inline REL::Relocation<decltype(&AdvanceMovieEx)> _AdvanceMovie;
|
||||
|
||||
inline void AdvanceMovieEx(RE::IMenu* a_this, float a_interval, std::uint32_t a_currentTime)
|
||||
{
|
||||
_AdvanceMovie(a_this, a_interval, a_currentTime);
|
||||
Tick();
|
||||
}
|
||||
|
||||
// VR is left alone: a headset has no pillarbox, and the rest of the widescreen work
|
||||
// (MenuAspectRatioFix) is desktop-only as well
|
||||
inline void Install(bool a_enabled)
|
||||
{
|
||||
if (!a_enabled || REL::Module::IsVR()) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_enabled = true;
|
||||
|
||||
REL::Relocation<uintptr_t> vtbl(RE::VTABLE_LoadingMenu[0]);
|
||||
_AdvanceMovie = vtbl.write_vfunc(0x5, &AdvanceMovieEx);
|
||||
|
||||
logger::info("Loading screen frame fix armed");
|
||||
}
|
||||
|
||||
// Called from EventListener when the Loading Menu opens: forget the previous load's totals.
|
||||
// Node addresses can be reused across loads, so the pointer-identity reset alone is not enough.
|
||||
inline void OnMenuOpen()
|
||||
{
|
||||
Centre(0);
|
||||
detail::g_state = {};
|
||||
}
|
||||
|
||||
// Called from EventListener when the Main Menu opens. Until that first happens, any billboard
|
||||
// in the UI3D scene is the menu background being staged during startup, not loading art.
|
||||
inline void OnMainMenuOpen()
|
||||
{
|
||||
g_mainMenuSeen = true;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user