Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions OloEditor/src/EditorLayer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2009,6 +2009,13 @@ namespace OloEngine
Application::Get().SetFrameRateCap(m_Prefs.FrameRateCap);
Application::Get().SetFrameTimeSmoothing(m_Prefs.FrameTimeSmoothing);

// Render interpolation (#502): apply live so toggling it in Preferences
// takes effect on the running Play session without a restart.
if (m_ActiveScene)
{
m_ActiveScene->SetRenderInterpolationEnabled(m_Prefs.RenderInterpolation);
}

auto& physicsSettings = Physics3DSystem::GetSettings();
physicsSettings.m_CaptureOnPlay = m_Prefs.CapturePhysicsOnPlay;

Expand Down Expand Up @@ -3155,6 +3162,11 @@ namespace OloEngine
}
}

// Apply the render-interpolation preference to the fresh runtime scene
// (#502): Play renders interpolated poses between fixed ticks unless the
// user disabled it.
m_ActiveScene->SetRenderInterpolationEnabled(m_Prefs.RenderInterpolation);

m_ActiveScene->OnRuntimeStart();

// Stream quest/inventory gameplay events to the Console panel for this
Expand Down
21 changes: 21 additions & 0 deletions OloEditor/src/Panels/EditorPreferencesPanel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,24 @@ namespace OloEngine
ImGui::SetTooltip("EMA weight for the newest frame's delta.\nLower = smoother but laggier. 1.0 disables smoothing.");
}
}

ImGui::Spacing();
ImGui::Text("Render Interpolation");
ImGui::Separator();
ImGui::TextWrapped("Decouple rendering from the fixed simulation tick: the sim runs at a "
"stable fixed rate while each displayed frame renders a pose "
"interpolated between the last two ticks. Keeps motion smooth when the "
"refresh rate isn't a multiple of the sim rate (e.g. 60 Hz sim / 144 Hz "
"display). On by default.");
ImGui::Spacing();
ImGui::Checkbox("Interpolate render pose between fixed ticks", &m_Draft.RenderInterpolation);
ImGui::SameLine();
ImGui::TextDisabled("(?)");
if (ImGui::IsItemHovered())
{
ImGui::SetTooltip("Off = one render per sim tick (may judder at non-multiple refresh rates).\n"
"Purely a presentation blend — never affects simulation determinism.");
}
}

std::filesystem::path EditorPreferencesPanel::GetPrefsPath(const std::filesystem::path& projectDir)
Expand Down Expand Up @@ -309,6 +327,7 @@ namespace OloEngine
out << YAML::Key << "RenderBudgetMs" << YAML::Value << prefs.RenderBudgetMs;
out << YAML::Key << "FrameRateCap" << YAML::Value << prefs.FrameRateCap;
out << YAML::Key << "FrameTimeSmoothing" << YAML::Value << prefs.FrameTimeSmoothing;
out << YAML::Key << "RenderInterpolation" << YAML::Value << prefs.RenderInterpolation;
out << YAML::Key << "EnableAutoSave" << YAML::Value << prefs.EnableAutoSave;
out << YAML::Key << "AutoSaveIntervalSeconds" << YAML::Value << prefs.AutoSaveIntervalSeconds;
out << YAML::Key << "McpAutoStart" << YAML::Value << prefs.McpAutoStart;
Expand Down Expand Up @@ -419,6 +438,8 @@ namespace OloEngine
if (node["FrameTimeSmoothing"])
if (f32 v = node["FrameTimeSmoothing"].as<f32>(); std::isfinite(v))
prefs.FrameTimeSmoothing = std::clamp(v, 0.01f, 1.0f);
if (node["RenderInterpolation"])
prefs.RenderInterpolation = node["RenderInterpolation"].as<bool>();
if (node["EnableAutoSave"])
prefs.EnableAutoSave = node["EnableAutoSave"].as<bool>();
if (node["AutoSaveIntervalSeconds"])
Expand Down
6 changes: 6 additions & 0 deletions OloEditor/src/Panels/EditorPreferencesPanel.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ namespace OloEngine
u32 FrameRateCap = 0;
f32 FrameTimeSmoothing = 1.0f;

