Мир перестаёт стучить при ходьбе
Статтеры были не на сервере, а в клиенте, и стоили дорого. Измерено: перевод чанка в игровой формат шел через setBlockState, а он каждый раз обновляет карту высот и карту света. Чанк стоил 10-156 мс на главном потоке, их было 208, и переход через границу чанка упирался в 325 мс за один тик. Сделано: - Секции чанка пишутся прямо в формате игры. Палитра и упакованные индексы строятся игровым же классом, так что формат совпадает по определению, а заливка по блокам и карты высот и света не нужны. Чанк подешевел с 25 мс до 1-2 мс, это в 15-25 раз. - Обработка входящих пакетов ограничена тремя миллисекундами на тик. Колонка чанков, пришедшая за один раз, теперь размазывается по тикам: не влезшее ждет следующего, это 0.2 секунды, и глазом не видно. - Сгенерированный чанк больше не считается грязным. Террейн есть чистая функция от зерна и координат, он и так восстановится за пару миллисекунд, а писать его стоило 64 килобайта на чанк. Ходьба по миру больше не гоняет двадцать мегабайт по диску. На диск пишется только то, во что игрок построился. Проверено: 169 чанков при заходе, медленных (больше 4 мс) три, остальные быстрее; на диске ноль файлов, потому что игрок ничего не строил.
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user