Gate entities on client chunk residency

This commit is contained in:
Master290 2026-07-21 11:25:24 +03:00
parent 2b07d6be20
commit 4453bdeb55
3 changed files with 79 additions and 2 deletions

View file

@ -270,6 +270,47 @@ func TestIntegrationFourClientsVisibilityMovementLeaveAndLight(t *testing.T) {
srv.Entities().Remove(mobID) srv.Entities().Remove(mobID)
} }
func TestEntityVisibilityWaitsForResidentChunk(t *testing.T) {
cfg := server.DefaultConfig()
cfg.WorldDir = ""
srv, err := server.NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
if err != nil {
t.Fatal(err)
}
recorder := &recordingConn{}
h := &handler{conn: NewConn(recorder), srv: srv, log: slog.Default(), viewDistance: 2}
h.conn.Profile = server.Profile{Name: "Alice", UUID: server.OfflineUUID("Alice")}
h.session, err = srv.RegisterPlayer(h.conn.Profile, h.conn.Send)
if err != nil {
t.Fatal(err)
}
defer srv.UnregisterPlayer(h.session)
srv.SetPlayerTransform(h.session, 8, 80, 8, 0, 0, true)
h.streamer = &streamer{resident: make(map[[2]int32]bool)}
srv.Entities().Add(&world.Entity{
TypeID: 100, TypeName: "minecraft:pig", X: 24, Y: 80, Z: 8,
})
if err := h.syncVisibleEntities(); err != nil {
t.Fatal(err)
}
if got := countPackets(recorder.take(t), protocol.PlayAddEntity); got != 0 {
t.Fatalf("add-entity packets before chunk = %d, want 0", got)
}
h.streamer.setResident([2]int32{1, 0}, true)
if err := h.syncVisibleEntities(); err != nil {
t.Fatal(err)
}
assertPacketIDs(t, recorder.take(t), protocol.PlayAddEntity)
h.streamer.setResident([2]int32{1, 0}, false)
if err := h.syncVisibleEntities(); err != nil {
t.Fatal(err)
}
assertPacketIDs(t, recorder.take(t), protocol.PlayRemoveEntities)
}
func countPackets(packets []protocol.Packet, id int32) int { func countPackets(packets []protocol.Packet, id int32) int {
count := 0 count := 0
for _, packet := range packets { for _, packet := range packets {

View file

@ -261,7 +261,8 @@ func (h *handler) syncVisibleEntities() error {
currentEntities := make(map[int32]visibleEntity) currentEntities := make(map[int32]visibleEntity)
for _, player := range players { for _, player := range players {
if player.EntityID == viewer.EntityID || !playerVisible(viewer, player, h.visibilityRadius()) { if player.EntityID == viewer.EntityID || !playerVisible(viewer, player, h.visibilityRadius()) ||
!h.entityChunkResident(player.X, player.Z) {
continue continue
} }
currentEntities[player.EntityID] = visibleEntity{ currentEntities[player.EntityID] = visibleEntity{
@ -275,7 +276,7 @@ func (h *handler) syncVisibleEntities() error {
} }
} }
for _, entity := range h.srv.Entities().All() { for _, entity := range h.srv.Entities().All() {
if entityVisible(viewer, entity, h.visibilityRadius()) { if entityVisible(viewer, entity, h.visibilityRadius()) && h.entityChunkResident(entity.X, entity.Z) {
currentEntities[entity.ID] = visibleEntity{entity: entity, onGround: true} currentEntities[entity.ID] = visibleEntity{entity: entity, onGround: true}
} }
} }
@ -314,6 +315,15 @@ func (h *handler) syncVisibleEntities() error {
return nil return nil
} }
func (h *handler) entityChunkResident(x, z float64) bool {
if h.streamer == nil {
return true
}
cx := int32(int64(math.Floor(x)) >> 4)
cz := int32(int64(math.Floor(z)) >> 4)
return h.streamer.isResident(cx, cz)
}
func playerVisible(viewer, target server.PlayerSnapshot, radius int) bool { func playerVisible(viewer, target server.PlayerSnapshot, radius int) bool {
return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius) return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius)
} }

View file

@ -41,6 +41,12 @@ type streamer struct {
centerZ int32 centerZ int32
hasCenter bool hasCenter bool
// resident is the cross-goroutine view of chunks whose level_chunk packet
// has reached the connection. Entity sync uses it to avoid exposing mobs or
// players before the terrain under them exists client-side.
residentMu sync.RWMutex
resident map[[2]int32]bool
viewRadius int // chunks within this Chebyshev radius are sent to the client viewRadius int // chunks within this Chebyshev radius are sent to the client
genRadius int // viewRadius + 1: pre-generated but not sent (predictive ring) genRadius int // viewRadius + 1: pre-generated but not sent (predictive ring)
poolSize int // parallel generation workers poolSize int // parallel generation workers
@ -75,6 +81,7 @@ func newStreamer(cache *world.Cache, conn *Conn, log *slog.Logger, viewDistance
log: log, log: log,
recenter: make(chan recenterReq, 4), recenter: make(chan recenterReq, 4),
loaded: make(map[[2]int32]bool), loaded: make(map[[2]int32]bool),
resident: make(map[[2]int32]bool),
viewRadius: viewDistance, viewRadius: viewDistance,
genRadius: viewDistance + 1, genRadius: viewDistance + 1,
poolSize: pool, poolSize: pool,
@ -173,6 +180,7 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) (recenterR
// only server-side by tickets and never left loaded on the client. // only server-side by tickets and never left loaded on the client.
for key := range s.loaded { for key := range s.loaded {
if !view[key] { if !view[key] {
s.setResident(key, false)
s.sendForgetLevelChunk(key[0], key[1]) s.sendForgetLevelChunk(key[0], key[1])
delete(s.loaded, key) delete(s.loaded, key)
} }
@ -344,9 +352,27 @@ func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) {
} }
} }
s.loaded[[2]int32{j.cx, j.cz}] = true s.loaded[[2]int32{j.cx, j.cz}] = true
s.setResident([2]int32{j.cx, j.cz}, true)
} }
} }
func (s *streamer) setResident(key [2]int32, present bool) {
s.residentMu.Lock()
if present {
s.resident[key] = true
} else {
delete(s.resident, key)
}
s.residentMu.Unlock()
}
func (s *streamer) isResident(cx, cz int32) bool {
s.residentMu.RLock()
present := s.resident[[2]int32{cx, cz}]
s.residentMu.RUnlock()
return present
}
// parallelPreload warms terrain for the given chunks without calculating light, // parallelPreload warms terrain for the given chunks without calculating light,
// encoding frames, or sending packets. Errors are ignored. // encoding frames, or sending packets. Errors are ignored.
func (s *streamer) parallelPreload(ctx context.Context, keys [][2]int32) { func (s *streamer) parallelPreload(ctx context.Context, keys [][2]int32) {