mojira.dev
MC-302347

Current entity update intervals and client interpolation cause excessive visual delay

EntityType.Builder constructs mostly every entity in EntityTypes with an updateInterval of 3 ticks, some sensitive entities like the player and allay being 2 ticks. This means with 1 tick being around 50ms on ideal conditions, clients are receiving entity updates from the server at an arbitrary 150ms delay. With poor server conditions, these updates are further delayed, causing more perceived lag. This has been standard common since release 1.3.1, and as far back as alpha for servers.

The client features InterpolationHandler, which interpolates the received visual data over 3 client ticks to prevent jitter/snapping from the servers 3 tick delay. This primarily only affects the velocity animation for the client, most other movements are covered by this system or not applied. This is 150ms of interpolation, adding more perceived delay on top of the 150ms server delay from being overly smooth. This was introduced in release 1.8.

Another issue MC-297196 adds a 1 tick interaction delay from click to screen. It halts any velocity impulse from entities for 50ms.

This means that even on ideal conditions, clients are experiencing 200ms of unnecessary delay, and 150ms of smoothing on top of that.

By forcing the EntityTypes builder to 1 tick per updateInterval, and setting InterpolationHandler to step at 1 tick, it restores the animation responsiveness and snappier velocity changes seen in pre 1.3 singleplayer on top of keeping the packet and networking system intact. This performs with no noticeable cost to tps times or FPS in singleplayer, but could be costly in multiplayer around 3 or more players simultaneously loading entities around them.

Environment

Java 21
Windows 11

Linked issues

Attachments

Comments 7

So, few complaints: This is a duplicate of MC-297196 and also is incorrect.

Whilst your code analysis is fundamentally true, all behaviour mentioned in the code analysis already existed before 1.14 and was merely updated across versions. The actual changes in 1.14 responsible for this are to do with the entity trackers being moved to the ChunkMap class and thus happening before the entity ticks. The fix you have described treats the symptoms, but not the actual bug.

The knockback example was attached before I knew that bug report was the root cause. That’s what I thought was related to this report. This is a separate issue, maybe more so a feature request, because if you actually try forcing impulses to true at all times, you will notice a difference. Try it out for yourself.

Oh, okay, I would like to note that the way entities are updated actually predates even 1.3 from the server side, 1.2.5 singleplayer is indeed much more responsive however that is because of no integrated server. This is from 1.2.5 server code using MCP mappings:

