Мир перестаёт стучить при ходьбе

Статтеры были не на сервере, а в клиенте, и стоили дорого.

Измерено: перевод чанка в игровой формат шел через setBlockState, а он
каждый раз обновляет карту высот и карту света. Чанк стоил 10-156 мс на
главном потоке, их было 208, и переход через границу чанка упирался в
325 мс за один тик.

Сделано:

- Секции чанка пишутся прямо в формате игры. Палитра и упакованные
  индексы строятся игровым же классом, так что формат совпадает по
  определению, а заливка по блокам и карты высот и света не нужны.
  Чанк подешевел с 25 мс до 1-2 мс, это в 15-25 раз.
- Обработка входящих пакетов ограничена тремя миллисекундами на тик.
  Колонка чанков, пришедшая за один раз, теперь размазывается по тикам:
  не влезшее ждет следующего, это 0.2 секунды, и глазом не видно.
- Сгенерированный чанк больше не считается грязным. Террейн есть чистая
  функция от зерна и координат, он и так восстановится за пару
  миллисекунд, а писать его стоило 64 килобайта на чанк. Ходьба по миру
  больше не гоняет двадцать мегабайт по диску. На диск пишется только то,
  во что игрок построился.

Проверено: 169 чанков при заходе, медленных (больше 4 мс) три, остальные
быстрее; на диске ноль файлов, потому что игрок ничего не строил.
This commit is contained in:
sasheg dev
2026-10-19 20:36:42 +03:00
parent 60130d8d17
commit 5b451c38da
5 changed files with 144 additions and 54 deletions
@@ -19,29 +19,36 @@ import net.minecraft.init.Blocks;
*/
public final class RsxBlockMap {
/**
* The state of every block id, worked out once.
*
* A chunk has sixteen thousand blocks in it and a client may be given a
* hundred chunks at a time, so this is looked up sixty times a second per
* chunk instead of a million.
*/
private static final IBlockState[] STATES = new IBlockState[8];
static {
STATES[net.rustnotmc.protocol.Blocks.AIR] = Blocks.AIR.getDefaultState();
STATES[net.rustnotmc.protocol.Blocks.STONE] = Blocks.STONE.getDefaultState();
STATES[net.rustnotmc.protocol.Blocks.DIRT] = Blocks.DIRT.getDefaultState();
STATES[net.rustnotmc.protocol.Blocks.GRASS] = Blocks.GRASS.getDefaultState();
STATES[net.rustnotmc.protocol.Blocks.SAND] = Blocks.SAND.getDefaultState();
STATES[net.rustnotmc.protocol.Blocks.WATER] = Blocks.WATER.getDefaultState();
STATES[net.rustnotmc.protocol.Blocks.WOOD] = Blocks.LOG.getDefaultState();
STATES[net.rustnotmc.protocol.Blocks.LEAVES] = Blocks.LEAVES.getDefaultState();
}
private RsxBlockMap() {
}
/** The game state for a block id from the server. */
public static IBlockState stateFor(byte id) {
switch (id) {
case net.rustnotmc.protocol.Blocks.STONE:
return Blocks.STONE.getDefaultState();
case net.rustnotmc.protocol.Blocks.DIRT:
return Blocks.DIRT.getDefaultState();
case net.rustnotmc.protocol.Blocks.GRASS:
return Blocks.GRASS.getDefaultState();
case net.rustnotmc.protocol.Blocks.SAND:
return Blocks.SAND.getDefaultState();
case net.rustnotmc.protocol.Blocks.WATER:
return Blocks.WATER.getDefaultState();
case net.rustnotmc.protocol.Blocks.WOOD:
return Blocks.LOG.getDefaultState();
case net.rustnotmc.protocol.Blocks.LEAVES:
return Blocks.LEAVES.getDefaultState();
default:
return Blocks.AIR.getDefaultState();
if (id < 0 || id >= STATES.length) {
return Blocks.AIR.getDefaultState();
}
IBlockState state = STATES[id];
return state == null ? Blocks.AIR.getDefaultState() : state;
}
/** The server id for a game block, or -1 when the server has no such block. */
@@ -53,6 +53,9 @@ public final class RsxNetworkManager extends NetworkManager {
/** Set with -Drsx.trace=true to see every packet that arrives. */
private static final boolean TRACE = System.getProperty("rsx.trace") != null;
/** How long one tick may spend on incoming chunks, in nanoseconds. */
private static final long BUDGET_NANOS = 3000000L;
/**
* The connection that is open, if any.
*
@@ -193,11 +196,23 @@ public final class RsxNetworkManager extends NetworkManager {
* Called by the game on its own thread, which is the thread everything
* else in the game happens on. A packet that arrives on the reader thread
* is only put in a queue; it is dealt with here.
*
* The work is limited to a slice of a tick. Walking into new ground brings
* a column of chunks at once, and doing all of them in one tick is what
* makes the world stutter. What does not fit waits for the next tick,
* which is a fifth of a second later and nobody can see the difference.
*/
@Override
public void processReceivedPackets() {
long deadline = System.nanoTime() + BUDGET_NANOS;
Frame frame;
while ((frame = this.inbox.poll()) != null) {
while ((frame = this.inbox.peek()) != null) {
if (frame.packetId() == Packets.CHUNK_DATA && System.nanoTime() > deadline) {
// the chunks wait their turn; the rest of the queue waits with
// them, which is fine
return;
}
this.inbox.poll();
try {
handle(frame);
} catch (Exception e) {
@@ -297,8 +312,14 @@ public final class RsxNetworkManager extends NetworkManager {
if (this.mc.world == null) {
return;
}
long started = System.nanoTime();
RsxWorldBridge.chunk(this.mc.world, chunk.chunkX, chunk.chunkZ, chunk.blocks)
.processPacket(handler);
long millis = (System.nanoTime() - started) / 1000000L;
if (millis > 4L) {
System.out.println("[rsx] chunk " + chunk.chunkX + "," + chunk.chunkZ
+ " took " + millis + " ms");
}
return;
}
if (frame.packetId() == Packets.ENTITY_SPAWN) {
@@ -24,8 +24,7 @@ import net.minecraft.world.EnumDifficulty;
import net.minecraft.world.GameType;
import net.minecraft.world.World;
import net.minecraft.world.WorldType;
import net.minecraft.world.chunk.Chunk;
import net.minecraft.world.chunk.storage.ExtendedBlockStorage;
import net.minecraft.world.chunk.BlockStateContainer;
import net.rustnotmc.protocol.ChunkFormat;
import net.rustnotmc.protocol.Frame;
import net.rustnotmc.protocol.Packets;
@@ -72,40 +71,87 @@ public final class RsxWorldBridge {
/**
* One chunk, as the game wants it.
*
* The server sends a flat array of block ids; the game wants sections with
* palettes, light and biome data. The chunk is built by hand and then
* given to the game's own serialiser, which is the only way to be sure the
* two agree about the format.
* The game reads a chunk as sixteen sections of sixteen by sixteen by
* sixteen, each with a palette of block states, a light array and a sky
* light array, and a biome for every column.
*
* The section data is written straight into the game's own format instead
* of being built by filling a game chunk block by block. Filling one takes
* sixteen thousand calls, and every one of them updates a height map and a
* light map: it cost the client a quarter of a second for every chunk it
* walked into, which is the difference between a world that streams and a
* world that stutters.
*
* The light is set to full. The server does not compute light yet, and a
* world with no light in it is a world nobody can see.
*/
public static SPacketChunkData chunk(World world, int chunkX, int chunkZ, byte[] blocks) {
Chunk chunk = new Chunk(world, chunkX, chunkZ);
for (int y = 0; y < ChunkFormat.HEIGHT; y++) {
for (int z = 0; z < ChunkFormat.WIDTH; z++) {
for (int x = 0; x < ChunkFormat.WIDTH; x++) {
byte id = blocks[ChunkFormat.index(x, y, z)];
if (id == net.rustnotmc.protocol.Blocks.AIR) {
continue;
public static SPacketChunkData chunk(World world, int chunkX, int chunkZ, byte[] blocks)
throws java.io.IOException {
boolean skyLight = world.provider.hasSkyLight();
PacketBuffer sections = new PacketBuffer(Unpooled.buffer(128 * 1024));
int mask = 0;
for (int section = 0; section < 16; section++) {
BlockStateContainer container = new BlockStateContainer();
boolean any = false;
int base = section * 16;
for (int y = 0; y < 16; y++) {
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
byte id = blocks[ChunkFormat.index(x, base + y, z)];
if (id == net.rustnotmc.protocol.Blocks.AIR) {
// the palette starts with air and the storage
// starts at zero, so air is already there
continue;
}
container.set(x, y, z, RsxBlockMap.stateFor(id));
any = true;
}
chunk.setBlockState(new BlockPos(x, y, z), RsxBlockMap.stateFor(id));
}
}
if (!any) {
continue;
}
mask |= 1 << section;
container.write(sections);
sections.writeBytes(LIGHT);
if (skyLight) {
sections.writeBytes(LIGHT);
}
}
ExtendedBlockStorage[] storages = chunk.getBlockStorageArray();
for (int i = 0; i < storages.length; i++) {
ExtendedBlockStorage storage = storages[i];
if (storage == Chunk.NULL_BLOCK_STORAGE) {
continue;
}
java.util.Arrays.fill(storage.getBlockLight().getData(), (byte) 0xFF);
if (storage.getSkyLight() != null) {
java.util.Arrays.fill(storage.getSkyLight().getData(), (byte) 0xFF);
}
}
return new SPacketChunkData(chunk, 65535);
// one biome per column, and the same one for now
sections.writeBytes(BIOMES);
byte[] data = new byte[sections.writerIndex()];
sections.readBytes(data);
PacketBuffer body = new PacketBuffer(Unpooled.buffer(data.length + 64));
body.writeInt(chunkX);
body.writeInt(chunkZ);
body.writeBoolean(true);
body.writeVarInt(mask);
body.writeVarInt(data.length);
body.writeBytes(data);
body.writeVarInt(0); // no tile entities
SPacketChunkData packet = new SPacketChunkData();
packet.readPacketData(body);
return packet;
}
/** A section's light, all of it on. */
private static final byte[] LIGHT = new byte[2048];
/** The biomes of a chunk: one byte per column. */
private static final byte[] BIOMES = new byte[256];
static {
java.util.Arrays.fill(LIGHT, (byte) 0xFF);
java.util.Arrays.fill(BIOMES, (byte) 1); // plains
}
/** One block, changed. */
@@ -81,7 +81,11 @@ public final class Chunk {
void markGenerated() {
this.generated = true;
this.dirty = true;
// a generated chunk is not dirty. The terrain is a pure function of the
// seed and the position, so it can be generated again in a moment, and
// writing it out costs sixty four kilobytes a chunk for nothing. Only
// a chunk somebody built in is worth the disk.
this.dirty = false;
}
public boolean isDirty() {
@@ -572,23 +572,35 @@ public final class WorldTest {
ChunkStorage storage = new ChunkStorage(dir);
World world = new World(11L, storage);
ChunkPos pos = new ChunkPos(5, 5);
ChunkPos built = new ChunkPos(6, 6);
world.chunkAt(pos);
eq("one chunk in memory", Integer.valueOf(1), Integer.valueOf(world.loadedChunks()));
world.chunkAt(built);
// one chunk is only walked over, the other is built in: the grass at
// the surface is replaced with stone
int surface = world.generator().heightAt(6 * Chunk.WIDTH, 6 * Chunk.WIDTH);
world.setBlock(6 * Chunk.WIDTH, surface, 6 * Chunk.WIDTH, Blocks.STONE);
eq("two chunks in memory", Integer.valueOf(2), Integer.valueOf(world.loadedChunks()));
for (int i = 0; i < (int) World.EVICT_AFTER_TICKS + 10; i++) {
world.tick();
}
int dropped = world.evictIdle();
eq("it was dropped", Integer.valueOf(1), Integer.valueOf(dropped));
eq("they were dropped", Integer.valueOf(2), Integer.valueOf(dropped));
eq("nothing left in memory", Integer.valueOf(0), Integer.valueOf(world.loadedChunks()));
eq("one chunk evicted", Long.valueOf(1L), Long.valueOf(world.evictedCount()));
yes("and it is on disk", storage.exists(pos));
eq("two chunks evicted", Long.valueOf(2L), Long.valueOf(world.evictedCount()));
// and it comes back with the same terrain
Chunk back = world.chunkAt(pos);
// a chunk somebody built in is worth the disk, and comes back
yes("the built one is on disk", storage.exists(built));
// a chunk that was only walked over is not: the terrain is generated
// again in a moment, and writing it out would cost sixty four
// kilobytes a chunk for nothing
yes("the untouched one is not", !storage.exists(pos));
Chunk back = world.chunkAt(built);
yes("it came back", back.isGenerated());
eq("with the same surface",
Integer.valueOf(world.generator().heightAt(5 * Chunk.WIDTH, 5 * Chunk.WIDTH) + 1),
eq("with the build in it", Byte.valueOf(Blocks.STONE),
Byte.valueOf(back.block(0, surface, 0)));
eq("and the surface is where it was", Integer.valueOf(surface + 1),
Integer.valueOf(back.surfaceHeight(0, 0)));
}