Дальний вид мира: сетка высот и цвета

- Новый пакет LOD_REGION: сервер отдаёт участок дальнего вида — сетку высот
  и цветов, по одной пробе каждые 4 блока.
- LodGenerator на сервере считает участки от начала мира, держит их в
  памяти и пишет на диск рядом с миром, чтобы считать один раз.
- Клиент строит из участка сетку и рисует своим шейдером (#version 330) до
  обычных чанков: чанки ближе и подробнее, поэтому закрывают дальний вид.
- Дальний вид приходит по мере того, как игрок ходит по миру: сервер шлёт
  участки вокруг него раз в тик.

Проверки в сервере 96, в протоколе 24, в клиенте 128.
This commit is contained in:
sasheg dev
2026-10-22 10:28:51 +03:00
parent 30cffa19da
commit b5fb57bb4f
11 changed files with 845 additions and 0 deletions
+1
View File
@@ -49,6 +49,7 @@ $suites = @(
'net.rustnotmc.server.ServerTest',
'net.rustnotmc.server.world.WorldTest',
'net.rustnotmc.server.world.NodeTest',
'net.rustnotmc.server.world.LodTest',
'net.rustnotmc.server.item.InventoryTest',
'net.rustnotmc.server.craft.CraftTest',
'net.rustnotmc.server.hitbox.HitboxTest',
@@ -66,6 +66,10 @@ public final class ClientSession implements Runnable {
/** The identity of the player, as the launcher gave it. */
private volatile String uuid = "";
/** The regions of the far view that were sent already. */
private final java.util.Set<Integer> sentLodRegions =
new java.util.LinkedHashSet<Integer>();
/** When the session runs out. */
private volatile long sessionExpiresAt;
@@ -459,6 +463,37 @@ public final class ClientSession implements Runnable {
send(Packets.ADMIN_STATE, new Packets.AdminState(admin));
}
/**
* Sends the far view around the player.
*
* The far view is a grid of heights and colours, one region at a time. A
* region is only sent once: the client keeps what it got.
*/
void sendLod() {
if (this.player == null) {
return;
}
int chunkX = (int) Math.floor(this.player.x() / 16.0D);
int chunkZ = (int) Math.floor(this.player.z() / 16.0D);
int regionX = net.rustnotmc.server.world.LodGenerator.regionChunk(chunkX);
int regionZ = net.rustnotmc.server.world.LodGenerator.regionChunk(chunkZ);
for (int dx = -2; dx <= 2; dx++) {
for (int dz = -2; dz <= 2; dz++) {
int rx = regionX + dx * net.rustnotmc.server.world.LodGenerator.REGION_CHUNKS;
int rz = regionZ + dz * net.rustnotmc.server.world.LodGenerator.REGION_CHUNKS;
Integer key = Integer.valueOf(rx * 100003 + rz);
if (this.sentLodRegions.contains(key)) {
continue;
}
this.sentLodRegions.add(key);
if (this.sentLodRegions.size() > 128) {
this.sentLodRegions.clear();
}
send(Packets.LOD_REGION, this.server.lod().region(rx, rz));
}
}
}
private void onAuth(Frame frame) {
if (this.state != SessionState.HANDSHAKING) {
return;
@@ -61,6 +61,7 @@ public final class Server {
private volatile AdminRegistry admins = new AdminRegistry(null);
private volatile net.rustnotmc.server.player.PlayerData playerData =
new net.rustnotmc.server.player.PlayerData(null);
private volatile net.rustnotmc.server.world.LodGenerator lod;
private volatile HitboxRegistry hitboxes = new HitboxRegistry(null);
private volatile ServerSocket listener;
@@ -82,6 +83,9 @@ public final class Server {
this.capacity = capacity;
this.port = port;
this.world = world;
// the far view of the world, kept in memory unless the caller says
// where it should be written down
this.lod = new net.rustnotmc.server.world.LodGenerator(world.generator(), null);
}
// -------------------------------------------------------------- getters
@@ -251,6 +255,15 @@ public final class Server {
}
}
/** The far view of the world. */
public net.rustnotmc.server.world.LodGenerator lod() {
return this.lod;
}
public void lod(net.rustnotmc.server.world.LodGenerator lod) {
this.lod = lod;
}
/** Where the data of the players is kept. */
public net.rustnotmc.server.player.PlayerData playerData() {
return this.playerData;
@@ -502,6 +515,7 @@ public final class Server {
this.world.evictIdle();
for (Player player : this.players.values()) {
tickResearch(player);
player.session().sendLod();
// the rights of a player can be taken away while they play, and
// the client has to hear about it at once
boolean admin = player.isAdmin();
@@ -129,6 +129,9 @@ public final class ServerMain {
// The administrators are the server's own list.
Path adminFile = adminsFile != null ? adminsFile : worldDir.resolve("admins.txt");
server.admins(new net.rustnotmc.server.admin.AdminRegistry(adminFile));
// the far view of the world, kept beside it
server.lod(new net.rustnotmc.server.world.LodGenerator(world.generator(),
worldDir.resolve("lod")));
// where the players leave their things
server.playerData(new net.rustnotmc.server.player.PlayerData(
worldDir.resolve("players")));
@@ -0,0 +1,159 @@
package net.rustnotmc.server.world;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import net.rustnotmc.protocol.Blocks;
import net.rustnotmc.protocol.Packets;
/**
* The far view of the world.
*
* The chunks around a player hold the whole detail, but a player sees much
* further than that. The far view is the ground of the world seen from far
* away: a grid of heights and colours, one sample every four blocks. The
* client turns the grid into a mesh and draws it beyond the loaded chunks.
*
* The regions are counted from the origin of the world, so that every server
* and every client numbers them the same way, and they are written to disk,
* so that a region is only worked out once.
*/
public final class LodGenerator {
/** How many chunks there are on the side of a region. */
public static final int REGION_CHUNKS = 16;
/** How many blocks there are on the side of a region. */
public static final int REGION_BLOCKS = REGION_CHUNKS * 16;
/** How many blocks there are between two samples. */
public static final int STEP = 4;
/** How many samples there are on the side of a region. */
public static final int SAMPLES = REGION_BLOCKS / STEP;
private final WorldGenerator generator;
/** Where the regions are kept, or null when they are not kept. */
private final Path cache;
public LodGenerator(WorldGenerator generator, Path cache) {
this.generator = generator;
this.cache = cache;
}
/** The chunk a region starts at. */
public static int regionChunk(int chunk) {
return Math.floorDiv(chunk, REGION_CHUNKS) * REGION_CHUNKS;
}
/**
* The far view of one region.
*
* The region is read from the disk when it is there, and worked out when
* it is not.
*/
public Packets.LodRegion region(int chunkX, int chunkZ) {
int startChunk = regionChunk(chunkX);
int startZ = regionChunk(chunkZ);
int originX = startChunk * 16;
int originZ = startZ * 16;
byte[] heights = new byte[SAMPLES * SAMPLES];
byte[] colors = new byte[SAMPLES * SAMPLES * 3];
if (!read(startChunk, startZ, heights, colors)) {
write(startChunk, startZ, heights, colors);
}
return new Packets.LodRegion(originX, originZ, STEP, SAMPLES, heights, colors);
}
/** Reads a region from the disk. */
private boolean read(int chunkX, int chunkZ, byte[] heights, byte[] colors) {
if (this.cache == null) {
return false;
}
Path file = this.cache.resolve(chunkX + "_" + chunkZ + ".bin");
if (!Files.isRegularFile(file)) {
return false;
}
try {
byte[] data = Files.readAllBytes(file);
int expected = heights.length + colors.length;
if (data.length != expected) {
return false;
}
System.arraycopy(data, 0, heights, 0, heights.length);
System.arraycopy(data, heights.length, colors, 0, colors.length);
return true;
} catch (IOException broken) {
return false;
}
}
/** Works a region out and writes it down. */
private void write(int chunkX, int chunkZ, byte[] heights, byte[] colors) {
for (int z = 0; z < SAMPLES; z++) {
for (int x = 0; x < SAMPLES; x++) {
int worldX = chunkX * 16 + x * STEP;
int worldZ = chunkZ * 16 + z * STEP;
int height = this.generator.heightAt(worldX, worldZ);
int at = z * SAMPLES + x;
heights[at] = (byte) height;
int colour = colourOf(worldX, worldZ, height);
colors[at * 3] = (byte) ((colour >> 16) & 0xFF);
colors[at * 3 + 1] = (byte) ((colour >> 8) & 0xFF);
colors[at * 3 + 2] = (byte) (colour & 0xFF);
}
}
if (this.cache == null) {
return;
}
try {
Files.createDirectories(this.cache);
byte[] data = new byte[heights.length + colors.length];
System.arraycopy(heights, 0, data, 0, heights.length);
System.arraycopy(colors, 0, data, heights.length, colors.length);
Path file = this.cache.resolve(chunkX + "_" + chunkZ + ".bin");
Path temp = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(temp, data);
Files.move(temp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} catch (IOException broken) {
// The far view is a nicety: a region that cannot be written is
// worked out again next time.
}
}
/** The colour of the ground at a place, as the far view shows it. */
private int colourOf(int worldX, int worldZ, int height) {
if (height < WorldGenerator.SEA_LEVEL) {
return 0x2E5C8A;
}
byte top = topBlock(worldX, worldZ, height);
switch (top) {
case Blocks.SAND:
return 0xC8B47C;
case Blocks.STONE:
return 0x6E6E6E;
case Blocks.ASPHALT:
return 0x3A3A3A;
case Blocks.WATER:
return 0x2E5C8A;
default:
return 0x4E7A34;
}
}
/** The block on the top of a column. */
private byte topBlock(int worldX, int worldZ, int height) {
if (this.generator.isRoad(worldX, worldZ)) {
return Blocks.ASPHALT;
}
if (height < WorldGenerator.SEA_LEVEL) {
return Blocks.WATER;
}
if (height <= WorldGenerator.SEA_LEVEL + 1) {
return Blocks.SAND;
}
return Blocks.GRASS;
}
}
@@ -0,0 +1,137 @@
package net.rustnotmc.server.world;
import java.nio.file.Files;
import java.nio.file.Path;
import net.rustnotmc.protocol.Packets;
/**
* Tests the far view of the world.
*
* The far view is what a player sees beyond the loaded chunks: a grid of
* heights and colours. It has to agree with the terrain the server has, or
* the world would look wrong at the edge.
*/
public final class LodTest {
private static int checks;
private static int failed;
private static final java.util.List<String> failures = new java.util.ArrayList<String>();
public static void main(String[] args) throws Exception {
Path work = Path.of(args.length > 0 ? args[0] : "build/selftest");
Files.createDirectories(work);
run("a region holds a grid of samples", new Throwing() {
public void go() throws Exception { testGrid(work); }
});
run("the heights are the heights of the terrain", new Throwing() {
public void go() throws Exception { testHeights(work); }
});
run("the region is kept on disk", new Throwing() {
public void go() throws Exception { testCache(work); }
});
run("the regions are numbered from the origin", new Throwing() {
public void go() throws Exception { testNumbering(); }
});
System.out.println();
for (int i = 0; i < failures.size(); i++) {
System.out.println(" FAIL " + failures.get(i));
}
System.out.println();
System.out.println("checks: " + checks + ", failed: " + failed);
if (failed != 0) {
System.out.println("FAILED");
System.exit(1);
}
System.out.println("OK");
}
private interface Throwing {
void go() throws Exception;
}
private static void run(String name, Throwing body) {
checks++;
int before = failed;
try {
body.go();
} catch (Throwable t) {
failed++;
failures.add(name + ": threw " + t);
t.printStackTrace();
return;
}
if (failed == before) {
System.out.println(" ok " + name);
} else {
System.out.println(" FAIL " + name);
}
}
private static void eq(Object what, Object expected, Object actual) {
if (expected == null ? actual != null : !expected.equals(actual)) {
failed++;
failures.add(what + ": expected " + expected + " but got " + actual);
}
}
private static void yes(String what, boolean condition) {
if (!condition) {
failed++;
failures.add(what);
}
}
private static void testGrid(Path work) throws Exception {
LodGenerator lod = new LodGenerator(new WorldGenerator(1234L), null);
Packets.LodRegion region = lod.region(0, 0);
eq("the samples on a side", Integer.valueOf(LodGenerator.SAMPLES),
Integer.valueOf(region.size));
eq("the heights", Integer.valueOf(LodGenerator.SAMPLES * LodGenerator.SAMPLES),
Integer.valueOf(region.heights.length));
eq("the colours", Integer.valueOf(LodGenerator.SAMPLES * LodGenerator.SAMPLES * 3),
Integer.valueOf(region.colors.length));
eq("the step", Integer.valueOf(LodGenerator.STEP), Integer.valueOf(region.step));
}
private static void testHeights(Path work) throws Exception {
WorldGenerator generator = new WorldGenerator(1234L);
LodGenerator lod = new LodGenerator(generator, null);
Packets.LodRegion region = lod.region(0, 0);
for (int z = 0; z < 4; z++) {
for (int x = 0; x < 4; x++) {
int worldX = x * LodGenerator.STEP;
int worldZ = z * LodGenerator.STEP;
int at = z * region.size + x;
eq("the height at " + worldX + "," + worldZ,
Integer.valueOf(generator.heightAt(worldX, worldZ)),
Integer.valueOf(region.heights[at] & 0xFF));
}
}
}
private static void testCache(Path work) throws Exception {
Path cache = work.resolve("lod-test");
WorldGenerator generator = new WorldGenerator(1234L);
LodGenerator lod = new LodGenerator(generator, cache);
Packets.LodRegion first = lod.region(0, 0);
yes("the region is written down", Files.isRegularFile(cache.resolve("0_0.bin")));
// the second time it is read back, and it is the same
Packets.LodRegion second = lod.region(0, 0);
yes("the same heights", java.util.Arrays.equals(first.heights, second.heights));
yes("the same colours", java.util.Arrays.equals(first.colors, second.colors));
}
private static void testNumbering() {
eq("the first region", Integer.valueOf(0),
Integer.valueOf(LodGenerator.regionChunk(0)));
eq("the region of a chunk inside", Integer.valueOf(0),
Integer.valueOf(LodGenerator.regionChunk(15)));
eq("the next region", Integer.valueOf(16),
Integer.valueOf(LodGenerator.regionChunk(16)));
eq("and back for the negative ones", Integer.valueOf(-16),
Integer.valueOf(LodGenerator.regionChunk(-1)));
}
}