// EntityTrackerEntry.java
        if (this.updateCounter++ % this.field_9234_e == 0 || this.trackedEntity.isAirBorne)
        {
            int var2 = MathHelper.floor_double(this.trackedEntity.posX * 32.0D);
            int var3 = MathHelper.floor_double(this.trackedEntity.posY * 32.0D);
            int var4 = MathHelper.floor_double(this.trackedEntity.posZ * 32.0D);
            int var5 = MathHelper.floor_float(this.trackedEntity.rotationYaw * 256.0F / 360.0F);
            int var6 = MathHelper.floor_float(this.trackedEntity.rotationPitch * 256.0F / 360.0F);
            int var7 = var2 - this.encodedPosX;
            int var8 = var3 - this.encodedPosY;
            int var9 = var4 - this.encodedPosZ;
            Object var10 = null;
            boolean var11 = Math.abs(var7) >= 4 || Math.abs(var8) >= 4 || Math.abs(var9) >= 4;
            boolean var12 = Math.abs(var5 - this.encodedRotationYaw) >= 4 || Math.abs(var6 - this.encodedRotationPitch) >= 4;

            if (var7 >= -128 && var7 < 128 && var8 >= -128 && var8 < 128 && var9 >= -128 && var9 < 128 && this.field_28165_t <= 400)
            {
                if (var11 && var12)
                {
                    var10 = new Packet33RelEntityMoveLook(this.trackedEntity.entityId, (byte)var7, (byte)var8, (byte)var9, (byte)var5, (byte)var6);
                }
                else if (var11)
                {
                    var10 = new Packet31RelEntityMove(this.trackedEntity.entityId, (byte)var7, (byte)var8, (byte)var9);
                }
                else if (var12)
                {
                    var10 = new Packet32EntityLook(this.trackedEntity.entityId, (byte)var5, (byte)var6);
                }
            }
            else
            {
                this.field_28165_t = 0;
                this.trackedEntity.posX = (double)var2 / 32.0D;
                this.trackedEntity.posY = (double)var3 / 32.0D;
                this.trackedEntity.posZ = (double)var4 / 32.0D;
                var10 = new Packet34EntityTeleport(this.trackedEntity.entityId, var2, var3, var4, (byte)var5, (byte)var6);
            }

there is a slight difference in that the update counter is updated before the check rather than after however this should not result in a substantial gameplay difference.
This was not changed in 1.3, as can be seen here:

            if (this.ticks++ % this.updateFrequency == 0 || this.myEntity.isAirBorne)
            {
                int var2 = this.myEntity.myEntitySize.multiplyBy32AndRound(this.myEntity.posX);
                int var3 = MathHelper.floor_double(this.myEntity.posY * 32.0D);
                int var4 = this.myEntity.myEntitySize.multiplyBy32AndRound(this.myEntity.posZ);
                int var5 = MathHelper.floor_float(this.myEntity.rotationYaw * 256.0F / 360.0F);
                int var6 = MathHelper.floor_float(this.myEntity.rotationPitch * 256.0F / 360.0F);
                int var7 = var2 - this.lastScaledXPosition;
                int var8 = var3 - this.lastScaledYPosition;
                int var9 = var4 - this.lastScaledZPosition;
                Object var10 = null;
                boolean var11 = Math.abs(var7) >= 4 || Math.abs(var8) >= 4 || Math.abs(var9) >= 4;
                boolean var12 = Math.abs(var5 - this.lastYaw) >= 4 || Math.abs(var6 - this.lastPitch) >= 4;

                if (var7 >= -128 && var7 < 128 && var8 >= -128 && var8 < 128 && var9 >= -128 && var9 < 128 && this.ticksSinceLastForcedTeleport <= 400)
                {
                    if (var11 && var12)
                    {
                        var10 = new Packet33RelEntityMoveLook(this.myEntity.entityId, (byte)var7, (byte)var8, (byte)var9, (byte)var5, (byte)var6);
                    }
                    else if (var11)
                    {
                        var10 = new Packet31RelEntityMove(this.myEntity.entityId, (byte)var7, (byte)var8, (byte)var9);
                    }
                    else if (var12)
                    {
                        var10 = new Packet32EntityLook(this.myEntity.entityId, (byte)var5, (byte)var6);
                    }
                }
                else
                {
                    this.ticksSinceLastForcedTeleport = 0;
                    var10 = new Packet34EntityTeleport(this.myEntity.entityId, var2, var3, var4, (byte)var5, (byte)var6);
                }

Though by 1.8 they did add more conditions, oddly enough:

        if (this.updateCounter % this.updateFrequency == 0 || this.trackedEntity.isAirBorne || this.trackedEntity.getDataWatcher().hasObjectChanged())
        {
            if (this.trackedEntity.ridingEntity == null)
            {
                ++this.ticksSinceLastForcedTeleport;
                int k = MathHelper.floor_double(this.trackedEntity.posX * 32.0D);
                int j1 = MathHelper.floor_double(this.trackedEntity.posY * 32.0D);
                int k1 = MathHelper.floor_double(this.trackedEntity.posZ * 32.0D);
                int l1 = MathHelper.floor_float(this.trackedEntity.rotationYaw * 256.0F / 360.0F);
                int i2 = MathHelper.floor_float(this.trackedEntity.rotationPitch * 256.0F / 360.0F);
                int j2 = k - this.encodedPosX;
                int k2 = j1 - this.encodedPosY;
                int i = k1 - this.encodedPosZ;
                Packet packet1 = null;
                boolean flag = Math.abs(j2) >= 4 || Math.abs(k2) >= 4 || Math.abs(i) >= 4 || this.updateCounter % 60 == 0;
                boolean flag1 = Math.abs(l1 - this.encodedRotationYaw) >= 4 || Math.abs(i2 - this.encodedRotationPitch) >= 4;

                if (this.updateCounter > 0 || this.trackedEntity instanceof EntityArrow)
                {
                    if (j2 >= -128 && j2 < 128 && k2 >= -128 && k2 < 128 && i >= -128 && i < 128 && this.ticksSinceLastForcedTeleport <= 400 && !this.ridingEntity && this.onGround == this.trackedEntity.onGround)
                    {
                        if ((!flag || !flag1) && !(this.trackedEntity instanceof EntityArrow))
                        {
                            if (flag)
                            {
                                packet1 = new S14PacketEntity.S15PacketEntityRelMove(this.trackedEntity.getEntityId(), (byte)j2, (byte)k2, (byte)i, this.trackedEntity.onGround);
                            }
                            else if (flag1)
                            {
                                packet1 = new S14PacketEntity.S16PacketEntityLook(this.trackedEntity.getEntityId(), (byte)l1, (byte)i2, this.trackedEntity.onGround);
                            }
                        }
                        else
                        {
                            packet1 = new S14PacketEntity.S17PacketEntityLookMove(this.trackedEntity.getEntityId(), (byte)j2, (byte)k2, (byte)i, (byte)l1, (byte)i2, this.trackedEntity.onGround);
                        }
                    }
                    else
                    {
                        this.onGround = this.trackedEntity.onGround;
                        this.ticksSinceLastForcedTeleport = 0;
                        packet1 = new S18PacketEntityTeleport(this.trackedEntity.getEntityId(), k, j1, k1, (byte)l1, (byte)i2, this.trackedEntity.onGround);
                    }
                }

The differences between versions show that with only the fix to tracker update timings you should have the same core functionality if not more fluid in modern versions. Thus, this is more a suggestion than a bug.

But I do agree, though it might have minor performance impacts this would in general be an improvement, but not necessarily a bug since it’s how the server code was intentionally designed and has always worked, 1.3 just made it impact singleplayer.

If I had to speculate, the performance impact was more substantial back when the game was older, so these checks were an optimisation made to account for that. my preferred fix would be bumping the base tick speed for the game up to either run entirely on delta time or to run at a higher tick rate since this also minimises tick jitter and makes the update intervals less important.

I do admit my analyzation was a little rough because I’m only checking modern code rather than old code. But it does seem that it’s simply just outdated networking code that wouldn’t hurt to be updated. Making it configurable even, allowing server/clients to control the update/interpolation rates depending on what their system can handle.

Videos for each version where latency was impacted

Duffy Springpat

(Unassigned)

Plausible

Platform HC

Normal

Networking, Performance

1.21.8, 1.21.9 Release Candidate 1, 1.21.9, 26.2, 26.3 Snapshot 4, 26.3

Retrieved