// Render interpolation (#502). When true, Play/runtime rendering blends
// between the two most recent fixed-tick poses (alpha = accumulator /
// fixedStep) so motion stays smooth at non-multiple refresh rates. Purely
// presentational — never affects simulation determinism. On by default.
bool RenderInterpolation = true;

// Physics debug
bool CapturePhysicsOnPlay = false;

Expand Down
209 changes: 209 additions & 0 deletions OloEngine/src/OloEngine/Scene/Scene.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
#include "OloEngine/Project/Project.h"

#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
#include <ranges>

// Box2D
Expand Down Expand Up @@ -863,6 +864,12 @@ namespace OloEngine
m_SimulationTick = 0;
m_FixedTimeAccumulator = 0.0f;
m_SimulationTime = 0.0f;
// Discard interpolation snapshots so the first rendered frame of a fresh
// run draws the exact tick-0 pose (no blend from a stale prior run).
m_InterpPrev.clear();
m_InterpCurr.clear();
m_HasInterpSnapshots = false;
m_RenderInterpAlpha = 0.0f;
RandomUtils::SetGlobalSeed(Application::Get().GetRandomSeed());

// Unified diagnostics timeline (#306 item B): the single authoritative fire for
Expand Down Expand Up @@ -1141,6 +1148,10 @@ namespace OloEngine
m_SimulationTick = 0;
m_FixedTimeAccumulator = 0.0f;
m_SimulationTime = 0.0f;
m_InterpPrev.clear();
m_InterpCurr.clear();
m_HasInterpSnapshots = false;
m_RenderInterpAlpha = 0.0f;

OnPhysics2DStart();
OnPhysics3DStart();
Expand Down Expand Up @@ -1544,6 +1555,13 @@ namespace OloEngine
--m_StepFrames;
SimulateRuntimeStep(fixedDt);
}

// Render the live (frozen or just-stepped) pose exactly while
// paused — force alpha to 0 so ShouldInterpolateThisFrame() is
// false. Without this the stale pre-pause alpha/snapshot pair
// would make RenderRuntime blend BACKWARD toward the previous
// tick, and a single-step advance would be invisible (issue #502).
m_RenderInterpAlpha = 0.0f;
}
else
{
Expand All @@ -1567,16 +1585,113 @@ namespace OloEngine
u32 steps = 0;
while (m_FixedTimeAccumulator >= fixedDt && steps < kMaxFixedStepsPerFrame)
{
// Render interpolation (issue #502): snapshot the live local
// transforms into m_InterpPrev BEFORE each step so, after the
// loop, m_InterpPrev holds the state one tick behind the final
// step and m_InterpCurr (captured below) holds the post-step
// state. RenderRuntime blends between them by alpha. Skipped
// entirely when interpolation is off. The overwrite each
// iteration is intentional — only the pre-last-step pose
// survives, which is exactly the "previous" state to blend from.
if (m_RenderInterpolationEnabled)
{
CaptureLocalTransforms(m_InterpPrev);
}
SimulateRuntimeStep(fixedDt);
m_FixedTimeAccumulator -= fixedDt;
++steps;
}

// Publish the fresh "current" pose and the blend factor for the
// render below. Only when a step actually ran this frame — an
// idle frame (accumulator < fixedDt) keeps the existing pair and
// just advances alpha, which is what fills the gap between ticks.
if (steps > 0 && m_RenderInterpolationEnabled)
{
CaptureLocalTransforms(m_InterpCurr);
m_HasInterpSnapshots = true;
}

// Blend factor for this frame's render: how far the leftover
// accumulator has advanced toward the next fixed tick, in [0, 1].
// Paused / fixedDt<=0 frames leave it untouched (last value), so a
// paused view shows the frozen pose it last rendered.
m_RenderInterpAlpha = std::clamp(m_FixedTimeAccumulator / fixedDt, 0.0f, 1.0f);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

RenderRuntime(safeFrameTs);
}

