Fix terrain streaming and surface spawning
This commit is contained in:
parent
f0279cdb65
commit
2b07d6be20
17 changed files with 424 additions and 111 deletions
|
|
@ -98,15 +98,15 @@ func (h *handler) handleClientInformation(pkt protocol.Packet) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// view_distance drives the chunk streamer radius. The client sends 2..32;
|
||||
// clamp into a sane server range so a huge view distance doesn't trigger a
|
||||
// generation explosion.
|
||||
// view_distance drives the chunk streamer radius. The server owns the upper
|
||||
// bound because accepting the client's render distance can multiply cold
|
||||
// generation work into hundreds of chunks.
|
||||
vdInt := int(int8(vd))
|
||||
if vdInt < 2 {
|
||||
vdInt = 2
|
||||
}
|
||||
if vdInt > 16 {
|
||||
vdInt = 16
|
||||
if max := h.srv.Config().MaxViewDistance; vdInt > max {
|
||||
vdInt = max
|
||||
}
|
||||
h.viewDistance = vdInt
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type handler struct {
|
|||
session *server.PlayerSession
|
||||
knownPlayers map[[16]byte]bool
|
||||
knownEntities map[int32]visibleEntity
|
||||
spawnY float64
|
||||
|
||||
// Creative inventory state for block placement.
|
||||
heldSlot int32 // selected hotbar index (0-8)
|
||||
|
|
|
|||
|
|
@ -150,11 +150,13 @@ func TestBoundaryEditBroadcastsEveryChangedLightChunk(t *testing.T) {
|
|||
srv.SetPlayerViewDistance(h.session, 2)
|
||||
|
||||
valid, lightChunks := cache.SetBlockWithLight(15, 100, 8, world.StateGlowstone)
|
||||
if !valid || len(lightChunks) != 2 {
|
||||
t.Fatalf("boundary edit valid=%v light chunks=%v; want two", valid, lightChunks)
|
||||
if !valid || len(lightChunks) != 6 {
|
||||
t.Fatalf("boundary edit valid=%v light chunks=%v; want six cached neighbors", valid, lightChunks)
|
||||
}
|
||||
h.broadcastBlockUpdate(15, 100, 8, world.StateGlowstone, lightChunks)
|
||||
assertPacketIDs(t, recorder.take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate)
|
||||
assertPacketIDs(t, recorder.take(t), protocol.PlayBlockUpdate,
|
||||
protocol.PlayLightUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate,
|
||||
protocol.PlayLightUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate)
|
||||
}
|
||||
|
||||
func TestIntegrationFourClientsVisibilityMovementLeaveAndLight(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ import (
|
|||
"regionio/internal/world"
|
||||
)
|
||||
|
||||
// Spawn coordinates. Y sits above the maximum terrain height so the player
|
||||
// drops onto the generated surface rather than spawning inside it.
|
||||
// Spawn column. The feet-level Y is resolved from the generated surface when
|
||||
// the player enters the play phase.
|
||||
const (
|
||||
spawnX = 8.5
|
||||
spawnY = 200.0
|
||||
spawnZ = 8.5
|
||||
)
|
||||
|
||||
|
|
@ -29,7 +28,12 @@ func (h *handler) beginPlay() error {
|
|||
return err
|
||||
}
|
||||
h.session = session
|
||||
h.srv.SetPlayerTransform(session, spawnX, spawnY, spawnZ, 0, 0, true)
|
||||
spawnY, ok := h.srv.Chunks().SafeSpawnY(int(math.Floor(spawnX)), int(math.Floor(spawnZ)))
|
||||
if !ok {
|
||||
spawnY = world.SeaLevel + 1
|
||||
}
|
||||
h.spawnY = float64(spawnY)
|
||||
h.srv.SetPlayerTransform(session, spawnX, h.spawnY, spawnZ, 0, 0, true)
|
||||
h.srv.SetPlayerViewDistance(session, h.visibilityRadius())
|
||||
for i := range h.hotbar {
|
||||
h.hotbar[i] = -1 // empty
|
||||
|
|
@ -56,7 +60,7 @@ func (h *handler) beginPlay() error {
|
|||
}
|
||||
// Launch the background chunk streamer. It owns generation + sending so the
|
||||
// read loop stays free; requestRecenter is a non-blocking push.
|
||||
h.streamer = newStreamer(h.srv.Chunks(), h.conn, h.log, h.viewDistance)
|
||||
h.streamer = newStreamer(h.srv.Chunks(), h.conn, h.log, h.visibilityRadius())
|
||||
go h.streamer.run(h.ctx)
|
||||
h.streamer.requestRecenter(0, 0)
|
||||
go h.keepAliveLoop()
|
||||
|
|
@ -76,7 +80,7 @@ func (h *handler) sendDefaultSpawnPosition() error {
|
|||
// yaw and pitch. GlobalPos starts with the dimension resource key.
|
||||
w := protocol.NewWriter(40)
|
||||
w.String("minecraft:overworld")
|
||||
w.Position(8, 100, 8)
|
||||
w.Position(8, int(math.Floor(h.spawnY)), 8)
|
||||
w.Float32(0.0)
|
||||
w.Float32(0.0)
|
||||
return h.conn.SendWriter(protocol.PlayDefaultSpawnPos, w)
|
||||
|
|
@ -109,13 +113,14 @@ func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround boo
|
|||
}
|
||||
|
||||
func (h *handler) visibilityRadius() int {
|
||||
if h.viewDistance < 2 {
|
||||
return defaultViewRadius
|
||||
distance := h.viewDistance
|
||||
if distance < 2 {
|
||||
distance = defaultViewRadius
|
||||
}
|
||||
if h.viewDistance > 16 {
|
||||
return 16
|
||||
if max := h.srv.Config().MaxViewDistance; distance > max {
|
||||
distance = max
|
||||
}
|
||||
return h.viewDistance
|
||||
return distance
|
||||
}
|
||||
|
||||
// sendPlayLogin writes the clientbound play "login" packet. Field layout was
|
||||
|
|
@ -176,11 +181,11 @@ func (h *handler) sendGameEvent(event byte, value float32) error {
|
|||
func (h *handler) sendPlayerPosition(teleportID int32) error {
|
||||
w := protocol.NewWriter(64)
|
||||
w.VarInt(teleportID)
|
||||
w.Float64(spawnX).Float64(spawnY).Float64(spawnZ) // position
|
||||
w.Float64(0).Float64(0).Float64(0) // velocity
|
||||
w.Float32(0) // yaw
|
||||
w.Float32(0) // pitch
|
||||
w.Int32(0) // relative flags
|
||||
w.Float64(spawnX).Float64(h.spawnY).Float64(spawnZ) // position
|
||||
w.Float64(0).Float64(0).Float64(0) // velocity
|
||||
w.Float32(0) // yaw
|
||||
w.Float32(0) // pitch
|
||||
w.Int32(0) // relative flags
|
||||
return h.conn.SendWriter(protocol.PlayPlayerPosition, w)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
"regionio/internal/server"
|
||||
"regionio/internal/world"
|
||||
)
|
||||
|
||||
func TestSendDefaultSpawnPositionLayout(t *testing.T) {
|
||||
recorder := &recordingConn{}
|
||||
h := &handler{conn: NewConn(recorder)}
|
||||
h := &handler{conn: NewConn(recorder), spawnY: 100}
|
||||
|
||||
if err := h.sendDefaultSpawnPosition(); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -38,3 +41,17 @@ func TestSendDefaultSpawnPositionLayout(t *testing.T) {
|
|||
t.Fatalf("remaining bytes = %d, want 0", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibilityRadiusUsesServerLimit(t *testing.T) {
|
||||
cfg := server.DefaultConfig()
|
||||
cfg.WorldDir = ""
|
||||
cfg.MaxViewDistance = 2
|
||||
srv, err := server.NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handler{srv: srv, log: slog.Default(), viewDistance: 16}
|
||||
if got := h.visibilityRadius(); got != 2 {
|
||||
t.Fatalf("visibility radius = %d, want server limit 2", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,8 +48,9 @@ type streamer struct {
|
|||
}
|
||||
|
||||
// defaultViewRadius is used when the client hasn't sent client_information or
|
||||
// sent an implausible value. Matches the legacy chunkRadius.
|
||||
const defaultViewRadius = 4
|
||||
// sent an implausible value. Vanilla generation is expensive, so keep the cold
|
||||
// start bounded until nearby terrain has warmed in the cache.
|
||||
const defaultViewRadius = 2
|
||||
|
||||
// newStreamer constructs a streamer for the given cache/conn. viewDistance comes
|
||||
// from the client's client_information (clamped to a safe range); genRadius is
|
||||
|
|
@ -161,6 +162,13 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) (recenterR
|
|||
s.tickets.Replace(viewTickets, prefetchTickets)
|
||||
}
|
||||
|
||||
// The center frame needs a 3x3 terrain neighborhood for lighting. Preload
|
||||
// those chunks concurrently so the first visible chunk is not delayed by
|
||||
// eight sequential generator calls inside the lighting pass.
|
||||
if !s.loaded[[2]int32{cx, cz}] {
|
||||
s.parallelPreload(ctx, spiralOrder(cx, cz, 1))
|
||||
}
|
||||
|
||||
// Client residency follows viewRadius exactly. The prefetch ring is retained
|
||||
// only server-side by tickets and never left loaded on the client.
|
||||
for key := range s.loaded {
|
||||
|
|
@ -175,7 +183,8 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) (recenterR
|
|||
if next, superseded := s.streamPriority(ctx, cx, cz, toSend, true); superseded {
|
||||
return next, true
|
||||
}
|
||||
// Pre-generate the ring so the next recenter finds frames warm in the cache.
|
||||
// Preload terrain only. Building full frames here would calculate lighting
|
||||
// for off-screen chunks and recursively generate yet another outer ring.
|
||||
if next, superseded := s.streamPriority(ctx, cx, cz, toPreGen, false); superseded {
|
||||
return next, true
|
||||
}
|
||||
|
|
@ -207,7 +216,7 @@ func (s *streamer) streamPriority(ctx context.Context, cx, cz int32, keys [][2]i
|
|||
if send {
|
||||
s.parallelSend(ctx, keys[start:end])
|
||||
} else {
|
||||
s.parallelGenerate(ctx, keys[start:end])
|
||||
s.parallelPreload(ctx, keys[start:end])
|
||||
}
|
||||
if next, ok := s.latestRecenter(); ok {
|
||||
return next, true
|
||||
|
|
@ -255,8 +264,8 @@ func (s *streamer) sendForgetLevelChunk(cx, cz int32) {
|
|||
_ = s.conn.SendWriter(protocol.PlayForgetLevelChunk, w)
|
||||
}
|
||||
|
||||
// parallelSend generates the given chunks across the worker pool and sends each
|
||||
// frame as soon as it is ready (order is best-effort; the client reassembles).
|
||||
// parallelSend generates the given chunks across the worker pool, then sends
|
||||
// them in caller order so the client receives a contiguous near-first view.
|
||||
// Already-loaded chunks are skipped. Returns when all are sent or ctx cancels.
|
||||
func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) {
|
||||
var pending []frameJob
|
||||
|
|
@ -304,13 +313,26 @@ func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) {
|
|||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
generated := make(map[[2]int32]frameResult, len(pending))
|
||||
failed := false
|
||||
for r := range results {
|
||||
if r.err != nil {
|
||||
// Send failed — the connection is likely closing. Bail out; the
|
||||
// serve loop will tear us down via ctx cancel.
|
||||
if s.log != nil {
|
||||
s.log.Debug("streamer frame failed", "cx", r.cx, "cz", r.cz, "err", r.err)
|
||||
}
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
generated[[2]int32{r.cx, r.cz}] = r
|
||||
}
|
||||
if failed {
|
||||
return
|
||||
}
|
||||
// Generation completes out of order, but client presentation should not.
|
||||
// Emit the contiguous spiral order supplied by the caller.
|
||||
for _, j := range pending {
|
||||
r, ok := generated[[2]int32{j.cx, j.cz}]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if s.conn != nil {
|
||||
|
|
@ -321,13 +343,13 @@ func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) {
|
|||
return
|
||||
}
|
||||
}
|
||||
s.loaded[[2]int32{r.cx, r.cz}] = true
|
||||
s.loaded[[2]int32{j.cx, j.cz}] = true
|
||||
}
|
||||
}
|
||||
|
||||
// parallelGenerate warms the cache for the given chunks without sending them
|
||||
// (used for the predictive ring). Errors are ignored.
|
||||
func (s *streamer) parallelGenerate(ctx context.Context, keys [][2]int32) {
|
||||
// parallelPreload warms terrain for the given chunks without calculating light,
|
||||
// encoding frames, or sending packets. Errors are ignored.
|
||||
func (s *streamer) parallelPreload(ctx context.Context, keys [][2]int32) {
|
||||
var pending []frameJob
|
||||
for _, k := range keys {
|
||||
if s.loaded[k] {
|
||||
|
|
@ -355,7 +377,7 @@ func (s *streamer) parallelGenerate(ctx context.Context, keys [][2]int32) {
|
|||
return
|
||||
default:
|
||||
}
|
||||
_, _ = s.cache.FrameErrContext(ctx, j.cx, j.cz) // warm cache; discard frame
|
||||
_ = s.cache.PreloadErrContext(ctx, j.cx, j.cz)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
@ -381,8 +403,8 @@ type frameResult struct {
|
|||
err error
|
||||
}
|
||||
|
||||
// generateWorker reads jobs, generates+frames the chunk via the (thread-safe)
|
||||
// cache, and sends the frame to the conn. It exits when jobs closes.
|
||||
// generateWorker reads jobs and generates+frames chunks via the thread-safe
|
||||
// cache. It exits when jobs closes.
|
||||
func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, results chan<- frameResult) {
|
||||
for j := range jobs {
|
||||
select {
|
||||
|
|
@ -407,9 +429,7 @@ func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, res
|
|||
}
|
||||
|
||||
// spiralOrder returns chunk coordinates in a square of side (2*radius+1) around
|
||||
// (cx, cz), ordered from the centre outward (Chebyshev rings). The centre is
|
||||
// first, then ring 1, ring 2, … ring `radius`. Within a ring the order is
|
||||
// deterministic but not otherwise constrained — nearest-first is what matters.
|
||||
// (cx, cz), ordered from the centre outward in contiguous Chebyshev rings.
|
||||
func spiralOrder(cx, cz int32, radius int) [][2]int32 {
|
||||
if radius < 0 {
|
||||
radius = 0
|
||||
|
|
@ -417,14 +437,18 @@ func spiralOrder(cx, cz int32, radius int) [][2]int32 {
|
|||
out := make([][2]int32, 0, (2*radius+1)*(2*radius+1))
|
||||
out = append(out, [2]int32{cx, cz})
|
||||
for r := 1; r <= radius; r++ {
|
||||
// Walk the perimeter of the ring at Chebyshev distance r.
|
||||
for d := -r; d <= r; d++ {
|
||||
out = append(out, [2]int32{cx + int32(d), cz - int32(r)}) // top edge
|
||||
out = append(out, [2]int32{cx + int32(d), cz + int32(r)}) // bottom edge
|
||||
// Walk one continuous perimeter: top, right, bottom, left.
|
||||
for x := -r; x <= r; x++ {
|
||||
out = append(out, [2]int32{cx + int32(x), cz - int32(r)})
|
||||
}
|
||||
for d := -r + 1; d <= r-1; d++ {
|
||||
out = append(out, [2]int32{cx - int32(r), cz + int32(d)}) // left edge
|
||||
out = append(out, [2]int32{cx + int32(r), cz + int32(d)}) // right edge
|
||||
for z := -r + 1; z <= r; z++ {
|
||||
out = append(out, [2]int32{cx + int32(r), cz + int32(z)})
|
||||
}
|
||||
for x := r - 1; x >= -r; x-- {
|
||||
out = append(out, [2]int32{cx + int32(x), cz + int32(r)})
|
||||
}
|
||||
for z := r - 1; z >= -r+1; z-- {
|
||||
out = append(out, [2]int32{cx - int32(r), cz + int32(z)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -69,6 +69,28 @@ func TestSpiralOrderRingStructure(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSpiralOrderWalksEachRingContiguously(t *testing.T) {
|
||||
order := spiralOrder(0, 0, 4)
|
||||
for i := 1; i < len(order); i++ {
|
||||
previousRing := chunkDistanceFrom(0, 0, order[i-1])
|
||||
currentRing := chunkDistanceFrom(0, 0, order[i])
|
||||
if previousRing != currentRing {
|
||||
continue
|
||||
}
|
||||
dx := order[i][0] - order[i-1][0]
|
||||
if dx < 0 {
|
||||
dx = -dx
|
||||
}
|
||||
dz := order[i][1] - order[i-1][1]
|
||||
if dz < 0 {
|
||||
dz = -dz
|
||||
}
|
||||
if dx+dz != 1 {
|
||||
t.Fatalf("ring %d jumps from %v to %v", currentRing, order[i-1], order[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestRecenterNonBlocking confirms requestRecenter never blocks the
|
||||
// caller even when many requests are pushed rapidly (the streamer drains stale
|
||||
// ones). This is the property the read loop relies on to stay responsive.
|
||||
|
|
@ -104,6 +126,7 @@ func TestStreamerSendsStrictlyNearFirst(t *testing.T) {
|
|||
|
||||
lastDistance := int32(-1)
|
||||
chunks := 0
|
||||
wantOrder := spiralOrder(7, -3, 2)
|
||||
for _, packet := range recorder.take(t) {
|
||||
if packet.ID != protocol.PlayLevelChunk {
|
||||
continue
|
||||
|
|
@ -122,6 +145,9 @@ func TestStreamerSendsStrictlyNearFirst(t *testing.T) {
|
|||
t.Fatalf("chunk (%d,%d) at distance %d arrived after distance %d", x, z, distance, lastDistance)
|
||||
}
|
||||
lastDistance = distance
|
||||
if want := wantOrder[chunks]; x != want[0] || z != want[1] {
|
||||
t.Fatalf("chunk[%d] = (%d,%d), want %v", chunks, x, z, want)
|
||||
}
|
||||
chunks++
|
||||
}
|
||||
if chunks != 25 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue