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) {
|
} else if (a_event->menuName == RE::LoadingMenu::MENU_NAME) {
|
||||||
LoadScreenFrameFix::OnMenuOpen();
|
LoadScreenFrameFix::OnMenuOpen();
|
||||||
|
} else if (a_event->menuName == RE::MainMenu::MENU_NAME) {
|
||||||
|
LoadScreenFrameFix::OnMainMenuOpen();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (a_event->menuName == RE::DialogueMenu::MENU_NAME) {
|
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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// starting at alpha 0, and setting any property on a timeline instance from script (in-movie
|
||||||
@ -42,36 +54,85 @@
|
|||||||
namespace LoadScreenFrameFix
|
namespace LoadScreenFrameFix
|
||||||
{
|
{
|
||||||
inline bool g_enabled = false;
|
inline bool g_enabled = false;
|
||||||
|
inline bool g_mainMenuSeen = false;
|
||||||
inline void Install(bool a_enabled)
|
|
||||||
{
|
|
||||||
g_enabled = a_enabled;
|
|
||||||
|
|
||||||
if (a_enabled) {
|
|
||||||
logger::info("Loading screen frame fix armed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace detail
|
namespace detail
|
||||||
{
|
{
|
||||||
// The loading screen billboard is the only thing attached to the UI3D scene while the
|
struct Art
|
||||||
// Loading Menu is up, and the only node there with a non-empty bound - the sibling fog
|
{
|
||||||
// emitter has none.
|
RE::NiAVObject* root{ nullptr }; // the menuObjects attachment node - corrections are written here
|
||||||
inline RE::NiAVObject* FindArt()
|
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();
|
const auto manager = RE::UI3DSceneManager::GetSingleton();
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
return nullptr;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& slot : manager->menuObjects) {
|
for (const auto& slot : manager->menuObjects) {
|
||||||
const auto object = slot.get();
|
const auto object = slot.get();
|
||||||
if (object && object->worldBound.radius > 0.0f) {
|
if (object) {
|
||||||
return 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
|
// 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.
|
// 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.
|
// 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) {
|
if (a_projectedRadius <= 0.0f) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto view = RE::UI::GetSingleton()->GetMovieView(RE::LoadingMenu::MENU_NAME);
|
if (a_view->GetViewScaleMode() != RE::GFxMovieView::ScaleModeType::kShowAll) {
|
||||||
if (!view || view->GetViewScaleMode() != RE::GFxMovieView::ScaleModeType::kShowAll) {
|
|
||||||
// Under kExactFit the stage is stretched to the whole screen, so the frame is as
|
// 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.
|
// wide as the art already is; resizing to a kShowAll target would blow it up.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
RE::GViewport viewport;
|
// kShowAll fits the 1280x720 stage to the shorter axis; the caller guarantees the
|
||||||
view->GetViewport(&viewport);
|
// viewport is wider than 16:9, so the stage covers (16/9)/aspect of the screen width.
|
||||||
if (viewport.height <= 0 || viewport.width <= 0) {
|
const float stageWidth = (16.0f / 9.0f) / a_aspect;
|
||||||
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;
|
|
||||||
const float target = 0.35617f * stageWidth;
|
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
|
// Wide deadband: the arts are not all authored at the 794.7-unit width the target is
|
||||||
if (ratio > 0.95f && ratio < 1.05f) {
|
// 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;
|
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;
|
const float total = std::clamp(g_state.scale * ratio, 1.0f / 3.0f, 3.0f);
|
||||||
if (!logged) {
|
ratio = total / g_state.scale;
|
||||||
logged = true;
|
|
||||||
|
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);
|
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
|
// Runs once per frame from the LoadingMenu::AdvanceMovie hook below. Earlier versions
|
||||||
inline void Centre(std::uint32_t a_pass)
|
// 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) {
|
if (!g_enabled) {
|
||||||
return;
|
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();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,9 +265,14 @@ namespace LoadScreenFrameFix
|
|||||||
const auto camera = manager ? manager->camera.get() : nullptr;
|
const auto camera = manager ? manager->camera.get() : nullptr;
|
||||||
const auto art = detail::FindArt();
|
const auto art = detail::FindArt();
|
||||||
|
|
||||||
if (art && camera) {
|
if (art.root && camera) {
|
||||||
const auto& centre = art->worldBound.center;
|
if (detail::g_state.shape != art.shape) {
|
||||||
const float radius = art->worldBound.radius;
|
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 };
|
const RE::NiPoint3 probe{ centre.x + radius, centre.y, centre.z };
|
||||||
|
|
||||||
float x = 0.0f, y = 0.0f, z = 0.0f;
|
float x = 0.0f, y = 0.0f, z = 0.0f;
|
||||||
@ -162,23 +286,31 @@ namespace LoadScreenFrameFix
|
|||||||
|
|
||||||
if (std::fabs(perUnit) > 1e-8f) {
|
if (std::fabs(perUnit) > 1e-8f) {
|
||||||
// Size first - it moves the art, so centring has to measure afterwards
|
// 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{};
|
RE::NiUpdateData updateData{};
|
||||||
art->Update(updateData);
|
art.root->Update(updateData);
|
||||||
} else {
|
} else {
|
||||||
const float delta = (0.5f - x) / perUnit;
|
const float delta = (0.5f - x) / perUnit;
|
||||||
|
|
||||||
// ~1 px at 3840 wide; below that the loop has converged
|
// ~1 px at 3840 wide; below that the loop has converged
|
||||||
if (std::fabs(delta) > 0.05f) {
|
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;
|
||||||
|
|
||||||
|
if (std::fabs(applied) > 0.05f) {
|
||||||
|
detail::g_state.shift = total;
|
||||||
|
art.root->local.translate.x += applied;
|
||||||
|
|
||||||
RE::NiUpdateData updateData{};
|
RE::NiUpdateData updateData{};
|
||||||
art->Update(updateData);
|
art.root->Update(updateData);
|
||||||
|
|
||||||
static bool logged = false;
|
if (!detail::g_state.loggedCentre) {
|
||||||
if (!logged) {
|
detail::g_state.loggedCentre = true;
|
||||||
logged = true;
|
logger::info("Loading screen art centred: projected x {:.4f}, moved {:.2f} units along X", x, applied);
|
||||||
logger::info("Loading screen art centred: projected x {:.4f}, moved {:.2f} units along X", x, delta);
|
}
|
||||||
|
} 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()
|
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