void Scene::CaptureLocalTransforms(std::unordered_map<u32, InterpTransform>& out)
{
out.clear();
auto view = m_Registry.view<TransformComponent>();
out.reserve(view.size());
for (auto entity : view)
{
const auto& tc = view.get<TransformComponent>(entity);
out.emplace(static_cast<u32>(std::to_underlying(entity)),
InterpTransform{ tc.Translation, tc.GetRotation(), tc.Scale });
}
}

bool Scene::ShouldInterpolateThisFrame() const
{
// No point blending when the toggle is off, we have no prior state to
// blend from, or we're sitting exactly on a tick boundary (alpha == 0
// renders the current pose verbatim, so skip the overwrite cost).
return m_RenderInterpolationEnabled && m_HasInterpSnapshots && m_RenderInterpAlpha > 0.0f;
}

bool Scene::ComputeInterpolatedLocal(entt::entity entity, InterpTransform& out) const
{
if (!m_RenderInterpolationEnabled || !m_HasInterpSnapshots)
{
return false;
}

const auto id = static_cast<u32>(std::to_underlying(entity));
const auto prevIt = m_InterpPrev.find(id);
const auto currIt = m_InterpCurr.find(id);
// An entity present in only one snapshot (spawned or destroyed between
// the two ticks) has no meaningful blend — render it at its live pose.
if (prevIt == m_InterpPrev.end() || currIt == m_InterpCurr.end())
{
return false;
}

const InterpTransform& prev = prevIt->second;
const InterpTransform& curr = currIt->second;
const f32 a = m_RenderInterpAlpha;

out.Translation = glm::mix(prev.Translation, curr.Translation, a);
out.Scale = glm::mix(prev.Scale, curr.Scale, a);
// Shortest-arc quaternion blend so a near-180° tick doesn't spin the
// long way. glm::slerp normalizes and picks the shorter hemisphere.
out.Rotation = glm::slerp(prev.Rotation, curr.Rotation, a);
return true;
}

glm::mat4 Scene::GetInterpolatedLocalTransform(entt::entity entity) const
{
if (InterpTransform interp; ComputeInterpolatedLocal(entity, interp))
{
// glm::mat4_cast (not toMat4): mat4_cast is declared in
// <glm/gtc/quaternion.hpp>, which this file includes explicitly, so
// the quaternion→matrix dependency isn't a transitive include.
return glm::translate(glm::mat4(1.0f), interp.Translation) *
glm::mat4_cast(interp.Rotation) *
glm::scale(glm::mat4(1.0f), interp.Scale);
}
// Fall back to the live local transform.
if (auto const* tc = m_Registry.try_get<TransformComponent>(entity))
{
return tc->GetTransform();
}
return glm::mat4(1.0f);
}

void Scene::UpdateStreaming()
{
// Refresh LocalizedTextComponent → TextComponent.TextString if the
Expand Down Expand Up @@ -2455,6 +2570,11 @@ namespace OloEngine
// Find the primary camera
Camera const* mainCamera = nullptr;
glm::mat4 cameraTransform;
// Remember the primary camera entity + whether it's the live fly-cam so
// the render-interpolation pass (below) can blend a gameplay camera's
// view but leave the display-rate fly-cam untouched (issue #502).
entt::entity primaryCameraEntity = entt::null;
bool primaryCameraIsFlyCam = false;
{
for (const auto view = m_Registry.view<TransformComponent, CameraComponent>(); const auto entity : view)
{
Expand Down Expand Up @@ -2526,11 +2646,84 @@ namespace OloEngine

mainCamera = &camera.Camera;
cameraTransform = transform.GetTransform();
primaryCameraEntity = entity;
primaryCameraIsFlyCam = camera.RuntimeControl;
break;
}
}
}

