Fix terrain streaming and surface spawning

This commit is contained in:
Master290 2026-07-21 11:11:19 +03:00
parent f0279cdb65
commit 2b07d6be20
17 changed files with 424 additions and 111 deletions

View file

@ -40,7 +40,7 @@ type Cache struct {
maxChunks int // LRU capacity; 0 = unbounded
mu sync.Mutex
lightMu sync.Mutex
lightMu sync.RWMutex
chunks map[[2]int32]*Chunk
frames map[[2]int32][]byte
dirty map[[2]int32]uint64
@ -248,6 +248,22 @@ func (c *Cache) FrameErrContext(ctx context.Context, cx, cz int32) ([]byte, erro
return c.frameErr(cx, cz)
}
// PreloadErrContext loads or generates a chunk without calculating lighting or
// encoding a network frame. Streamers use it for predictive terrain work so a
// prefetch ring does not recursively expand through lighting neighborhoods.
func (c *Cache) PreloadErrContext(ctx context.Context, cx, cz int32) error {
select {
case c.frameSlots <- struct{}{}:
defer func() { <-c.frameSlots }()
case <-ctx.Done():
return ctx.Err()
}
release := c.beginUse([2]int32{cx, cz})
defer release()
_, err := c.chunkAtErr(cx, cz)
return err
}
func (c *Cache) frameErr(cx, cz int32) ([]byte, error) {
key := [2]int32{cx, cz}
@ -307,6 +323,33 @@ func (c *Cache) GetBlock(x, y, z int) uint16 {
return ch.GetBlock(x, y, z)
}
// SafeSpawnY returns a feet-level Y with a supporting floor and two air blocks
// above it. Water columns and decorative plants are skipped rather than
// spawning an entity inside them. Loading happens once for the whole column.
func (c *Cache) SafeSpawnY(x, z int) (int, bool) {
cx := int32(x >> 4)
cz := int32(z >> 4)
release := c.beginUse([2]int32{cx, cz})
defer release()
ch, err := c.chunkAtErr(cx, cz)
if err != nil {
return 0, false
}
ch.mu.RLock()
defer ch.mu.RUnlock()
for y := MinY + WorldHeight - 3; y >= MinY; y-- {
floor := ch.getBlock(x, y, z)
if !supportsEntitySpawn(floor) {
continue
}
if ch.getBlock(x, y+1, z) == StateAir && ch.getBlock(x, y+2, z) == StateAir {
return y + 1, true
}
}
return 0, false
}
// LightUpdate returns the standalone light_update body for a loaded chunk.
func (c *Cache) LightUpdate(cx, cz int32) ([]byte, error) {
release := c.beginUse([2]int32{cx, cz})

View file

@ -10,8 +10,8 @@ import (
// neighborhood. Light attenuates to zero within 15 blocks, so chunks outside
// that neighborhood cannot affect the center chunk.
func (c *Cache) ensureLight(chunk *Chunk) error {
c.lightMu.Lock()
defer c.lightMu.Unlock()
c.lightMu.RLock()
defer c.lightMu.RUnlock()
return c.ensureLightLocked(chunk)
}
@ -65,44 +65,57 @@ func (c *Cache) ensureLightLocked(chunk *Chunk) error {
}
}
// lightInputSnapshot returns blocks for a neighboring chunk without inserting
// a cache miss into the LRU. This keeps lighting correct even for very small
// cache limits and avoids an eight-chunk eviction cascade per frame.
// lightInputSnapshot returns a stable neighbor snapshot. Cache misses are kept
// in the LRU so adjacent frames reuse the same expensive terrain instead of
// regenerating up to eight neighbors for every lighting calculation.
func (c *Cache) lightInputSnapshot(cx, cz int32) (*Chunk, error) {
key := [2]int32{cx, cz}
c.mu.Lock()
if chunk := c.chunks[key]; chunk != nil {
c.touch(key)
c.mu.Unlock()
snapshot, _ := chunk.snapshot()
return snapshot, nil
}
if pending := c.loads[key]; pending != nil {
c.mu.Unlock()
<-pending.done
if pending.err != nil {
return nil, pending.err
}
snapshot, _ := pending.ch.snapshot()
return snapshot, nil
}
c.mu.Unlock()
if c.store != nil {
loaded, err := c.store.LoadChunk(cx, cz)
if err == nil {
snapshot, _ := loaded.snapshot()
// A cache smaller than the required 3x3 neighborhood cannot retain these
// inputs usefully. Keep misses detached to avoid evicting the requested
// center chunk and churning the LRU on every frame.
if c.maxChunks > 0 && c.maxChunks < 9 {
c.mu.Lock()
if chunk := c.chunks[key]; chunk != nil {
c.touch(key)
c.mu.Unlock()
snapshot, _ := chunk.snapshot()
return snapshot, nil
}
if !errors.Is(err, ErrChunkNotFound) {
return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err)
if pending := c.loads[key]; pending != nil {
c.mu.Unlock()
<-pending.done
if pending.err != nil {
return nil, pending.err
}
snapshot, _ := pending.ch.snapshot()
return snapshot, nil
}
c.mu.Unlock()
if c.store != nil {
loaded, err := c.store.LoadChunk(cx, cz)
if err == nil {
snapshot, _ := loaded.snapshot()
return snapshot, nil
}
if !errors.Is(err, ErrChunkNotFound) {
return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err)
}
}
generated := c.gen(cx, cz)
if generated == nil {
return nil, fmt.Errorf("world: generator returned nil light neighbor (%d,%d)", cx, cz)
}
snapshot, _ := generated.snapshot()
return snapshot, nil
}
generated := c.gen(cx, cz)
if generated == nil {
return nil, fmt.Errorf("world: generator returned nil light neighbor (%d,%d)", cx, cz)
release := c.beginUse(key)
defer release()
chunk, err := c.chunkAtErr(cx, cz)
if err != nil {
return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err)
}
snapshot, _ := generated.snapshot()
snapshot, _ := chunk.snapshot()
return snapshot, nil
}

View file

@ -0,0 +1,35 @@
package world
import (
"sync"
"testing"
)
func TestAdjacentFramesReuseGeneratedLightNeighbors(t *testing.T) {
var mu sync.Mutex
generated := make(map[[2]int32]int)
cache := NewCache(-1, func(cx, cz int32) *Chunk {
mu.Lock()
generated[[2]int32{cx, cz}]++
mu.Unlock()
return NewChunk(cx, cz, BiomePlains)
})
if _, err := cache.FrameErr(0, 0); err != nil {
t.Fatal(err)
}
if _, err := cache.FrameErr(1, 0); err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(generated) != 12 {
t.Fatalf("generated chunks = %d, want 12 shared neighborhood chunks", len(generated))
}
for pos, count := range generated {
if count != 1 {
t.Fatalf("chunk %v generated %d times, want once", pos, count)
}
}
}

View file

@ -0,0 +1,41 @@
package world
import "testing"
func TestSafeSpawnYUsesGeneratedSurface(t *testing.T) {
cache := NewCache(-1, GenerateFlat)
y, ok := cache.SafeSpawnY(8, 8)
if !ok || y != FlatSurfaceY+1 {
t.Fatalf("SafeSpawnY = %d, %v; want %d, true", y, ok, FlatSurfaceY+1)
}
}
func TestSafeSpawnYRejectsUnderwaterColumn(t *testing.T) {
cache := NewCache(-1, func(cx, cz int32) *Chunk {
chunk := NewChunk(cx, cz, BiomePlains)
chunk.setBlockRaw(8, 60, 8, StateStone)
for y := 61; y <= SeaLevel; y++ {
chunk.setBlockRaw(8, y, 8, StateWater)
}
return chunk
})
if y, ok := cache.SafeSpawnY(8, 8); ok {
t.Fatalf("SafeSpawnY = %d, true; want underwater column rejected", y)
}
}
func TestSafeSpawnYAcceptsNonOpaqueSolidFloor(t *testing.T) {
stairs := nameToStateID("minecraft:oak_stairs", nil)
if stairs == StateAir {
t.Fatal("oak stairs state is unavailable")
}
cache := NewCache(-1, func(cx, cz int32) *Chunk {
chunk := NewChunk(cx, cz, BiomePlains)
chunk.setBlockRaw(8, 70, 8, stairs)
return chunk
})
y, ok := cache.SafeSpawnY(8, 8)
if !ok || y != 71 {
t.Fatalf("SafeSpawnY = %d, %v; want 71, true for stairs", y, ok)
}
}

View file

@ -3,6 +3,7 @@ package world
import (
_ "embed"
"encoding/json"
"strings"
"sync"
"regionio/internal/nbt"
@ -39,6 +40,41 @@ func stateByID(id uint16) (stateName, bool) {
return s, ok
}
// supportsEntitySpawn distinguishes collision floors from decorative blocks.
// Light opacity is not sufficient here: stairs and slabs can have opacity zero
// while still supporting an entity.
func supportsEntitySpawn(id uint16) bool {
if id == StateAir || id == StateWater {
return false
}
if lightOpacity(id) > 0 {
return true
}
state, ok := stateByID(id)
if !ok {
return false
}
name := state.Name
for _, suffix := range []string{
"_sapling", "_flower", "_tulip", "_mushroom", "_torch",
"_rail", "_button", "_pressure_plate", "_carpet", "_banner",
"_sign", "_hanging_sign",
} {
if strings.HasSuffix(name, suffix) {
return false
}
}
switch name {
case "minecraft:short_grass", "minecraft:tall_grass", "minecraft:fern",
"minecraft:large_fern", "minecraft:dead_bush", "minecraft:dandelion",
"minecraft:poppy", "minecraft:allium", "minecraft:azure_bluet",
"minecraft:oxeye_daisy", "minecraft:cornflower",
"minecraft:lily_of_the_valley", "minecraft:sunflower":
return false
}
return true
}
func buildStateTable() {
var blocks map[string]struct {
States []struct {