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

- Новый пакет 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
+11
View File
@@ -1327,3 +1327,14 @@ import org.lwjgl.opengl.ContextCapabilities;
this.debugFeedbackTranslated(flag ? "debug.show_hitboxes.on" : "debug.show_hitboxes.off");
return true;
# The far view of the world is drawn before the chunks, so that the chunks,
# which are nearer and have all the detail, cover it where they overlap.
=== FILE net/minecraft/client/renderer/EntityRenderer.java
--- BEFORE
this.mc.profiler.endStartSection("terrain_setup");
renderglobal.setupTerrain(entity, (double) f, frustum, this.frameCount++, this.mc.player.isSpectator());
--- AFTER
this.mc.profiler.endStartSection("terrain_setup");
net.rustnotmc.client.RsxLod.render(this.mc);
renderglobal.setupTerrain(entity, (double) f, frustum, this.frameCount++, this.mc.player.isSpectator());
@@ -0,0 +1,415 @@
package net.rustnotmc.client;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import net.minecraft.client.Minecraft;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import net.minecraft.client.renderer.GlStateManager;
import net.rustnotmc.protocol.Packets;
import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.GL11;
import org.lwjgl.opengl.GL15;
import org.lwjgl.opengl.GL20;
/**
* 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
* server sends it a region at a time, and the client turns each region into
* a mesh and draws it beyond the loaded chunks.
*
* The mesh is drawn with its own shader, because the far view has no blocks
* and no textures: only the shape of the ground and its colour.
*/
public final class RsxLod {
/** The regions that came from the server, by the corner of the region. */
private static final java.util.Map<Long, Packets.LodRegion> REGIONS =
new java.util.concurrent.ConcurrentHashMap<Long, Packets.LodRegion>();
/** The meshes that were built, by the corner of the region. */
private static final java.util.Map<Long, Mesh> MESHES =
new java.util.concurrent.ConcurrentHashMap<Long, Mesh>();
/** Where the far view writes down what went wrong. */
private static final Logger LOGGER = LogManager.getLogger();
/** Whether the far view is drawn at all. */
private static boolean enabled = true;
/** The shader of the far view, built when it is first needed. */
private static Program program;
/** How many meshes were drawn the last time. */
private static int drawn;
/** Whether the drawing of the far view was already said out loud. */
private static boolean announced;
private RsxLod() {
}
/** Takes a region from the server and builds its mesh. */
public static void accept(Packets.LodRegion region) {
Long key = Long.valueOf(key(region.originX, region.originZ));
REGIONS.put(key, region);
Mesh old = MESHES.remove(key);
if (old != null) {
old.free();
}
if (REGIONS.size() == 1) {
LOGGER.info("RustNotMC: the far view came: region at " + region.originX + ","
+ region.originZ + " with " + region.size + " samples");
}
}
/** Throws the far view away, for when the player leaves the world. */
public static void clear() {
REGIONS.clear();
for (Mesh mesh : MESHES.values()) {
mesh.free();
}
MESHES.clear();
}
/** Whether the far view is drawn. */
public static boolean enabled() {
return enabled;
}
public static void enabled(boolean on) {
enabled = on;
}
/** How many meshes were drawn the last time. */
public static int drawn() {
return drawn;
}
/** How many regions came from the server. */
public static int regions() {
return REGIONS.size();
}
/** The key of a region: its corner, folded into one number. */
private static long key(int originX, int originZ) {
return ((long) originX << 32) | (originZ & 0xFFFFFFFFL);
}
/**
* Draws the far view.
*
* The far view is drawn before the chunks, so that the chunks, which are
* nearer and have all the detail, cover it where they overlap.
*/
public static void render(Minecraft mc) {
drawn = 0;
if (!enabled || REGIONS.isEmpty() || mc.player == null) {
return;
}
if (mc.gameSettings.renderDistanceChunks <= 0) {
return;
}
Program prog = program();
if (prog == null) {
return;
}
FloatBuffer projection = BufferUtils.createFloatBuffer(16);
FloatBuffer model = BufferUtils.createFloatBuffer(16);
GL11.glGetFloat(GL11.GL_PROJECTION_MATRIX, projection);
GL11.glGetFloat(GL11.GL_MODELVIEW_MATRIX, model);
float[] projectionArray = new float[16];
float[] modelArray = new float[16];
projection.get(projectionArray);
model.get(modelArray);
double camX = mc.getRenderViewEntity().lastTickPosX
+ (mc.getRenderViewEntity().posX - mc.getRenderViewEntity().lastTickPosX)
* (double) mc.getRenderPartialTicks();
double camY = mc.getRenderViewEntity().lastTickPosY
+ (mc.getRenderViewEntity().posY - mc.getRenderViewEntity().lastTickPosY)
* (double) mc.getRenderPartialTicks();
double camZ = mc.getRenderViewEntity().lastTickPosZ
+ (mc.getRenderViewEntity().posZ - mc.getRenderViewEntity().lastTickPosZ)
* (double) mc.getRenderPartialTicks();
// the colour the far view fades into: the colour of the sky
float fogStart = (float) (mc.gameSettings.renderDistanceChunks * 16.0D * 0.75D);
float fogEnd = (float) (mc.gameSettings.renderDistanceChunks * 16.0D);
float fogRed = 0.47F;
float fogGreen = 0.65F;
float fogBlue = 1.0F;
GlStateManager.pushMatrix();
GlStateManager.disableAlpha();
GlStateManager.disableLighting();
GlStateManager.depthMask(true);
GlStateManager.enableDepth();
GlStateManager.disableCull();
GlStateManager.shadeModel(7425);
prog.use();
GL20.glUniformMatrix4(prog.uniform("uProj"), false, projection);
GL20.glUniformMatrix4(prog.uniform("uModel"), false, model);
GL20.glUniform3f(prog.uniform("uCam"), (float) camX, (float) camY, (float) camZ);
GL20.glUniform4f(prog.uniform("uFogColour"), fogRed, fogGreen, fogBlue, 1.0F);
GL20.glUniform1f(prog.uniform("uFogStart"), fogStart);
GL20.glUniform1f(prog.uniform("uFogEnd"), fogEnd);
for (java.util.Map.Entry<Long, Packets.LodRegion> entry : REGIONS.entrySet()) {
Long key = entry.getKey();
Mesh mesh = MESHES.get(key);
if (mesh == null) {
mesh = build(entry.getValue());
MESHES.put(key, mesh);
}
mesh.draw();
drawn++;
}
if (drawn > 0 && !announced) {
announced = true;
LOGGER.info("RustNotMC: the far view is drawn: " + drawn + " regions");
}
prog.unuse();
GlStateManager.shadeModel(7424);
GlStateManager.enableCull();
GlStateManager.enableLighting();
GlStateManager.enableAlpha();
GlStateManager.popMatrix();
}
/** Builds the mesh of one region. */
private static Mesh build(Packets.LodRegion region) {
int size = region.size;
int step = region.step;
int cells = (size - 1) * (size - 1);
int vertices = cells * 6;
ByteBuffer data = ByteBuffer.allocateDirect(vertices * 16).order(ByteOrder.nativeOrder());
for (int z = 0; z < size - 1; z++) {
for (int x = 0; x < size - 1; x++) {
int at = z * size + x;
int h00 = region.heights[at] & 0xFF;
int h10 = region.heights[at + 1] & 0xFF;
int h01 = region.heights[at + size] & 0xFF;
int h11 = region.heights[at + size + 1] & 0xFF;
float x0 = region.originX + x * step;
float x1 = x0 + step;
float z0 = region.originZ + z * step;
float z1 = z0 + step;
int c = at * 3;
float r = (region.colors[c] & 0xFF) / 255.0F;
float g = (region.colors[c + 1] & 0xFF) / 255.0F;
float b = (region.colors[c + 2] & 0xFF) / 255.0F;
// two triangles: the first one along the diagonal, the second
// one the other way, so that the ground is never torn
vertex(data, x0, h00, z0, r, g, b);
vertex(data, x1, h10, z0, r, g, b);
vertex(data, x1, h11, z1, r, g, b);
vertex(data, x0, h00, z0, r, g, b);
vertex(data, x1, h11, z1, r, g, b);
vertex(data, x0, h01, z1, r, g, b);
}
}
data.flip();
int id = GL15.glGenBuffers();
GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, id);
GL15.glBufferData(GL15.GL_ARRAY_BUFFER, data, GL15.GL_STATIC_DRAW);
GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, 0);
return new Mesh(id, vertices);
}
/** One vertex of the far view: a place and a colour. */
private static void vertex(ByteBuffer data, float x, float y, float z, float r, float g,
float b) {
data.putFloat(x);
data.putFloat(y);
data.putFloat(z);
data.put((byte) (r * 255.0F));
data.put((byte) (g * 255.0F));
data.put((byte) (b * 255.0F));
data.put((byte) 255);
}
/** What the driver says about a shader that did not build. */
private static String infoLog(int shader) {
java.nio.IntBuffer length = BufferUtils.createIntBuffer(1);
java.nio.ByteBuffer text = BufferUtils.createByteBuffer(8192);
GL20.glGetShaderInfoLog(shader, length, text);
byte[] bytes = new byte[Math.max(0, length.get(0))];
text.get(bytes);
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
}
/** What the driver says about a program that did not link. */
private static String programLog(int program) {
java.nio.IntBuffer length = BufferUtils.createIntBuffer(1);
java.nio.ByteBuffer text = BufferUtils.createByteBuffer(8192);
GL20.glGetProgramInfoLog(program, length, text);
byte[] bytes = new byte[Math.max(0, length.get(0))];
text.get(bytes);
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
}
/** Builds the shader of the far view. */
private static Program program() {
if (program != null) {
return program.ok ? program : null;
}
String vertex =
"#version 330\n"
+ "in vec3 aPos;\n"
+ "in vec4 aColour;\n"
+ "uniform mat4 uProj;\n"
+ "uniform mat4 uModel;\n"
+ "uniform vec3 uCam;\n"
+ "uniform vec4 uFogColour;\n"
+ "uniform float uFogStart;\n"
+ "uniform float uFogEnd;\n"
+ "out vec4 vColour;\n"
+ "out float vDistance;\n"
+ "void main() {\n"
+ " vColour = aColour;\n"
+ " vDistance = length(aPos - uCam);\n"
+ " gl_Position = uProj * uModel * vec4(aPos, 1.0);\n"
+ "}\n";
String fragment =
"#version 330\n"
+ "in vec4 vColour;\n"
+ "in float vDistance;\n"
+ "uniform vec4 uFogColour;\n"
+ "uniform float uFogStart;\n"
+ "uniform float uFogEnd;\n"
+ "out vec4 fragColour;\n"
+ "void main() {\n"
+ " float f = clamp((vDistance - uFogStart) / max(uFogEnd - uFogStart, 0.001),"
+ " 0.0, 1.0);\n"
+ " fragColour = vec4(mix(vColour.rgb, uFogColour.rgb, f), 1.0);\n"
+ "}\n";
program = Program.build(vertex, fragment);
if (!program.ok) {
LOGGER.error("RustNotMC: LOD: the shader would not build");
}
return program.ok ? program : null;
}
/** The mesh of one region. */
private static final class Mesh {
/** The buffer of the vertices. */
final int buffer;
/** How many vertices there are. */
final int count;
Mesh(int buffer, int count) {
this.buffer = buffer;
this.count = count;
}
/** Draws the mesh. */
void draw() {
GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, this.buffer);
GL20.glEnableVertexAttribArray(0);
GL20.glEnableVertexAttribArray(1);
GL20.glVertexAttribPointer(0, 3, GL11.GL_FLOAT, false, 16, 0L);
GL20.glVertexAttribPointer(1, 4, GL11.GL_UNSIGNED_BYTE, true, 16, 12L);
GL11.glDrawArrays(GL11.GL_TRIANGLES, 0, this.count);
GL20.glDisableVertexAttribArray(1);
GL20.glDisableVertexAttribArray(0);
GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, 0);
}
/** Throws the mesh away. */
void free() {
GL15.glDeleteBuffers(this.buffer);
}
}
/** A shader program of the far view. */
private static final class Program {
/** The program itself. */
final int id;
/** Whether the program is good. */
final boolean ok;
/** The names of the uniforms. */
final java.util.Map<String, Integer> uniforms =
new java.util.HashMap<String, Integer>();
private Program(int id, boolean ok) {
this.id = id;
this.ok = ok;
}
/** Builds a program, or says why it did not build. */
static Program build(String vertex, String fragment) {
int vs = GL20.glCreateShader(GL20.GL_VERTEX_SHADER);
GL20.glShaderSource(vs, vertex);
GL20.glCompileShader(vs);
if (GL20.glGetShaderi(vs, GL20.GL_COMPILE_STATUS) == GL11.GL_FALSE) {
String log = infoLog(vs);
GL20.glDeleteShader(vs);
LOGGER.error("RustNotMC: LOD: the vertex shader: " + log);
return new Program(0, false);
}
int fs = GL20.glCreateShader(GL20.GL_FRAGMENT_SHADER);
GL20.glShaderSource(fs, fragment);
GL20.glCompileShader(fs);
if (GL20.glGetShaderi(fs, GL20.GL_COMPILE_STATUS) == GL11.GL_FALSE) {
String log = infoLog(fs);
GL20.glDeleteShader(fs);
GL20.glDeleteShader(vs);
LOGGER.error("RustNotMC: LOD: the fragment shader: " + log);
return new Program(0, false);
}
int program = GL20.glCreateProgram();
GL20.glAttachShader(program, vs);
GL20.glAttachShader(program, fs);
GL20.glBindAttribLocation(program, 0, "aPos");
GL20.glBindAttribLocation(program, 1, "aColour");
GL20.glLinkProgram(program);
if (GL20.glGetProgrami(program, GL20.GL_LINK_STATUS) == GL11.GL_FALSE) {
String log = programLog(program);
GL20.glDeleteProgram(program);
LOGGER.error("RustNotMC: LOD: the program: " + log);
return new Program(0, false);
}
return new Program(program, true);
}
/** Uses the program. */
void use() {
GL20.glUseProgram(this.id);
}
/** Stops using the program. */
void unuse() {
GL20.glUseProgram(0);
}
/** The place of a uniform. */
int uniform(String name) {
Integer known = this.uniforms.get(name);
if (known != null) {
return known.intValue();
}
int at = GL20.glGetUniformLocation(this.id, name);
this.uniforms.put(name, Integer.valueOf(at));
return at;
}
}
}
@@ -296,6 +296,11 @@ public final class RsxNetworkManager extends NetworkManager {
}
return;
}
case Packets.LOD_REGION: {
Packets.LodRegion region = Packets.LodRegion.read(new Buf(frame.body()));
net.rustnotmc.client.RsxLod.accept(region);
return;
}
default:
onPlay(frame);
}
@@ -158,6 +158,9 @@ public final class Packets {
/** The client asks the server to read its box file again. */
public static final int HITBOXES_RELOAD = 0x2C;
/** A piece of the far view: the ground of one region, in a grid. */
public static final int LOD_REGION = 0x34;
/** The client hits something. */
public static final int ATTACK = 0x2D;
@@ -1834,6 +1837,66 @@ public final class Packets {
}
}
/**
* A piece of the far view.
*
* The far view is the ground of the world seen from far away: a grid of
* heights and colours, one sample every few blocks. The client turns it
* into a mesh and draws it beyond the chunks that are loaded.
*/
public static final class LodRegion implements Packet {
/** The block the region starts at. */
public final int originX;
public final int originZ;
/** How many blocks there are between two samples. */
public final int step;
/** How many samples there are on a side. */
public final int size;
/** The height of every sample, in blocks. */
public final byte[] heights;
/** The colour of every sample: three bytes each, red green blue. */
public final byte[] colors;
public LodRegion(int originX, int originZ, int step, int size, byte[] heights,
byte[] colors) {
this.originX = originX;
this.originZ = originZ;
this.step = step;
this.size = size;
this.heights = heights;
this.colors = colors;
}
public Buf write() {
Buf buf = new Buf(32 + this.heights.length + this.colors.length);
buf.writeVarInt(this.originX);
buf.writeVarInt(this.originZ);
buf.writeVarInt(this.step);
buf.writeVarInt(this.size);
buf.writeBytes(this.heights);
buf.writeBytes(this.colors);
return buf;
}
public static LodRegion read(Buf in) {
int originX = in.readVarInt();
int originZ = in.readVarInt();
int step = in.readVarInt();
int size = in.readVarInt();
if (size < 0 || size > 1024) {
throw new ProtocolException("that many samples is not real");
}
byte[] heights = in.readBytes(size * size);
byte[] colors = in.readBytes(size * size * 3);
return new LodRegion(originX, originZ, step, size, heights, colors);
}
}
/**
* Every packet id this build knows, and what it decodes into.
*
@@ -1877,6 +1940,7 @@ public final class Packets {
map.put(Integer.valueOf(HITBOXES), "Hitboxes");
map.put(Integer.valueOf(HITBOX_INSTANCES), "HitboxInstances");
map.put(Integer.valueOf(HITBOXES_RELOAD), "HitboxesReload");
map.put(Integer.valueOf(LOD_REGION), "LodRegion");
map.put(Integer.valueOf(ATTACK), "Attack");
map.put(Integer.valueOf(CHAT), "Chat");
map.put(Integer.valueOf(CHAT_SEND), "ChatSend");
@@ -554,6 +554,7 @@ public final class ProtocolTest {
Packets.HITBOXES, Packets.HITBOX_INSTANCES, Packets.HITBOXES_RELOAD,
Packets.ATTACK, Packets.CHAT, Packets.CHAT_SEND, Packets.PLAYER_LIST,
Packets.SESSION_RELOAD, Packets.SESSION_RENEWED, Packets.ADMIN_STATE,
Packets.LOD_REGION,
Packets.DISCONNECT,
};
for (int i = 0; i < ids.length; i++) {
+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)));
}
}