// ── Render interpolation (issue #502) ───────────────────────────────
// Overwrite every entity's live local transform with the pose blended
// between the last two fixed-tick snapshots (alpha = accumulator /
// fixedStep), recompose world matrices, render, then restore the
// authoritative poses at the end of the frame. This makes EVERY render
// read — GetWorldTransform (world matrices) AND the direct
// TransformComponent::GetTransform() reads in RenderScene3D — see the
// interpolated pose without threading alpha through every draw call. The
// simulation state itself is untouched (restored before this function
// returns), so determinism (#484) is preserved and any post-render
// consumer this frame (SaveGameManager::Tick, editor gizmos) still reads
// the exact fixed-tick pose.
//
// The primary camera is excluded from the overwrite: its live local may
// have diverged from the snapshot this frame (the fly-cam mutates it at
// display rate above), so restoring from the snapshot would erase that
// movement. A gameplay (non-fly) camera is instead blended directly into
// cameraTransform so the VIEW interpolates without an ECS round-trip.
const bool interpolateThisFrame = ShouldInterpolateThisFrame();
// Full-component copies (not just TRS): TransformComponent's Rotation /
// RotationEuler are private and SetRotation() re-derives the Euler
// representation, so restoring via the setter could drift RotationEuler
// even when the quat is bit-identical (and the serializer persists the
// Euler — see SceneSerializer). Copying the whole component and assigning
// it back restores every field — incl. the private Euler and the matrix
// cache — exactly, guaranteeing zero simulation-state drift.
//
// A function-local `static thread_local` scratch buffer, cleared (not
// reallocated) each frame, so the hot render path doesn't heap-allocate a
// fresh vector per frame — the same "reuse capacity" intent as
// PropagateWorldTransforms' m_TransformOrder buffers. It can't be a Scene
// member because TransformComponent is incomplete in Scene.h (which
// deliberately doesn't include the heavy Components.h); render is
// synchronous per game thread, so a per-thread reused buffer is safe.
static thread_local std::vector<std::pair<entt::entity, TransformComponent>> restorePoses;
restorePoses.clear();
if (interpolateThisFrame)
{
if (mainCamera && !primaryCameraIsFlyCam)
{
cameraTransform = GetInterpolatedLocalTransform(primaryCameraEntity);
}

for (const auto view = m_Registry.view<TransformComponent>(); const auto entity : view)
{
if (entity == primaryCameraEntity)
{
continue;
}
InterpTransform interp;
if (!ComputeInterpolatedLocal(entity, interp))
{
continue;
}
auto& tc = view.get<TransformComponent>(entity);
// Save the authoritative pose before overwriting so it can be
// restored verbatim after the draw.
restorePoses.emplace_back(entity, tc);
tc.Translation = interp.Translation;
tc.Scale = interp.Scale;
tc.SetRotation(interp.Rotation);
}

// Recompose parent-chain world matrices from the interpolated locals
// so GetWorldTransform() reads see the blended pose.
if (!restorePoses.empty())
{
PropagateWorldTransforms();
}
}

// Update camera VP matrix before resolving UI layout so world-anchor
// projections use the current frame's camera, not the previous one.
if (mainCamera)
Expand Down Expand Up @@ -2682,6 +2875,22 @@ namespace OloEngine
// instead of using whatever timestamp was last recorded.
m_LastAnimationTime = -1.0f;
}

// Restore the authoritative fixed-tick poses overwritten for the
// interpolated draw above, then recompose world matrices so any
// post-render reader this frame sees the exact simulation state (#502).
if (!restorePoses.empty())
{
for (const auto& [entity, pose] : restorePoses)
{
if (auto* tc = m_Registry.try_get<TransformComponent>(entity))
{
*tc = pose;
}
}
PropagateWorldTransforms();
restorePoses.clear();
}
}

void Scene::OnUpdateSimulation(const Timestep ts, EditorCamera const& camera)
Expand Down
Loading
Loading