Repository navigation
v0.24.0
Remote entities are now interpolated in ECS, by netcode's core rather than by a second copy of it
Until this release the DOTS path had exactly one way to place a replicated entity: whatever position
reached IEntityView.SetState was written straight to LocalTransform. That was correct and it was
never the whole story — the smoothing was happening one layer up, inside WorldViewBinder, and this
package was consuming its output. It worked, and it fixed nothing that netcode's own interpolation
could not fix, but it meant the ECS path could never render a remote entity from anything richer
than a single position, and it meant an ECS client had no way to use the buffered, tick-bracketed,
clock-driven core that netcode 0.18.0 extracted precisely so a Burst job could call it.
It can now. RemoteInterpolationSystem is a [BurstCompile] ISystem scheduling an IJobEntity
over (DynamicBuffer<SnapshotSample>, ref LocalTransform, ref LocalToWorld, ref InterpolationState),
and the body of that job is one call to
Cuvara.Netcode.Interpolation.SnapshotInterpolation.Evaluate — the identical method the GameObject
path calls, over a SnapshotSampleBuffer instead of over a pooled array. There is no interpolation
arithmetic anywhere in this package. That is the whole design constraint and not a stylistic one:
the day two implementations disagree, the symptom is a remote avatar drawn in the wrong place with
both copies passing their own tests, and the only defence is that the second copy does not exist.
What a player gets. A remote avatar that moves at frame rate along the path the server actually
described, rather than one that holds still for 66 ms and jumps. It is drawn about 100 ms behind the
newest received tick — netcode's TargetDelay, one and a half snapshot intervals at the 15 Hz world
rate — and that delay is what buys the smoothness: an early snapshot waits in the buffer instead of
displacing the segment being drawn, a late one is covered by the margin instead of stalling, and a
dropped one interpolates across two ticks' worth of distance in two ticks' worth of time instead of
sprinting and freezing. The local player pays none of it. A predicted entity carries
PredictedTransform, the job excludes it with WithNone, and the one entity whose response delay
the player is holding a key to feel keeps its zero.
It is opt-in, because the server tick is. IEntityView.SetState carries (id, x, y, hp, maxHp)
and no tick, and a sample without a tick cannot be placed on a timeline — the same wall
ReconciliationAnchor documents about the anchor's own tick. So the adapter gained its own entry
point, DotsEntityView.SetStateAtTick(id, x, y, hp, maxHp, tick, receiveTimeSeconds), for a caller
that has the tick in its hand: a snapshot handler reading WorldState.Tick. Nothing else changes.
A consumer that keeps calling SetState gets 0.23.1's behaviour byte for byte.
And the two paths are mutually exclusive per entity, enforced rather than documented.
WorldViewBinder.Tick interpolates on the netcode side and hands the result to SetState, so a
view driven by the binder is already receiving a rendered position. Buffering those and evaluating
them again would stack a second TargetDelay on top of the first: every remote entity twice as far
behind the server, moving perfectly smoothly, with no error, no exception and nothing in any log to
notice — the failure shape this workspace keeps paying for. The drain therefore decides per state:
a tick means "buffer it, the transform belongs to interpolation", no tick means "write it, the buffer
stays empty and the job passes over this entity". A refused sample — a duplicate or reordered tick,
rejected by netcode's shared InterpolationRing.Accepts — deliberately does not fall back to a
direct write, because the entity is still owned by interpolation and its superseded state is not
worth rendering.
Added
ViewInterpolationGroup, insideViewSystemGroupandUpdateBefore(ViewLifecycleGroup).
Presentation, not simulation, and that is the load-bearing choice: interpolation answers "where is
this drawn on this frame", which is asked once per drawn frame, andSimulationSystemGroupwould
tie it to fixed-step semantics it must not have — a rendered position evaluated at 60 Hz fixed
steps while the client draws at 144 Hz is the stutter this exists to remove, reintroduced one layer
up. Before the lifecycle group because both of the other view groups read the transform:
ViewLifecycleGroupplaces a newly spawned view fromLocalToWorldandViewTransformSyncGroup
copies it onto every live GameObject, so running later would show this frame's views at last
frame's position — a constant one-frame lag on every remote entity, visible as softness and blamed
on the render delay. Created empty byDotsViewBootstrapeven without netcode, like every other
group here, so a consumer's[UpdateAfter]resolves today and does not change meaning later.
BecauseLocalTransformis now written in presentation, afterTransformSystemGrouphas already
finished for the frame, the job composesLocalToWorlditself — exactly asApplySpawnand
ApplyStatealready do, for exactly the same reason.SnapshotSample, an[InternalBufferCapacity(8)]IBufferElementDatawrapping netcode's
InterpolationSample, andInterpolationState, recording what was last drawn and at which
render tick. Both are added inApplySpawnalongsideReconciliationAnchor, for the reason that
component already states: a component set that changed on the first state would change archetype at
snapshot rate, and every query over mirrors would then iterate two chunk sets. The capacity is 8
because 8 x 24 B = 192 B is what keeps the buffer inline in the chunk; a ninth sample moves the
whole thing to a per-entity heap allocation that area-of-interest churn would repeat. It is the
same 8 thatInterpolationConfig.RingCapacitydefaults to, and that constant's own documentation
names this attribute.InterpolationSettingsandInterpolationTimeline, two blittable singletons: netcode's
InterpolationConfigplus theSnapshotSpaceMapping, and the world'sInterpolationClock.
Components rather than aScriptableObjectand rather than a managed reference, because the
consumer is a Bursted job and a Bursted job cannot follow either. The mapping is seeded from the
view's own, so the space the samples were produced in is the space they are rendered in — samples
are stored in server coordinates verbatim, exactly asReconciliationAnchor.ServerPositionis, and
projected once after evaluation. Seed the tuning with
DotsNetcodeBootstrap.Install(world, view, interpolation);defaultmeans netcode's defaults,
every non-positive field filled in byNormalized().InterpolationClockSystem, advancing the render clock once per frame from
SystemAPI.Time.DeltaTime— a system of its own rather than the first lines of the evaluation,
so that the frame's advance has one owner no matter how many things come to read the timeline.
Ordered by an explicit[UpdateBefore]rather thanOrderFirst, because Entities sorts
OrderFirstmembers into a separate batch and then drops ordering relations between that batch and
ordinary members, with a warning — the trapMovementSystemGroupalready documents. The timeline
has two writers at two declared points in the frame and that is deliberate: the drain calls
NoteSnapshoton arrival in initialization, this callsAdvanceonce per frame in presentation,
and an arrival is not a frame, so they cannot be collapsed into one.- Zero per-frame allocation on the whole path. The samples live in chunk memory, the singletons
are copied by value into job fields, the query isScheduleParallelwith noToEntityArray, no
Complete()and no main-thread walk, and theISampleBufferwrapper is a struct passed to a
where TBuffer : structgeneric so the indexer calls are constrained calls Burst specialises
rather than interface dispatch that boxes. The only copy is the front-shift when a full buffer
admits a new sample — at most seven 24-byte elements, inside the chunk, on snapshot arrivals and
never on the frame path. - Eight tests in
Tests/Editor.Netcode/RemoteInterpolationTests.cs, driven through the public
groups and withSystemAPI.Timestamped explicitly, because a world updated group by group never
advances it and every assertion about motion would otherwise be an assertion about nothing: a
ticked state is buffered and drawn from the buffer rather than at the newest state; the rendered
position never steps backwards across twenty frames and never runs past what the server sent; a
predicted entity is not interpolated and keeps accumulating samples so that releasing the tag
hands interpolation a history rather than a cold buffer; an unticked state is still written
straight to the transform; an empty buffer and a single-sample buffer are both legal and neither
throws; a duplicate tick is refused without falling back to a direct write; the clock does not
advance before the first snapshot.NetcodeSystemLayoutTestsgains three more asserting the group
containment, theUpdateBeforerelations in both directions, and that both new systems are
internal and not auto-created.ViewSystemGroupLayoutTests' hand-maintained group roster gains
ViewInterpolationGroup— that list is what the "no package group is auto-created" and "every
group is public" checks iterate, so a group missing from it is a group nothing checks.
Changed
- The
versionDefinesfloor forCUVARA_NETCODEmoves0.4.0->0.19.0, in
Cuvara.DOTS.Netcode,Cuvara.DOTS.Netcode.Prediction, both of their test assemblies and the
NetworkedPredictionsample. It had to.Cuvara.Netcode.Interpolationdoes not exist before
netcode 0.18.0, and 0.19.0 is the version this package now pins and builds against. Left at
0.4.0 the define would still be set against a netcode with no such namespace, and the adapter
would fail to compile with a missing-type error namingSnapshotInterpolation— a message that
looks like a typo and says nothing about versions, in an assembly whose whole purpose is to be
absent rather than broken when its dependency is too old. The prediction assemblies move with it
rather than staying at0.15.0for a reason that is not tidiness: they reference
Cuvara.DOTS.Netcode, so a project on netcode 0.16 would set their define and not this one,
leaving them referencing an assembly that did not compile. - CI's netcode pin moves
v0.16.1->v0.19.0, the coupling that file's own header warns about:
the pin and what depends on it move together. Nothing in this change compiles against the old pin. DotsNetcodeBootstrap.Installgained an optionalInterpolationConfigparameter and now creates
the presentation half of the adapter as well as the initialization half. Existing two-argument call
sites compile and behave unchanged.