Swap to a single ServiceThreadPool workload

This commit is contained in:
mcrcortex
2024-08-06 21:50:05 +10:00
parent 351fac9052
commit 403317fd29
14 changed files with 409 additions and 358 deletions

View File

@@ -23,7 +23,6 @@ public class Voxy implements ClientModInitializer {
@Override @Override
public void onInitializeClient() { public void onInitializeClient() {
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> {
dispatcher.register(WorldImportCommand.register()); dispatcher.register(WorldImportCommand.register());
}); });

View File

@@ -24,15 +24,10 @@ public class VoxyConfig {
public boolean enabled = true; public boolean enabled = true;
public boolean ingestEnabled = true; public boolean ingestEnabled = true;
public int qualityScale = 12; public int renderDistance = 128;//Unused at the present
public int maxSections = 200_000; public int serviceThreads = Math.max(Runtime.getRuntime().availableProcessors()/2, 1);
public int renderDistance = 128;
public int geometryBufferSize = (1<<30)/8;
public int ingestThreads = 2;
public int savingThreads = 4;
public int renderThreads = 5;
public boolean useMeshShaderIfPossible = true;
public String defaultSaveConfig; public String defaultSaveConfig;
public int renderQuality = 256;//Smaller is higher quality
public static VoxyConfig loadOrCreate() { public static VoxyConfig loadOrCreate() {
@@ -69,8 +64,4 @@ public class VoxyConfig {
.getConfigDir() .getConfigDir()
.resolve("voxy-config.json"); .resolve("voxy-config.json");
} }
public boolean useMeshShaders() {
return this.useMeshShaderIfPossible && Capabilities.INSTANCE.meshShaders;
}
} }

View File

