From 06bef93c8228a760403da78d5851ac8989cce196 Mon Sep 17 00:00:00 2001 From: James Seibel Date: Wed, 22 Oct 2025 07:25:04 -0500 Subject: [PATCH] run occlusion culling whenever saving a LOD Also run culling for every column in an LOD, which improves compression by about 20% - Thanks Scaevolus --- .../fullData/FullDataPointIdMap.java | 2 +- .../fullData/sources/FullDataSourceV2.java | 68 +++++- .../transformers/FullDataOcclusionCuller.java | 199 ++++++++++++++++++ .../transformers/LodDataBuilder.java | 154 +------------- 4 files changed, 262 insertions(+), 161 deletions(-) create mode 100644 core/src/main/java/com/seibel/distanthorizons/core/dataObjects/transformers/FullDataOcclusionCuller.java diff --git a/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/fullData/FullDataPointIdMap.java b/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/fullData/FullDataPointIdMap.java index 120c50450..b9034ded9 100644 --- a/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/fullData/FullDataPointIdMap.java +++ b/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/fullData/FullDataPointIdMap.java @@ -100,7 +100,7 @@ public class FullDataPointIdMap } catch (IndexOutOfBoundsException e) { - throw new IndexOutOfBoundsException("FullData ID Map out of sync for pos: "+this.pos+". ID: ["+id+"] greater than the number of known ID's: ["+this.entryList.size()+"]."); + throw new IndexOutOfBoundsException("FullData ID Map out of sync for pos: "+DhSectionPos.toString(this.pos)+". ID: ["+id+"] greater than the number of known ID's: ["+this.entryList.size()+"]."); } return entry; diff --git a/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/fullData/sources/FullDataSourceV2.java b/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/fullData/sources/FullDataSourceV2.java index 793772808..0fdee4d55 100644 --- a/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/fullData/sources/FullDataSourceV2.java +++ b/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/fullData/sources/FullDataSourceV2.java @@ -23,7 +23,9 @@ import com.seibel.distanthorizons.api.enums.config.EDhApiWorldCompressionMode; import com.seibel.distanthorizons.api.enums.worldGeneration.EDhApiWorldGenerationStep; import com.seibel.distanthorizons.api.objects.data.DhApiTerrainDataPoint; import com.seibel.distanthorizons.api.objects.data.IDhApiFullDataSource; +import com.seibel.distanthorizons.core.config.Config; import com.seibel.distanthorizons.core.dataObjects.fullData.FullDataPointIdMap; +import com.seibel.distanthorizons.core.dataObjects.transformers.FullDataOcclusionCuller; import com.seibel.distanthorizons.core.dataObjects.transformers.LodDataBuilder; import com.seibel.distanthorizons.core.file.AbstractDataSourceHandler; import com.seibel.distanthorizons.core.file.IDataSource; @@ -45,8 +47,8 @@ import it.unimi.dsi.fastutil.bytes.ByteArrayList; import it.unimi.dsi.fastutil.longs.LongArrayList; import com.seibel.distanthorizons.core.logging.DhLogger; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -283,13 +285,25 @@ public class FullDataSourceV2 - //======// + //=========// // getters // - //======// + //=========// public LongArrayList get(int relX, int relZ) throws IndexOutOfBoundsException { return this.dataPoints[relativePosToIndex(relX, relZ)]; } + @Nullable + public LongArrayList tryGet(int relX, int relZ) + { + int index = tryGetRelativePosToIndex(relX, relZ); + if (index == -1) + { + return null; + } + + return this.dataPoints[index]; + } + /** * returns {@link FullDataPointUtil#EMPTY_DATA_POINT} if the given {@link DhBlockPos} * is outside this data source's boundaries. @@ -435,8 +449,31 @@ public class FullDataSourceV2 throw new UnsupportedOperationException("Unsupported data source update. Expected input detail level of ["+(thisDetailLevel-1)+"], ["+thisDetailLevel+"], or ["+(thisDetailLevel+1)+"], received detail level ["+inputDetailLevel+"]."); } + + + + if (dataChanged) { + EDhApiWorldCompressionMode worldCompressionMode = Config.Common.LodBuilding.worldCompression.get(); + boolean cullHiddenBlocks = (worldCompressionMode != EDhApiWorldCompressionMode.MERGE_SAME_BLOCKS); + if (cullHiddenBlocks) + { + for (int x = 0; x < WIDTH; x++) + { + for (int z = 0; z < WIDTH; z++) + { + LongArrayList dataColumn = this.get(x, z); + if (dataColumn != null + && dataColumn.size() > 1) + { + FullDataOcclusionCuller.cullHiddenDatapointsInColumn(this, x, z); + } + } + } + } + + // update the hash code this.generateHashCode(); } @@ -1072,19 +1109,36 @@ public class FullDataSourceV2 // helper methods // //================// + /** + * Usually this should just be used internally, but there may be instances + * where the raw data arrays are available without the data source object. + * + * @return -1 if given an out-of-bounds relative position + */ + public static int tryGetRelativePosToIndex(int relX, int relZ) + { + if (relX < 0 || relZ < 0 + || relX >= WIDTH || relZ >= WIDTH) + { + return -1; + } + + return (relX * WIDTH) + relZ; + } + /** * Usually this should just be used internally, but there may be instances * where the raw data arrays are available without the data source object. */ public static int relativePosToIndex(int relX, int relZ) throws IndexOutOfBoundsException { - if (relX < 0 || relZ < 0 || - relX > WIDTH || relZ > WIDTH) + int index = tryGetRelativePosToIndex(relX, relZ); + if (index < 0) { - throw new IndexOutOfBoundsException("Relative data source positions must be between [0] and ["+WIDTH+"] (inclusive) the relative pos: ["+relX+","+relZ+"] is outside of those boundaries."); + throw new IndexOutOfBoundsException("Relative data source positions must be between [0] (inclusive) and ["+WIDTH+"] (exclusive) the relative pos: ["+relX+","+relZ+"] is outside those boundaries."); } - return (relX * WIDTH) + relZ; + return index; } /** diff --git a/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/transformers/FullDataOcclusionCuller.java b/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/transformers/FullDataOcclusionCuller.java new file mode 100644 index 000000000..ec776a62f --- /dev/null +++ b/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/transformers/FullDataOcclusionCuller.java @@ -0,0 +1,199 @@ +package com.seibel.distanthorizons.core.dataObjects.transformers; + +import com.seibel.distanthorizons.core.dataObjects.fullData.sources.FullDataSourceV2; +import com.seibel.distanthorizons.core.util.FullDataPointUtil; +import com.seibel.distanthorizons.core.util.LodUtil; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import org.jetbrains.annotations.NotNull; + +public class FullDataOcclusionCuller +{ + /** + * Mutates the given datasource so blocks that aren't visible + * (IE completely surrounded by other opaque blocks) + * are removed from the data column. + * + * @param dataSource + * @param relX relative X position in the datasource + * @param relZ relative Z position in the datasource + */ + public static void cullHiddenDatapointsInColumn( + FullDataSourceV2 dataSource, + int relX, int relZ + ) + { + LongArrayList centerColumn = dataSource.get(relX, relZ); + LongArrayList posXColumn = dataSource.tryGet(relX + 1, relZ); + LongArrayList negXColumn = dataSource.tryGet(relX - 1, relZ); + LongArrayList posZColumn = dataSource.tryGet(relX, relZ + 1); + LongArrayList negZColumn = dataSource.tryGet(relX, relZ - 1); + + if (posXColumn == null || posXColumn.size() == 0 + || negXColumn == null || negXColumn.size() == 0 + || posZColumn == null || posZColumn.size() == 0 + || negZColumn == null || negZColumn.size() == 0) + { + // if any adjacent columns are empty then we can't + // cull this column, since at least one side will be open + // to air/void + return; + } + + + int centerIndex = centerColumn.size() - 1; + int posXIndex = (posXColumn.size() - 1); + int negXIndex = (negXColumn.size() - 1); + int posZIndex = (posZColumn.size() - 1); + int negZIndex = (negZColumn.size() - 1); + + for (; centerIndex >= 0; centerIndex--) + { + long currentPoint = centerColumn.getLong(centerIndex); + + // Translucent data points are not eligible to be culled. + if (isTranslucent(dataSource, currentPoint)) + { + continue; + } + + // the top segment should never be culled. + if (centerIndex == 0 + || isTranslucent(dataSource, centerColumn.getLong(centerIndex - 1))) + { + continue; + } + + // the bottom segment can sometimes be culled. + // assume it will not be seen from below, + // because this would imply the player is in the void. + if (centerIndex + 1 < centerColumn.size() + && isTranslucent(dataSource, centerColumn.getLong(centerIndex + 1))) + { + continue; + } + + // the lowest/bedrock segment should not be culled + if (centerIndex + 1 == centerColumn.size()) + { + continue; + } + + + posXIndex = checkOcclusion(dataSource, currentPoint, posXColumn, posXIndex); + if (posXIndex < 0) + { + posXIndex = ~posXIndex; + continue; + } + + negXIndex = checkOcclusion(dataSource, currentPoint, negXColumn, negXIndex); + if (negXIndex < 0) + { + negXIndex = ~negXIndex; + continue; + } + + posZIndex = checkOcclusion(dataSource, currentPoint, posZColumn, posZIndex); + if (posZIndex < 0) + { + posZIndex = ~posZIndex; + continue; + } + + negZIndex = checkOcclusion(dataSource, currentPoint, negZColumn, negZIndex); + if (negZIndex < 0) + { + negZIndex = ~negZIndex; + continue; + } + + + // Current point is fully surrounded. remove it. + centerColumn.removeLong(centerIndex); + + // Make the above data point cover the area that the current point used to occupy. + // The element that was at `centerIndex - 1` is still at that position even after removal of centerIndex. + long above = centerColumn.getLong(centerIndex - 1); + above = FullDataPointUtil.setBottomY(above, FullDataPointUtil.getBottomY(currentPoint)); + above = FullDataPointUtil.setHeight(above, FullDataPointUtil.getHeight(currentPoint) + FullDataPointUtil.getHeight(above)); + centerColumn.set(centerIndex - 1, above); + } + } + /** + checks if centerPoint is "covered" by opaque data points in adjacentColumn. + centerPoint counts as covered if, and only if, for all Y levels in its height range, + there exists an opaque data point in adjacentColumn which overlaps with that Y level. + + @param source used to lookup blocks (and their opacities) based on their IDs. + @param centerPoint the point being checked to see if it's fully covered. + @param adjacentColumn the data points which might cover centerPoint. + @param adjacentIndex the starting index in adjacentColumn to start scanning at. + indices greater than adjacentIndex have already been checked and confirmed to + not overlap or only overlap partially with centerPoint's Y range. + + @return if centerPoint is covered, returns the index of the segment which finishes covering it. + the start of the covering may be a smaller index. in this case, the returned index may be used + as the adjacentIndex provided to this method on the next iteration which yields a new centerPoint. + + if centerPoint is NOT covered, returns the bitwise negation of the index of the + segment which did not cover it. this guarantees that the returned value is negative. + the caller should check for negative return values and manually un-negate them to proceed with the loop. + + in other words, this function returns the index of the next adjacent data + point to use in the loop, AND a boolean indicating whether or not the + centerPoint is covered; both are packed into the same int, and returned. + */ + private static int checkOcclusion(@NotNull FullDataSourceV2 source, long centerPoint, @NotNull LongArrayList adjacentColumn, int adjacentIndex) + { + // check if this point is adjacent to an empty column + // if so it will always be shown + if (adjacentColumn.isEmpty()) + { + return ~adjacentIndex; + } + else if (adjacentColumn.size() == 1 + && adjacentColumn.getLong(0) == FullDataPointUtil.EMPTY_DATA_POINT) + { + return ~adjacentIndex; + } + + + int bottomOfCenter = FullDataPointUtil.getBottomY(centerPoint); + int topOfCenter = bottomOfCenter + FullDataPointUtil.getHeight(centerPoint); + for (; adjacentIndex >= 0; adjacentIndex--) + { + long adjacentPoint = adjacentColumn.getLong(adjacentIndex); + int topOfAdjacent = FullDataPointUtil.getBottomY(adjacentPoint) + FullDataPointUtil.getHeight(adjacentPoint); + if (topOfAdjacent <= bottomOfCenter) + { + // the adjacent point is below the center point, + // check the next one + continue; + } + else if (isTranslucent(source, adjacentPoint)) + { + // this point is adjacent to a transparent LOD and should be shown + return ~adjacentIndex; + } + else if (topOfAdjacent >= topOfCenter) + { + // the adjacent point covers the center point + return adjacentIndex; + } + } + + + // the Adjacent column ends before center column does, + // this point should be visible + return ~adjacentIndex; + } + private static boolean isTranslucent(FullDataSourceV2 source, long point) + { + int id = FullDataPointUtil.getId(point); + int opacity = source.mapping.getBlockStateWrapper(id).getOpacity(); + return opacity < LodUtil.BLOCK_FULLY_OPAQUE; + } + + + +} diff --git a/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/transformers/LodDataBuilder.java b/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/transformers/LodDataBuilder.java index f0ed42aef..d454fbf20 100644 --- a/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/transformers/LodDataBuilder.java +++ b/core/src/main/java/com/seibel/distanthorizons/core/dataObjects/transformers/LodDataBuilder.java @@ -47,6 +47,7 @@ import com.seibel.distanthorizons.core.wrapperInterfaces.world.ILevelWrapper; import com.seibel.distanthorizons.coreapi.DependencyInjection.ApiEventInjector; import it.unimi.dsi.fastutil.longs.LongArrayList; import com.seibel.distanthorizons.core.logging.DhLogger; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public class LodDataBuilder @@ -118,7 +119,6 @@ public class LodDataBuilder //==========================// EDhApiWorldCompressionMode worldCompressionMode = Config.Common.LodBuilding.worldCompression.get(); - boolean ignoreHiddenBlocks = (worldCompressionMode != EDhApiWorldCompressionMode.MERGE_SAME_BLOCKS); try { @@ -257,11 +257,6 @@ public class LodDataBuilder dataSource.setSingleColumn(longs, columnX, columnZ, EDhApiWorldGenerationStep.LIGHT, worldCompressionMode); } } - - if (ignoreHiddenBlocks) - { - cullHiddenBlocks(dataSource, chunkOffsetX, chunkOffsetZ); - } } catch (DataCorruptedException e) { @@ -273,153 +268,6 @@ public class LodDataBuilder return dataSource; } - private static void cullHiddenBlocks(FullDataSourceV2 dataSource, int chunkOffsetX, int chunkOffsetZ) - { - for (int relZ = 1; relZ < LodUtil.CHUNK_WIDTH - 1; relZ++) - { - for (int relX = 1; relX < LodUtil.CHUNK_WIDTH - 1; relX++) - { - LongArrayList - centerColumn = dataSource.get(relX + chunkOffsetX, relZ + chunkOffsetZ), - posXColumn = dataSource.get(relX + chunkOffsetX + 1, relZ + chunkOffsetZ), - negXColumn = dataSource.get(relX + chunkOffsetX - 1, relZ + chunkOffsetZ), - posZColumn = dataSource.get(relX + chunkOffsetX, relZ + chunkOffsetZ + 1), - negZColumn = dataSource.get(relX + chunkOffsetX, relZ + chunkOffsetZ - 1); - int - centerIndex = centerColumn.size() - 1, - posXIndex = posXColumn.size() - 1, - negXIndex = negXColumn.size() - 1, - posZIndex = posZColumn.size() - 1, - negZIndex = negZColumn.size() - 1; - for (; centerIndex >= 0; centerIndex--) - { - long currentPoint = centerColumn.getLong(centerIndex); - - // Translucent data points are not eligible to be culled. - if (isTranslucent(dataSource, currentPoint)) - { - continue; - } - - // the top segment should never be culled. - if (centerIndex == 0 - || isTranslucent(dataSource, centerColumn.getLong(centerIndex - 1)) - ) - { - continue; - } - - // the bottom segment can sometimes be culled. - // assume it will not be seen from below, - // because this would imply the player is in the void. - if (centerIndex + 1 < centerColumn.size() - && isTranslucent(dataSource, centerColumn.getLong(centerIndex + 1)) - ) - { - continue; - } - - // the lowest/bedrock segment should not be culled - if (centerIndex + 1 == centerColumn.size()) - { - continue; - } - - posXIndex = checkOcclusion(dataSource, currentPoint, posXColumn, posXIndex); - if (posXIndex < 0) - { - posXIndex = ~posXIndex; - continue; - } - - negXIndex = checkOcclusion(dataSource, currentPoint, negXColumn, negXIndex); - if (negXIndex < 0) - { - negXIndex = ~negXIndex; - continue; - } - - posZIndex = checkOcclusion(dataSource, currentPoint, posZColumn, posZIndex); - if (posZIndex < 0) - { - posZIndex = ~posZIndex; - continue; - } - - negZIndex = checkOcclusion(dataSource, currentPoint, negZColumn, negZIndex); - if (negZIndex < 0) - { - negZIndex = ~negZIndex; - continue; - } - - // Current point is fully surrounded. remove it. - centerColumn.removeLong(centerIndex); - - // Make the above data point cover the area that the current point used to occupy. - // The element that was at `centerIndex - 1` is still at that position even after removal of centerIndex. - long above = centerColumn.getLong(centerIndex - 1); - above = FullDataPointUtil.setBottomY(above, FullDataPointUtil.getBottomY(currentPoint)); - above = FullDataPointUtil.setHeight(above, FullDataPointUtil.getHeight(currentPoint) + FullDataPointUtil.getHeight(above)); - centerColumn.set(centerIndex - 1, above); - } - } - } - } - - /** - checks if centerPoint is "covered" by opaque data points in adjacentColumn. - centerPoint counts as covered if, and only if, for all Y levels in its height range, - there exists an opaque data point in adjacentColumn which overlaps with that Y level. - - @param source used to lookup blocks (and their opacities) based on their IDs. - @param centerPoint the point being checked to see if it's fully covered. - @param adjacentColumn the data points which might cover centerPoint. - @param adjacentIndex the starting index in adjacentColumn to start scanning at. - indices greater than adjacentIndex have already been checked and confirmed to - not overlap or only overlap partially with centerPoint's Y range. - - @return if centerPoint is covered, returns the index of the segment which finishes covering it. - the start of the covering may be a smaller index. in this case, the returned index may be used - as the adjacentIndex provided to this method on the next iteration which yields a new centerPoint. - - if centerPoint is NOT covered, returns the bitwise negation of the index of the - segment which did not cover it. this guarantees that the returned value is negative. - the caller should check for negative return values and manually un-negate them to proceed with the loop. - - in other words, this function returns the index of the next adjacent data - point to use in the loop, AND a boolean indicating whether or not the - centerPoint is covered; both are packed into the same int, and returned. - */ - private static int checkOcclusion(FullDataSourceV2 source, long centerPoint, LongArrayList adjacentColumn, int adjacentIndex) - { - int bottomOfCenter = FullDataPointUtil.getBottomY(centerPoint); - int topOfCenter = bottomOfCenter + FullDataPointUtil.getHeight(centerPoint); - for (; adjacentIndex >= 0; adjacentIndex--) - { - long adjacentPoint = adjacentColumn.getLong(adjacentIndex); - int topOfAdjacent = FullDataPointUtil.getBottomY(adjacentPoint) + FullDataPointUtil.getHeight(adjacentPoint); - if (topOfAdjacent <= bottomOfCenter) - { - continue; - } - else if (isTranslucent(source, adjacentPoint)) - { - return ~adjacentIndex; - } - else if (topOfAdjacent >= topOfCenter) - { - return adjacentIndex; - } - } - - throw new LodUtil.AssertFailureException("Adjacent column ends before center column does."); - } - - private static boolean isTranslucent(FullDataSourceV2 source, long point) { - return source.mapping.getBlockStateWrapper(FullDataPointUtil.getId(point)).getOpacity() < LodUtil.BLOCK_FULLY_OPAQUE; - } - /** @throws ClassCastException if an API user returns the wrong object type(s) */