可见性计算
Some checks failed
check-does-build / build (push) Failing after 7s

This commit is contained in:
2026-01-06 15:07:22 +08:00
parent 2236bf19a5
commit 0ccd024f37
5 changed files with 163 additions and 62 deletions

View File

@@ -3,6 +3,8 @@ package me.cortex.voxy.common.config;
import com.google.gson.FieldNamingPolicy; import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import com.google.gson.annotations.SerializedName;
import com.google.gson.stream.JsonReader;
import me.cortex.voxy.common.Logger; import me.cortex.voxy.common.Logger;
import me.cortex.voxy.commonImpl.VoxyCommon; import me.cortex.voxy.commonImpl.VoxyCommon;
import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.FabricLoader;
@@ -22,61 +24,76 @@ public class VoxyServerConfig {
public static VoxyServerConfig CONFIG = loadOrCreate(); public static VoxyServerConfig CONFIG = loadOrCreate();
@SerializedName(value = "view_distance", alternate = {"viewDistance"})
public int viewDistance = 32; public int viewDistance = 32;
public int dirtyUpdateDelay = 20; // Ticks before sending dirty update @SerializedName(value = "dirty_update_delay", alternate = {"dirtyUpdateDelay"})
public int dirtyUpdateDelay = 20;
@SerializedName(value = "ingest_enabled", alternate = {"ingestEnabled"})
public boolean ingestEnabled = true; public boolean ingestEnabled = true;
private static VoxyServerConfig loadOrCreate() { private static VoxyServerConfig loadOrCreate() {
if (VoxyCommon.isAvailable()) { Path path = getConfigPath();
var path = getConfigPath(); try {
if (Files.exists(path)) { if (Files.exists(path)) {
try (FileReader reader = new FileReader(path.toFile())) { try (FileReader reader = new FileReader(path.toFile())) {
var conf = GSON.fromJson(reader, VoxyServerConfig.class); JsonReader jr = new JsonReader(reader);
jr.setLenient(true);
VoxyServerConfig conf = GSON.fromJson(jr, VoxyServerConfig.class);
if (conf != null) { if (conf != null) {
// conf.save(); // Don't save immediately on load, just return it. This avoids overwriting. Logger.info("Loaded voxy server config from " + path);
return conf; return conf;
} else { } else {
Logger.error("Failed to load voxy server config (null), resetting"); Logger.error("Failed to load voxy server config (null), using defaults");
} return new VoxyServerConfig();
} catch (Exception e) { // Catch all exceptions including syntax errors
Logger.error("Could not parse config, resetting to default", e);
} }
} }
Logger.info("Server config doesnt exist or failed to load, creating new");
var config = new VoxyServerConfig();
config.save();
return config;
} else { } else {
Logger.info("Config not found, creating default at " + path);
VoxyServerConfig config = new VoxyServerConfig();
config.saveIfMissing();
return config;
}
} catch (Exception e) {
Logger.error("Error loading voxy server config, using defaults", e);
return new VoxyServerConfig(); return new VoxyServerConfig();
} }
} }
public void save() { public void save() {
if (!VoxyCommon.isAvailable()) { Path path = getConfigPath();
return;
}
try { try {
// Only save if file doesn't exist to prevent overwriting user changes? Files.createDirectories(path.getParent());
// Actually, usually save() is called to persist changes. Files.writeString(path, GSON.toJson(this));
// But if we call save() immediately after load(), it just re-formats.
// The issue user reported is "resetting to default".
// That happens if GSON.fromJson returns null or exception.
// OR if we create new instance and overwrite.
// If we want to preserve comments or formatting, we shouldn't overwrite unless needed.
// But JSON doesn't support comments.
// If the user says it "resets", maybe loadOrCreate is failing?
// loadOrCreate checks if file exists.
// Let's modify save to be safe?
Files.writeString(getConfigPath(), GSON.toJson(this));
} catch (IOException e) { } catch (IOException e) {
Logger.error("Failed to write config file", e); Logger.error("Failed to write config file", e);
} }
} }
public void saveIfMissing() {
Path path = getConfigPath();
try {
Files.createDirectories(path.getParent());
if (!Files.exists(path)) {
Files.writeString(path, GSON.toJson(this));
Logger.info("Created voxy server config at " + path);
}
} catch (IOException e) {
Logger.error("Failed to write missing config file", e);
}
}
public static VoxyServerConfig reload() {
try (FileReader reader = new FileReader(getConfigPath().toFile())) {
JsonReader jr = new JsonReader(reader);
jr.setLenient(true);
VoxyServerConfig conf = GSON.fromJson(jr, VoxyServerConfig.class);
return conf != null ? conf : new VoxyServerConfig();
} catch (Exception e) {
Logger.error("Failed to reload config, using defaults", e);
return new VoxyServerConfig();
}
}
private static Path getConfigPath() { private static Path getConfigPath() {
return FabricLoader.getInstance() return FabricLoader.getInstance()
.getConfigDir() .getConfigDir()

View File

@@ -98,6 +98,7 @@ public class DirtyUpdateService {
int voxyDistance = VoxyServerConfig.CONFIG.viewDistance * 16; int voxyDistance = VoxyServerConfig.CONFIG.viewDistance * 16;
int standardViewDist = server.getPlayerList().getViewDistance() * 16; int standardViewDist = server.getPlayerList().getViewDistance() * 16;
int m = (1 << (lvl + 1)) - 1; int m = (1 << (lvl + 1)) - 1;
int perPlayerBudget = 25;
for (int dx = 0; dx <= m; dx++) { for (int dx = 0; dx <= m; dx++) {
for (int dz = 0; dz <= m; dz++) { for (int dz = 0; dz <= m; dz++) {
double centerX = (((sx << (lvl + 1)) | dx) * sectionSize) + (sectionSize / 2.0); double centerX = (((sx << (lvl + 1)) | dx) * sectionSize) + (sectionSize / 2.0);
@@ -106,14 +107,19 @@ public class DirtyUpdateService {
double dzp = player.getZ() - centerZ; double dzp = player.getZ() - centerZ;
double distSq = dxp * dxp + dzp * dzp; double distSq = dxp * dxp + dzp * dzp;
if (distSq > standardViewDist * standardViewDist && distSq < voxyDistance * voxyDistance) { if (distSq > standardViewDist * standardViewDist && distSq < voxyDistance * voxyDistance) {
for (int dy = 0; dy <= m; dy++) {
int absX = (sx << (lvl + 1)) | dx; int absX = (sx << (lvl + 1)) | dx;
int absY = (sy << (lvl + 1)) | dy;
int absZ = (sz << (lvl + 1)) | dz; int absZ = (sz << (lvl + 1)) | dz;
int minAbsY = (sy << (lvl + 1));
int maxAbsY = minAbsY + m;
int[] yList = VisibleBandUtil.computeVisibleBands(matchLevel, dirty.world, absX, absZ);
for (int i = 0; i < yList.length && perPlayerBudget > 0; i++) {
int absY = yList[i];
if (absY < minAbsY || absY > maxAbsY) continue;
var voxelized = WorldSectionToVoxelizedConverter.convert(dirty.section, lvl, absX, absY, absZ); var voxelized = WorldSectionToVoxelizedConverter.convert(dirty.section, lvl, absX, absY, absZ);
if (voxelized.lvl0NonAirCount > 0) { if (voxelized.lvl0NonAirCount > 0) {
var payload = VoxyNetwork.LodUpdatePayload.create(voxelized, dirty.world.getMapper()); var payload = VoxyNetwork.LodUpdatePayload.create(voxelized, dirty.world.getMapper());
ServerPlayNetworking.send(player, payload); ServerPlayNetworking.send(player, payload);
perPlayerBudget--;
} }
} }
} }

View File

@@ -27,6 +27,7 @@ public class PlayerLodTracker {
final RingTracker tracker; final RingTracker tracker;
int lastX = Integer.MIN_VALUE; int lastX = Integer.MIN_VALUE;
int lastZ = Integer.MIN_VALUE; int lastZ = Integer.MIN_VALUE;
final java.util.Map<Long, ColumnProgress> sentColumns = new java.util.concurrent.ConcurrentHashMap<>();
PlayerState(int radius, int x, int z) { PlayerState(int radius, int x, int z) {
this.tracker = new RingTracker(radius, x, z, true); this.tracker = new RingTracker(radius, x, z, true);
@@ -87,7 +88,8 @@ public class PlayerLodTracker {
// RingTracker is initialized with maxVoxyDist, so it won't generate events beyond that. // RingTracker is initialized with maxVoxyDist, so it won't generate events beyond that.
// But we should double check here? // But we should double check here?
state.tracker.process(50, (x, z) -> { // Process up to 50 chunks per tick per player java.util.concurrent.atomic.AtomicInteger budget = new java.util.concurrent.atomic.AtomicInteger(50);
state.tracker.process(20, (x, z) -> {
// On Add (Load) // On Add (Load)
double dx = x - state.lastX; double dx = x - state.lastX;
double dz = z - state.lastZ; double dz = z - state.lastZ;
@@ -104,20 +106,36 @@ public class PlayerLodTracker {
// 2. Check if inside Voxy Max Distance // 2. Check if inside Voxy Max Distance
// This is implicitly handled by RingTracker radius, but good to be explicit. // This is implicitly handled by RingTracker radius, but good to be explicit.
if (distSq > vanillaBoundary * vanillaBoundary && distSq <= maxVoxyDist * maxVoxyDist) { if (distSq > vanillaBoundary * vanillaBoundary && distSq <= maxVoxyDist * maxVoxyDist) {
sendLod(player, engine, x, z); long key = (((long)x) << 32) ^ (z & 0xffffffffL);
var prog = state.sentColumns.computeIfAbsent(key, k -> new ColumnProgress());
if (prog.yList == null) {
prog.yList = VisibleBandUtil.computeVisibleBands(level, engine, x, z);
prog.nextIdx = 0;
}
budget.addAndGet(-processVerticalList(player, engine, x, z, prog, budget.get()));
} }
}, (x, z) -> { }, (x, z) -> {
// On Remove (Unload) // On Remove (Unload)
// We don't explicit send unload packets for LODs usually, client manages its own cache/LRU? long key = (((long)x) << 32) ^ (z & 0xffffffffL);
// Or maybe we should? Voxy client has its own tracker. state.sentColumns.remove(key);
// Sending unloads might be good to free memory.
// But currently there is no S2C_LodUnload packet.
// Client's RenderDistanceTracker handles unloading when it moves.
// So we don't need to do anything here.
}); });
if (budget.get() > 0) {
for (var entry : state.sentColumns.entrySet()) {
if (budget.get() <= 0) break;
long key = entry.getKey();
int x = (int)(key >> 32);
int z = (int)key;
var prog = entry.getValue();
if (prog.yList == null) {
prog.yList = VisibleBandUtil.computeVisibleBands(level, engine, x, z);
prog.nextIdx = 0;
}
budget.addAndGet(-processVerticalList(player, engine, x, z, prog, budget.get()));
}
}
} }
private void sendLod(ServerPlayer player, WorldEngine engine, int x, int z) { private void sendLodSection(ServerPlayer player, WorldEngine engine, int x, int z, int y) {
// Decide LOD level based on distance? // Decide LOD level based on distance?
// For now, let's just send LOD 0 if it exists. // For now, let's just send LOD 0 if it exists.
// Optimization: Distant things can be higher LOD. // Optimization: Distant things can be higher LOD.
@@ -155,10 +173,6 @@ public class PlayerLodTracker {
this.instance.getThreadPool().execute(() -> { this.instance.getThreadPool().execute(() -> {
try { try {
int minSec = player.level().getMinSectionY();
int maxSec = player.level().getMaxSectionY();
for (int y = minSec; y <= maxSec; y++) {
var sec = engine.acquire(lvl, x >> (lvl + 1), y >> (lvl + 1), z >> (lvl + 1)); var sec = engine.acquire(lvl, x >> (lvl + 1), y >> (lvl + 1), z >> (lvl + 1));
if (sec != null) { if (sec != null) {
try { try {
@@ -173,10 +187,30 @@ public class PlayerLodTracker {
} else { } else {
//Logger.warn("Failed to acquire section " + x + ", " + y + ", " + z); //Logger.warn("Failed to acquire section " + x + ", " + y + ", " + z);
} }
}
} catch (Exception e) { } catch (Exception e) {
Logger.error("Error sending LOD to player", e); Logger.error("Error sending LOD to player", e);
} }
}); });
} }
private int processVerticalList(ServerPlayer player, WorldEngine engine, int x, int z, ColumnProgress prog, int budget) {
if (budget <= 0) return 0;
if (prog.yList == null || prog.nextIdx >= prog.yList.length) return 0;
int step = Math.min(budget, 8);
int sent = 0;
while (sent < step && prog.nextIdx < prog.yList.length) {
int y = prog.yList[prog.nextIdx++];
sendLodSection(player, engine, x, z, y);
sent++;
}
return sent;
}
private static final class ColumnProgress {
int[] yList;
int nextIdx;
ColumnProgress() {}
}
// Visible bands now provided by VisibleBandUtil
} }

View File

@@ -0,0 +1,44 @@
package me.cortex.voxy.server;
import me.cortex.voxy.common.world.WorldEngine;
import net.minecraft.server.level.ServerLevel;
final class VisibleBandUtil {
static int[] computeVisibleBands(ServerLevel level, WorldEngine engine, int chunkX, int chunkZ) {
int minSec = level.getMinSectionY();
int maxSec = level.getMaxSectionY();
int lvl = 0;
int surfaceY = Integer.MIN_VALUE;
for (int y = maxSec; y >= minSec; y--) {
var sec = engine.acquire(lvl, chunkX >> (lvl + 1), y >> (lvl + 1), chunkZ >> (lvl + 1));
if (sec != null) {
int count = sec.getNonEmptyBlockCount();
sec.release();
if (count > 0) {
surfaceY = y;
break;
}
}
}
java.util.ArrayList<Integer> ys = new java.util.ArrayList<>();
if (surfaceY != Integer.MIN_VALUE) {
for (int dy = -2; dy <= 3; dy++) {
int yy = surfaceY + dy;
if (yy >= minSec && yy <= maxSec) ys.add(yy);
}
for (int dy = 4; dy <= 6; dy++) {
int yy = surfaceY + dy;
if (yy >= minSec && yy <= maxSec) ys.add(yy);
}
for (int dy = -3; dy <= -1; dy++) {
int yy = surfaceY + dy;
if (yy >= minSec && yy <= maxSec) ys.add(yy);
}
} else {
for (int y = minSec; y <= maxSec; y++) ys.add(y);
}
int[] out = new int[ys.size()];
for (int i = 0; i < ys.size(); i++) out[i] = ys.get(i);
return out;
}
}

View File

@@ -25,7 +25,7 @@ public class VoxyServerInstance extends VoxyInstance {
public VoxyServerInstance(MinecraftServer server) { public VoxyServerInstance(MinecraftServer server) {
super(); super();
// Ensure config is loaded/created // Ensure config is loaded/created
// VoxyServerConfig.CONFIG.save(); // Don't force save, just accessing it will load it VoxyServerConfig.CONFIG.saveIfMissing();
this.server = server; this.server = server;
this.basePath = server.getWorldPath(LevelResource.ROOT).resolve("voxy"); this.basePath = server.getWorldPath(LevelResource.ROOT).resolve("voxy");