@@ -46,8 +46,6 @@ public class VoxyConfigScreenFactory implements ModMenuApi {
} }
private static void addGeneralCategory(ConfigBuilder builder, VoxyConfig config) { private static void addGeneralCategory(ConfigBuilder builder, VoxyConfig config) {
ConfigCategory category = builder.getOrCreateCategory(Text.translatable("voxy.config.general")); ConfigCategory category = builder.getOrCreateCategory(Text.translatable("voxy.config.general"));
ConfigEntryBuilder entryBuilder = builder.entryBuilder(); ConfigEntryBuilder entryBuilder = builder.entryBuilder();
@@ -76,12 +74,13 @@ public class VoxyConfigScreenFactory implements ModMenuApi {
.setDefaultValue(DEFAULT.ingestEnabled) .setDefaultValue(DEFAULT.ingestEnabled)
.build()); .build());
category.addEntry(entryBuilder.startIntSlider(Text.translatable("voxy.config.general.quality"), config.qualityScale, 8, 32) category.addEntry(entryBuilder.startIntSlider(Text.translatable("voxy.config.general.quality"), config.renderQuality, 32, 512)
.setTooltip(Text.translatable("voxy.config.general.quality.tooltip")) .setTooltip(Text.translatable("voxy.config.general.quality.tooltip"))
.setSaveConsumer(val -> config.qualityScale = val) .setSaveConsumer(val -> config.renderQuality = val)
.setDefaultValue(DEFAULT.qualityScale) .setDefaultValue(DEFAULT.renderQuality)
.build()); .build());
/*
category.addEntry(entryBuilder.startIntSlider(Text.translatable("voxy.config.general.geometryBuffer"), config.geometryBufferSize, (1<<27)/8, ((1<<31)-1)/8) category.addEntry(entryBuilder.startIntSlider(Text.translatable("voxy.config.general.geometryBuffer"), config.geometryBufferSize, (1<<27)/8, ((1<<31)-1)/8)
.setTooltip(Text.translatable("voxy.config.general.geometryBuffer.tooltip")) .setTooltip(Text.translatable("voxy.config.general.geometryBuffer.tooltip"))
.setSaveConsumer(val -> config.geometryBufferSize = val) .setSaveConsumer(val -> config.geometryBufferSize = val)
@@ -93,32 +92,22 @@ public class VoxyConfigScreenFactory implements ModMenuApi {
.setSaveConsumer(val -> config.maxSections = val) .setSaveConsumer(val -> config.maxSections = val)
.setDefaultValue(DEFAULT.maxSections) .setDefaultValue(DEFAULT.maxSections)
.build()); .build());
*/
category.addEntry(entryBuilder.startIntField(Text.translatable("voxy.config.general.renderDistance"), config.renderDistance) category.addEntry(entryBuilder.startIntField(Text.translatable("voxy.config.general.renderDistance"), config.renderDistance)
.setTooltip(Text.translatable("voxy.config.general.renderDistance.tooltip")) .setTooltip(Text.translatable("voxy.config.general.renderDistance.tooltip"))
.setSaveConsumer(val -> config.renderDistance = val) .setSaveConsumer(val -> config.renderDistance = val)
.setDefaultValue(DEFAULT.renderDistance) .setDefaultValue(DEFAULT.renderDistance)
.build()); .build());
category.addEntry(entryBuilder.startBooleanToggle(Text.translatable("voxy.config.general.nvmesh"), config.useMeshShaderIfPossible)
.setTooltip(Text.translatable("voxy.config.general.nvmesh.tooltip"))
.setSaveConsumer(val -> config.useMeshShaderIfPossible = val)
.setDefaultValue(DEFAULT.useMeshShaderIfPossible)
.build());
//category.addEntry(entryBuilder.startIntSlider(Text.translatable("voxy.config.general.compression"), config.savingCompressionLevel, 1, 21)
// .setTooltip(Text.translatable("voxy.config.general.compression.tooltip"))
// .setSaveConsumer(val -> config.savingCompressionLevel = val)
// .setDefaultValue(DEFAULT.savingCompressionLevel)
// .build());
} }
private static void addThreadsCategory(ConfigBuilder builder, VoxyConfig config) { private static void addThreadsCategory(ConfigBuilder builder, VoxyConfig config) {
ConfigCategory category = builder.getOrCreateCategory(Text.translatable("voxy.config.threads")); ConfigCategory category = builder.getOrCreateCategory(Text.translatable("voxy.config.threads"));
ConfigEntryBuilder entryBuilder = builder.entryBuilder(); ConfigEntryBuilder entryBuilder = builder.entryBuilder();
category.addEntry(entryBuilder.startIntSlider(Text.translatable("voxy.config.threads.ingest"), config.ingestThreads, 1, Runtime.getRuntime().availableProcessors()) /*
.setTooltip(Text.translatable("voxy.config.ingest.tooltip")) category.addEntry(entryBuilder.startIntSlider(Text.translatable("voxy.config.threads.service"), config.serviceThreads, 1, Runtime.getRuntime().availableProcessors())
.setTooltip(Text.translatable("voxy.config.threads.tooltip"))
.setSaveConsumer(val -> config.ingestThreads = val) .setSaveConsumer(val -> config.ingestThreads = val)
.setDefaultValue(DEFAULT.ingestThreads) .setDefaultValue(DEFAULT.ingestThreads)
.build()); .build());
@@ -134,6 +123,7 @@ public class VoxyConfigScreenFactory implements ModMenuApi {
.setSaveConsumer(val -> config.renderThreads = val) .setSaveConsumer(val -> config.renderThreads = val)
.setDefaultValue(DEFAULT.renderThreads) .setDefaultValue(DEFAULT.renderThreads)
.build()); .build());
*/
} }
private static void addStorageCategory(ConfigBuilder builder, VoxyConfig config) { private static void addStorageCategory(ConfigBuilder builder, VoxyConfig config) {

View File

@@ -2,6 +2,7 @@ package me.cortex.voxy.client.core;
import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.systems.RenderSystem;
import me.cortex.voxy.client.Voxy; import me.cortex.voxy.client.Voxy;
import me.cortex.voxy.client.config.VoxyConfig;
import me.cortex.voxy.client.core.rendering.*; import me.cortex.voxy.client.core.rendering.*;
import me.cortex.voxy.client.core.rendering.post.PostProcessing; import me.cortex.voxy.client.core.rendering.post.PostProcessing;
import me.cortex.voxy.client.core.rendering.util.DownloadStream; import me.cortex.voxy.client.core.rendering.util.DownloadStream;
@@ -9,6 +10,7 @@ import me.cortex.voxy.client.core.util.IrisUtil;
import me.cortex.voxy.client.saver.ContextSelectionSystem; import me.cortex.voxy.client.saver.ContextSelectionSystem;
import me.cortex.voxy.common.world.WorldEngine; import me.cortex.voxy.common.world.WorldEngine;
import me.cortex.voxy.client.importers.WorldImporter; import me.cortex.voxy.client.importers.WorldImporter;
import me.cortex.voxy.common.world.thread.ServiceThreadPool;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.hud.ClientBossBar; import net.minecraft.client.gui.hud.ClientBossBar;
import net.minecraft.client.render.Camera; import net.minecraft.client.render.Camera;
@@ -54,20 +56,21 @@ public class VoxelCore {
private final RenderService renderer; private final RenderService renderer;
private final PostProcessing postProcessing; private final PostProcessing postProcessing;
private final ServiceThreadPool serviceThreadPool;
//private final Thread shutdownThread = new Thread(this::shutdown);
private WorldImporter importer; private WorldImporter importer;
public VoxelCore(ContextSelectionSystem.Selection worldSelection) { public VoxelCore(ContextSelectionSystem.Selection worldSelection) {
this.world = worldSelection.createEngine();
var cfg = worldSelection.getConfig(); var cfg = worldSelection.getConfig();
this.serviceThreadPool = new ServiceThreadPool(VoxyConfig.CONFIG.serviceThreads);
this.world = worldSelection.createEngine(this.serviceThreadPool);
System.out.println("Initializing voxy core"); System.out.println("Initializing voxy core");
//Trigger the shared index buffer loading //Trigger the shared index buffer loading
SharedIndexBuffer.INSTANCE.id(); SharedIndexBuffer.INSTANCE.id();
Capabilities.init();//Ensure clinit is called Capabilities.init();//Ensure clinit is called
this.renderer = new RenderService(this.world); this.renderer = new RenderService(this.world, this.serviceThreadPool);
System.out.println("Using " + this.renderer.getClass().getSimpleName()); System.out.println("Using " + this.renderer.getClass().getSimpleName());
this.postProcessing = new PostProcessing(); this.postProcessing = new PostProcessing();
@@ -183,6 +186,8 @@ public class VoxelCore {
if (this.postProcessing!=null){try {this.postProcessing.shutdown();} catch (Exception e) {e.printStackTrace();}} if (this.postProcessing!=null){try {this.postProcessing.shutdown();} catch (Exception e) {e.printStackTrace();}}
System.out.println("Shutting down world engine"); System.out.println("Shutting down world engine");
try {this.world.shutdown();} catch (Exception e) {e.printStackTrace();} try {this.world.shutdown();} catch (Exception e) {e.printStackTrace();}
System.out.println("Shutting down service thread pool");
this.serviceThreadPool.shutdown();
System.out.println("Voxel core shut down"); System.out.println("Voxel core shut down");
} }

View File

@@ -15,6 +15,7 @@ import me.cortex.voxy.client.core.rendering.util.DownloadStream;
import me.cortex.voxy.client.core.rendering.util.UploadStream; import me.cortex.voxy.client.core.rendering.util.UploadStream;
import me.cortex.voxy.common.world.WorldEngine; import me.cortex.voxy.common.world.WorldEngine;
import me.cortex.voxy.common.world.WorldSection; import me.cortex.voxy.common.world.WorldSection;
import me.cortex.voxy.common.world.thread.ServiceThreadPool;
import net.minecraft.client.render.Camera; import net.minecraft.client.render.Camera;
import java.util.Arrays; import java.util.Arrays;
@@ -40,7 +41,7 @@ public class RenderService<T extends AbstractSectionRenderer<J, ?>, J extends Vi
private final ConcurrentLinkedDeque<BuiltSection> sectionBuildResultQueue = new ConcurrentLinkedDeque<>(); private final ConcurrentLinkedDeque<BuiltSection> sectionBuildResultQueue = new ConcurrentLinkedDeque<>();
public RenderService(WorldEngine world) { public RenderService(WorldEngine world, ServiceThreadPool serviceThreadPool) {
this.modelService = new ModelBakerySubsystem(world.getMapper()); this.modelService = new ModelBakerySubsystem(world.getMapper());
//Max sections: ~500k //Max sections: ~500k
@@ -53,7 +54,7 @@ public class RenderService<T extends AbstractSectionRenderer<J, ?>, J extends Vi
this.nodeManager = new HierarchicalNodeManager(1<<21, this.sectionRenderer.getGeometryManager(), positionFilterForwarder); this.nodeManager = new HierarchicalNodeManager(1<<21, this.sectionRenderer.getGeometryManager(), positionFilterForwarder);
this.viewportSelector = new ViewportSelector<>(this.sectionRenderer::createViewport); this.viewportSelector = new ViewportSelector<>(this.sectionRenderer::createViewport);
this.renderGen = new RenderGenerationService(world, this.modelService, VoxyConfig.CONFIG.renderThreads, this.sectionBuildResultQueue::add, this.sectionRenderer.getGeometryManager() instanceof IUsesMeshlets); this.renderGen = new RenderGenerationService(world, this.modelService, serviceThreadPool, this.sectionBuildResultQueue::add, this.sectionRenderer.getGeometryManager() instanceof IUsesMeshlets);
positionFilterForwarder.setCallback(this.renderGen::enqueueTask); positionFilterForwarder.setCallback(this.renderGen::enqueueTask);
this.traversal = new HierarchicalOcclusionTraverser(this.nodeManager, 512); this.traversal = new HierarchicalOcclusionTraverser(this.nodeManager, 512);

View File

@@ -7,6 +7,8 @@ import me.cortex.voxy.client.core.model.ModelBakerySubsystem;
import me.cortex.voxy.common.world.WorldEngine; import me.cortex.voxy.common.world.WorldEngine;
import me.cortex.voxy.common.world.WorldSection; import me.cortex.voxy.common.world.WorldSection;
import me.cortex.voxy.common.world.other.Mapper; import me.cortex.voxy.common.world.other.Mapper;
import me.cortex.voxy.common.world.thread.ServiceSlice;
import me.cortex.voxy.common.world.thread.ServiceThreadPool;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text; import net.minecraft.text.Text;
@@ -20,31 +22,30 @@ public class RenderGenerationService {
public interface TaskChecker {boolean check(int lvl, int x, int y, int z);} public interface TaskChecker {boolean check(int lvl, int x, int y, int z);}
private record BuildTask(long position, Supplier<WorldSection> sectionSupplier, boolean[] hasDoneModelRequest) {} private record BuildTask(long position, Supplier<WorldSection> sectionSupplier, boolean[] hasDoneModelRequest) {}
private volatile boolean running = true;
private final Thread[] workers;
private final Long2ObjectLinkedOpenHashMap<BuildTask> taskQueue = new Long2ObjectLinkedOpenHashMap<>(); private final Long2ObjectLinkedOpenHashMap<BuildTask> taskQueue = new Long2ObjectLinkedOpenHashMap<>();
private final Semaphore taskCounter = new Semaphore(0);
private final WorldEngine world; private final WorldEngine world;
private final ModelBakerySubsystem modelBakery; private final ModelBakerySubsystem modelBakery;
private final Consumer<BuiltSection> resultConsumer; private final Consumer<BuiltSection> resultConsumer;
private final BuiltSectionMeshCache meshCache = new BuiltSectionMeshCache(); private final BuiltSectionMeshCache meshCache = new BuiltSectionMeshCache();
private final boolean emitMeshlets; private final boolean emitMeshlets;
public RenderGenerationService(WorldEngine world, ModelBakerySubsystem modelBakery, int workers, Consumer<BuiltSection> consumer, boolean emitMeshlets) { private final ServiceSlice threads;
public RenderGenerationService(WorldEngine world, ModelBakerySubsystem modelBakery, ServiceThreadPool serviceThreadPool, Consumer<BuiltSection> consumer, boolean emitMeshlets) {
this.emitMeshlets = emitMeshlets; this.emitMeshlets = emitMeshlets;
this.world = world; this.world = world;
this.modelBakery = modelBakery; this.modelBakery = modelBakery;
this.resultConsumer = consumer; this.resultConsumer = consumer;
this.workers = new Thread[workers];
for (int i = 0; i < workers; i++) { this.threads = serviceThreadPool.createService("Section mesh generation service", 100, ()->{
this.workers[i] = new Thread(this::renderWorker); //Thread local instance of the factory
this.workers[i].setPriority(3); var factory = new RenderDataFactory(this.world, this.modelBakery.factory, this.emitMeshlets);
this.workers[i].setDaemon(true); return () -> {
this.workers[i].setName("Render generation service #" + i); this.processJob(factory);
this.workers[i].start(); };
} });
} }
//NOTE: the biomes are always fully populated/kept up to date //NOTE: the biomes are always fully populated/kept up to date
@@ -65,64 +66,53 @@ public class RenderGenerationService {
} }
//TODO: add a generated render data cache //TODO: add a generated render data cache
private void renderWorker() { private void processJob(RenderDataFactory factory) {
//Thread local instance of the factory BuildTask task;
var factory = new RenderDataFactory(this.world, this.modelBakery.factory, this.emitMeshlets); synchronized (this.taskQueue) {
while (this.running) { task = this.taskQueue.removeFirst();
this.taskCounter.acquireUninterruptibly(); }
if (!this.running) break; var section = task.sectionSupplier.get();
try { if (section == null) {
BuildTask task; this.resultConsumer.accept(new BuiltSection(task.position));
synchronized (this.taskQueue) { return;
task = this.taskQueue.removeFirst(); }
} section.assertNotFree();
var section = task.sectionSupplier.get(); BuiltSection mesh = null;
if (section == null) { try {
this.resultConsumer.accept(new BuiltSection(task.position)); mesh = factory.generateMesh(section);
continue; } catch (IdNotYetComputedException e) {
} if (!this.modelBakery.factory.hasModelForBlockId(e.id)) {
section.assertNotFree(); this.modelBakery.requestBlockBake(e.id);
BuiltSection mesh = null; }
if (task.hasDoneModelRequest[0]) {
try { try {
mesh = factory.generateMesh(section); Thread.sleep(10);
} catch (IdNotYetComputedException e) { } catch (InterruptedException ex) {
if (!this.modelBakery.factory.hasModelForBlockId(e.id)) { throw new RuntimeException(ex);
this.modelBakery.requestBlockBake(e.id);
}
if (task.hasDoneModelRequest[0]) {
try {
Thread.sleep(10);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
} else {
//The reason for the extra id parameter is that we explicitly add/check against the exception id due to e.g. requesting accross a chunk boarder wont be captured in the request
this.computeAndRequestRequiredModels(section, e.id);
}
//We need to reinsert the build task into the queue
//System.err.println("Render task failed to complete due to un-computed client id");
synchronized (this.taskQueue) {
var queuedTask = this.taskQueue.computeIfAbsent(section.key, (a)->task);
queuedTask.hasDoneModelRequest[0] = true;//Mark (or remark) the section as having chunks requested
if (queuedTask == task) {//use the == not .equal to see if we need to release a permit
this.taskCounter.release();//Since we put in queue, release permit
}
}
} }
} else {
//The reason for the extra id parameter is that we explicitly add/check against the exception id due to e.g. requesting accross a chunk boarder wont be captured in the request
this.computeAndRequestRequiredModels(section, e.id);
}
//We need to reinsert the build task into the queue
//System.err.println("Render task failed to complete due to un-computed client id");
synchronized (this.taskQueue) {
var queuedTask = this.taskQueue.computeIfAbsent(section.key, (a)->task);
queuedTask.hasDoneModelRequest[0] = true;//Mark (or remark) the section as having chunks requested
//TODO: if the section was _not_ built, maybe dont release it, or release it with the hint if (queuedTask == task) {//use the == not .equal to see if we need to release a permit
section.release(); this.threads.execute();//Since we put in queue, release permit
if (mesh != null) {
//TODO: if the mesh is null, need to clear the cache at that point
this.resultConsumer.accept(mesh.clone());
if (!this.meshCache.putMesh(mesh)) {
mesh.free();
}
} }
} catch (Exception e) { }
e.printStackTrace(); }
MinecraftClient.getInstance().executeSync(()->MinecraftClient.getInstance().player.sendMessage(Text.literal("Voxy render service had an exception while executing please check logs and report error")));
//TODO: if the section was _not_ built, maybe dont release it, or release it with the hint
section.release();
if (mesh != null) {
//TODO: if the mesh is null, need to clear the cache at that point
this.resultConsumer.accept(mesh.clone());
if (!this.meshCache.putMesh(mesh)) {
mesh.free();
} }
} }
} }
@@ -169,7 +159,7 @@ public class RenderGenerationService {
} }
synchronized (this.taskQueue) { synchronized (this.taskQueue) {
this.taskQueue.computeIfAbsent(ikey, key->{ this.taskQueue.computeIfAbsent(ikey, key->{
this.taskCounter.release(); this.threads.execute();
return new BuildTask(ikey, ()->{ return new BuildTask(ikey, ()->{
if (checker.check(WorldEngine.getLevel(ikey), WorldEngine.getX(ikey), WorldEngine.getY(ikey), WorldEngine.getZ(ikey))) { if (checker.check(WorldEngine.getLevel(ikey), WorldEngine.getX(ikey), WorldEngine.getY(ikey), WorldEngine.getZ(ikey))) {
return this.world.acquireIfExists(WorldEngine.getLevel(ikey), WorldEngine.getX(ikey), WorldEngine.getY(ikey), WorldEngine.getZ(ikey)); return this.world.acquireIfExists(WorldEngine.getLevel(ikey), WorldEngine.getX(ikey), WorldEngine.getY(ikey), WorldEngine.getZ(ikey));
@@ -196,6 +186,7 @@ public class RenderGenerationService {
this.meshCache.clearMesh(WorldEngine.getWorldSectionId(lvl, x, y, z)); this.meshCache.clearMesh(WorldEngine.getWorldSectionId(lvl, x, y, z));
} }
/*
public void removeTask(int lvl, int x, int y, int z) { public void removeTask(int lvl, int x, int y, int z) {
synchronized (this.taskQueue) { synchronized (this.taskQueue) {
if (this.taskQueue.remove(WorldEngine.getWorldSectionId(lvl, x, y, z)) != null) { if (this.taskQueue.remove(WorldEngine.getWorldSectionId(lvl, x, y, z)) != null) {
@@ -203,32 +194,14 @@ public class RenderGenerationService {
} }
} }
} }
*/
public int getTaskCount() { public int getTaskCount() {
return this.taskCounter.availablePermits(); return this.threads.getJobCount();
} }
public void shutdown() { public void shutdown() {
boolean anyAlive = false; this.threads.shutdown();
for (var worker : this.workers) {
anyAlive |= worker.isAlive();
}
if (!anyAlive) {
System.err.println("Render gen workers already dead on shutdown! this is very very bad, check log for errors from this thread");
return;
}
//Since this is just render data, dont care about any tasks needing to finish
this.running = false;
this.taskCounter.release(1000);
//Wait for thread to join
try {
for (var worker : this.workers) {
worker.join();
}
} catch (InterruptedException e) {throw new RuntimeException(e);}
//Cleanup any remaining data //Cleanup any remaining data
while (!this.taskQueue.isEmpty()) { while (!this.taskQueue.isEmpty()) {

View File

@@ -9,6 +9,7 @@ import me.cortex.voxy.common.storage.config.StorageConfig;
import me.cortex.voxy.common.storage.other.CompressionStorageAdaptor; import me.cortex.voxy.common.storage.other.CompressionStorageAdaptor;
import me.cortex.voxy.common.storage.rocksdb.RocksDBStorageBackend; import me.cortex.voxy.common.storage.rocksdb.RocksDBStorageBackend;
import me.cortex.voxy.common.world.WorldEngine; import me.cortex.voxy.common.world.WorldEngine;
import me.cortex.voxy.common.world.thread.ServiceThreadPool;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.client.world.ClientWorld; import net.minecraft.client.world.ClientWorld;
import net.minecraft.util.WorldSavePath; import net.minecraft.util.WorldSavePath;
@@ -96,8 +97,8 @@ public class ContextSelectionSystem {
return this.config.storageConfig.build(ctx); return this.config.storageConfig.build(ctx);
} }
public WorldEngine createEngine() { public WorldEngine createEngine(ServiceThreadPool serviceThreadPool) {
return new WorldEngine(this.createStorageBackend(), VoxyConfig.CONFIG.ingestThreads, VoxyConfig.CONFIG.savingThreads, 5); return new WorldEngine(this.createStorageBackend(), serviceThreadPool, 5);
} }
//Saves the config for the world selection or something, need to figure out how to make it work with dimensional configs maybe? //Saves the config for the world selection or something, need to figure out how to make it work with dimensional configs maybe?

View File

@@ -1,11 +1,11 @@
package me.cortex.voxy.common.world; package me.cortex.voxy.common.world;
import me.cortex.voxy.common.storage.StorageCompressor;
import me.cortex.voxy.common.voxelization.VoxelizedSection; import me.cortex.voxy.common.voxelization.VoxelizedSection;
import me.cortex.voxy.common.world.other.Mapper; import me.cortex.voxy.common.world.other.Mapper;
import me.cortex.voxy.common.world.service.SectionSavingService; import me.cortex.voxy.common.world.service.SectionSavingService;
import me.cortex.voxy.common.world.service.VoxelIngestService; import me.cortex.voxy.common.world.service.VoxelIngestService;
import me.cortex.voxy.common.storage.StorageBackend; import me.cortex.voxy.common.storage.StorageBackend;
import me.cortex.voxy.common.world.thread.ServiceThreadPool;
import org.lwjgl.system.MemoryUtil; import org.lwjgl.system.MemoryUtil;
import java.util.Arrays; import java.util.Arrays;
@@ -22,22 +22,21 @@ public class WorldEngine {
private Consumer<WorldSection> dirtyCallback; private Consumer<WorldSection> dirtyCallback;
private final int maxMipLevels; private final int maxMipLevels;
public void setDirtyCallback(Consumer<WorldSection> tracker) { public void setDirtyCallback(Consumer<WorldSection> tracker) {
this.dirtyCallback = tracker; this.dirtyCallback = tracker;
} }
public Mapper getMapper() {return this.mapper;} public Mapper getMapper() {return this.mapper;}
public WorldEngine(StorageBackend storageBackend, int ingestWorkers, int savingServiceWorkers, int maxMipLayers) { public WorldEngine(StorageBackend storageBackend, ServiceThreadPool serviceThreadPool, int maxMipLayers) {
this.maxMipLevels = maxMipLayers; this.maxMipLevels = maxMipLayers;
this.storage = storageBackend; this.storage = storageBackend;
this.mapper = new Mapper(this.storage); this.mapper = new Mapper(this.storage);
//4 cache size bits means that the section tracker has 16 separate maps that it uses //4 cache size bits means that the section tracker has 16 separate maps that it uses
this.sectionTracker = new ActiveSectionTracker(3, this::unsafeLoadSection); this.sectionTracker = new ActiveSectionTracker(3, this::unsafeLoadSection);
this.savingService = new SectionSavingService(this, savingServiceWorkers); this.savingService = new SectionSavingService(this, serviceThreadPool);
this.ingestService = new VoxelIngestService(this, ingestWorkers); this.ingestService = new VoxelIngestService(this, serviceThreadPool);
} }
private int unsafeLoadSection(WorldSection into) { private int unsafeLoadSection(WorldSection into) {

View File

@@ -1,58 +1,42 @@
package me.cortex.voxy.common.world.service; package me.cortex.voxy.common.world.service;
import me.cortex.voxy.common.storage.StorageCompressor;
import me.cortex.voxy.common.world.SaveLoadSystem; import me.cortex.voxy.common.world.SaveLoadSystem;
import me.cortex.voxy.common.world.WorldEngine; import me.cortex.voxy.common.world.WorldEngine;
import me.cortex.voxy.common.world.WorldSection; import me.cortex.voxy.common.world.WorldSection;
import me.cortex.voxy.common.world.thread.ServiceSlice;
import me.cortex.voxy.common.world.thread.ServiceThreadPool;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import org.lwjgl.system.MemoryUtil; import org.lwjgl.system.MemoryUtil;
import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.Semaphore;
//TODO: add an option for having synced saving, that is when call enqueueSave, that will instead, instantly //TODO: add an option for having synced saving, that is when call enqueueSave, that will instead, instantly
// save to the db, this can be useful for just reducing the amount of thread pools in total // save to the db, this can be useful for just reducing the amount of thread pools in total
// might have some issues with threading if the same section is saved from multiple threads? // might have some issues with threading if the same section is saved from multiple threads?
public class SectionSavingService { public class SectionSavingService {
private volatile boolean running = true; private final ServiceSlice threads;
private final Thread[] workers;
private final ConcurrentLinkedDeque<WorldSection> saveQueue = new ConcurrentLinkedDeque<>(); private final ConcurrentLinkedDeque<WorldSection> saveQueue = new ConcurrentLinkedDeque<>();
private final Semaphore saveCounter = new Semaphore(0);
private final WorldEngine world; private final WorldEngine world;
public SectionSavingService(WorldEngine worldEngine, ServiceThreadPool threadPool) {
public SectionSavingService(WorldEngine worldEngine, int workers) {
this.workers = new Thread[workers];
for (int i = 0; i < workers; i++) {
var worker = new Thread(this::saveWorker);
worker.setDaemon(false);
worker.setName("Saving service #" + i);
worker.start();
this.workers[i] = worker;
}
this.world = worldEngine; this.world = worldEngine;
this.threads = threadPool.createService("Section saving service", 100, () -> this::processJob);
} }
private void saveWorker() { private void processJob() {
while (running) { var section = this.saveQueue.pop();
this.saveCounter.acquireUninterruptibly(); section.assertNotFree();
if (!this.running) break; try {
var section = this.saveQueue.pop(); section.inSaveQueue.set(false);
section.assertNotFree(); var saveData = SaveLoadSystem.serialize(section);
try { this.world.storage.setSectionData(section.key, saveData);
section.inSaveQueue.set(false); MemoryUtil.memFree(saveData);
var saveData = SaveLoadSystem.serialize(section); } catch (Exception e) {
this.world.storage.setSectionData(section.key, saveData); e.printStackTrace();
MemoryUtil.memFree(saveData); MinecraftClient.getInstance().executeSync(()->MinecraftClient.getInstance().player.sendMessage(Text.literal("Voxy saver had an exception while executing please check logs and report error")));
} catch (Exception e) {
e.printStackTrace();
MinecraftClient.getInstance().executeSync(()->MinecraftClient.getInstance().player.sendMessage(Text.literal("Voxy saver had an exception while executing please check logs and report error")));
}
section.release();
} }
section.release();
} }
public void enqueueSave(WorldSection section) { public void enqueueSave(WorldSection section) {
@@ -61,47 +45,25 @@ public class SectionSavingService {
//Acquire the section for use //Acquire the section for use
section.acquire(); section.acquire();
this.saveQueue.add(section); this.saveQueue.add(section);
this.saveCounter.release(); this.threads.execute();
} }
} }
public void shutdown() { public void shutdown() {
boolean anyAlive = false; if (this.threads.getJobCount() != 0) {
boolean allAlive = true; System.err.println("Voxy section saving still in progress, estimated " + this.threads.getJobCount() + " sections remaining.");
for (var worker : this.workers) { while (this.threads.getJobCount() != 0) {
anyAlive |= worker.isAlive(); Thread.onSpinWait();
allAlive &= worker.isAlive();
}
if (!anyAlive) {
System.err.println("Section saving workers already dead on shutdown! this is very very bad, check log for errors from this thread");
return;
}
if (!allAlive) {
System.err.println("Some section saving works have died, please check log and report errors.");
}
int i = 0;
//Wait for all the saving to finish
while (this.saveCounter.availablePermits() != 0) {
try {Thread.sleep(500);} catch (InterruptedException e) {break;}
if (i++%10 == 0) {
System.out.println("Section saving shutdown has " + this.saveCounter.availablePermits() + " tasks remaining");
} }
} }
//Shutdown this.threads.shutdown();
this.running = false; //Manually save any remaining entries
this.saveCounter.release(1000); while (!this.saveQueue.isEmpty()) {
//Wait for threads to join this.processJob();
try { }
for (var worker : this.workers) {
worker.join();
}
} catch (InterruptedException e) {throw new RuntimeException(e);}
} }
public int getTaskCount() { public int getTaskCount() {
return this.saveCounter.availablePermits(); return this.threads.getJobCount();
} }
} }

View File

@@ -1,72 +0,0 @@
package me.cortex.voxy.common.world.service;
import me.cortex.voxy.common.world.WorldEngine;
import me.cortex.voxy.common.world.WorldSection;
import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.Semaphore;
//TODO:
//FIXME:
// FINISHME:
// Use this instead of seperate thread pools, use a single shared pool where tasks are submitted to and worked on
public class ServiceThreadPool {
private volatile boolean running = true;
private final Thread[] workers;
private final Semaphore jobCounter = new Semaphore(0);
//TODO: have a wrapper to specify extra information about the job for debugging
private final ConcurrentLinkedDeque<Runnable> jobQueue = new ConcurrentLinkedDeque<>();
public ServiceThreadPool(int workers) {
this.workers = new Thread[workers];
for (int i = 0; i < workers; i++) {
var worker = new Thread(this::worker);
worker.setDaemon(false);
worker.setName("Service worker #" + i);
worker.start();
this.workers[i] = worker;
}
}
private void worker() {
while (true) {
this.jobCounter.acquireUninterruptibly();
if (!this.running) {
break;
}
var job = this.jobQueue.pop();
try {
job.run();
} catch (Exception e) {
e.printStackTrace();
MinecraftClient.getInstance().executeSync(()->
MinecraftClient.getInstance().player.sendMessage(
Text.literal(
"Voxy ingester had an exception while executing service job please check logs and report error")));
}
}
}
public void shutdown() {
//Wait for the tasks to finish
while (this.jobCounter.availablePermits() != 0) {
Thread.onSpinWait();
}
//Shutdown
this.running = false;
this.jobCounter.release(1000);
//Wait for thread to join
try {
for (var worker : this.workers) {
worker.join();
}
} catch (InterruptedException e) {throw new RuntimeException(e);}
}
}

View File

@@ -4,6 +4,8 @@ import it.unimi.dsi.fastutil.Pair;
import me.cortex.voxy.common.voxelization.VoxelizedSection; import me.cortex.voxy.common.voxelization.VoxelizedSection;
import me.cortex.voxy.common.voxelization.WorldConversionFactory; import me.cortex.voxy.common.voxelization.WorldConversionFactory;
import me.cortex.voxy.common.world.WorldEngine; import me.cortex.voxy.common.world.WorldEngine;
import me.cortex.voxy.common.world.thread.ServiceSlice;
import me.cortex.voxy.common.world.thread.ServiceThreadPool;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import net.minecraft.util.math.ChunkSectionPos; import net.minecraft.util.math.ChunkSectionPos;
@@ -18,69 +20,48 @@ import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
public class VoxelIngestService { public class VoxelIngestService {
private volatile boolean running = true; private final ServiceSlice threads;
private final Thread[] workers;
private final ConcurrentLinkedDeque<WorldChunk> ingestQueue = new ConcurrentLinkedDeque<>(); private final ConcurrentLinkedDeque<WorldChunk> ingestQueue = new ConcurrentLinkedDeque<>();
private final Semaphore ingestCounter = new Semaphore(0);
private final ConcurrentHashMap<Long, Pair<ChunkNibbleArray, ChunkNibbleArray>> captureLightMap = new ConcurrentHashMap<>(1000,0.75f, 7); private final ConcurrentHashMap<Long, Pair<ChunkNibbleArray, ChunkNibbleArray>> captureLightMap = new ConcurrentHashMap<>(1000,0.75f, 7);
private final WorldEngine world; private final WorldEngine world;
public VoxelIngestService(WorldEngine world, int workers) { public VoxelIngestService(WorldEngine world, ServiceThreadPool pool) {
this.world = world; this.world = world;
this.threads = pool.createService("Ingest service", 100, ()-> this::processJob);
this.workers = new Thread[workers];
for (int i = 0; i < workers; i++) {
var worker = new Thread(this::ingestWorker);
worker.setDaemon(false);
worker.setName("Ingest service #" + i);
worker.start();
this.workers[i] = worker;
}
} }
private void ingestWorker() { private void processJob() {
while (this.running) { var chunk = this.ingestQueue.pop();
this.ingestCounter.acquireUninterruptibly(); int i = chunk.getBottomSectionCoord() - 1;
if (!this.running) break; for (var section : chunk.getSectionArray()) {
try { i++;
var chunk = this.ingestQueue.pop(); var lighting = this.captureLightMap.remove(ChunkSectionPos.from(chunk.getPos(), i).asLong());
int i = chunk.getBottomSectionCoord() - 1; if (section.isEmpty()) {
for (var section : chunk.getSectionArray()) { //TODO: add local cache so that it doesnt constantly create new sections
i++; this.world.insertUpdate(VoxelizedSection.createEmpty().setPosition(chunk.getPos().x, i, chunk.getPos().z));
var lighting = this.captureLightMap.remove(ChunkSectionPos.from(chunk.getPos(), i).asLong()); } else {
if (section.isEmpty()) { VoxelizedSection csec = WorldConversionFactory.convert(
//TODO: add local cache so that it doesnt constantly create new sections VoxelizedSection.createEmpty().setPosition(chunk.getPos().x, i, chunk.getPos().z),
this.world.insertUpdate(VoxelizedSection.createEmpty().setPosition(chunk.getPos().x, i, chunk.getPos().z)); this.world.getMapper(),
} else { section.getBlockStateContainer(),
VoxelizedSection csec = WorldConversionFactory.convert( section.getBiomeContainer(),
VoxelizedSection.createEmpty().setPosition(chunk.getPos().x, i, chunk.getPos().z), (x, y, z, state) -> {
this.world.getMapper(), if (lighting == null || ((lighting.first() != null && lighting.first().isUninitialized())&&(lighting.second()!=null&&lighting.second().isUninitialized()))) {
section.getBlockStateContainer(), return (byte) 0x0f;
section.getBiomeContainer(), } else {
(x, y, z, state) -> { //Lighting is a piece of shit cause its done per face
if (lighting == null || ((lighting.first() != null && lighting.first().isUninitialized())&&(lighting.second()!=null&&lighting.second().isUninitialized()))) { int block = lighting.first()!=null?Math.min(15,lighting.first().get(x, y, z)):0;
return (byte) 0x0f; int sky = lighting.second()!=null?Math.min(15,lighting.second().get(x, y, z)):0;
} else { if (block<state.getLuminance()) {
//Lighting is a piece of shit cause its done per face block = state.getLuminance();
int block = lighting.first()!=null?Math.min(15,lighting.first().get(x, y, z)):0;
int sky = lighting.second()!=null?Math.min(15,lighting.second().get(x, y, z)):0;
if (block<state.getLuminance()) {
block = state.getLuminance();
}
sky = 15-sky;//This is cause sky light is inverted which saves memory when saving empty sections
return (byte) (sky|(block<<4));
}
} }
); sky = 15-sky;//This is cause sky light is inverted which saves memory when saving empty sections
WorldConversionFactory.mipSection(csec, this.world.getMapper()); return (byte) (sky|(block<<4));
this.world.insertUpdate(csec); }
} }
} );
} catch (Exception e) { WorldConversionFactory.mipSection(csec, this.world.getMapper());
e.printStackTrace(); this.world.insertUpdate(csec);
MinecraftClient.getInstance().executeSync(()->MinecraftClient.getInstance().player.sendMessage(Text.literal("Voxy ingester had an exception while executing please check logs and report error")));
} }
} }
} }
@@ -118,41 +99,14 @@ public class VoxelIngestService {
public void enqueueIngest(WorldChunk chunk) { public void enqueueIngest(WorldChunk chunk) {
fetchLightingData(this.captureLightMap, chunk); fetchLightingData(this.captureLightMap, chunk);
this.ingestQueue.add(chunk); this.ingestQueue.add(chunk);
this.ingestCounter.release(); this.threads.execute();
} }
public int getTaskCount() { public int getTaskCount() {
return this.ingestCounter.availablePermits(); return this.threads.getJobCount();
} }
public void shutdown() { public void shutdown() {
boolean anyAlive = false; this.threads.shutdown();
boolean allAlive = true;
for (var worker : this.workers) {
anyAlive |= worker.isAlive();
allAlive &= worker.isAlive();
}
if (!anyAlive) {
System.err.println("Ingest workers already dead on shutdown! this is very very bad, check log for errors from this thread");
return;
}
if (!allAlive) {
System.err.println("Some ingest workers already dead on shutdown! this is very very bad, check log for errors from this thread");
}
//Wait for the ingest to finish
while (this.ingestCounter.availablePermits() != 0) {
Thread.onSpinWait();
}
//Shutdown
this.running = false;
this.ingestCounter.release(1000);
//Wait for thread to join
try {
for (var worker : this.workers) {
worker.join();
}
} catch (InterruptedException e) {throw new RuntimeException(e);}
} }
} }

View File

@@ -0,0 +1,102 @@
package me.cortex.voxy.common.world.thread;
import me.cortex.voxy.common.util.TrackedObject;
import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
public class ServiceSlice extends TrackedObject {
private final String name;
final int weightPerJob;
private volatile boolean alive = true;
private final ServiceThreadPool threadPool;
private final Supplier<Runnable> workerGenerator;
final Semaphore jobCount = new Semaphore(0);
private final Runnable[] runningCtxs;
private final AtomicInteger activeCount = new AtomicInteger();
ServiceSlice(ServiceThreadPool threadPool, Supplier<Runnable> workerGenerator, String name, int weightPerJob) {
this.threadPool = threadPool;
this.runningCtxs = new Runnable[threadPool.getThreadCount()];
this.workerGenerator = workerGenerator;
this.name = name;
this.weightPerJob = weightPerJob;
}
boolean doRun(int threadIndex) {
//Run this thread once if possible
if (!this.jobCount.tryAcquire()) {
return false;
}
if (!this.alive) {
return true;//Return true because we have "consumed" the job (needed to keep weight tracking correct)
}
this.activeCount.incrementAndGet();
//Check that we are still alive
if (!this.alive) {
if (this.activeCount.decrementAndGet() < 0) {
throw new IllegalStateException("Alive count negative!");
}
return true;
}
//If the running context is null, create and set it
var ctx = this.runningCtxs[threadIndex];
if (ctx == null) {
ctx = this.workerGenerator.get();
this.runningCtxs[threadIndex] = ctx;
}
//Run the job
try {
ctx.run();
} catch (Exception e) {
System.err.println("Unexpected error occurred while executing a service job, expect things to break badly");
e.printStackTrace();
MinecraftClient.getInstance().execute(()->MinecraftClient.getInstance().player.sendMessage(Text.literal("A voxy service had an exception while executing please check logs and report error")));
} finally {
if (this.activeCount.decrementAndGet() < 0) {
throw new IllegalStateException("Alive count negative!");
}
}
return true;
}
//Tells the system that a single instance of this service needs executing
public void execute() {
if (!this.alive) {
throw new IllegalStateException("Tried to do work on a dead service");
}
this.threadPool.execute(this);
}
public void shutdown() {
this.alive = false;
//Wait till all is finished
while (this.activeCount.get() != 0) {
Thread.onSpinWait();
}
//Tell parent to remove
this.threadPool.removeService(this);
super.free0();
}
@Override
public void free() {
this.shutdown();
}
public int getJobCount() {
return this.jobCount.availablePermits();
}
}

View File

@@ -0,0 +1,146 @@
package me.cortex.voxy.common.world.thread;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
//TODO: could also probably replace all of this with just VirtualThreads and a Executors.newThreadPerTaskExecutor with a fixed thread pool
// it is probably better anyway
public class ServiceThreadPool {
private volatile boolean running = true;
private final Thread[] workers;
private final Semaphore jobCounter = new Semaphore(0);
private volatile ServiceSlice[] serviceSlices = new ServiceSlice[0];
private final AtomicLong totalJobWeight = new AtomicLong();
public ServiceThreadPool(int workers) {
this.workers = new Thread[workers];
for (int i = 0; i < workers; i++) {
int threadId = i;
var worker = new Thread(()->this.worker(threadId));
worker.setDaemon(false);
worker.setName("Service worker #" + i);
worker.start();
worker.setUncaughtExceptionHandler(this::handleUncaughtException);
this.workers[i] = worker;
}
}
public synchronized ServiceSlice createService(String name, int weight, Supplier<Runnable> workGenerator) {
var current = this.serviceSlices;
var newList = new ServiceSlice[current.length + 1];
System.arraycopy(current, 0, newList, 0, current.length);
var service = new ServiceSlice(this, workGenerator, name, weight);
newList[current.length] = service;
this.serviceSlices = newList;
return service;
}
synchronized void removeService(ServiceSlice service) {
this.removeServiceFromArray(service);
this.totalJobWeight.addAndGet(-((long) service.weightPerJob) * service.jobCount.availablePermits());
}
private synchronized void removeServiceFromArray(ServiceSlice service) {
var lst = this.serviceSlices;
int idx;
for (idx = 0; idx < lst.length; idx++) {
if (lst[idx] == service) {
break;
}
}
if (idx == lst.length) {
throw new IllegalStateException("Service not in service list");
}
//Remove the slice from the array and set it back
if (lst.length-1 == 0) {
this.serviceSlices = new ServiceSlice[0];
return;
}
ServiceSlice[] newArr = new ServiceSlice[lst.length-1];
System.arraycopy(lst, 0, newArr, 0, idx);
if (lst.length-1 != idx) {
//Need to do a second copy
System.arraycopy(lst, idx+1, newArr, idx, newArr.length-idx);
}
this.serviceSlices = newArr;
}
void execute(ServiceSlice service) {
this.totalJobWeight.addAndGet(service.weightPerJob);
this.jobCounter.release(1);
}
private void worker(int threadId) {
long seed = 1234342;
while (true) {
seed = (seed ^ seed >>> 30) * -4658895280553007687L;
seed = (seed ^ seed >>> 27) * -7723592293110705685L;
long clamped = seed&((1L<<63)-1);
this.jobCounter.acquireUninterruptibly();
if (!this.running) {
break;
}
while (true) {
var ref = this.serviceSlices;
long chosenNumber = clamped % this.totalJobWeight.get();
ServiceSlice service = ref[(int) (clamped % ref.length)];
for (var slice : ref) {
chosenNumber -= ((long) slice.weightPerJob) * slice.jobCount.availablePermits();
if (chosenNumber <= 0) {
service = slice;
}
}
//Run the job
if (!service.doRun(threadId)) {
//Didnt consume the job, find a new job
continue;
}
//Consumed a job from the service, decrease weight by the amount
if (this.totalJobWeight.addAndGet(-service.weightPerJob)<0) {
throw new IllegalStateException("Total job weight is negative");
}
break;
}
}
}
private void handleUncaughtException(Thread thread, Throwable throwable) {
System.err.println("Service worker thread has exploded unexpectedly! this is really not good very very bad.");
throwable.printStackTrace();
}
public void shutdown() {
if (this.serviceSlices.length != 0) {
throw new IllegalStateException("All service slices must be shutdown before thread pool can exit");
}
//Wait for the tasks to finish
while (this.jobCounter.availablePermits() != 0) {
Thread.onSpinWait();
}
//Shutdown
this.running = false;
this.jobCounter.release(1000);
//Wait for thread to join
try {
for (var worker : this.workers) {
worker.join();
}
} catch (InterruptedException e) {throw new RuntimeException(e);}
}
public int getThreadCount() {
return this.workers.length;
}
}

View File

@@ -10,7 +10,7 @@
"voxy.config.general.ingest": "Chunk Ingest", "voxy.config.general.ingest": "Chunk Ingest",
"voxy.config.general.ingest.tooltip": "Enables or disables voxies ability to convert new chunks into LoDs", "voxy.config.general.ingest.tooltip": "Enables or disables voxies ability to convert new chunks into LoDs",
"voxy.config.general.quality": "LoD Quality", "voxy.config.general.quality": "LoD Quality",
"voxy.config.general.quality.tooltip": "How far each LoD ring lasts before its downgraded to a lower detail level", "voxy.config.general.quality.tooltip": "How big of an area a section should be on screen before it subdivides (pixels^2)",
"voxy.config.general.geometryBuffer": "Geometry Buffer Quads", "voxy.config.general.geometryBuffer": "Geometry Buffer Quads",
"voxy.config.general.geometryBuffer.tooltip": "How many quads the geometry buffer can hold", "voxy.config.general.geometryBuffer.tooltip": "How many quads the geometry buffer can hold",
"voxy.config.general.maxSections": "Max Sections", "voxy.config.general.maxSections": "Max Sections",