Skip to content

fix: NetworkTransform interpolation render time - #4133

Open
NoelStephensUnity wants to merge 10 commits into
develop-2.0.0from
fix/networktransform-interpolation-render-time
Open

fix: NetworkTransform interpolation render time#4133
NoelStephensUnity wants to merge 10 commits into
develop-2.0.0from
fix/networktransform-interpolation-render-time

Conversation

@NoelStephensUnity

@NoelStephensUnity NoelStephensUnity commented Aug 19, 2026

Copy link
Copy Markdown
Member

Purpose of this PR

A NetworkTransform state's SentTime comes from its NetworkTick — a server tick — but the render time the interpolators were given was derived from LocalTime. Since LocalTime already leads ServerTime by roughly the tick latency, subtracting the tick latency from it put the render time back at approximately ServerTime instead of a buffer behind it. Clients were therefore asked to render a point in time at or ahead of the newest state that could exist, leaving the interpolator with nothing to interpolate towards.

Now derived from ServerTime, which also matches the rest of the component — NetworkTransform already resets its interpolators using ServerTime.

The tradeoff reviewers should weigh: non-authority instances now sit a full tick latency behind rather than approximately at the present. That is roughly 75ms of additional visual latency, in exchange for actual interpolation instead of snapping between state updates. No-op on host/server, where both clocks are the same.

GetTickLatencyInSeconds now returns the actual tick latency in seconds.

Jira ticket

TODO: add ticket

Changelog

  • Fixed: Issue where NetworkTransform interpolated towards a point in time taken from the local clock rather than the server clock that state updates are stamped on, which starved the interpolator on clients and reduced interpolation to snapping between state updates.
  • Fixed: Issue where NetworkTransform.GetTickLatencyInSeconds returned an absolute network timestamp that grew for as long as the session ran, rather than the tick latency as a duration in seconds that it is documented to return.

Documentation

  • No documentation changes or additions were necessary.

Testing & QA (How your changes can be verified during release Playtest)

New integration test measures how far behind ServerTime the state being interpolated towards was sent. That value can never be less than the tick latency, since the render time is ServerTime minus the tick latency and only states sent at or before it are eligible.

Validated in both directions on develop-2.0.0: without the fix both fixtures fail, reporting the target as −0.899 and −1.059 ticks — the interpolator chasing a state the server clock says has not happened yet. With the fix both pass. The full NetworkTransform playmode suite is green (3942/3942).

The test widens the client's local time buffer before measuring, because an in-process test has no round trip time to separate the two clocks and would otherwise pass regardless of which one is used. It waits for that separation to take hold and fails if it never does, so it cannot silently become a no-op.

For release playtest, the thing to look at is client-side smoothness of moving networked objects, and whether the added latency is acceptable.

Functional Testing

Manual testing :

  • Manual testing done

Automated tests:

  • Covered by existing automated tests
  • Covered by new automated tests

Does the change require QA team to:

  • Review automated tests?
  • Execute manual tests?
  • Provide feedback about the PR?

Up-port

Up-port: #4135
Required.

Backports

Not needed.

Adds an integration test that measures how far behind the server clock the
state a non-authority NetworkTransform is interpolating towards was sent.

Only states sent at or before the render time are eligible to be interpolated
towards, and the render time is the server clock minus the tick latency, so
that measurement can never be less than the tick latency. It currently is,
and goes negative, meaning the interpolator is chasing a state that the server
clock says has not happened yet.

An in-process integration test has effectively no round trip time, so the test
first widens the client's local time buffer to separate LocalTime and
ServerTime by a known amount and waits for that separation to take hold.
Without it the two clocks sit close enough together that the test would pass
regardless of which one the render time is derived from.

This commit contains the test only, so it can be run against an unfixed tree.
A NetworkTransform state's SentTime comes from its NetworkTick, which is a
server tick, but the render time the interpolators were given was derived from
LocalTime. That mixes two clocks. LocalTime leads ServerTime, so subtracting
the tick latency from it lands the render time back at approximately ServerTime
rather than a whole tick latency behind it, and a state's SentTime is floored to
a tick boundary on top of that. The render time therefore sat at or ahead of the
newest state that could exist and the interpolator had nothing to interpolate
towards.

Measuring from ServerTime makes the offset the whole tick latency instead of
whatever is left of it, and is self correcting: as the round trip time grows the
tick latency grows and the render time moves further back with it. This also
matches the rest of the component, which already resets the interpolators using
ServerTime.

This is a no-op on a host or server, where the two clocks are the same, so it
only affects clients.

GetTickLatencyInSeconds returns an absolute time rather than a duration and had
the same defect, so it now derives from ServerTime as well. GetTickLatency is
left alone because it returns a tick count rather than a point in time.
Comment and changelog wording only, no behavioral or test logic changes.

Trims the explanation in UpdateInterpolation from twenty one lines to six and
drops the measurement anecdote and the unfilled Jira placeholder, keeping the
reason the server clock is the correct one to measure from. Shortens the test's
remarks and constant comments to match the density of the surrounding tests.

The removed detail, the measurements behind the fix, and the metrics that were
tried and rejected while building the test are recorded outside the repository.
Adding PR number to changelog entries.
@codecov-github-com

codecov-github-com Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

All modified and coverable lines are covered by tests ✅

@@                Coverage Diff                @@
##           develop-2.0.0    #4133      +/-   ##
=================================================
+ Coverage          73.88%   73.93%   +0.05%     
=================================================
  Files                172      172              
  Lines              28105    28106       +1     
=================================================
+ Hits               20765    20781      +16     
+ Misses              7340     7325      -15     
Flag Coverage Δ
NGOv2_project_testproject_ubuntu_pinnedTrunk 73.67% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...gameobjects/Runtime/Components/NetworkTransform.cs 88.78% <100.00%> (+0.36%) ⬆️

... and 1 file with indirect coverage changes

Components Coverage Δ
com.unity.netcode.gameobjects 73.93% <100.00%> (+0.05%) ⬆️

ℹ️ Need help interpreting these results?

@EmandM
EmandM requested a review from u-pr August 20, 2026 14:13

@u-pr u-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Harness Review

LGTM

The interpolation clock change aligns render-time calculations with the timestamps assigned to received states, but the altered public helper remains inconsistent with its documented duration contract.

Reviewed commit 347eea6

🤖 Helpful? 👍/👎

Comment thread com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs Outdated
GetTickLatencyInSeconds returned TimeTicksAgo(...).Time, which is an absolute
network timestamp rather than a duration, so the value grew for as long as the
session ran. It is documented as returning the tick latency in seconds, and
NetworkTimeSystem.TickLatency points at it as a way to inspect that latency, so
the contract was misleading regardless of which clock it was measured from. It
now returns the tick count multiplied by the tick interval.

This also takes the clock question out of this method entirely, since a duration
does not reference LocalTime or ServerTime. The change to derive interpolation
render time from ServerTime now applies only to UpdateInterpolation.

Adds integration tests covering the documented contract: the value tracks the
tick latency rather than elapsed time, and lengthens by exactly the tick
interval for each tick of additional buffering. Both fail against the previous
implementation, the second regardless of how long the session has run, since
buffering more ticks used to make the reported latency smaller.
Removing using directive for UnityEngine as it is an unused namespace.
NetworkTimeSystem.TickLatency is recomputed from the averaged round trip time
and can legitimately change mid-run. Both tests assumed it would not, and one
failed on macOS when it moved from two ticks to three, reporting the value as
having gone from 0.0666s to 0.1s.

The duration is now only held to being unchanged across samples where the tick
latency itself did not change, and the buffer offset test accounts for any tick
latency movement between its two samples so that only the buffering is held to
an exact figure.

Both still fail against the previous absolute timestamp implementation.
Comment on lines +21 to +22
[TestFixture(HostOrServer.Host, NetworkTransform.InterpolationTypes.Lerp)]
[TestFixture(HostOrServer.Host, NetworkTransform.InterpolationTypes.SmoothDampening)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to define HostOrServer if we're not using it or changing it. HostOrServer.Host is the default.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

Comment on lines +57 to +61
// TODO: [CmbServiceTests] ServerTime's meaning under a CMB service session has not been verified.
protected override bool UseCMBService()
{
return false;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're only running Host tests, we don't need to turn off the CMB tests because they won't be run

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

Comment on lines +93 to +108
private bool AllClientsSpawnedInstance()
{
foreach (var networkManager in m_NetworkManagers)
{
if (networkManager == m_AuthorityNetworkManager)
{
continue;
}

if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityInstance.NetworkObject.NetworkObjectId))
{
return false;
}
}
return true;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't needed, should use the existing helper

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

return true;
}

private List<NetworkTransform> GetNonAuthorityInstances()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this method is doing too much work.

All of the loops over the nonAuthorityInstances array are are mostly using it to pull out the NetworkManager so m_NetworkManagers should be used instead.

This test also only has one connected client, so this array will only have one value. We should follow our existing pattern of just using var nonAuthorityInstance directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

yield return WaitForConditionOrTimeOut(AllClientsSpawnedInstance);
AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!");

var nonAuthorityInstances = GetNonAuthorityInstances();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's only one connected client on this test. No need for a list, just refer to things directly.

Suggested change
var nonAuthorityInstances = GetNonAuthorityInstances();
var nonAuthority = GetNonAuthorityNetworkManager();
var nonAuthorityInstance = nonAuthority.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObjectId];

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

Comment on lines +169 to +170
yield return WaitForConditionOrTimeOut(() => m_TickCount >= warmUpTarget);
AssertOnTimeout("Timed out waiting for the authority to keep moving!");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be a timed check? Or can it just be a `yield return k_DefaultTickRate * 2?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

[UnityTest]
public IEnumerator GetTickLatencyInSecondsReturnsADuration()
{
var client = m_ClientNetworkManagers[0];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a DA test, but might it's easier to keep it compatible now than to have to change it over later.

Suggested change
var client = m_ClientNetworkManagers[0];
var client = GetNonAuthorityNetworkManager();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

}

[UnityTest]
public IEnumerator GetTickLatencyInSecondsReturnsADuration()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this test testing? It looks like it might be an overly specific unit test that tests "does this code do what we've currently written it to do.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

[UnityTest]
public IEnumerator GetTickLatencyInSecondsTracksTheBufferTickOffset()
{
var client = m_ClientNetworkManagers[0];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var client = m_ClientNetworkManagers[0];
var client = GetNonAuthorityNetworkManager();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

}

[UnityTest]
public IEnumerator GetTickLatencyInSecondsTracksTheBufferTickOffset()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question here. Or can this test be combined with the previous test?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all cleaned up. 👍

- Drop the redundant HostOrServer fixture argument and the UseCMBService
  override; Host is the default and a client-server fixture never runs
  under the CMB service.
- Use WaitForSpawnedOnAllOrTimeOut, GetNonAuthorityNetworkManager and
  WaitForTicks instead of hand rolled equivalents.
- There is only ever one connected client, so drop the collections and
  refer to the single non-authority instance directly.
- Fold the two tick latency tests into one and drop the assertion that
  recomputed the implementation's own formula. What is left is what can
  actually regress: the value does not drift with session time, and it
  grows by exactly the ticks added to the interpolation buffer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants