From 68e0b65176730a027cb6883d80fcaebe08a46e8d Mon Sep 17 00:00:00 2001 From: douira Date: Thu, 15 Feb 2024 02:46:29 +0100 Subject: [PATCH] Efficient CPU Translucency Sorting with BSP Trees and Heuristics (#2016) This PR implements efficient CPU translucency sorting. See #2016 for a useful overview of the concepts. Closes #38 --- CONTRIBUTING.md | 2 +- build.gradle.kts | 1 + .../mods/sodium/api/util/NormI8.java | 40 +- .../client/gui/SodiumGameOptionPages.java | 11 + .../sodium/client/gui/SodiumGameOptions.java | 7 + .../client/model/quad/BakedQuadView.java | 4 +- .../client/model/quad/ModelQuadView.java | 40 ++ .../quad/properties/ModelQuadFacing.java | 80 +++ .../client/render/SodiumWorldRenderer.java | 47 +- .../client/render/chunk/ChunkUpdateType.java | 32 +- .../render/chunk/DefaultChunkRenderer.java | 127 +++- .../client/render/chunk/RenderSection.java | 53 +- .../render/chunk/RenderSectionManager.java | 303 +++++++--- .../chunk/compile/BuilderTaskOutput.java | 28 + .../chunk/compile/ChunkBuildBuffers.java | 12 +- .../chunk/compile/ChunkBuildOutput.java | 52 +- .../render/chunk/compile/ChunkSortOutput.java | 25 + .../chunk/compile/OutputWithIndexData.java | 7 + .../chunk/compile/executor/ChunkBuilder.java | 37 +- .../chunk/compile/executor/ChunkJob.java | 2 + .../compile/executor/ChunkJobCollector.java | 49 +- .../chunk/compile/executor/ChunkJobQueue.java | 20 +- .../chunk/compile/executor/ChunkJobTyped.java | 8 +- .../compile/pipeline/BlockRenderContext.java | 12 +- .../chunk/compile/pipeline/BlockRenderer.java | 4 + .../pipeline/DefaultFluidRenderer.java | 61 +- .../chunk/compile/pipeline/FluidRenderer.java | 12 +- .../tasks/ChunkBuilderMeshingTask.java | 52 +- .../tasks/ChunkBuilderSortingTask.java | 41 ++ .../chunk/compile/tasks/ChunkBuilderTask.java | 43 +- .../chunk/data/SectionRenderDataStorage.java | 149 ++++- .../chunk/data/SectionRenderDataUnsafe.java | 44 +- .../render/chunk/lists/SortedRenderLists.java | 5 + .../chunk/lists/VisibleChunkCollector.java | 6 +- .../render/chunk/region/RenderRegion.java | 75 ++- .../chunk/region/RenderRegionManager.java | 119 +++- .../translucent_sorting/AlignableNormal.java | 94 +++ .../translucent_sorting/SortBehavior.java | 60 ++ .../chunk/translucent_sorting/SortType.java | 52 ++ .../chunk/translucent_sorting/TQuad.java | 183 ++++++ .../TranslucentGeometryCollector.java | 555 ++++++++++++++++++ .../bsp_tree/BSPBuildFailureException.java | 7 + .../translucent_sorting/bsp_tree/BSPNode.java | 113 ++++ .../bsp_tree/BSPResult.java | 19 + .../bsp_tree/BSPSortState.java | 320 ++++++++++ .../bsp_tree/BSPWorkspace.java | 44 ++ .../bsp_tree/InnerBinaryPartitionBSPNode.java | 102 ++++ .../bsp_tree/InnerMultiPartitionBSPNode.java | 159 +++++ .../bsp_tree/InnerPartitionBSPNode.java | 437 ++++++++++++++ .../bsp_tree/LeafDoubleBSPNode.java | 22 + .../bsp_tree/LeafMultiBSPNode.java | 19 + .../bsp_tree/LeafSingleBSPNode.java | 19 + .../bsp_tree/Partition.java | 11 + .../data/AnyOrderData.java | 55 ++ .../data/BSPDynamicData.java | 72 +++ .../data/CombinedCameraPos.java | 10 + .../translucent_sorting/data/DynamicData.java | 42 ++ .../data/MixedDirectionData.java | 20 + .../translucent_sorting/data/NoData.java | 33 ++ .../data/PresentTranslucentData.java | 72 +++ .../data/SplitDirectionData.java | 24 + .../data/StaticNormalRelativeData.java | 149 +++++ .../data/StaticTopoAcyclicData.java | 47 ++ .../data/TopoGraphSorting.java | 331 +++++++++++ .../data/TopoSortDynamicData.java | 250 ++++++++ .../data/TranslucentData.java | 91 +++ .../trigger/CameraMovement.java | 9 + .../trigger/DirectTriggers.java | 233 ++++++++ .../trigger/GFNITriggers.java | 118 ++++ .../trigger/GeometryPlanes.java | 133 +++++ .../translucent_sorting/trigger/Group.java | 81 +++ .../trigger/NormalList.java | 176 ++++++ .../trigger/NormalPlanes.java | 83 +++ .../trigger/SortTriggering.java | 243 ++++++++ .../mods/sodium/client/util/MathUtil.java | 18 + .../sodium/client/util/ModelQuadUtil.java | 75 --- .../client/util/collections/BitArray.java | 270 +++++++++ .../sodium/client/util/sorting/RadixSort.java | 82 +++ .../client/util/sorting/VertexSorters.java | 3 + .../mixin/core/model/quad/BakedQuadMixin.java | 9 +- .../resources/assets/sodium/lang/en_us.json | 4 +- 81 files changed, 6223 insertions(+), 336 deletions(-) create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/BuilderTaskOutput.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkSortOutput.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/OutputWithIndexData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderSortingTask.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/AlignableNormal.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/SortBehavior.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/SortType.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/TQuad.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/TranslucentGeometryCollector.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPBuildFailureException.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPNode.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPResult.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPSortState.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPWorkspace.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerBinaryPartitionBSPNode.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerMultiPartitionBSPNode.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerPartitionBSPNode.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafDoubleBSPNode.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafMultiBSPNode.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafSingleBSPNode.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/Partition.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/AnyOrderData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/BSPDynamicData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/CombinedCameraPos.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/DynamicData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/MixedDirectionData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/NoData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/PresentTranslucentData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/SplitDirectionData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/StaticNormalRelativeData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/StaticTopoAcyclicData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TopoGraphSorting.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TopoSortDynamicData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TranslucentData.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/CameraMovement.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/DirectTriggers.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/GFNITriggers.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/GeometryPlanes.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/Group.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/NormalList.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/NormalPlanes.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/SortTriggering.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/util/collections/BitArray.java create mode 100644 src/main/java/net/caffeinemc/mods/sodium/client/util/sorting/RadixSort.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5122efbd4b..4cc2a27414 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ with some minor changes, as described below. - If you are using more than three levels of indentation, you should likely consider restructuring your code. - Branches which are only exceptionally or very rarely taken should remain concise. When this is not possible, prefer breaking out to a new method (where it makes sense) as this helps the compiler better optimize the code. -- Use `this` to qualify member and field access, as it avoids some ambiguity in certain contexts. +- Use `this` to qualify method and field access, as it avoids some ambiguity in certain contexts. We also provide these code styles as [EditorConfig](https://editorconfig.org/) files, which most Java IDEs will automatically detect and make use of. diff --git a/build.gradle.kts b/build.gradle.kts index ceeb84e477..1540c03131 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { minecraft(group = "com.mojang", name = "minecraft", version = Constants.MINECRAFT_VERSION) mappings(loom.officialMojangMappings()) modImplementation(group = "net.fabricmc", name = "fabric-loader", version = Constants.FABRIC_LOADER_VERSION) + include(implementation(group = "com.lodborg", name = "interval-tree", version = "1.0.0")) fun addEmbeddedFabricModule(name: String) { val module = fabricApi.module(name, Constants.FABRIC_API_VERSION) diff --git a/src/api/java/net/caffeinemc/mods/sodium/api/util/NormI8.java b/src/api/java/net/caffeinemc/mods/sodium/api/util/NormI8.java index e0f0ed7639..7dc0b68c34 100644 --- a/src/api/java/net/caffeinemc/mods/sodium/api/util/NormI8.java +++ b/src/api/java/net/caffeinemc/mods/sodium/api/util/NormI8.java @@ -1,7 +1,7 @@ package net.caffeinemc.mods.sodium.api.util; import net.minecraft.util.Mth; -import org.joml.Vector3f; +import org.joml.Vector3fc; /** * Provides some utilities for working with packed normal vectors. Each normal component provides 8 bits of @@ -27,7 +27,7 @@ public class NormI8 { */ private static final float NORM = 1.0f / COMPONENT_RANGE; - public static int pack(Vector3f normal) { + public static int pack(Vector3fc normal) { return pack(normal.x(), normal.y(), normal.z()); } @@ -78,4 +78,40 @@ public static float unpackY(int norm) { public static float unpackZ(int norm) { return ((byte) ((norm >> Z_COMPONENT_OFFSET) & 0xFF)) * NORM; } + + /** + * Flips the direction of a packed normal by negating each component. (multiplication by -1) + * @param norm The packed normal + */ + public static int flipPacked(int norm) { + int normX = (((norm >> X_COMPONENT_OFFSET) & 0xFF) * -1) & 0xFF; + int normY = (((norm >> Y_COMPONENT_OFFSET) & 0xFF) * -1) & 0xFF; + int normZ = (((norm >> Z_COMPONENT_OFFSET) & 0xFF) * -1) & 0xFF; + + return (normZ << Z_COMPONENT_OFFSET) | (normY << Y_COMPONENT_OFFSET) | (normX << X_COMPONENT_OFFSET); + } + + /** + * Returns true if the two packed normals are opposite directions. + * + * TODO: this could possibly be faster by using normA == (~normB + 0x010101) but + * that has to special case when a component is zero since that wouldn't + * overflow correctly back to zero. (~0+1 == 0 but not if it's somewhere inside + * th int) + * + * @param normA The first packed normal + * @param normB The second packed normal + */ + public static boolean isOpposite(int normA, int normB) { + // use byte to automatically sign extend the components + byte normAX = (byte) (normA >> X_COMPONENT_OFFSET); + byte normAY = (byte) (normA >> Y_COMPONENT_OFFSET); + byte normAZ = (byte) (normA >> Z_COMPONENT_OFFSET); + + byte normBX = (byte) (normB >> X_COMPONENT_OFFSET); + byte normBY = (byte) (normB >> Y_COMPONENT_OFFSET); + byte normBZ = (byte) (normB >> Z_COMPONENT_OFFSET); + + return normAX == -normBX && normAY == -normBY && normAZ == -normBZ; + } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumGameOptionPages.java b/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumGameOptionPages.java index 1ecb78883c..d86625ea2f 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumGameOptionPages.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumGameOptionPages.java @@ -312,6 +312,17 @@ public static OptionPage performance() { .build()) .build()); + groups.add(OptionGroup.createBuilder() + .add(OptionImpl.createBuilder(boolean.class, sodiumOpts) + .setName(Component.translatable("sodium.options.sort_behavior.name")) + .setTooltip(Component.translatable("sodium.options.sort_behavior.tooltip")) + .setControl(TickBoxControl::new) + .setBinding((opts, value) -> opts.performance.sortingEnabled = value, opts -> opts.performance.sortingEnabled) + .setImpact(OptionImpact.LOW) + .setFlags(OptionFlag.REQUIRES_RENDERER_RELOAD) + .build()) + .build()); + return new OptionPage(Component.translatable("sodium.options.pages.performance"), ImmutableList.copyOf(groups)); } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumGameOptions.java b/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumGameOptions.java index 2d9010c8c9..1c80791be2 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumGameOptions.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumGameOptions.java @@ -6,6 +6,7 @@ import com.google.gson.annotations.SerializedName; import net.caffeinemc.mods.sodium.client.gui.options.TextProvider; import net.caffeinemc.mods.sodium.client.util.FileUtil; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortBehavior; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.client.GraphicsStatus; import net.minecraft.network.chat.Component; @@ -44,6 +45,12 @@ public static class PerformanceSettings { public boolean useFogOcclusion = true; public boolean useBlockFaceCulling = true; public boolean useNoErrorGLContext = true; + + public boolean sortingEnabled = true; + + public SortBehavior getSortBehavior() { + return this.sortingEnabled ? SortBehavior.DYNAMIC_DEFER_NEARBY_ONE_FRAME : SortBehavior.OFF; + } } public static class AdvancedSettings { diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/BakedQuadView.java b/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/BakedQuadView.java index 98f14ab45b..b95ee43fa2 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/BakedQuadView.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/BakedQuadView.java @@ -4,6 +4,8 @@ public interface BakedQuadView extends ModelQuadView { ModelQuadFacing getNormalFace(); - + + int getNormal(); + boolean hasShade(); } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/ModelQuadView.java b/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/ModelQuadView.java index 4c77b0398f..fb0a0ee88b 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/ModelQuadView.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/ModelQuadView.java @@ -1,6 +1,7 @@ package net.caffeinemc.mods.sodium.client.model.quad; import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFlags; +import net.caffeinemc.mods.sodium.api.util.NormI8; import net.minecraft.client.renderer.texture.TextureAtlasSprite; import net.minecraft.core.Direction; @@ -61,4 +62,43 @@ public interface ModelQuadView { default boolean hasColor() { return this.getColorIndex() != -1; } + + default int calculateNormal() { + final float x0 = getX(0); + final float y0 = getY(0); + final float z0 = getZ(0); + + final float x1 = getX(1); + final float y1 = getY(1); + final float z1 = getZ(1); + + final float x2 = getX(2); + final float y2 = getY(2); + final float z2 = getZ(2); + + final float x3 = getX(3); + final float y3 = getY(3); + final float z3 = getZ(3); + + final float dx0 = x2 - x0; + final float dy0 = y2 - y0; + final float dz0 = z2 - z0; + final float dx1 = x3 - x1; + final float dy1 = y3 - y1; + final float dz1 = z3 - z1; + + float normX = dy0 * dz1 - dz0 * dy1; + float normY = dz0 * dx1 - dx0 * dz1; + float normZ = dx0 * dy1 - dy0 * dx1; + + // normalize by length for the packed normal + float length = (float) Math.sqrt(normX * normX + normY * normY + normZ * normZ); + if (length != 0.0 && length != 1.0) { + normX /= length; + normY /= length; + normZ /= length; + } + + return NormI8.pack(normX, normY, normZ); + } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/properties/ModelQuadFacing.java b/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/properties/ModelQuadFacing.java index 7506cb65ef..655727aae5 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/properties/ModelQuadFacing.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/model/quad/properties/ModelQuadFacing.java @@ -1,6 +1,14 @@ package net.caffeinemc.mods.sodium.client.model.quad.properties; +import net.caffeinemc.mods.sodium.client.util.DirectionUtil; +import net.caffeinemc.mods.sodium.api.util.NormI8; import net.minecraft.core.Direction; +import net.minecraft.util.Mth; +import org.joml.Math; +import org.joml.Vector3f; +import org.joml.Vector3fc; + +import java.util.Arrays; public enum ModelQuadFacing { POS_X, @@ -14,10 +22,28 @@ public enum ModelQuadFacing { public static final ModelQuadFacing[] VALUES = ModelQuadFacing.values(); public static final int COUNT = VALUES.length; + public static final int DIRECTIONS = VALUES.length - 1; public static final int NONE = 0; public static final int ALL = (1 << COUNT) - 1; + public static final Vector3fc[] ALIGNED_NORMALS = new Vector3fc[] { + new Vector3f(1, 0, 0), + new Vector3f(0, 1, 0), + new Vector3f(0, 0, 1), + new Vector3f(-1, 0, 0), + new Vector3f(0, -1, 0), + new Vector3f(0, 0, -1), + }; + + public static final int[] PACKED_ALIGNED_NORMALS = Arrays.stream(ALIGNED_NORMALS) + .mapToInt(NormI8::pack) + .toArray(); + + public static final int OPPOSING_X = 1 << ModelQuadFacing.POS_X.ordinal() | 1 << ModelQuadFacing.NEG_X.ordinal(); + public static final int OPPOSING_Y = 1 << ModelQuadFacing.POS_Y.ordinal() | 1 << ModelQuadFacing.NEG_Y.ordinal(); + public static final int OPPOSING_Z = 1 << ModelQuadFacing.POS_Z.ordinal() | 1 << ModelQuadFacing.NEG_Z.ordinal(); + public static ModelQuadFacing fromDirection(Direction dir) { return switch (dir) { case DOWN -> NEG_Y; @@ -40,4 +66,58 @@ public ModelQuadFacing getOpposite() { default -> UNASSIGNED; }; } + + public int getSign() { + return switch (this) { + case POS_Y, POS_X, POS_Z -> 1; + case NEG_Y, NEG_X, NEG_Z -> -1; + default -> 0; + }; + } + + public int getAxis() { + return switch (this) { + case POS_X, NEG_X -> 0; + case POS_Y, NEG_Y -> 1; + case POS_Z, NEG_Z -> 2; + default -> -1; + }; + } + + public boolean isAligned() { + return this != UNASSIGNED; + } + + public Vector3fc getAlignedNormal() { + if (!this.isAligned()) { + throw new IllegalStateException("Cannot get aligned normal for unassigned facing"); + } + return ALIGNED_NORMALS[this.ordinal()]; + } + + public int getPackedAlignedNormal() { + if (!this.isAligned()) { + throw new IllegalStateException("Cannot get packed aligned normal for unassigned facing"); + } + return PACKED_ALIGNED_NORMALS[this.ordinal()]; + } + + public static ModelQuadFacing fromNormal(float x, float y, float z) { + if (!(Math.isFinite(x) && Math.isFinite(y) && Math.isFinite(z))) { + return ModelQuadFacing.UNASSIGNED; + } + + for (Direction face : DirectionUtil.ALL_DIRECTIONS) { + var step = face.step(); + if (Mth.equal(Math.fma(x, step.x(), Math.fma(y, step.y(), z * step.z())), 1.0f)) { + return ModelQuadFacing.fromDirection(face); + } + } + + return ModelQuadFacing.UNASSIGNED; + } + + public static ModelQuadFacing fromPackedNormal(int normal) { + return fromNormal(NormI8.unpackX(normal), NormI8.unpackY(normal), NormI8.unpackZ(normal)); + } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/SodiumWorldRenderer.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/SodiumWorldRenderer.java index c70402ac17..9597a3ccaf 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/SodiumWorldRenderer.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/SodiumWorldRenderer.java @@ -16,6 +16,7 @@ import net.caffeinemc.mods.sodium.client.render.chunk.map.ChunkTracker; import net.caffeinemc.mods.sodium.client.render.chunk.map.ChunkTrackerHolder; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.DefaultTerrainRenderPasses; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.CameraMovement; import net.caffeinemc.mods.sodium.client.render.viewport.Viewport; import net.caffeinemc.mods.sodium.client.util.NativeBuffer; import net.caffeinemc.mods.sodium.client.world.LevelRendererExtension; @@ -42,6 +43,8 @@ import java.util.Iterator; import java.util.SortedSet; +import org.joml.Vector3d; + /** * Provides an extension to vanilla's {@link LevelRenderer}. */ @@ -51,7 +54,7 @@ public class SodiumWorldRenderer { private ClientLevel level; private int renderDistance; - private double lastCameraX, lastCameraY, lastCameraZ; + private Vector3d lastCameraPos; private double lastCameraPitch, lastCameraYaw; private float lastFogDistance; @@ -174,32 +177,35 @@ public void setupTerrain(Camera camera, throw new IllegalStateException("Client instance has no active player entity"); } - Vec3 pos = camera.getPosition(); + Vec3 posRaw = camera.getPosition(); + Vector3d pos = new Vector3d(posRaw.x(), posRaw.y(), posRaw.z()); float pitch = camera.getXRot(); float yaw = camera.getYRot(); float fogDistance = RenderSystem.getShaderFogEnd(); - boolean dirty = pos.x != this.lastCameraX || pos.y != this.lastCameraY || pos.z != this.lastCameraZ || - pitch != this.lastCameraPitch || yaw != this.lastCameraYaw || fogDistance != this.lastFogDistance; - - if (dirty) { - this.renderSectionManager.markGraphDirty(); + if (this.lastCameraPos == null) { + this.lastCameraPos = new Vector3d(pos); } + boolean cameraLocationChanged = !pos.equals(this.lastCameraPos); + boolean cameraAngleChanged = pitch != this.lastCameraPitch || yaw != this.lastCameraYaw || fogDistance != this.lastFogDistance; - this.lastCameraX = pos.x; - this.lastCameraY = pos.y; - this.lastCameraZ = pos.z; this.lastCameraPitch = pitch; this.lastCameraYaw = yaw; - this.lastFogDistance = fogDistance; - profiler.popPush("chunk_update"); + if (cameraLocationChanged || cameraAngleChanged) { + this.renderSectionManager.markGraphDirty(); + } - this.renderSectionManager.updateChunks(updateChunksImmediately); + this.lastFogDistance = fogDistance; - profiler.popPush("chunk_upload"); + this.renderSectionManager.updateCameraState(pos, camera); - this.renderSectionManager.uploadChunks(); + if (cameraLocationChanged) { + profiler.popPush("translucent_triggering"); + + this.renderSectionManager.processGFNIMovement(new CameraMovement(this.lastCameraPos, pos)); + this.lastCameraPos = new Vector3d(pos); + } if (this.renderSectionManager.needsUpdate()) { profiler.popPush("chunk_render_lists"); @@ -207,11 +213,14 @@ public void setupTerrain(Camera camera, this.renderSectionManager.update(camera, viewport, frame, spectator); } - if (updateChunksImmediately) { - profiler.popPush("chunk_upload_immediately"); + profiler.popPush("chunk_update"); - this.renderSectionManager.uploadChunks(); - } + this.renderSectionManager.cleanupAndFlip(); + this.renderSectionManager.updateChunks(updateChunksImmediately); + + profiler.popPush("chunk_upload"); + + this.renderSectionManager.uploadChunks(); profiler.popPush("chunk_render_tick"); diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/ChunkUpdateType.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/ChunkUpdateType.java index 4e90fcb5cc..2cd7859716 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/ChunkUpdateType.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/ChunkUpdateType.java @@ -1,18 +1,32 @@ package net.caffeinemc.mods.sodium.client.render.chunk; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkBuilder; + public enum ChunkUpdateType { - INITIAL_BUILD(128), - REBUILD(Integer.MAX_VALUE), - IMPORTANT_REBUILD(Integer.MAX_VALUE); + SORT(Integer.MAX_VALUE, ChunkBuilder.LOW_EFFORT), + INITIAL_BUILD(128, ChunkBuilder.HIGH_EFFORT), + REBUILD(Integer.MAX_VALUE, ChunkBuilder.HIGH_EFFORT), + IMPORTANT_REBUILD(Integer.MAX_VALUE, ChunkBuilder.HIGH_EFFORT), + IMPORTANT_SORT(Integer.MAX_VALUE, ChunkBuilder.LOW_EFFORT); private final int maximumQueueSize; + private final int taskEffort; - ChunkUpdateType(int maximumQueueSize) { + ChunkUpdateType(int maximumQueueSize, int taskEffort) { this.maximumQueueSize = maximumQueueSize; + this.taskEffort = taskEffort; } - public static boolean canPromote(ChunkUpdateType prev, ChunkUpdateType next) { - return prev == null || (prev == REBUILD && next == IMPORTANT_REBUILD); + public static ChunkUpdateType getPromotionUpdateType(ChunkUpdateType prev, ChunkUpdateType next) { + if (prev == null || prev == SORT || prev == next) { + return next; + } + if (next == IMPORTANT_REBUILD + || (prev == IMPORTANT_SORT && next == REBUILD) + || (prev == REBUILD && next == IMPORTANT_SORT)) { + return IMPORTANT_REBUILD; + } + return null; } public int getMaximumQueueSize() { @@ -20,6 +34,10 @@ public int getMaximumQueueSize() { } public boolean isImportant() { - return this == IMPORTANT_REBUILD; + return this == IMPORTANT_REBUILD || this == IMPORTANT_SORT; + } + + public int getTaskEffort() { + return this.taskEffort; } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/DefaultChunkRenderer.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/DefaultChunkRenderer.java index 22ef480d41..8a83eb6ba7 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/DefaultChunkRenderer.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/DefaultChunkRenderer.java @@ -18,7 +18,9 @@ import net.caffeinemc.mods.sodium.client.render.chunk.region.RenderRegion; import net.caffeinemc.mods.sodium.client.render.chunk.shader.ChunkShaderBindingPoints; import net.caffeinemc.mods.sodium.client.render.chunk.shader.ChunkShaderInterface; +import net.caffeinemc.mods.sodium.client.render.chunk.terrain.DefaultTerrainRenderPasses; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.TerrainRenderPass; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortBehavior; import net.caffeinemc.mods.sodium.client.render.chunk.vertex.format.ChunkMeshAttribute; import net.caffeinemc.mods.sodium.client.render.chunk.vertex.format.ChunkVertexType; import net.caffeinemc.mods.sodium.client.render.viewport.CameraTransform; @@ -38,6 +40,11 @@ public DefaultChunkRenderer(RenderDevice device, ChunkVertexType vertexType) { this.sharedIndexBuffer = new SharedQuadIndexBuffer(device.createCommandList(), SharedQuadIndexBuffer.IndexType.INTEGER); } + /** + * Renders the terrain for a particular render pass. Each region is rendered + * with one draw call. The command buffer for each draw command is filled by + * iterating the sections and adding the draw commands for each section. + */ @Override public void render(ChunkRenderMatrices matrices, CommandList commandList, @@ -46,7 +53,8 @@ public void render(ChunkRenderMatrices matrices, CameraTransform camera) { super.begin(renderPass); - boolean useBlockFaceCulling = SodiumClientMod.options().performance.useBlockFaceCulling; + final boolean useBlockFaceCulling = SodiumClientMod.options().performance.useBlockFaceCulling; + final boolean useIndexedTessellation = isTranslucentRenderPass(renderPass); ChunkShaderInterface shader = this.activeProgram.getInterface(); shader.setProjectionMatrix(matrices.projection()); @@ -70,9 +78,19 @@ public void render(ChunkRenderMatrices matrices, continue; } - this.sharedIndexBuffer.ensureCapacity(commandList, this.batch.getIndexBufferSize()); + // When the shared index buffer is being used, we must ensure the storage has been allocated *before* + // the tessellation is prepared. + if (!useIndexedTessellation) { + this.sharedIndexBuffer.ensureCapacity(commandList, this.batch.getIndexBufferSize()); + } + + GlTessellation tessellation; - var tessellation = this.prepareTessellation(commandList, region); + if (useIndexedTessellation) { + tessellation = this.prepareIndexedTessellation(commandList, region); + } else { + tessellation = this.prepareTessellation(commandList, region); + } setModelMatrixUniforms(shader, region, camera); executeDrawBatch(commandList, tessellation, this.batch); @@ -81,6 +99,11 @@ public void render(ChunkRenderMatrices matrices, super.end(renderPass); } + private static boolean isTranslucentRenderPass(TerrainRenderPass renderPass) { + return renderPass == DefaultTerrainRenderPasses.TRANSLUCENT + && SodiumClientMod.options().performance.getSortBehavior() != SortBehavior.OFF; + } + private static void fillCommandBuffer(MultiDrawBatch batch, RenderRegion renderRegion, SectionRenderDataStorage renderDataStorage, @@ -96,6 +119,7 @@ private static void fillCommandBuffer(MultiDrawBatch batch, return; } + // The origin of the chunk in world space int originX = renderRegion.getChunkX(); int originY = renderRegion.getChunkY(); int originZ = renderRegion.getChunkZ(); @@ -103,12 +127,13 @@ private static void fillCommandBuffer(MultiDrawBatch batch, while (iterator.hasNext()) { int sectionIndex = iterator.nextByteAsInt(); + var pMeshData = renderDataStorage.getDataPointer(sectionIndex); + int chunkX = originX + LocalSectionIndex.unpackX(sectionIndex); int chunkY = originY + LocalSectionIndex.unpackY(sectionIndex); int chunkZ = originZ + LocalSectionIndex.unpackZ(sectionIndex); - var pMeshData = renderDataStorage.getDataPointer(sectionIndex); - + // The bit field of "visible" geometry sets which should be rendered int slices; if (useBlockFaceCulling) { @@ -117,16 +142,43 @@ private static void fillCommandBuffer(MultiDrawBatch batch, slices = ModelQuadFacing.ALL; } + // Mask off any geometry sets which are empty (contain no geometry) slices &= SectionRenderDataUnsafe.getSliceMask(pMeshData); - if (slices != 0) { - addDrawCommands(batch, pMeshData, slices); + // If there are no geometry sets to render, don't try to build a draw command buffer for this section + if (slices == 0) { + continue; } + + addDrawCommands(batch, pMeshData, slices); } } - @SuppressWarnings("IntegerMultiplicationImplicitCastToLong") + /** + * Add the draw command into the multi draw batch of the current region for one + * section. The section's mesh data is given as a pointer into the render data + * storage's allocated memory. It goes through each direction and writes the + * offsets and lengths of the already uploaded vertex and index data. The multi + * draw batch provides pointers to arrays where each of the section's data is + * stored. The batch's size counts how many commands it contains. + */ private static void addDrawCommands(MultiDrawBatch batch, long pMeshData, int mask) { + int elementOffset = SectionRenderDataUnsafe.getBaseElement(pMeshData); + + // If high bit is set, the indices should be sourced from the arena's index buffer + if ((elementOffset & SectionRenderDataUnsafe.BASE_ELEMENT_MSB) != 0) { + addIndexedDrawCommands(batch, pMeshData, mask); + } else { + addNonIndexedDrawCommands(batch, pMeshData, mask); + } + } + + /** + * Generates the draw commands for a chunk's meshes using the shared index buffer. + */ + @SuppressWarnings("IntegerMultiplicationImplicitCastToLong") + private static void addNonIndexedDrawCommands(MultiDrawBatch batch, long pMeshData, int mask) { + final var pElementPointer = batch.pElementPointer; final var pBaseVertex = batch.pBaseVertex; final var pElementCount = batch.pElementCount; @@ -135,7 +187,41 @@ private static void addDrawCommands(MultiDrawBatch batch, long pMeshData, int ma for (int facing = 0; facing < ModelQuadFacing.COUNT; facing++) { MemoryUtil.memPutInt(pBaseVertex + (size << 2), SectionRenderDataUnsafe.getVertexOffset(pMeshData, facing)); MemoryUtil.memPutInt(pElementCount + (size << 2), SectionRenderDataUnsafe.getElementCount(pMeshData, facing)); + MemoryUtil.memPutAddress(pElementPointer + (size << 3), 0 /* using a shared index buffer */); + + size += (mask >> facing) & 1; + } + + batch.size = size; + } + + /** + * Generates the draw commands for a chunk's meshes, where each mesh has a separate index buffer. This is used + * when rendering translucent geometry, as each geometry set needs a sorted index buffer. + */ + @SuppressWarnings("IntegerMultiplicationImplicitCastToLong") + private static void addIndexedDrawCommands(MultiDrawBatch batch, long pMeshData, int mask) { + final var pElementPointer = batch.pElementPointer; + final var pBaseVertex = batch.pBaseVertex; + final var pElementCount = batch.pElementCount; + + int size = batch.size; + + int elementOffset = SectionRenderDataUnsafe.getBaseElement(pMeshData) + & ~SectionRenderDataUnsafe.BASE_ELEMENT_MSB; + + for (int facing = 0; facing < ModelQuadFacing.COUNT; facing++) { + final var elementCount = SectionRenderDataUnsafe.getElementCount(pMeshData, facing); + MemoryUtil.memPutInt(pBaseVertex + (size << 2), SectionRenderDataUnsafe.getVertexOffset(pMeshData, facing)); + MemoryUtil.memPutInt(pElementCount + (size << 2), elementCount); + + // * 4 to convert to bytes (the buffer contains 32-bit integers) + // the section render data storage for the indices stores the offset in indices (also called elements) + MemoryUtil.memPutAddress(pElementPointer + (size << 3), elementOffset << 2); + + // adding the number of elements works because the index data has one index per element (which are the indices) + elementOffset += elementCount; size += (mask >> facing) & 1; } @@ -207,18 +293,31 @@ private static float getCameraTranslation(int chunkBlockPos, int cameraBlockPos, private GlTessellation prepareTessellation(CommandList commandList, RenderRegion region) { var resources = region.getResources(); - var tessellation = resources.getTessellation(); + GlTessellation tessellation = resources.getTessellation(); + if (tessellation == null) { + tessellation = this.createRegionTessellation(commandList, resources, true); + resources.updateTessellation(commandList, tessellation); + } + + return tessellation; + } + + private GlTessellation prepareIndexedTessellation(CommandList commandList, RenderRegion region) { + var resources = region.getResources(); + + GlTessellation tessellation = resources.getIndexedTessellation(); if (tessellation == null) { - resources.updateTessellation(commandList, tessellation = this.createRegionTessellation(commandList, resources)); + tessellation = this.createRegionTessellation(commandList, resources, false); + resources.updateIndexedTessellation(commandList, tessellation); } return tessellation; } - private GlTessellation createRegionTessellation(CommandList commandList, RenderRegion.DeviceResources resources) { + private GlTessellation createRegionTessellation(CommandList commandList, RenderRegion.DeviceResources resources, boolean useSharedIndexBuffer) { return commandList.createTessellation(GlPrimitiveType.TRIANGLES, new TessellationBinding[] { - TessellationBinding.forVertexBuffer(resources.getVertexBuffer(), new GlVertexAttributeBinding[] { + TessellationBinding.forVertexBuffer(resources.getGeometryBuffer(), new GlVertexAttributeBinding[] { new GlVertexAttributeBinding(ChunkShaderBindingPoints.ATTRIBUTE_POSITION_ID, this.vertexFormat.getAttribute(ChunkMeshAttribute.POSITION_MATERIAL_MESH)), new GlVertexAttributeBinding(ChunkShaderBindingPoints.ATTRIBUTE_COLOR, @@ -228,7 +327,9 @@ private GlTessellation createRegionTessellation(CommandList commandList, RenderR new GlVertexAttributeBinding(ChunkShaderBindingPoints.ATTRIBUTE_LIGHT_TEXTURE, this.vertexFormat.getAttribute(ChunkMeshAttribute.LIGHT_TEXTURE)) }), - TessellationBinding.forElementBuffer(this.sharedIndexBuffer.getBufferObject()) + TessellationBinding.forElementBuffer(useSharedIndexBuffer + ? this.sharedIndexBuffer.getBufferObject() + : resources.getIndexBuffer()) }); } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/RenderSection.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/RenderSection.java index d14444d182..0330576445 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/RenderSection.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/RenderSection.java @@ -5,6 +5,7 @@ import net.caffeinemc.mods.sodium.client.render.chunk.occlusion.GraphDirectionSet; import net.caffeinemc.mods.sodium.client.render.chunk.occlusion.VisibilityEncoding; import net.caffeinemc.mods.sodium.client.render.chunk.region.RenderRegion; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TranslucentData; import net.caffeinemc.mods.sodium.client.util.task.CancellationToken; import net.minecraft.client.renderer.texture.TextureAtlasSprite; import net.minecraft.core.BlockPos; @@ -47,16 +48,17 @@ public class RenderSection { private BlockEntity @Nullable[] globalBlockEntities; private BlockEntity @Nullable[] culledBlockEntities; private TextureAtlasSprite @Nullable[] animatedSprites; - + @Nullable + private TranslucentData translucentData; // Pending Update State @Nullable - private CancellationToken buildCancellationToken = null; + private CancellationToken taskCancellationToken = null; @Nullable private ChunkUpdateType pendingUpdateType; - private int lastBuiltFrame = -1; + private int lastUploadFrame = -1; private int lastSubmittedFrame = -1; // Lifetime state @@ -110,15 +112,32 @@ public int getAdjacentMask() { return this.adjacentMask; } + public TranslucentData getTranslucentData() { + return this.translucentData; + } + + public void setTranslucentData(TranslucentData translucentData) { + if (translucentData == null) { + throw new IllegalArgumentException("new translucentData cannot be null"); + } + if (this.translucentData != null && this.translucentData != translucentData) { + this.translucentData.delete(); + } + this.translucentData = translucentData; + } + /** * Deletes all data attached to this render and drops any pending tasks. This should be used when the render falls * out of view or otherwise needs to be destroyed. After the render has been destroyed, the object can no longer * be used. */ public void delete() { - if (this.buildCancellationToken != null) { - this.buildCancellationToken.setCancelled(); - this.buildCancellationToken = null; + if (this.taskCancellationToken != null) { + this.taskCancellationToken.setCancelled(); + this.taskCancellationToken = null; + } + if (this.translucentData != null) { + this.translucentData.delete(); } this.clearRenderState(); @@ -311,12 +330,12 @@ public long getVisibilityData() { return this.globalBlockEntities; } - public @Nullable CancellationToken getBuildCancellationToken() { - return this.buildCancellationToken; + public @Nullable CancellationToken getTaskCancellationToken() { + return this.taskCancellationToken; } - public void setBuildCancellationToken(@Nullable CancellationToken token) { - this.buildCancellationToken = token; + public void setTaskCancellationToken(@Nullable CancellationToken token) { + this.taskCancellationToken = token; } public @Nullable ChunkUpdateType getPendingUpdate() { @@ -327,12 +346,18 @@ public void setPendingUpdate(@Nullable ChunkUpdateType type) { this.pendingUpdateType = type; } - public int getLastBuiltFrame() { - return this.lastBuiltFrame; + public void prepareTrigger(boolean isDirectTrigger) { + if (this.translucentData != null) { + this.translucentData.prepareTrigger(isDirectTrigger); + } + } + + public int getLastUploadFrame() { + return this.lastUploadFrame; } - public void setLastBuiltFrame(int lastBuiltFrame) { - this.lastBuiltFrame = lastBuiltFrame; + public void setLastUploadFrame(int lastSortFrame) { + this.lastUploadFrame = lastSortFrame; } public int getLastSubmittedFrame() { diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/RenderSectionManager.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/RenderSectionManager.java index 4b9ef8ccbb..8833c97d52 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/RenderSectionManager.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/RenderSectionManager.java @@ -11,11 +11,15 @@ import net.caffeinemc.mods.sodium.client.SodiumClientMod; import net.caffeinemc.mods.sodium.client.gl.device.CommandList; import net.caffeinemc.mods.sodium.client.gl.device.RenderDevice; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.BuilderTaskOutput; import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildOutput; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkSortOutput; import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkBuilder; import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkJobResult; import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkJobCollector; import net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks.ChunkBuilderMeshingTask; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks.ChunkBuilderSortingTask; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks.ChunkBuilderTask; import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionInfo; import net.caffeinemc.mods.sodium.client.render.chunk.lists.ChunkRenderList; import net.caffeinemc.mods.sodium.client.render.chunk.lists.SortedRenderLists; @@ -25,6 +29,13 @@ import net.caffeinemc.mods.sodium.client.render.chunk.region.RenderRegion; import net.caffeinemc.mods.sodium.client.render.chunk.region.RenderRegionManager; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.TerrainRenderPass; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortBehavior.DeferMode; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortBehavior.PriorityMode; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.NoData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TopoSortDynamicData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TranslucentData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.CameraMovement; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.SortTriggering; import net.caffeinemc.mods.sodium.client.render.chunk.vertex.format.ChunkMeshFormats; import net.caffeinemc.mods.sodium.client.render.texture.SpriteUtil; import net.caffeinemc.mods.sodium.client.render.util.RenderAsserts; @@ -46,6 +57,7 @@ import org.apache.commons.lang3.ArrayUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.joml.Vector3dc; import java.util.*; import java.util.concurrent.ConcurrentLinkedDeque; @@ -58,7 +70,7 @@ public class RenderSectionManager { private final Long2ReferenceMap sectionByPosition = new Long2ReferenceOpenHashMap<>(); - private final ConcurrentLinkedDeque> buildResults = new ConcurrentLinkedDeque<>(); + private final ConcurrentLinkedDeque> buildResults = new ConcurrentLinkedDeque<>(); private final ChunkRenderer chunkRenderer; @@ -70,17 +82,22 @@ public class RenderSectionManager { private final int renderDistance; + private final SortTriggering sortTriggering; + + private ChunkJobCollector lastBlockingCollector; + @NotNull private SortedRenderLists renderLists; @NotNull - private Map> rebuildLists; + private Map> taskLists; private int lastUpdatedFrame; - private boolean needsUpdate; + private boolean needsGraphUpdate; - private @Nullable BlockPos lastCameraPosition; + private @Nullable BlockPos cameraBlockPos; + private @Nullable Vector3dc cameraPosition; public RenderSectionManager(ClientLevel level, int renderDistance, CommandList commandList) { this.chunkRenderer = new DefaultChunkRenderer(RenderDevice.INSTANCE, ChunkMeshFormats.COMPACT); @@ -88,28 +105,33 @@ public RenderSectionManager(ClientLevel level, int renderDistance, CommandList c this.level = level; this.builder = new ChunkBuilder(level, ChunkMeshFormats.COMPACT); - this.needsUpdate = true; + this.needsGraphUpdate = true; this.renderDistance = renderDistance; + this.sortTriggering = new SortTriggering(); + this.regions = new RenderRegionManager(commandList); this.sectionCache = new ClonedChunkSectionCache(this.level); this.renderLists = SortedRenderLists.empty(); this.occlusionCuller = new OcclusionCuller(Long2ReferenceMaps.unmodifiable(this.sectionByPosition), this.level); - this.rebuildLists = new EnumMap<>(ChunkUpdateType.class); + this.taskLists = new EnumMap<>(ChunkUpdateType.class); for (var type : ChunkUpdateType.values()) { - this.rebuildLists.put(type, new ArrayDeque<>()); + this.taskLists.put(type, new ArrayDeque<>()); } } - public void update(Camera camera, Viewport viewport, int frame, boolean spectator) { - this.lastCameraPosition = camera.getBlockPosition(); + public void updateCameraState(Vector3dc cameraPosition, Camera camera) { + this.cameraBlockPos = camera.getBlockPosition(); + this.cameraPosition = cameraPosition; + } + public void update(Camera camera, Viewport viewport, int frame, boolean spectator) { this.createTerrainRenderList(camera, viewport, frame, spectator); - this.needsUpdate = false; + this.needsGraphUpdate = false; this.lastUpdatedFrame = frame; } @@ -124,7 +146,7 @@ private void createTerrainRenderList(Camera camera, Viewport viewport, int frame this.occlusionCuller.findVisible(visitor, viewport, searchDistance, useOcclusionCulling, frame); this.renderLists = visitor.createRenderLists(); - this.rebuildLists = visitor.getRebuildLists(); + this.taskLists = visitor.getRebuildLists(); } private float getSearchDistance() { @@ -156,7 +178,7 @@ private boolean shouldUseOcclusionCulling(Camera camera, boolean spectator) { private void resetRenderLists() { this.renderLists = SortedRenderLists.empty(); - for (var list : this.rebuildLists.values()) { + for (var list : this.taskLists.values()) { list.clear(); } } @@ -186,16 +208,21 @@ public void onSectionAdded(int x, int y, int z) { this.connectNeighborNodes(renderSection); - this.needsUpdate = true; + this.needsGraphUpdate = true; } public void onSectionRemoved(int x, int y, int z) { - RenderSection section = this.sectionByPosition.remove(SectionPos.asLong(x, y, z)); + long sectionPos = SectionPos.asLong(x, y, z); + RenderSection section = this.sectionByPosition.remove(sectionPos); if (section == null) { return; } + if (section.getTranslucentData() != null) { + this.sortTriggering.removeSection(section.getTranslucentData(), sectionPos); + } + RenderRegion region = section.getRegion(); if (region != null) { @@ -207,7 +234,7 @@ public void onSectionRemoved(int x, int y, int z) { section.delete(); - this.needsUpdate = true; + this.needsGraphUpdate = true; } public void renderLayer(ChunkRenderMatrices matrices, TerrainRenderPass pass, double x, double y, double z) { @@ -262,20 +289,6 @@ public boolean isSectionVisible(int x, int y, int z) { return render.getLastVisibleFrame() == this.lastUpdatedFrame; } - public void updateChunks(boolean updateImmediately) { - this.sectionCache.cleanup(); - this.regions.update(); - - var blockingRebuilds = new ChunkJobCollector(Integer.MAX_VALUE, this.buildResults::add); - var deferredRebuilds = new ChunkJobCollector(this.builder.getSchedulingBudget(), this.buildResults::add); - - this.submitRebuildTasks(blockingRebuilds, ChunkUpdateType.IMPORTANT_REBUILD); - this.submitRebuildTasks(updateImmediately ? blockingRebuilds : deferredRebuilds, ChunkUpdateType.REBUILD); - this.submitRebuildTasks(updateImmediately ? blockingRebuilds : deferredRebuilds, ChunkUpdateType.INITIAL_BUILD); - - blockingRebuilds.awaitCompletion(this.builder); - } - public void uploadChunks() { var results = this.collectChunkBuildResults(); @@ -283,31 +296,52 @@ public void uploadChunks() { return; } - this.processChunkBuildResults(results); + // only mark as needing a graph update if the uploads could have changed the graph + // (sort results never change the graph) + // generally there's no sort results without a camera movement, which would also trigger + // a graph update, but it can sometimes happen because of async task execution + this.needsGraphUpdate = this.needsGraphUpdate || this.processChunkBuildResults(results); for (var result : results) { - result.delete(); + result.deleteAfterUploadSafe(); } - - this.needsUpdate = true; } - private void processChunkBuildResults(ArrayList results) { + private boolean processChunkBuildResults(ArrayList results) { var filtered = filterChunkBuildResults(results); - this.regions.uploadMeshes(RenderDevice.INSTANCE.createCommandList(), filtered); + this.regions.uploadResults(RenderDevice.INSTANCE.createCommandList(), filtered); + boolean touchedSectionInfo = false; for (var result : filtered) { - this.updateSectionInfo(result.render, result.info); + TranslucentData oldData = result.render.getTranslucentData(); + if (result instanceof ChunkBuildOutput chunkBuildOutput) { + this.updateSectionInfo(result.render, chunkBuildOutput.info); + touchedSectionInfo = true; - var job = result.render.getBuildCancellationToken(); + if (chunkBuildOutput.translucentData != null) { + this.sortTriggering.integrateTranslucentData(oldData, chunkBuildOutput.translucentData, this.cameraPosition, this::scheduleSort); - if (job != null && result.buildTime >= result.render.getLastSubmittedFrame()) { - result.render.setBuildCancellationToken(null); + // a rebuild always generates new translucent data which means applyTriggerChanges isn't necessary + result.render.setTranslucentData(chunkBuildOutput.translucentData); + } + } else if (result instanceof ChunkSortOutput chunkSortOutput + && chunkSortOutput.dynamicData instanceof TopoSortDynamicData data) { + this.sortTriggering.applyTriggerChanges(data, result.render.getPosition(), this.cameraPosition); } - result.render.setLastBuiltFrame(result.buildTime); + var job = result.render.getTaskCancellationToken(); + + // clear the cancellation token (thereby marking the section as not having an + // active task) if this job is the most recent submitted job for this section + if (job != null && result.submitTime >= result.render.getLastSubmittedFrame()) { + result.render.setTaskCancellationToken(null); + } + + result.render.setLastUploadFrame(result.submitTime); } + + return touchedSectionInfo; } private void updateSectionInfo(RenderSection render, BuiltSectionInfo info) { @@ -320,28 +354,34 @@ private void updateSectionInfo(RenderSection render, BuiltSectionInfo info) { } } - private static List filterChunkBuildResults(ArrayList outputs) { - var map = new Reference2ReferenceLinkedOpenHashMap(); + private static List filterChunkBuildResults(ArrayList outputs) { + var map = new Reference2ReferenceLinkedOpenHashMap(); for (var output : outputs) { - if (output.render.isDisposed() || output.render.getLastBuiltFrame() > output.buildTime) { + // when outdated or duplicate outputs are thrown out, make sure to delete their + // buffers to avoid memory leaks + if (output.render.isDisposed() || output.render.getLastUploadFrame() > output.submitTime) { + output.deleteFully(); continue; } var render = output.render; var previous = map.get(render); - if (previous == null || previous.buildTime < output.buildTime) { + if (previous == null || previous.submitTime < output.submitTime) { map.put(render, output); + if (previous != null) { + previous.deleteFully(); + } } } return new ArrayList<>(map.values()); } - private ArrayList collectChunkBuildResults() { - ArrayList results = new ArrayList<>(); - ChunkJobResult result; + private ArrayList collectChunkBuildResults() { + ArrayList results = new ArrayList<>(); + ChunkJobResult result; while ((result = this.buildResults.poll()) != null) { results.add(result.unwrap()); @@ -350,29 +390,117 @@ private ArrayList collectChunkBuildResults() { return results; } - private void submitRebuildTasks(ChunkJobCollector collector, ChunkUpdateType type) { - var queue = this.rebuildLists.get(type); + public void cleanupAndFlip() { + this.sectionCache.cleanup(); + this.regions.update(); + } + + public void updateChunks(boolean updateImmediately) { + var thisFrameBlockingCollector = this.lastBlockingCollector; + this.lastBlockingCollector = null; + if (thisFrameBlockingCollector == null) { + thisFrameBlockingCollector = new ChunkJobCollector(this.buildResults::add); + } + + if (updateImmediately) { + // for a perfect frame where everything is finished use the last frame's blocking collector + // and add all tasks to it so that they're waited on + this.submitSectionTasks(thisFrameBlockingCollector, thisFrameBlockingCollector, thisFrameBlockingCollector); + + thisFrameBlockingCollector.awaitCompletion(this.builder); + } else { + var nextFrameBlockingCollector = new ChunkJobCollector(this.buildResults::add); + var deferredCollector = new ChunkJobCollector( + this.builder.getHighEffortSchedulingBudget(), + this.builder.getLowEffortSchedulingBudget(), + this.buildResults::add); + + // if zero frame delay is allowed, submit important sorts with the current frame blocking collector. + // otherwise submit with the collector that the next frame is blocking on. + if (SodiumClientMod.options().performance.getSortBehavior().getDeferMode() == DeferMode.ZERO_FRAMES) { + this.submitSectionTasks(thisFrameBlockingCollector, nextFrameBlockingCollector, deferredCollector); + } else { + this.submitSectionTasks(nextFrameBlockingCollector, nextFrameBlockingCollector, deferredCollector); + } - while (!queue.isEmpty() && collector.canOffer()) { + // wait on this frame's blocking collector which contains the important tasks from this frame + // and semi-important tasks from the last frame + thisFrameBlockingCollector.awaitCompletion(this.builder); + + // store the semi-important collector to wait on it in the next frame + this.lastBlockingCollector = nextFrameBlockingCollector; + } + } + + private void submitSectionTasks( + ChunkJobCollector importantCollector, + ChunkJobCollector semiImportantCollector, + ChunkJobCollector deferredCollector) { + this.submitSectionTasks(importantCollector, ChunkUpdateType.IMPORTANT_SORT, true); + this.submitSectionTasks(semiImportantCollector, ChunkUpdateType.IMPORTANT_REBUILD, true); + + // since the sort tasks are run last, the effort category can be ignored and + // simply fills up the remaining budget. Splitting effort categories is still + // important to prevent high effort tasks from using up the entire budget if it + // happens to divide evenly. + this.submitSectionTasks(deferredCollector, ChunkUpdateType.REBUILD, false); + this.submitSectionTasks(deferredCollector, ChunkUpdateType.INITIAL_BUILD, false); + this.submitSectionTasks(deferredCollector, ChunkUpdateType.SORT, true); + } + + private void submitSectionTasks(ChunkJobCollector collector, ChunkUpdateType type, boolean ignoreEffortCategory) { + var queue = this.taskLists.get(type); + + while (!queue.isEmpty() && collector.hasBudgetFor(type.getTaskEffort(), ignoreEffortCategory)) { RenderSection section = queue.remove(); if (section.isDisposed()) { continue; } + // stop if the section is in this list but doesn't have this update type + var pendingUpdate = section.getPendingUpdate(); + if (pendingUpdate != null && pendingUpdate != type) { + continue; + } + int frame = this.lastUpdatedFrame; - ChunkBuilderMeshingTask task = this.createRebuildTask(section, frame); + ChunkBuilderTask task; + if (type == ChunkUpdateType.SORT || type == ChunkUpdateType.IMPORTANT_SORT) { + task = this.createSortTask(section, frame); + + if (task == null) { + // when a sort task is null it means the render section has no dynamic data and + // doesn't need to be sorted. Nothing needs to be done. + continue; + } + } else { + task = this.createRebuildTask(section, frame); + + if (task == null) { + // if the section is empty or doesn't exist submit this null-task to set the + // built flag on the render section. + // It's important to use a NoData instead of null translucency data here in + // order for it to clear the old data from the translucency sorting system. + // This doesn't apply to sorting tasks as that would result in the section being + // marked as empty just because it was scheduled to be sorted and its dynamic + // data has since been removed. In that case simply nothing is done as the + // rebuild that must have happened in the meantime includes new non-dynamic + // index data. + var result = ChunkJobResult.successfully(new ChunkBuildOutput( + section, frame, NoData.forEmptySection(section.getPosition()), + BuiltSectionInfo.EMPTY, Collections.emptyMap())); + this.buildResults.add(result); + + section.setTaskCancellationToken(null); + } + } if (task != null) { var job = this.builder.scheduleTask(task, type.isImportant(), collector::onJobFinished); collector.addSubmittedJob(job); - section.setBuildCancellationToken(job); - } else { - var result = ChunkJobResult.successfully(new ChunkBuildOutput(section, BuiltSectionInfo.EMPTY, Collections.emptyMap(), frame)); - this.buildResults.add(result); - - section.setBuildCancellationToken(null); + section.setTaskCancellationToken(job); } section.setLastSubmittedFrame(frame); @@ -387,15 +515,23 @@ private void submitRebuildTasks(ChunkJobCollector collector, ChunkUpdateType typ return null; } - return new ChunkBuilderMeshingTask(render, context, frame); + return new ChunkBuilderMeshingTask(render, frame, this.cameraPosition, context); + } + + public ChunkBuilderSortingTask createSortTask(RenderSection render, int frame) { + return ChunkBuilderSortingTask.createTask(render, frame, this.cameraPosition); + } + + public void processGFNIMovement(CameraMovement movement) { + this.sortTriggering.triggerSections(this::scheduleSort, movement); } public void markGraphDirty() { - this.needsUpdate = true; + this.needsGraphUpdate = true; } public boolean needsUpdate() { - return this.needsUpdate; + return this.needsGraphUpdate; } public ChunkBuilder getBuilder() { @@ -406,7 +542,11 @@ public void destroy() { this.builder.shutdown(); // stop all the workers, and cancel any tasks for (var result : this.collectChunkBuildResults()) { - result.delete(); // delete resources for any pending tasks (including those that were cancelled) + result.deleteFully(); // delete resources for any pending tasks (including those that were cancelled) + } + + for (var section : this.sectionByPosition.values()) { + section.delete(); } this.sectionsWithGlobalEntities.clear(); @@ -434,6 +574,24 @@ public int getVisibleChunkCount() { return sections; } + public void scheduleSort(long sectionPos, boolean isDirectTrigger) { + RenderSection section = this.sectionByPosition.get(sectionPos); + + if (section != null) { + var pendingUpdate = ChunkUpdateType.SORT; + var priorityMode = SodiumClientMod.options().performance.getSortBehavior().getPriorityMode(); + if (priorityMode == PriorityMode.ALL + || priorityMode == PriorityMode.NEARBY && this.shouldPrioritizeTask(section)) { + pendingUpdate = ChunkUpdateType.IMPORTANT_SORT; + } + pendingUpdate = ChunkUpdateType.getPromotionUpdateType(section.getPendingUpdate(), pendingUpdate); + if (pendingUpdate != null) { + section.setPendingUpdate(pendingUpdate); + section.prepareTrigger(isDirectTrigger); + } + } + } + public void scheduleRebuild(int x, int y, int z, boolean important) { RenderAsserts.validateCurrentThread(); @@ -444,24 +602,25 @@ public void scheduleRebuild(int x, int y, int z, boolean important) { if (section != null && section.isBuilt()) { ChunkUpdateType pendingUpdate; - if (allowImportantRebuilds() && (important || this.shouldPrioritizeRebuild(section))) { + if (allowImportantRebuilds() && (important || this.shouldPrioritizeTask(section))) { pendingUpdate = ChunkUpdateType.IMPORTANT_REBUILD; } else { pendingUpdate = ChunkUpdateType.REBUILD; } - if (ChunkUpdateType.canPromote(section.getPendingUpdate(), pendingUpdate)) { + pendingUpdate = ChunkUpdateType.getPromotionUpdateType(section.getPendingUpdate(), pendingUpdate); + if (pendingUpdate != null) { section.setPendingUpdate(pendingUpdate); - this.needsUpdate = true; + this.needsGraphUpdate = true; } } } private static final float NEARBY_REBUILD_DISTANCE = Mth.square(16.0f); - private boolean shouldPrioritizeRebuild(RenderSection section) { - return this.lastCameraPosition != null && section.getSquaredDistance(this.lastCameraPosition) < NEARBY_REBUILD_DISTANCE; + private boolean shouldPrioritizeTask(RenderSection section) { + return this.cameraPosition != null && section.getSquaredDistance(this.cameraBlockPos) < NEARBY_REBUILD_DISTANCE; } private static boolean allowImportantRebuilds() { @@ -540,17 +699,19 @@ public Collection getDebugStrings() { list.add(String.format("Geometry Pool: %d/%d MiB (%d buffers)", MathUtil.toMib(deviceUsed), MathUtil.toMib(deviceAllocated), count)); list.add(String.format("Transfer Queue: %s", this.regions.getStagingBuffer().toString())); - list.add(String.format("Chunk Builder: Permits=%02d | Busy=%02d | Total=%02d", - this.builder.getScheduledJobCount(), this.builder.getBusyThreadCount(), this.builder.getTotalThreadCount()) + list.add(String.format("Chunk Builder: Permits=%02d (E %03d) | Busy=%02d | Total=%02d", + this.builder.getScheduledJobCount(), this.builder.getScheduledEffort(), this.builder.getBusyThreadCount(), this.builder.getTotalThreadCount()) ); list.add(String.format("Chunk Queues: U=%02d (P0=%03d | P1=%03d | P2=%03d)", this.buildResults.size(), - this.rebuildLists.get(ChunkUpdateType.IMPORTANT_REBUILD).size(), - this.rebuildLists.get(ChunkUpdateType.REBUILD).size(), - this.rebuildLists.get(ChunkUpdateType.INITIAL_BUILD).size()) + this.taskLists.get(ChunkUpdateType.IMPORTANT_REBUILD).size() + this.taskLists.get(ChunkUpdateType.IMPORTANT_SORT).size(), + this.taskLists.get(ChunkUpdateType.REBUILD).size() + this.taskLists.get(ChunkUpdateType.SORT).size(), + this.taskLists.get(ChunkUpdateType.INITIAL_BUILD).size()) ); + this.sortTriggering.addDebugStrings(list); + return list; } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/BuilderTaskOutput.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/BuilderTaskOutput.java new file mode 100644 index 0000000000..6eca395788 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/BuilderTaskOutput.java @@ -0,0 +1,28 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.compile; + +import net.caffeinemc.mods.sodium.client.render.chunk.RenderSection; + +public abstract class BuilderTaskOutput { + public final RenderSection render; + public final int submitTime; + private boolean fullyDeleted; + + public BuilderTaskOutput(RenderSection render, int buildTime) { + this.render = render; + this.submitTime = buildTime; + } + + public void deleteFully() { + this.fullyDeleted = true; + this.deleteAfterUpload(); + } + + public void deleteAfterUploadSafe() { + if (!this.fullyDeleted) { + this.deleteAfterUpload(); + } + } + + protected void deleteAfterUpload() { + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkBuildBuffers.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkBuildBuffers.java index 2bc1794375..f7be0bee49 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkBuildBuffers.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkBuildBuffers.java @@ -57,7 +57,7 @@ public ChunkModelBuilder get(Material material) { * have been rendered to pass the finished meshes over to the graphics card. This function can be called multiple * times to return multiple copies. */ - public BuiltSectionMeshParts createMesh(TerrainRenderPass pass) { + public BuiltSectionMeshParts createMesh(TerrainRenderPass pass, boolean forceUnassigned) { var builder = this.builders.get(pass); List vertexBuffers = new ArrayList<>(); @@ -73,11 +73,17 @@ public BuiltSectionMeshParts createMesh(TerrainRenderPass pass) { } vertexBuffers.add(buffer.slice()); - vertexRanges[facing.ordinal()] = new VertexRange(vertexCount, buffer.count()); + if (!forceUnassigned) { + vertexRanges[facing.ordinal()] = new VertexRange(vertexCount, buffer.count()); + } vertexCount += buffer.count(); } + if (forceUnassigned) { + vertexRanges[ModelQuadFacing.UNASSIGNED.ordinal()] = new VertexRange(0, vertexCount); + } + if (vertexCount == 0) { return null; } @@ -89,7 +95,7 @@ public BuiltSectionMeshParts createMesh(TerrainRenderPass pass) { mergedBufferBuilder.put(buffer); } - mergedBufferBuilder.flip(); + mergedBufferBuilder.flip(); // TODO: necessary? return new BuiltSectionMeshParts(mergedBuffer, vertexRanges); } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkBuildOutput.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkBuildOutput.java index 1a0fcef168..17f85cb392 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkBuildOutput.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkBuildOutput.java @@ -2,40 +2,64 @@ import net.caffeinemc.mods.sodium.client.render.chunk.RenderSection; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.TerrainRenderPass; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.PresentTranslucentData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TranslucentData; import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionInfo; import java.util.Map; /** - * The result of a chunk rebuild task which contains any and all data that needs to be processed or uploaded on - * the main thread. If a task is cancelled after finishing its work and not before the result is processed, the result - * will instead be discarded. + * The result of a chunk rebuild task which contains any and all data that needs + * to be processed or uploaded on the main thread. If a task is cancelled after + * finishing its work and not before the result is processed, the result will + * instead be discarded. */ -public class ChunkBuildOutput { - public final RenderSection render; - +public class ChunkBuildOutput extends BuilderTaskOutput implements OutputWithIndexData { public final BuiltSectionInfo info; + public final TranslucentData translucentData; public final Map meshes; - public final int buildTime; + public ChunkBuildOutput(RenderSection render, int buildTime, TranslucentData translucentData, BuiltSectionInfo info, + Map meshes) { + super(render, buildTime); - public ChunkBuildOutput(RenderSection render, BuiltSectionInfo info, Map meshes, int buildTime) { - this.render = render; this.info = info; + this.translucentData = translucentData; this.meshes = meshes; - - this.buildTime = buildTime; } public BuiltSectionMeshParts getMesh(TerrainRenderPass pass) { return this.meshes.get(pass); } - public void delete() { + @Override + public PresentTranslucentData getTranslucentData() { + if (this.translucentData instanceof PresentTranslucentData present) { + return present; + } + return null; + } + + @Override + public void deleteAfterUpload() { + super.deleteAfterUpload(); + + // delete translucent data if it's not persisted for dynamic sorting + if (this.translucentData != null && !this.translucentData.retainAfterUpload()) { + this.translucentData.delete(); + } + for (BuiltSectionMeshParts data : this.meshes.values()) { - data.getVertexData() - .free(); + data.getVertexData().free(); + } + } + + @Override + public void deleteFully() { + super.deleteFully(); + if (this.translucentData != null) { + this.translucentData.delete(); } } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkSortOutput.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkSortOutput.java new file mode 100644 index 0000000000..0702ab604d --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/ChunkSortOutput.java @@ -0,0 +1,25 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.compile; + +import net.caffeinemc.mods.sodium.client.render.chunk.RenderSection; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.DynamicData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.PresentTranslucentData; + +public class ChunkSortOutput extends BuilderTaskOutput implements OutputWithIndexData { + public final DynamicData dynamicData; + + public ChunkSortOutput(RenderSection render, int buildTime, DynamicData dynamicData) { + super(render, buildTime); + + this.dynamicData = dynamicData; + } + + @Override + public PresentTranslucentData getTranslucentData() { + return this.dynamicData; + } + + // doesn't implement deletion because the task doesn't allocate any new buffers. + // the buffers used belong to the section and are deleted when it is deleted. + // buffers created during section building are deleted at section deletion or + // when the rebuild is cancelled. +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/OutputWithIndexData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/OutputWithIndexData.java new file mode 100644 index 0000000000..fc96b50a30 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/OutputWithIndexData.java @@ -0,0 +1,7 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.compile; + +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.PresentTranslucentData; + +public interface OutputWithIndexData { + PresentTranslucentData getTranslucentData(); +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkBuilder.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkBuilder.java index 77a96a6831..792903a5b5 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkBuilder.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkBuilder.java @@ -1,6 +1,7 @@ package net.caffeinemc.mods.sodium.client.render.chunk.compile.executor; import net.caffeinemc.mods.sodium.client.SodiumClientMod; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.BuilderTaskOutput; import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildContext; import net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks.ChunkBuilderTask; import net.caffeinemc.mods.sodium.client.render.chunk.vertex.format.ChunkVertexType; @@ -16,6 +17,22 @@ import java.util.function.Consumer; public class ChunkBuilder { + /** + * The low and high efforts given to the sorting and meshing tasks, + * respectively. This split into two separate effort categories means more + * sorting tasks, which are faster, can be scheduled compared to mesh tasks. + * These values need to capture that there's a limit to how much data can be + * uploaded per frame. Since sort tasks generate index data, which is smaller + * per quad and (on average) per section, more of their results can be uploaded + * in one frame. This number should essentially be a conservative estimate of + * min((mesh task upload size) / (sort task upload size), (mesh task time) / + * (sort task time)). + */ + public static final int HIGH_EFFORT = 10; + public static final int LOW_EFFORT = 1; + public static final int EFFORT_PER_THREAD_PER_FRAME = HIGH_EFFORT + LOW_EFFORT; + private static final float HIGH_EFFORT_BUDGET_FACTOR = (float)HIGH_EFFORT / EFFORT_PER_THREAD_PER_FRAME; + static final Logger LOGGER = LogManager.getLogger("ChunkBuilder"); private final ChunkJobQueue queue = new ChunkJobQueue(); @@ -46,11 +63,19 @@ public ChunkBuilder(ClientLevel level, ChunkVertexType vertexType) { } /** - * Returns the remaining number of build tasks which should be scheduled this frame. If an attempt is made to + * Returns the remaining effort for tasks which should be scheduled this frame. If an attempt is made to * spawn more tasks than the budget allows, it will block until resources become available. */ - public int getSchedulingBudget() { - return Math.max(0, this.threads.size() - this.queue.size()); + private int getTotalRemainingBudget() { + return Math.max(0, this.threads.size() * EFFORT_PER_THREAD_PER_FRAME - this.queue.getEffortSum()); + } + + public int getHighEffortSchedulingBudget() { + return Math.max(HIGH_EFFORT, (int) (this.getTotalRemainingBudget() * HIGH_EFFORT_BUDGET_FACTOR)); + } + + public int getLowEffortSchedulingBudget() { + return Math.max(LOW_EFFORT, this.getTotalRemainingBudget() - this.getHighEffortSchedulingBudget()); } /** @@ -89,7 +114,7 @@ private void shutdownThreads() { this.threads.clear(); } - public , OUTPUT> ChunkJobTyped scheduleTask(TASK task, boolean important, + public , OUTPUT extends BuilderTaskOutput> ChunkJobTyped scheduleTask(TASK task, boolean important, Consumer> consumer) { Validate.notNull(task, "Task must be non-null"); @@ -144,6 +169,10 @@ public int getScheduledJobCount() { return this.queue.size(); } + public int getScheduledEffort() { + return this.queue.getEffortSum(); + } + public int getBusyThreadCount() { return this.busyThreadCount.get(); } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJob.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJob.java index 7f41393267..a0bcb33505 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJob.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJob.java @@ -7,4 +7,6 @@ public interface ChunkJob extends CancellationToken { void execute(ChunkBuildContext context); boolean isStarted(); + + int getEffort(); } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobCollector.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobCollector.java index 44885f8ab7..39d6d6ff2e 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobCollector.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobCollector.java @@ -1,6 +1,6 @@ package net.caffeinemc.mods.sodium.client.render.chunk.compile.executor; -import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildOutput; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.BuilderTaskOutput; import java.util.ArrayList; import java.util.List; @@ -9,17 +9,31 @@ public class ChunkJobCollector { private final Semaphore semaphore = new Semaphore(0); - private final Consumer> collector; + private final Consumer> collector; private final List submitted = new ArrayList<>(); + private int submittedHighEffort = 0; + private int submittedLowEffort = 0; - private final int budget; + private final int highEffortBudget; + private final int lowEffortBudget; + private final boolean unlimitedBudget; - public ChunkJobCollector(int budget, Consumer> collector) { - this.budget = budget; + public ChunkJobCollector(Consumer> collector) { + this.unlimitedBudget = true; + this.highEffortBudget = 0; + this.lowEffortBudget = 0; this.collector = collector; } - public void onJobFinished(ChunkJobResult result) { + public ChunkJobCollector(int highEffortBudget, int lowEffortBudget, + Consumer> collector) { + this.unlimitedBudget = false; + this.highEffortBudget = highEffortBudget; + this.lowEffortBudget = lowEffortBudget; + this.collector = collector; + } + + public void onJobFinished(ChunkJobResult result) { this.semaphore.release(1); this.collector.accept(result); } @@ -42,9 +56,28 @@ public void awaitCompletion(ChunkBuilder builder) { public void addSubmittedJob(ChunkJob job) { this.submitted.add(job); + + if (this.unlimitedBudget) { + return; + } + var effort = job.getEffort(); + if (effort <= ChunkBuilder.LOW_EFFORT) { + this.submittedLowEffort += effort; + } else { + this.submittedHighEffort += effort; + } } - public boolean canOffer() { - return (this.budget - this.submitted.size()) > 0; + public boolean hasBudgetFor(int effort, boolean ignoreEffortCategory) { + if (this.unlimitedBudget) { + return true; + } + if (ignoreEffortCategory) { + return this.submittedLowEffort + this.submittedHighEffort + effort + <= this.highEffortBudget + this.lowEffortBudget; + } + return effort <= ChunkBuilder.LOW_EFFORT + ? this.submittedLowEffort + effort <= this.lowEffortBudget + : this.submittedHighEffort + effort <= this.highEffortBudget; } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobQueue.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobQueue.java index 79fffb574b..0e6c2b9aa2 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobQueue.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobQueue.java @@ -7,10 +7,13 @@ import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; class ChunkJobQueue { private final ConcurrentLinkedDeque jobs = new ConcurrentLinkedDeque<>(); + private final AtomicInteger jobEffortSum = new AtomicInteger(); + private final Semaphore semaphore = new Semaphore(0); private final AtomicBoolean isRunning = new AtomicBoolean(true); @@ -27,6 +30,7 @@ public void add(ChunkJob job, boolean important) { } else { this.jobs.addLast(job); } + this.jobEffortSum.addAndGet(job.getEffort()); this.semaphore.release(1); } @@ -39,7 +43,11 @@ public ChunkJob waitForNextJob() throws InterruptedException { this.semaphore.acquire(); - return this.getNextTask(); + var job = this.getNextTask(); + if (job != null) { + this.jobEffortSum.addAndGet(-job.getEffort()); + } + return job; } public boolean stealJob(ChunkJob job) { @@ -49,7 +57,9 @@ public boolean stealJob(ChunkJob job) { var success = this.jobs.remove(job); - if (!success) { + if (success) { + this.jobEffortSum.addAndGet(-job.getEffort()); + } else { // If we didn't manage to actually steal the task, then we need to release the permit which we did steal this.semaphore.release(1); } @@ -79,6 +89,8 @@ public Collection shutdown() { // force the worker threads to wake up and exit this.semaphore.release(Runtime.getRuntime().availableProcessors()); + this.jobEffortSum.set(0); + return list; } @@ -86,6 +98,10 @@ public int size() { return this.semaphore.availablePermits(); } + public int getEffortSum() { + return this.jobEffortSum.get(); + } + public boolean isEmpty() { return this.size() == 0; } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobTyped.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobTyped.java index 7b0373bfa7..78e1242803 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobTyped.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/executor/ChunkJobTyped.java @@ -1,11 +1,12 @@ package net.caffeinemc.mods.sodium.client.render.chunk.compile.executor; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.BuilderTaskOutput; import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildContext; import net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks.ChunkBuilderTask; import java.util.function.Consumer; -public class ChunkJobTyped, OUTPUT> +public class ChunkJobTyped, OUTPUT extends BuilderTaskOutput> implements ChunkJob { private final TASK task; @@ -65,4 +66,9 @@ public void execute(ChunkBuildContext context) { public boolean isStarted() { return this.started; } + + @Override + public int getEffort() { + return this.task.getEffort(); + } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/BlockRenderContext.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/BlockRenderContext.java index 9297a25ec1..d65aaf086b 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/BlockRenderContext.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/BlockRenderContext.java @@ -1,5 +1,6 @@ package net.caffeinemc.mods.sodium.client.render.chunk.compile.pipeline; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TranslucentGeometryCollector; import net.caffeinemc.mods.sodium.client.world.LevelSlice; import net.minecraft.client.resources.model.BakedModel; import net.minecraft.core.BlockPos; @@ -9,6 +10,7 @@ public class BlockRenderContext { private final LevelSlice slice; + public final TranslucentGeometryCollector collector; private final BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); @@ -20,8 +22,9 @@ public class BlockRenderContext { private long seed; - public BlockRenderContext(LevelSlice slice) { + public BlockRenderContext(LevelSlice slice, TranslucentGeometryCollector collector) { this.slice = slice; + this.collector = collector; } public void update(BlockPos pos, BlockPos origin, BlockState state, BakedModel model, long seed) { @@ -34,6 +37,13 @@ public void update(BlockPos pos, BlockPos origin, BlockState state, BakedModel m this.seed = seed; } + /** + * @return The collector for translucent geometry sorting + */ + public TranslucentGeometryCollector collector() { + return this.collector; + } + /** * @return The position (in block space) of the block being rendered */ diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/BlockRenderer.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/BlockRenderer.java index 98b8d46999..789211ff69 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/BlockRenderer.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/BlockRenderer.java @@ -162,6 +162,10 @@ private void writeGeometry(BlockRenderContext ctx, out.light = light.lm[srcIndex]; } + if (material == DefaultMaterials.TRANSLUCENT && ctx.collector != null) { + ctx.collector.appendQuad(quad.getNormal(), vertices, normalFace); + } + var vertexBuffer = builder.getVertexBuffer(normalFace); vertexBuffer.push(vertices, material); } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/DefaultFluidRenderer.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/DefaultFluidRenderer.java index 8eb6c7b552..094727f219 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/DefaultFluidRenderer.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/DefaultFluidRenderer.java @@ -1,5 +1,11 @@ package net.caffeinemc.mods.sodium.client.render.chunk.compile.pipeline; + +import net.caffeinemc.mods.sodium.api.util.ColorABGR; +import net.caffeinemc.mods.sodium.api.util.NormI8; +import net.caffeinemc.mods.sodium.client.model.color.ColorProvider; +import net.caffeinemc.mods.sodium.client.model.color.ColorProviderRegistry; +import net.caffeinemc.mods.sodium.client.model.color.DefaultColorProviders; import net.caffeinemc.mods.sodium.client.model.light.LightMode; import net.caffeinemc.mods.sodium.client.model.light.LightPipeline; import net.caffeinemc.mods.sodium.client.model.light.LightPipelineProvider; @@ -9,15 +15,13 @@ import net.caffeinemc.mods.sodium.client.model.quad.ModelQuadViewMutable; import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFlags; -import net.caffeinemc.mods.sodium.client.model.color.ColorProviderRegistry; -import net.caffeinemc.mods.sodium.client.model.color.ColorProvider; -import net.caffeinemc.mods.sodium.client.model.color.DefaultColorProviders; import net.caffeinemc.mods.sodium.client.render.chunk.compile.buffers.ChunkModelBuilder; +import net.caffeinemc.mods.sodium.client.render.chunk.terrain.material.DefaultMaterials; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.material.Material; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TranslucentGeometryCollector; import net.caffeinemc.mods.sodium.client.render.chunk.vertex.format.ChunkVertexEncoder; import net.caffeinemc.mods.sodium.client.util.DirectionUtil; import net.caffeinemc.mods.sodium.client.world.LevelSlice; -import net.caffeinemc.mods.sodium.api.util.ColorABGR; import net.fabricmc.fabric.api.client.render.fluid.v1.FluidRenderHandler; import net.fabricmc.fabric.api.client.render.fluid.v1.FluidRenderHandlerRegistry; import net.minecraft.client.Minecraft; @@ -39,9 +43,10 @@ import org.apache.commons.lang3.mutable.MutableInt; public class DefaultFluidRenderer { - // TODO: allow this to be changed by vertex format + // TODO: allow this to be changed by vertex format, WARNING: make sure TranslucentGeometryCollector knows about EPSILON // TODO: move fluid rendering to a separate render pass and control glPolygonOffset and glDepthFunc to fix this properly - private static final float EPSILON = 0.001f; + public static final float EPSILON = 0.001f; + private static final float ALIGNED_EQUALS_EPSILON = 0.011f; private final BlockPos.MutableBlockPos scratchPos = new BlockPos.MutableBlockPos(); private final MutableFloat scratchHeight = new MutableFloat(0); @@ -98,7 +103,7 @@ private boolean isSideExposed(BlockAndTintGetter world, int x, int y, int z, Dir return true; } - public void render(LevelSlice level, FluidState fluidState, BlockPos blockPos, BlockPos offset, ChunkModelBuilder meshBuilder, Material material, FluidRenderHandler handler) { + public void render(LevelSlice level, FluidState fluidState, BlockPos blockPos, BlockPos offset, TranslucentGeometryCollector collector, ChunkModelBuilder meshBuilder, Material material, FluidRenderHandler handler) { int posX = blockPos.getX(); int posY = blockPos.getY(); int posZ = blockPos.getZ(); @@ -167,13 +172,11 @@ public void render(LevelSlice level, FluidState fluidState, BlockPos blockPos, B Vec3 velocity = fluidState.getFlow(level, blockPos); TextureAtlasSprite sprite; - ModelQuadFacing facing; float u1, u2, u3, u4; float v1, v2, v3, v4; if (velocity.x == 0.0D && velocity.z == 0.0D) { sprite = sprites[0]; - facing = ModelQuadFacing.POS_Y; u1 = sprite.getU(0.0f); v1 = sprite.getV(0.0f); u2 = u1; @@ -184,7 +187,6 @@ public void render(LevelSlice level, FluidState fluidState, BlockPos blockPos, B v4 = v1; } else { sprite = sprites[1]; - facing = ModelQuadFacing.UNASSIGNED; float dir = (float) Mth.atan2(velocity.z, velocity.x) - (1.5707964f); float sin = Mth.sin(dir) * 0.25F; float cos = Mth.cos(dir) * 0.25F; @@ -218,13 +220,18 @@ public void render(LevelSlice level, FluidState fluidState, BlockPos blockPos, B setVertex(quad, 2, 1.0F, southEastHeight, 1.0F, u3, v3); setVertex(quad, 3, 1.0F, northEastHeight, 0.0f, u4, v4); + // top surface alignedness is calculated with a more relaxed epsilon + boolean aligned = isAlignedEquals(northEastHeight, northWestHeight) + && isAlignedEquals(northWestHeight, southEastHeight) + && isAlignedEquals(southEastHeight, southWestHeight) + && isAlignedEquals(southWestHeight, northEastHeight); + this.updateQuad(quad, level, blockPos, lighter, Direction.UP, 1.0F, colorProvider, fluidState); - this.writeQuad(meshBuilder, material, offset, quad, facing, false); + this.writeQuad(meshBuilder, collector, material, offset, quad, aligned ? ModelQuadFacing.POS_Y : ModelQuadFacing.UNASSIGNED, false); if (fluidState.shouldRenderBackwardUpFace(level, this.scratchPos.set(posX, posY + 1, posZ))) { - this.writeQuad(meshBuilder, material, offset, quad, - ModelQuadFacing.NEG_Y, true); - + this.writeQuad(meshBuilder, collector, material, offset, quad, + aligned ? ModelQuadFacing.NEG_Y : ModelQuadFacing.UNASSIGNED, true); } } @@ -244,8 +251,7 @@ public void render(LevelSlice level, FluidState fluidState, BlockPos blockPos, B setVertex(quad, 3, 1.0F, yOffset, 1.0F, maxU, maxV); this.updateQuad(quad, level, blockPos, lighter, Direction.DOWN, 1.0F, colorProvider, fluidState); - this.writeQuad(meshBuilder, material, offset, quad, ModelQuadFacing.NEG_Y, false); - + this.writeQuad(meshBuilder, collector, material, offset, quad, ModelQuadFacing.NEG_Y, false); } quad.setFlags(ModelQuadFlags.IS_PARALLEL | ModelQuadFlags.IS_ALIGNED); @@ -345,16 +351,20 @@ public void render(LevelSlice level, FluidState fluidState, BlockPos blockPos, B ModelQuadFacing facing = ModelQuadFacing.fromDirection(dir); this.updateQuad(quad, level, blockPos, lighter, dir, br, colorProvider, fluidState); - this.writeQuad(meshBuilder, material, offset, quad, facing, false); + this.writeQuad(meshBuilder, collector, material, offset, quad, facing, false); if (!isOverlay) { - this.writeQuad(meshBuilder, material, offset, quad, facing.getOpposite(), true); + this.writeQuad(meshBuilder, collector, material, offset, quad, facing.getOpposite(), true); } } } } + private static boolean isAlignedEquals(float a, float b) { + return Math.abs(a - b) <= ALIGNED_EQUALS_EPSILON; + } + private ColorProvider getColorProvider(Fluid fluid, FluidRenderHandler handler) { var override = this.colorProviderRegistry.getColorProvider(fluid); @@ -390,7 +400,7 @@ private void updateQuad(ModelQuadView quad, LevelSlice level, BlockPos pos, Ligh } } - private void writeQuad(ChunkModelBuilder builder, Material material, BlockPos offset, ModelQuadView quad, + private void writeQuad(ChunkModelBuilder builder, TranslucentGeometryCollector collector, Material material, BlockPos offset, ModelQuadView quad, ModelQuadFacing facing, boolean flip) { var vertices = this.vertices; @@ -412,6 +422,19 @@ private void writeQuad(ChunkModelBuilder builder, Material material, BlockPos of builder.addSprite(sprite); } + if (material == DefaultMaterials.TRANSLUCENT && collector != null) { + int normal; + if (facing.isAligned()) { + normal = facing.getPackedAlignedNormal(); + } else { + normal = quad.calculateNormal(); + } + if (flip) { + normal = NormI8.flipPacked(normal); + } + collector.appendQuad(normal, vertices, facing); + } + var vertexBuffer = builder.getVertexBuffer(facing); vertexBuffer.push(vertices, material); } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/FluidRenderer.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/FluidRenderer.java index 17131ee586..dd9b23a956 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/FluidRenderer.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/pipeline/FluidRenderer.java @@ -6,6 +6,7 @@ import net.caffeinemc.mods.sodium.client.render.chunk.compile.buffers.ChunkModelBuilder; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.material.DefaultMaterials; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.material.Material; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TranslucentGeometryCollector; import net.caffeinemc.mods.sodium.client.world.LevelSlice; import net.fabricmc.fabric.api.client.render.fluid.v1.FluidRenderHandler; import net.fabricmc.fabric.api.client.render.fluid.v1.FluidRenderHandlerRegistry; @@ -25,7 +26,7 @@ public FluidRenderer(ColorProviderRegistry colorProviderRegistry, LightPipelineP defaultRenderer = new DefaultFluidRenderer(colorProviderRegistry, lighters); } - public void render(LevelSlice level, BlockState blockState, FluidState fluidState, BlockPos blockPos, BlockPos offset, ChunkBuildBuffers buffers) { + public void render(LevelSlice level, BlockState blockState, FluidState fluidState, BlockPos blockPos, BlockPos offset, TranslucentGeometryCollector collector, ChunkBuildBuffers buffers) { var material = DefaultMaterials.forFluidState(fluidState); var meshBuilder = buffers.get(material); @@ -59,7 +60,7 @@ public void render(LevelSlice level, BlockState blockState, FluidState fluidStat // parameters are bundled into a DefaultRenderContext which is stored in a ThreadLocal. DefaultRenderContext defaultContext = CURRENT_DEFAULT_CONTEXT.get(); - defaultContext.setUp(this.defaultRenderer, level, fluidState, blockPos, offset, meshBuilder, material, handler); + defaultContext.setUp(this.defaultRenderer, level, fluidState, blockPos, offset, collector, meshBuilder, material, handler); try { handler.renderFluid(blockPos, level, meshBuilder.asFallbackVertexConsumer(material), blockState, fluidState); @@ -78,16 +79,18 @@ private static class DefaultRenderContext { private FluidState fluidState; private BlockPos blockPos; private BlockPos offset; + private TranslucentGeometryCollector collector; private ChunkModelBuilder meshBuilder; private Material material; private FluidRenderHandler handler; - public void setUp(DefaultFluidRenderer renderer, LevelSlice level, FluidState fluidState, BlockPos blockPos, BlockPos offset, ChunkModelBuilder meshBuilder, Material material, FluidRenderHandler handler) { + public void setUp(DefaultFluidRenderer renderer, LevelSlice level, FluidState fluidState, BlockPos blockPos, BlockPos offset, TranslucentGeometryCollector collector, ChunkModelBuilder meshBuilder, Material material, FluidRenderHandler handler) { this.renderer = renderer; this.level = level; this.fluidState = fluidState; this.blockPos = blockPos; this.offset = offset; + this.collector = collector; this.meshBuilder = meshBuilder; this.material = material; this.handler = handler; @@ -99,6 +102,7 @@ public void clear() { this.fluidState = null; this.blockPos = null; this.offset = null; + this.collector = null; this.meshBuilder = null; this.material = null; this.handler = null; @@ -106,7 +110,7 @@ public void clear() { public boolean renderIfSetUp() { if (this.renderer != null) { - this.renderer.render(this.level, this.fluidState, this.blockPos, this.offset, this.meshBuilder, this.material, this.handler); + this.renderer.render(this.level, this.fluidState, this.blockPos, this.offset, this.collector, this.meshBuilder, this.material, this.handler); return true; } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderMeshingTask.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderMeshingTask.java index addafc8ed3..da4a2a0a77 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderMeshingTask.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderMeshingTask.java @@ -1,16 +1,22 @@ package net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks; import it.unimi.dsi.fastutil.objects.Reference2ReferenceOpenHashMap; +import net.caffeinemc.mods.sodium.client.SodiumClientMod; import net.caffeinemc.mods.sodium.client.render.chunk.RenderSection; import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildBuffers; import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildContext; import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildOutput; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkBuilder; import net.caffeinemc.mods.sodium.client.render.chunk.compile.pipeline.BlockRenderCache; import net.caffeinemc.mods.sodium.client.render.chunk.compile.pipeline.BlockRenderContext; import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionInfo; import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.DefaultTerrainRenderPasses; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.TerrainRenderPass; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortBehavior; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortType; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TranslucentGeometryCollector; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TranslucentData; import net.caffeinemc.mods.sodium.client.util.task.CancellationToken; import net.caffeinemc.mods.sodium.client.world.LevelSlice; import net.caffeinemc.mods.sodium.client.world.cloned.ChunkRenderContext; @@ -28,6 +34,8 @@ import net.minecraft.world.level.material.FluidState; import java.util.Map; +import org.joml.Vector3dc; + /** * Rebuilds all the meshes of a chunk for each given render pass with non-occluded blocks. The result is then uploaded * to graphics memory on the main thread. @@ -36,15 +44,11 @@ * array allocations, they are pooled to ensure that the garbage collector doesn't become overloaded. */ public class ChunkBuilderMeshingTask extends ChunkBuilderTask { - private final RenderSection render; private final ChunkRenderContext renderContext; - private final int buildTime; - - public ChunkBuilderMeshingTask(RenderSection render, ChunkRenderContext renderContext, int time) { - this.render = render; + public ChunkBuilderMeshingTask(RenderSection render, int buildTime, Vector3dc absoluteCameraPos, ChunkRenderContext renderContext) { + super(render, buildTime, absoluteCameraPos); this.renderContext = renderContext; - this.buildTime = time; } @Override @@ -72,7 +76,11 @@ public ChunkBuildOutput execute(ChunkBuildContext buildContext, CancellationToke BlockPos.MutableBlockPos blockPos = new BlockPos.MutableBlockPos(minX, minY, minZ); BlockPos.MutableBlockPos modelOffset = new BlockPos.MutableBlockPos(); - BlockRenderContext context = new BlockRenderContext(slice); + TranslucentGeometryCollector collector = null; + if (SodiumClientMod.options().performance.getSortBehavior() != SortBehavior.OFF) { + collector = new TranslucentGeometryCollector(render.getPosition()); + } + BlockRenderContext context = new BlockRenderContext(slice, collector); try { for (int y = minY; y < maxY; y++) { @@ -105,7 +113,7 @@ public ChunkBuildOutput execute(ChunkBuildContext buildContext, CancellationToke FluidState fluidState = blockState.getFluidState(); if (!fluidState.isEmpty()) { - cache.getFluidRenderer().render(slice, blockState, fluidState, blockPos, modelOffset, buffers); + cache.getFluidRenderer().render(slice, blockState, fluidState, blockPos, modelOffset, collector, buffers); } if (blockState.hasBlockEntity()) { @@ -134,10 +142,18 @@ public ChunkBuildOutput execute(ChunkBuildContext buildContext, CancellationToke throw fillCrashInfo(CrashReport.forThrowable(ex, "Encountered exception while building chunk meshes"), slice, blockPos); } + SortType sortType = SortType.NONE; + if (collector != null) { + sortType = collector.finishRendering(); + } + Map meshes = new Reference2ReferenceOpenHashMap<>(); for (TerrainRenderPass pass : DefaultTerrainRenderPasses.ALL) { - BuiltSectionMeshParts mesh = buffers.createMesh(pass); + // consolidate all translucent geometry into UNASSIGNED so that it's rendered + // all together if it needs to share an index buffer between the directions + boolean isTranslucent = pass == DefaultTerrainRenderPasses.TRANSLUCENT; + BuiltSectionMeshParts mesh = buffers.createMesh(pass, isTranslucent && sortType.needsDirectionMixing); if (mesh != null) { meshes.put(pass, mesh); @@ -145,9 +161,20 @@ public ChunkBuildOutput execute(ChunkBuildContext buildContext, CancellationToke } } + // cancellation opportunity right before translucent sorting + if (cancellationToken.isCancelled()) { + return null; + } + + TranslucentData translucentData = null; + if (collector != null) { + translucentData = collector.getTranslucentData( + this.render.getTranslucentData(), meshes.get(DefaultTerrainRenderPasses.TRANSLUCENT), this); + } + renderData.setOcclusionData(occluder.resolve()); - return new ChunkBuildOutput(this.render, renderData.build(), meshes, this.buildTime); + return new ChunkBuildOutput(this.render, this.submitTime, translucentData, renderData.build(), meshes); } private ReportedException fillCrashInfo(CrashReport report, LevelSlice slice, BlockPos pos) { @@ -166,4 +193,9 @@ private ReportedException fillCrashInfo(CrashReport report, LevelSlice slice, Bl return new ReportedException(report); } + + @Override + public int getEffort() { + return ChunkBuilder.HIGH_EFFORT; + } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderSortingTask.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderSortingTask.java new file mode 100644 index 0000000000..690fbae2d4 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderSortingTask.java @@ -0,0 +1,41 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks; + +import org.joml.Vector3dc; + +import net.caffeinemc.mods.sodium.client.render.chunk.RenderSection; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildContext; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkSortOutput; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkBuilder; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.DynamicData; +import net.caffeinemc.mods.sodium.client.util.task.CancellationToken; + +public class ChunkBuilderSortingTask extends ChunkBuilderTask { + private final DynamicData dynamicData; + + public ChunkBuilderSortingTask(RenderSection render, int frame, Vector3dc absoluteCameraPos, + DynamicData dynamicData) { + super(render, frame, absoluteCameraPos); + this.dynamicData = dynamicData; + } + + @Override + public ChunkSortOutput execute(ChunkBuildContext context, CancellationToken cancellationToken) { + if (cancellationToken.isCancelled()) { + return null; + } + this.render.getTranslucentData().sortOnTrigger(this.cameraPos); + return new ChunkSortOutput(this.render, this.submitTime, this.dynamicData); + } + + public static ChunkBuilderSortingTask createTask(RenderSection render, int frame, Vector3dc absoluteCameraPos) { + if (render.getTranslucentData() instanceof DynamicData dynamicData) { + return new ChunkBuilderSortingTask(render, frame, absoluteCameraPos, dynamicData); + } + return null; + } + + @Override + public int getEffort() { + return ChunkBuilder.LOW_EFFORT; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderTask.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderTask.java index 1a2b446c57..1735c23bb6 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderTask.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/compile/tasks/ChunkBuilderTask.java @@ -1,6 +1,13 @@ package net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks; +import org.joml.Vector3dc; +import org.joml.Vector3f; +import org.joml.Vector3fc; + +import net.caffeinemc.mods.sodium.client.render.chunk.RenderSection; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.BuilderTaskOutput; import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildContext; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.CombinedCameraPos; import net.caffeinemc.mods.sodium.client.util.task.CancellationToken; /** @@ -13,7 +20,29 @@ * After the task completes, it returns a "build result" which contains any computed data that needs to be handled * on the main thread. */ -public abstract class ChunkBuilderTask { +public abstract class ChunkBuilderTask implements CombinedCameraPos { + protected final RenderSection render; + protected final int submitTime; + protected final Vector3dc absoluteCameraPos; + protected final Vector3fc cameraPos; + + /** + * Constructs a new build task for the given chunk and converts the absolute camera position to a relative position. While the absolute position is stored as a double vector, the relative position is stored as a float vector. + * + * @param render The chunk to build + * @param time The frame in which this task was created + * @param absoluteCameraPos The absolute position of the camera + */ + public ChunkBuilderTask(RenderSection render, int time, Vector3dc absoluteCameraPos) { + this.render = render; + this.submitTime = time; + this.absoluteCameraPos = absoluteCameraPos; + this.cameraPos = new Vector3f( + (float) (absoluteCameraPos.x() - (double) render.getOriginX()), + (float) (absoluteCameraPos.y() - (double) render.getOriginY()), + (float) (absoluteCameraPos.z() - (double) render.getOriginZ())); + } + /** * Executes the given build task asynchronously from the calling thread. The implementation should be careful not * to access or modify global mutable state. @@ -24,4 +53,16 @@ public abstract class ChunkBuilderTask { * if the task was cancelled. */ public abstract OUTPUT execute(ChunkBuildContext context, CancellationToken cancellationToken); + + public abstract int getEffort(); + + @Override + public Vector3fc getRelativeCameraPos() { + return this.cameraPos; + } + + @Override + public Vector3dc getAbsoluteCameraPos() { + return this.absoluteCameraPos; + } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/data/SectionRenderDataStorage.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/data/SectionRenderDataStorage.java index f1b0dbc1eb..e677e36803 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/data/SectionRenderDataStorage.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/data/SectionRenderDataStorage.java @@ -4,26 +4,57 @@ import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; import net.caffeinemc.mods.sodium.client.render.chunk.region.RenderRegion; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Arrays; +/** + * The section render data storage stores the gl buffer segments of uploaded + * data on the gpu. There's one storage object per region. It stores information + * about vertex and optionally index buffer data. The array of buffer segment is + * indexed by the region-local section index. The data about the contents of + * buffer segments is stored in a natively allocated piece of memory referenced + * by {@code pMeshDataArray} and accessed through + * {@link SectionRenderDataUnsafe}. + * + * When the backing buffer (from the gl buffer arena) is resized, the storage + * object is notified and then it updates the changed offsets of the buffer + * segments. Since the index data's size and alignment directly corresponds to + * that of the vertex data except for the vertex/index scaling of two thirds, + * only an offset to the index data within the index data buffer arena is + * stored. + * + * Index and vertex data storage can be managed separately since they may be + * updated independently of each other (in both directions). + */ public class SectionRenderDataStorage { - private final GlBufferSegment[] allocations = new GlBufferSegment[RenderRegion.REGION_SIZE]; + private final @Nullable GlBufferSegment[] vertexAllocations; + private final @Nullable GlBufferSegment @Nullable[] elementAllocations; private final long pMeshDataArray; - public SectionRenderDataStorage() { + public SectionRenderDataStorage(boolean storesIndices) { + this.vertexAllocations = new GlBufferSegment[RenderRegion.REGION_SIZE]; + + if (storesIndices) { + this.elementAllocations = new GlBufferSegment[RenderRegion.REGION_SIZE]; + } else { + this.elementAllocations = null; + } + this.pMeshDataArray = SectionRenderDataUnsafe.allocateHeap(RenderRegion.REGION_SIZE); } - public void setMeshes(int localSectionIndex, - GlBufferSegment allocation, VertexRange[] ranges) { - if (this.allocations[localSectionIndex] != null) { - this.allocations[localSectionIndex].delete(); - this.allocations[localSectionIndex] = null; + public void setVertexData(int localSectionIndex, + GlBufferSegment allocation, VertexRange[] ranges) { + GlBufferSegment prev = this.vertexAllocations[localSectionIndex]; + + if (prev != null) { + prev.delete(); } - this.allocations[localSectionIndex] = allocation; + this.vertexAllocations[localSectionIndex] = allocation; var pMeshData = this.getDataPointer(localSectionIndex); @@ -53,15 +84,72 @@ public void setMeshes(int localSectionIndex, SectionRenderDataUnsafe.setSliceMask(pMeshData, sliceMask); } - public void removeMeshes(int localSectionIndex) { - if (this.allocations[localSectionIndex] == null) { + public void setIndexData(int localSectionIndex, GlBufferSegment allocation) { + if (this.elementAllocations == null) { + throw new IllegalStateException("Cannot set index data when storesIndices is false"); + } + + GlBufferSegment prev = this.elementAllocations[localSectionIndex]; + + if (prev != null) { + prev.delete(); + } + + this.elementAllocations[localSectionIndex] = allocation; + + var pMeshData = this.getDataPointer(localSectionIndex); + + SectionRenderDataUnsafe.setBaseElement(pMeshData, + allocation.getOffset() | SectionRenderDataUnsafe.BASE_ELEMENT_MSB); + } + + public void removeData(int localSectionIndex) { + this.removeVertexData(localSectionIndex, false); + + if (this.elementAllocations != null) { + this.removeIndexData(localSectionIndex); + } + } + + public void removeVertexData(int localSectionIndex) { + this.removeVertexData(localSectionIndex, true); + } + + private void removeVertexData(int localSectionIndex, boolean retainIndexData) { + GlBufferSegment prev = this.vertexAllocations[localSectionIndex]; + + if (prev == null) { return; } - this.allocations[localSectionIndex].delete(); - this.allocations[localSectionIndex] = null; + prev.delete(); + + this.vertexAllocations[localSectionIndex] = null; + + var pMeshData = this.getDataPointer(localSectionIndex); + + var baseElement = SectionRenderDataUnsafe.getBaseElement(pMeshData); + SectionRenderDataUnsafe.clear(pMeshData); + + if (retainIndexData) { + SectionRenderDataUnsafe.setBaseElement(pMeshData, baseElement); + } + } + + public void removeIndexData(int localSectionIndex) { + final GlBufferSegment[] allocations = this.elementAllocations; - SectionRenderDataUnsafe.clear(this.getDataPointer(localSectionIndex)); + if (allocations == null) { + throw new IllegalStateException("Cannot remove index data when storesIndices is false"); + } + + GlBufferSegment prev = allocations[localSectionIndex]; + + if (prev != null) { + prev.delete(); + } + + allocations[localSectionIndex] = null; } public void onBufferResized() { @@ -71,7 +159,7 @@ public void onBufferResized() { } private void updateMeshes(int sectionIndex) { - var allocation = this.allocations[sectionIndex]; + var allocation = this.vertexAllocations[sectionIndex]; if (allocation == null) { return; @@ -88,19 +176,42 @@ private void updateMeshes(int sectionIndex) { } } + public void onIndexBufferResized() { + if (this.elementAllocations == null) { + return; + } + + for (int sectionIndex = 0; sectionIndex < RenderRegion.REGION_SIZE; sectionIndex++) { + var allocation = this.elementAllocations[sectionIndex]; + + if (allocation != null) { + SectionRenderDataUnsafe.setBaseElement(this.getDataPointer(sectionIndex), + allocation.getOffset() | SectionRenderDataUnsafe.BASE_ELEMENT_MSB); + } + } + } + public long getDataPointer(int sectionIndex) { return SectionRenderDataUnsafe.heapPointer(this.pMeshDataArray, sectionIndex); } public void delete() { - for (var allocation : this.allocations) { + deleteAllocations(this.vertexAllocations); + + if (this.elementAllocations != null) { + deleteAllocations(this.elementAllocations); + } + + SectionRenderDataUnsafe.freeHeap(this.pMeshDataArray); + } + + private static void deleteAllocations(GlBufferSegment @NotNull [] allocations) { + for (var allocation : allocations) { if (allocation != null) { allocation.delete(); } } - Arrays.fill(this.allocations, null); - - SectionRenderDataUnsafe.freeHeap(this.pMeshDataArray); + Arrays.fill(allocations, null); } -} \ No newline at end of file +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/data/SectionRenderDataUnsafe.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/data/SectionRenderDataUnsafe.java index 90d1b04d05..cf483e1227 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/data/SectionRenderDataUnsafe.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/data/SectionRenderDataUnsafe.java @@ -14,18 +14,46 @@ // // Please never try to write performance critical code in Java. This is what it will do to you. And you will still be // three times slower than the most naive solution in literally any other language that LLVM can compile. + +// struct SectionRenderData { // 64 bytes +// base_element: u32 +// mask: u32, +// ranges: [VertexRange; 7] +// } +// +// struct VertexRange { // 8 bytes +// offset: u32, +// count: u32 +// } + public class SectionRenderDataUnsafe { - private static final long OFFSET_SLICE_MASK = 0; + public static final int BASE_ELEMENT_MSB = 1 << 31; + + /** + * When the "base element" field is not specified (indicated by setting the MSB to 0), the indices for the geometry set + * should be sourced from a monotonic sequence (see {@link net.caffeinemc.mods.sodium.client.render.chunk.SharedQuadIndexBuffer}). + * + * Otherwise, indices should be sourced from the index buffer for the render region using the specified offset. + */ + private static final long OFFSET_BASE_ELEMENT = 0; + + private static final long OFFSET_SLICE_MASK = 4; private static final long OFFSET_SLICE_RANGES = 8; - private static final long STRIDE = 64; + private static final long ALIGNMENT = 64; + private static final long STRIDE = 64; // cache-line friendly! :) public static long allocateHeap(int count) { - return MemoryUtil.nmemCalloc(count, STRIDE); + final var bytes = STRIDE * count; + + final var ptr = MemoryUtil.nmemAlignedAlloc(ALIGNMENT, bytes); + MemoryUtil.memSet(ptr, 0, bytes); + + return ptr; } public static void freeHeap(long pointer) { - MemoryUtil.nmemFree(pointer); + MemoryUtil.nmemAlignedFree(pointer); } public static void clear(long pointer) { @@ -44,6 +72,14 @@ public static int getSliceMask(long ptr) { return MemoryUtil.memGetInt(ptr + OFFSET_SLICE_MASK); } + public static void setBaseElement(long ptr, int value) { + MemoryUtil.memPutInt(ptr + OFFSET_BASE_ELEMENT, value); + } + + public static int getBaseElement(long ptr) { + return MemoryUtil.memGetInt(ptr + OFFSET_BASE_ELEMENT); + } + public static void setVertexOffset(long ptr, int facing, int value) { MemoryUtil.memPutInt(ptr + OFFSET_SLICE_RANGES + (facing * 8L) + 0L, value); } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/lists/SortedRenderLists.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/lists/SortedRenderLists.java index f8a3334d2a..1837e0ae9d 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/lists/SortedRenderLists.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/lists/SortedRenderLists.java @@ -5,6 +5,11 @@ import net.caffeinemc.mods.sodium.client.util.iterator.ReversibleObjectArrayIterator; import net.caffeinemc.mods.sodium.client.render.chunk.region.RenderRegion; +/** + * Stores one render list of sections per region, sorted by the order in which + * they were discovered in the BFS of the occlusion culler. It also generates + * render lists for sections of previously unseen regions. + */ public class SortedRenderLists implements ChunkRenderListIterable { private static final SortedRenderLists EMPTY = new SortedRenderLists(ObjectArrayList.of()); diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/lists/VisibleChunkCollector.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/lists/VisibleChunkCollector.java index ec5fa07db0..5ed657795f 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/lists/VisibleChunkCollector.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/lists/VisibleChunkCollector.java @@ -8,6 +8,10 @@ import java.util.*; +/** + * The visible chunk collector is passed to the occlusion graph search culler to + * collect the visible chunks. + */ public class VisibleChunkCollector implements OcclusionCuller.Visitor { private final ObjectArrayList sortedRenderLists; private final EnumMap> sortedRebuildLists; @@ -48,7 +52,7 @@ public void visit(RenderSection section, boolean visible) { private void addToRebuildLists(RenderSection section) { ChunkUpdateType type = section.getPendingUpdate(); - if (type != null && section.getBuildCancellationToken() == null) { + if (type != null && section.getTaskCancellationToken() == null) { Queue queue = this.sortedRebuildLists.get(type); if (queue.size() < type.getMaximumQueueSize()) { diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/region/RenderRegion.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/region/RenderRegion.java index 852034a3cc..b02b9acfe9 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/region/RenderRegion.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/region/RenderRegion.java @@ -9,6 +9,7 @@ import net.caffeinemc.mods.sodium.client.render.chunk.RenderSection; import net.caffeinemc.mods.sodium.client.render.chunk.data.SectionRenderDataStorage; import net.caffeinemc.mods.sodium.client.render.chunk.lists.ChunkRenderList; +import net.caffeinemc.mods.sodium.client.render.chunk.terrain.DefaultTerrainRenderPasses; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.TerrainRenderPass; import net.caffeinemc.mods.sodium.client.render.chunk.vertex.format.ChunkMeshFormats; import net.caffeinemc.mods.sodium.client.util.MathUtil; @@ -114,15 +115,17 @@ public SectionRenderDataStorage createStorage(TerrainRenderPass pass) { var storage = this.sectionRenderData.get(pass); if (storage == null) { - this.sectionRenderData.put(pass, storage = new SectionRenderDataStorage()); + storage = new SectionRenderDataStorage(pass == DefaultTerrainRenderPasses.TRANSLUCENT); + this.sectionRenderData.put(pass, storage); } return storage; } - public void refresh(CommandList commandList) { + public void refreshTesselation(CommandList commandList) { if (this.resources != null) { - this.resources.deleteTessellations(commandList); + this.resources.deleteTessellation(commandList); + this.resources.deleteIndexedTessellation(commandList); } for (var storage : this.sectionRenderData.values()) { @@ -130,6 +133,14 @@ public void refresh(CommandList commandList) { } } + public void refreshIndexedTesselation(CommandList commandList) { + if (this.resources != null) { + this.resources.deleteIndexedTessellation(commandList); + } + + this.sectionRenderData.get(DefaultTerrainRenderPasses.TRANSLUCENT).onIndexBufferResized(); + } + public void addSection(RenderSection section) { var sectionIndex = section.getSectionIndex(); var prev = this.sections[sectionIndex]; @@ -153,7 +164,7 @@ public void removeSection(RenderSection section) { } for (var storage : this.sectionRenderData.values()) { - storage.removeMeshes(sectionIndex); + storage.removeData(sectionIndex); } this.sections[sectionIndex] = null; @@ -189,11 +200,26 @@ public ChunkRenderList getRenderList() { public static class DeviceResources { private final GlBufferArena geometryArena; + private final GlBufferArena indexArena; private GlTessellation tessellation; - + private GlTessellation indexedTessellation; + + /** + * The buffer arenas return offsets in terms of how many stride units big things + * are. This means that if the stride is the length of a vertex, the buffer + * arena works with vertices and returns vertex offsets. The arena working with + * indices has as stride of four corresponding to the length of an integer. The + * two can't easily be combined because integers and vertices require different + * amounts of data which makes the returned offsets incompatible. + */ public DeviceResources(CommandList commandList, StagingBuffer stagingBuffer) { int stride = ChunkMeshFormats.COMPACT.getVertexFormat().getStride(); - this.geometryArena = new GlBufferArena(commandList, REGION_SIZE * 756, stride, stagingBuffer); + + // the magic number 756 for the initial size is arbitrary, it was made up. + var initialVertices = 756; + this.geometryArena = new GlBufferArena(commandList, REGION_SIZE * initialVertices, stride, stagingBuffer); + var initialIndices = (initialVertices / 4) * 6; + this.indexArena = new GlBufferArena(commandList, REGION_SIZE * initialIndices, Integer.BYTES, stagingBuffer); } public void updateTessellation(CommandList commandList, GlTessellation tessellation) { @@ -204,32 +230,61 @@ public void updateTessellation(CommandList commandList, GlTessellation tessellat this.tessellation = tessellation; } + public void updateIndexedTessellation(CommandList commandList, GlTessellation tessellation) { + if (this.indexedTessellation != null) { + this.indexedTessellation.delete(commandList); + } + + this.indexedTessellation = tessellation; + } + public GlTessellation getTessellation() { return this.tessellation; } - public void deleteTessellations(CommandList commandList) { + public GlTessellation getIndexedTessellation() { + return this.indexedTessellation; + } + + public void deleteTessellation(CommandList commandList) { if (this.tessellation != null) { this.tessellation.delete(commandList); this.tessellation = null; } } - public GlBuffer getVertexBuffer() { + public void deleteIndexedTessellation(CommandList commandList) { + if (this.indexedTessellation != null) { + this.indexedTessellation.delete(commandList); + this.indexedTessellation = null; + } + } + + public GlBuffer getGeometryBuffer() { return this.geometryArena.getBufferObject(); } + public GlBuffer getIndexBuffer() { + return this.indexArena.getBufferObject(); + } + public void delete(CommandList commandList) { - this.deleteTessellations(commandList); + this.deleteTessellation(commandList); + this.deleteIndexedTessellation(commandList); this.geometryArena.delete(commandList); + this.indexArena.delete(commandList); } public GlBufferArena getGeometryArena() { return this.geometryArena; } + public GlBufferArena getIndexArena() { + return this.indexArena; + } + public boolean shouldDelete() { - return this.geometryArena.isEmpty(); + return this.geometryArena.isEmpty() && this.indexArena.isEmpty(); } } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/region/RenderRegionManager.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/region/RenderRegionManager.java index 1042fac327..887d4b2341 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/region/RenderRegionManager.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/region/RenderRegionManager.java @@ -11,10 +11,14 @@ import net.caffeinemc.mods.sodium.client.gl.device.CommandList; import net.caffeinemc.mods.sodium.client.gl.device.RenderDevice; import net.caffeinemc.mods.sodium.client.render.chunk.RenderSection; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.BuilderTaskOutput; import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildOutput; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.OutputWithIndexData; import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.DefaultTerrainRenderPasses; import net.caffeinemc.mods.sodium.client.render.chunk.terrain.TerrainRenderPass; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.PresentTranslucentData; + import org.jetbrains.annotations.NotNull; import java.util.*; @@ -48,59 +52,113 @@ public void update() { } } - public void uploadMeshes(CommandList commandList, Collection results) { + public void uploadResults(CommandList commandList, Collection results) { for (var entry : this.createMeshUploadQueues(results)) { - this.uploadMeshes(commandList, entry.getKey(), entry.getValue()); + this.uploadResults(commandList, entry.getKey(), entry.getValue()); } } - private void uploadMeshes(CommandList commandList, RenderRegion region, Collection results) { - var uploads = new ArrayList(); + private void uploadResults(CommandList commandList, RenderRegion region, Collection results) { + var uploads = new ArrayList(); + var indexUploads = new ArrayList(); + + for (BuilderTaskOutput result : results) { + int renderSectionIndex = result.render.getSectionIndex(); + + if (result.render.isDisposed()) { + throw new IllegalStateException("Render section is disposed"); + } + + if (result instanceof ChunkBuildOutput chunkBuildOutput) { + for (TerrainRenderPass pass : DefaultTerrainRenderPasses.ALL) { + var storage = region.getStorage(pass); - for (ChunkBuildOutput result : results) { - for (TerrainRenderPass pass : DefaultTerrainRenderPasses.ALL) { - var storage = region.getStorage(pass); + if (storage != null) { + storage.removeVertexData(renderSectionIndex); + } - if (storage != null) { - storage.removeMeshes(result.render.getSectionIndex()); + BuiltSectionMeshParts mesh = chunkBuildOutput.getMesh(pass); + + if (mesh != null) { + uploads.add(new PendingSectionMeshUpload(result.render, mesh, pass, + new PendingUpload(mesh.getVertexData()))); + } } + } - BuiltSectionMeshParts mesh = result.getMesh(pass); + if (result instanceof OutputWithIndexData indexDataOutput) { + var indexData = indexDataOutput.getTranslucentData(); + boolean retainIndexData = false; + if (indexData != null) { + if (indexData.isReusingUploadedData()) { + retainIndexData = true; + } else { + var buffer = indexData.getBuffer(); + + // TODO: sometimes the buffer is null even when reuse isn't happening. maybe the data on the + // render section is being replaced before it gets here? + if (buffer == null) { + throw new IllegalStateException("Translucent data buffer is null"); + } + + indexUploads.add(new PendingSectionIndexBufferUpload(result.render, indexData, + new PendingUpload(buffer))); + } + } - if (mesh != null) { - uploads.add(new PendingSectionUpload(result.render, mesh, pass, - new PendingUpload(mesh.getVertexData()))); + if (!retainIndexData) { + var storage = region.getStorage(DefaultTerrainRenderPasses.TRANSLUCENT); + if (storage != null) { + storage.removeIndexData(renderSectionIndex); + } } } } // If we have nothing to upload, abort! - if (uploads.isEmpty()) { + if (uploads.isEmpty() && indexUploads.isEmpty()) { return; } var resources = region.createResources(commandList); - var arena = resources.getGeometryArena(); - boolean bufferChanged = arena.upload(commandList, uploads.stream() - .map(upload -> upload.vertexUpload)); + if (!uploads.isEmpty()) { + var arena = resources.getGeometryArena(); + boolean bufferChanged = arena.upload(commandList, uploads.stream() + .map(upload -> upload.vertexUpload)); - // If any of the buffers changed, the tessellation will need to be updated - // Once invalidated the tessellation will be re-created on the next attempted use - if (bufferChanged) { - region.refresh(commandList); + // If any of the buffers changed, the tessellation will need to be updated + // Once invalidated the tessellation will be re-created on the next attempted use + if (bufferChanged) { + region.refreshTesselation(commandList); + } + + // Collect the upload results + for (PendingSectionMeshUpload upload : uploads) { + var storage = region.createStorage(upload.pass); + storage.setVertexData(upload.section.getSectionIndex(), + upload.vertexUpload.getResult(), upload.meshData.getVertexRanges()); + } } - // Collect the upload results - for (PendingSectionUpload upload : uploads) { - var storage = region.createStorage(upload.pass); - storage.setMeshes(upload.section.getSectionIndex(), - upload.vertexUpload.getResult(), upload.meshData.getVertexRanges()); + if (!indexUploads.isEmpty()) { + var arena = resources.getIndexArena(); + boolean bufferChanged = arena.upload(commandList, indexUploads.stream() + .map(upload -> upload.indexBufferUpload)); + + if (bufferChanged) { + region.refreshIndexedTesselation(commandList); + } + + for (PendingSectionIndexBufferUpload upload : indexUploads) { + var storage = region.createStorage(DefaultTerrainRenderPasses.TRANSLUCENT); + storage.setIndexData(upload.section.getSectionIndex(), upload.indexBufferUpload.getResult()); + } } } - private Reference2ReferenceMap.FastEntrySet> createMeshUploadQueues(Collection results) { - var map = new Reference2ReferenceOpenHashMap>(); + private Reference2ReferenceMap.FastEntrySet> createMeshUploadQueues(Collection results) { + var map = new Reference2ReferenceOpenHashMap>(); for (var result : results) { var queue = map.computeIfAbsent(result.render.getRegion(), k -> new ArrayList<>()); @@ -145,7 +203,10 @@ private RenderRegion create(int x, int y, int z) { return instance; } - private record PendingSectionUpload(RenderSection section, BuiltSectionMeshParts meshData, TerrainRenderPass pass, PendingUpload vertexUpload) { + private record PendingSectionMeshUpload(RenderSection section, BuiltSectionMeshParts meshData, TerrainRenderPass pass, PendingUpload vertexUpload) { + } + + private record PendingSectionIndexBufferUpload(RenderSection section, PresentTranslucentData translucentData, PendingUpload indexBufferUpload) { } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/AlignableNormal.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/AlignableNormal.java new file mode 100644 index 0000000000..ef492d0f1a --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/AlignableNormal.java @@ -0,0 +1,94 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting; + +import org.joml.Vector3f; +import org.joml.Vector3fc; + +import it.unimi.dsi.fastutil.floats.FloatArrays; +import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; + +/** + * A normal vector that has additional information about its alignment. This is + * useful for better hashing and telling other code that the normal is aligned, + * which in turn enables many optimizations and fast paths to be taken. + */ +public class AlignableNormal extends Vector3f { + private static final AlignableNormal[] NORMALS = new AlignableNormal[ModelQuadFacing.DIRECTIONS]; + + static { + for (int i = 0; i < ModelQuadFacing.DIRECTIONS; i++) { + NORMALS[i] = new AlignableNormal(ModelQuadFacing.ALIGNED_NORMALS[i], i); + } + } + + private static final int UNASSIGNED = ModelQuadFacing.UNASSIGNED.ordinal(); + private final int alignedDirection; + + private AlignableNormal(Vector3fc v, int alignedDirection) { + super(v); + this.alignedDirection = alignedDirection; + } + + public static AlignableNormal fromAligned(int alignedDirection) { + return NORMALS[alignedDirection]; + } + + public static AlignableNormal fromUnaligned(Vector3fc v) { + return new AlignableNormal(v, UNASSIGNED); + } + + public int getAlignedDirection() { + return this.alignedDirection; + } + + public boolean isAligned() { + return this.alignedDirection != UNASSIGNED; + } + + @Override + public int hashCode() { + if (this.isAligned()) { + return this.alignedDirection; + } else { + return super.hashCode() + ModelQuadFacing.DIRECTIONS; + } + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!super.equals(obj)) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + AlignableNormal other = (AlignableNormal) obj; + return this.alignedDirection == other.alignedDirection; + } + + public static boolean queryRange(float[] sortedDistances, float start, float end) { + // test that there is actually an entry in the query range + int result = FloatArrays.binarySearch(sortedDistances, start); + if (result < 0) { + // recover the insertion point + int insertionPoint = -result - 1; + if (insertionPoint >= sortedDistances.length) { + // no entry in the query range + return false; + } + + // check if the entry at the insertion point, which is the next one greater than + // the start value, is less than or equal to the end value + if (sortedDistances[insertionPoint] <= end) { + // there is an entry in the query range + return true; + } + } else { + // exact match, trigger + return true; + } + return false; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/SortBehavior.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/SortBehavior.java new file mode 100644 index 0000000000..055270f7bf --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/SortBehavior.java @@ -0,0 +1,60 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting; + +public enum SortBehavior { + OFF("OFF", SortMode.NONE), + STATIC("S", SortMode.STATIC), + DYNAMIC_DEFER_ALWAYS("DF", PriorityMode.NONE, DeferMode.ALWAYS), + DYNAMIC_DEFER_NEARBY_ONE_FRAME("N1", PriorityMode.NEARBY, DeferMode.ONE_FRAME), + DYNAMIC_DEFER_NEARBY_ZERO_FRAMES("N0", PriorityMode.NEARBY, DeferMode.ZERO_FRAMES), + DYNAMIC_DEFER_ALL_ONE_FRAME("A1", PriorityMode.ALL, DeferMode.ONE_FRAME), + DYNAMIC_DEFER_ALL_ZERO_FRAMES("A0", PriorityMode.ALL, DeferMode.ZERO_FRAMES); + + private final String shortName; + private final SortBehavior.SortMode sortMode; + private final SortBehavior.PriorityMode priorityMode; + private final SortBehavior.DeferMode deferMode; + + SortBehavior(String shortName, SortBehavior.SortMode sortMode, SortBehavior.PriorityMode priorityMode, + SortBehavior.DeferMode deferMode) { + this.shortName = shortName; + this.sortMode = sortMode; + this.priorityMode = priorityMode; + this.deferMode = deferMode; + } + + SortBehavior(String shortName, SortBehavior.SortMode sortMode) { + this(shortName, sortMode, null, null); + } + + SortBehavior(String shortName, SortBehavior.PriorityMode priorityMode, SortBehavior.DeferMode deferMode) { + this(shortName, SortMode.DYNAMIC, priorityMode, deferMode); + } + + public String getShortName() { + return this.shortName; + } + + public SortBehavior.SortMode getSortMode() { + return this.sortMode; + } + + public SortBehavior.PriorityMode getPriorityMode() { + return this.priorityMode; + } + + public SortBehavior.DeferMode getDeferMode() { + return this.deferMode; + } + + public enum SortMode { + NONE, STATIC, DYNAMIC + } + + public enum PriorityMode { + NONE, NEARBY, ALL + } + + public enum DeferMode { + ALWAYS, ONE_FRAME, ZERO_FRAMES + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/SortType.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/SortType.java new file mode 100644 index 0000000000..d00713259a --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/SortType.java @@ -0,0 +1,52 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting; + +/** + * What type of sorting to use for a section. Calculated by a heuristic after + * building a section. + */ +public enum SortType { + /** + * The section is fully empty, no index buffer is needed. + */ + EMPTY_SECTION(false), + + /** + * The section has no translucent geometry, no index buffer is needed. + */ + NO_TRANSLUCENT(false), + + /** + * No sorting is required and the sort order doesn't matter. + */ + NONE(false), + + /** + * There is only one sort order. No active sorting is required, but an initial + * sort where quads of each facing are sorted according to their distances in + * regard to their normal. + * + * Currently assumes that there are no UNASSIGNED quads present. If this + * changes, remove this note and adjust StaticTranslucentData and anything that + * reads from it to handle UNASSIGNED quads. + */ + STATIC_NORMAL_RELATIVE(false), + + /** + * There is only one sort order and not active sorting is required, but + * determining the static sort order involves doing a toplogical sort of the + * quads. + */ + STATIC_TOPO(true), + + /** + * There are multiple sort orders. Sorting is required every time GFNI triggers + * this section. + */ + DYNAMIC(true); + + public final boolean needsDirectionMixing; + + SortType(boolean needsDirectionMixing) { + this.needsDirectionMixing = needsDirectionMixing; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/TQuad.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/TQuad.java new file mode 100644 index 0000000000..633ec915a1 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/TQuad.java @@ -0,0 +1,183 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting; + +import java.util.Arrays; + +import org.joml.Vector3f; +import org.joml.Vector3fc; + +import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; +import net.caffeinemc.mods.sodium.api.util.NormI8; + +/** + * Represents a quad for the purposes of translucency sorting. Called TQuad to + * avoid confusion with other quad classes. + */ +public class TQuad { + /** + * The quantization factor with which the normals are quantized such that there + * are fewer possible unique normals. The factor describes the number of steps + * in each direction per dimension that the components of the normals can have. + * It determines the density of the grid on the surface of a unit cube centered + * at the origin onto which the normals are projected. The normals are snapped + * to the nearest grid point. + */ + private static final int QUANTIZATION_FACTOR = 4; + + private ModelQuadFacing facing; + private final float[] extents; + private final int packedNormal; + private float dotProduct; + private Vector3fc center; // null on aligned quads + private Vector3fc quantizedNormal; + + private TQuad(ModelQuadFacing facing, float[] extents, Vector3fc center, int packedNormal) { + this.facing = facing; + this.extents = extents; + this.center = center; + this.packedNormal = packedNormal; + + if (this.facing.isAligned()) { + this.dotProduct = getAlignedDotProduct(this.facing, this.extents); + } else { + float normX = NormI8.unpackX(this.packedNormal); + float normY = NormI8.unpackY(this.packedNormal); + float normZ = NormI8.unpackZ(this.packedNormal); + this.dotProduct = this.getCenter().dot(normX, normY, normZ); + } + } + + private static float getAlignedDotProduct(ModelQuadFacing facing, float[] extents) { + return extents[facing.ordinal()] * facing.getSign(); + } + + static TQuad fromAligned(ModelQuadFacing facing, float[] extents) { + return new TQuad(facing, extents, null, ModelQuadFacing.PACKED_ALIGNED_NORMALS[facing.ordinal()]); + } + + static TQuad fromUnaligned(ModelQuadFacing facing, float[] extents, Vector3fc center, int packedNormal) { + return new TQuad(facing, extents, center, packedNormal); + } + + public ModelQuadFacing getFacing() { + return this.facing; + } + + /** + * Calculates the facing of the quad based on the quantized normal. This updates the dot product to be consistent with the new facing. Since this method computed and allocates the quantized normal, it should be used sparingly and only when the quantized normal is calculated anyway. Additionally, it can modify the facing and not product of the quad which the caller should be aware of. + * + * @return the (potentially changed) facing of the quad + */ + public ModelQuadFacing useQuantizedFacing() { + if (!this.facing.isAligned()) { + // quantize the normal, get the new facing and get fix the dot product to match + this.getQuantizedNormal(); + this.facing = ModelQuadFacing.fromNormal(this.quantizedNormal.x(), this.quantizedNormal.y(), this.quantizedNormal.z()); + if (this.facing.isAligned()) { + this.dotProduct = getAlignedDotProduct(this.facing, this.extents); + } + } + + return this.facing; + } + + public float[] getExtents() { + return this.extents; + } + + public Vector3fc getCenter() { + // calculate aligned quad center on demand + if (this.center == null) { + this.center = new Vector3f( + (this.extents[0] + this.extents[3]) / 2, + (this.extents[1] + this.extents[4]) / 2, + (this.extents[2] + this.extents[5]) / 2); + } + return this.center; + } + + public float getDotProduct() { + return this.dotProduct; + } + + public int getPackedNormal() { + return this.packedNormal; + } + + public Vector3fc getQuantizedNormal() { + if (this.quantizedNormal == null) { + if (this.facing.isAligned()) { + this.quantizedNormal = this.facing.getAlignedNormal(); + } else { + this.computeQuantizedNormal(); + } + } + return this.quantizedNormal; + } + + private void computeQuantizedNormal() { + float normX = NormI8.unpackX(this.packedNormal); + float normY = NormI8.unpackY(this.packedNormal); + float normZ = NormI8.unpackZ(this.packedNormal); + + // normalize onto the surface of a cube by dividing by the length of the longest + // component + float infNormLength = Math.max(Math.abs(normX), Math.max(Math.abs(normY), Math.abs(normZ))); + if (infNormLength != 0 && infNormLength != 1) { + normX /= infNormLength; + normY /= infNormLength; + normZ /= infNormLength; + } + + // quantize the coordinates on the surface of the cube. + // in each axis the number of values is 2 * QUANTIZATION_FACTOR + 1. + // the total number of normals is the number of points on that cube's surface. + var normal = new Vector3f( + (int) (normX * QUANTIZATION_FACTOR), + (int) (normY * QUANTIZATION_FACTOR), + (int) (normZ * QUANTIZATION_FACTOR)); + normal.normalize(); + this.quantizedNormal = normal; + } + + int getQuadHash() { + // the hash code needs to be particularly collision resistant + int result = 1; + result = 31 * result + Arrays.hashCode(this.extents); + if (this.facing.isAligned()) { + result = 31 * result + this.facing.hashCode(); + } else { + result = 31 * result + this.packedNormal; + } + result = 31 * result + Float.hashCode(this.dotProduct); + return result; + } + + public boolean extentsEqual(float[] other) { + return extentsEqual(this.extents, other); + } + + public static boolean extentsEqual(float[] a, float[] b) { + for (int i = 0; i < 6; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; + } + + public static boolean extentsIntersect(float[] extentsA, float[] extentsB) { + for (int axis = 0; axis < 3; axis++) { + var opposite = axis + 3; + + if (extentsA[axis] <= extentsB[opposite] + || extentsB[axis] <= extentsA[opposite]) { + return false; + } + } + return true; + } + + public static boolean extentsIntersect(TQuad a, TQuad b) { + return extentsIntersect(a.extents, b.extents); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/TranslucentGeometryCollector.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/TranslucentGeometryCollector.java new file mode 100644 index 0000000000..c4d16cc851 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/TranslucentGeometryCollector.java @@ -0,0 +1,555 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting; + +import it.unimi.dsi.fastutil.objects.ReferenceArrayList; +import net.caffeinemc.mods.sodium.client.SodiumClientMod; +import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.ChunkBuildOutput; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.pipeline.DefaultFluidRenderer; +import net.caffeinemc.mods.sodium.client.render.chunk.compile.pipeline.FluidRenderer; +import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree.BSPBuildFailureException; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.*; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.GeometryPlanes; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.SortTriggering; +import net.caffeinemc.mods.sodium.client.render.chunk.vertex.format.ChunkVertexEncoder; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.caffeinemc.mods.sodium.api.util.NormI8; +import net.minecraft.core.SectionPos; +import org.joml.Vector3f; + +import java.util.Arrays; + +/** + * The translucent geometry collector collects the data from the renderers and + * builds data structures for either dynamic triggering or static sorting. It + * determines the best sort type for the section and constructs various types of + * translucent data objects that then perform sorting and get registered with + * GFNI for triggering. + * + * An instance of this class is created for each meshing task. It goes through + * three stages: + * 1. During meshing, it collects the geometry and calculates some metrics on the + * fly. These are later used for the sort type heuristic. + * 2. With {@link #finishRendering()} it finishes the geometry collection, + * generates the quad list, and calculates additional metrics. Then the sort + * type is determined with a heuristic based on the collected metrics. This + * determines if block face culling can be enabled. + * - Now the {@link BuiltSectionMeshParts} is generated, which yields the vertex + * ranges. + * 3. The vertex ranges and the mesh parts object are used by the collector in + * the construction of the {@link TranslucentData} object. The data object + * allocates memory for the index data and performs the first (and for static + * sort types, only) sort. + * - The data object is put into the {@link ChunkBuildOutput}. + * + * When dynamic sorting is enabled, trigger information from {@link DynamicData} + * object is integrated into {@link SortTriggering} when the task result is + * received by the main thread. + */ +public class TranslucentGeometryCollector { + + private final SectionPos sectionPos; + + // true if there are any unaligned quads + private boolean hasUnaligned = false; + + // a bitmap of the aligned facings present in the section + private int alignedFacingBitmap = 0; + + // AABB of the geometry + private final float[] extents = new float[] { + Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY, + Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY + }; + + // true if one of the extents has more than one plane + private boolean alignedExtentsMultiple = false; + + // the maximum (or minimum for negative directions) of quads with a particular + // facing. (Dot product of the normal with a vertex for all aligned facings) + private final float[] alignedExtremes = new float[] { + Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY, + Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY + }; + + // keep track of two normals with each up to two distances for important special + // case heuristics + private int unalignedANormal = -1; + private float unalignedADistance1 = Float.NaN; + private float unalignedADistance2 = Float.NaN; + private int unalignedBNormal = -1; + private float unalignedBDistance1 = Float.NaN; + private float unalignedBDistance2 = Float.NaN; + + @SuppressWarnings("unchecked") + private ReferenceArrayList[] quadLists = new ReferenceArrayList[ModelQuadFacing.COUNT]; + private TQuad[] quads; + + private SortType sortType; + + private boolean quadHashPresent = false; + private int quadHash = 0; + + public TranslucentGeometryCollector(SectionPos sectionPos) { + this.sectionPos = sectionPos; + } + + private static final float INV_QUANTIZE_EPSILON = 256f; + private static final float QUANTIZE_EPSILON = 1f / INV_QUANTIZE_EPSILON; + + static { + // ensure it fits with the fluid renderer epsilon and that it's a power-of-two + // fraction + var targetEpsilon = DefaultFluidRenderer.EPSILON * 2.1f; + if (QUANTIZE_EPSILON <= targetEpsilon && Integer.bitCount((int) INV_QUANTIZE_EPSILON) == 1) { + throw new RuntimeException("epsilon is invalid: " + QUANTIZE_EPSILON); + } + } + + public void appendQuad(int packedNormal, ChunkVertexEncoder.Vertex[] vertices, ModelQuadFacing facing) { + float xSum = 0; + float ySum = 0; + float zSum = 0; + + // keep track of distinct vertices to compute the center accurately for + // degenerate quads + float lastX = vertices[3].x; + float lastY = vertices[3].y; + float lastZ = vertices[3].z; + int uniqueQuads = 0; + + float posXExtent = Float.NEGATIVE_INFINITY; + float posYExtent = Float.NEGATIVE_INFINITY; + float posZExtent = Float.NEGATIVE_INFINITY; + float negXExtent = Float.POSITIVE_INFINITY; + float negYExtent = Float.POSITIVE_INFINITY; + float negZExtent = Float.POSITIVE_INFINITY; + + for (int i = 0; i < 4; i++) { + float x = vertices[i].x; + float y = vertices[i].y; + float z = vertices[i].z; + + posXExtent = Math.max(posXExtent, x); + posYExtent = Math.max(posYExtent, y); + posZExtent = Math.max(posZExtent, z); + negXExtent = Math.min(negXExtent, x); + negYExtent = Math.min(negYExtent, y); + negZExtent = Math.min(negZExtent, z); + + if (x != lastX || y != lastY || z != lastZ) { + xSum += x; + ySum += y; + zSum += z; + uniqueQuads++; + } + if (i != 3) { + lastX = x; + lastY = y; + lastZ = z; + } + } + + // shrink quad in non-normal directions to prevent intersections caused by + // epsilon offsets applied by FluidRenderer + if (facing != ModelQuadFacing.POS_X && facing != ModelQuadFacing.NEG_X) { + posXExtent -= QUANTIZE_EPSILON; + negXExtent += QUANTIZE_EPSILON; + if (negXExtent > posXExtent) { + negXExtent = posXExtent; + } + } + if (facing != ModelQuadFacing.POS_Y && facing != ModelQuadFacing.NEG_Y) { + posYExtent -= QUANTIZE_EPSILON; + negYExtent += QUANTIZE_EPSILON; + if (negYExtent > posYExtent) { + negYExtent = posYExtent; + } + } + if (facing != ModelQuadFacing.POS_Z && facing != ModelQuadFacing.NEG_Z) { + posZExtent -= QUANTIZE_EPSILON; + negZExtent += QUANTIZE_EPSILON; + if (negZExtent > posZExtent) { + negZExtent = posZExtent; + } + } + + // POS_X, POS_Y, POS_Z, NEG_X, NEG_Y, NEG_Z + float[] extents = new float[] { posXExtent, posYExtent, posZExtent, negXExtent, negYExtent, negZExtent }; + + int direction = facing.ordinal(); + var quadList = this.quadLists[direction]; + if (quadList == null) { + quadList = new ReferenceArrayList<>(); + this.quadLists[direction] = quadList; + } + + if (facing.isAligned()) { + // only update global extents if there are no unaligned quads since this is only + // used for the convex box test which doesn't work with unaligned quads anyway + if (!this.hasUnaligned) { + this.extents[0] = Math.max(this.extents[0], posXExtent); + this.extents[1] = Math.max(this.extents[1], posYExtent); + this.extents[2] = Math.max(this.extents[2], posZExtent); + this.extents[3] = Math.min(this.extents[3], negXExtent); + this.extents[4] = Math.min(this.extents[4], negYExtent); + this.extents[5] = Math.min(this.extents[5], negZExtent); + } + + var quad = TQuad.fromAligned(facing, extents); + quadList.add(quad); + + var extreme = this.alignedExtremes[direction]; + var distance = quad.getDotProduct(); + + // check if this is a new dot product for this distance + var existingExtreme = this.alignedExtremes[direction]; + if (!this.alignedExtentsMultiple && !Float.isInfinite(existingExtreme) && existingExtreme != distance) { + this.alignedExtentsMultiple = true; + } + + // update the aligned extremes (which are the direction dependent dot products) + if (facing.getSign() > 0) { + this.alignedExtremes[direction] = Math.max(extreme, distance); + } else { + this.alignedExtremes[direction] = Math.min(extreme, distance); + } + } else { + this.hasUnaligned = true; + + var centerX = xSum / uniqueQuads; + var centerY = ySum / uniqueQuads; + var centerZ = zSum / uniqueQuads; + var center = new Vector3f(centerX, centerY, centerZ); + + var quad = TQuad.fromUnaligned(facing, extents, center, packedNormal); + quadList.add(quad); + + // update the two unaligned normals that are tracked + var distance = quad.getDotProduct(); + if (packedNormal == this.unalignedANormal) { + if (Float.isNaN(this.unalignedADistance1)) { + this.unalignedADistance1 = distance; + } else { + this.unalignedADistance2 = distance; + } + } else if (packedNormal == this.unalignedBNormal) { + if (Float.isNaN(this.unalignedBDistance1)) { + this.unalignedBDistance1 = distance; + } else { + this.unalignedBDistance2 = distance; + } + } else if (this.unalignedANormal == -1) { + this.unalignedANormal = packedNormal; + this.unalignedADistance1 = distance; + } else if (this.unalignedBNormal == -1) { + this.unalignedBNormal = packedNormal; + this.unalignedBDistance1 = distance; + } + } + } + + /** + * Filters the given sort type to fit within the selected sorting mode. If it + * doesn't match, then it's set to the NONE sort type. + * + * @param sortType the sort type to filter + */ + private static SortType filterSortType(SortType sortType) { + SortBehavior sortBehavior = SodiumClientMod.options().performance.getSortBehavior(); + switch (sortBehavior) { + case OFF: + return SortType.NONE; + case STATIC: + if (sortType == SortType.STATIC_NORMAL_RELATIVE || sortType == SortType.STATIC_TOPO) { + return sortType; + } else { + return SortType.NONE; + } + default: + return sortType; + } + } + + /** + * Array of how many quads a section can have with a given number of unique + * normals so that a static topo sort is attempted on it. -1 means the value is + * unused and doesn't make sense to give. + */ + private static final int[] STATIC_TOPO_SORT_ATTEMPT_LIMITS = new int[] { -1, -1, 250, 100, 50, 30 }; + + /** + * Determines the sort type for the collected geometry from the section. It + * determines a sort type, which is either no sorting, a static sort or a + * dynamic sort (section in GFNI only in this case). + * + * See the section on special cases for an explanation of the special sorting + * cases: ... + * + * A: If there are no or only one normal, this builder can be considered + * practically empty. + * + * B: If there are two face planes with opposing normals at the same distance, + * then + * they can't be seen through each other and this section can be ignored. + * + * C: If the translucent faces are on the surface of the convex hull of all + * translucent faces in the section and face outwards, then there is no way to + * see one through another. Since convex hulls are hard, a simpler case only + * uses the axis aligned normals: Under the condition that only aligned normals + * are used in the section, tracking the bounding box of the translucent + * geometry (the vertices) in the section and then checking if the normal + * distances line up with the bounding box allows the exclusion of some + * sections containing a single convex translucent cuboid (of which not all + * faces need to exist). + * + * D: If there are only two normals which are opposites of + * each other, then a special fixed sort order is always a correct sort order. + * This ordering sorts the two sets of face planes by their ascending + * normal-relative distance. The ordering between the two normals is irrelevant + * as they can't be seen through each other anyway. + * + * More heuristics can be performed here to conservatively determine if this + * section could possibly have more than one translucent sort order. + * + * @return the required sort type to ensure this section always looks correct + */ + private SortType sortTypeHeuristic() { + SortBehavior sortBehavior = SodiumClientMod.options().performance.getSortBehavior(); + if (sortBehavior.getSortMode() == SortBehavior.SortMode.NONE) { + return SortType.NONE; + } + + int alignedNormalCount = Integer.bitCount(this.alignedFacingBitmap); + int planeCount = getPlaneCount(alignedNormalCount); + + int unalignedNormalCount = 0; + if (this.unalignedANormal != -1) { + unalignedNormalCount++; + } + if (this.unalignedBNormal != -1) { + unalignedNormalCount++; + } + + int normalCount = alignedNormalCount + unalignedNormalCount; + + // special case A + if (planeCount <= 1) { + return SortType.NONE; + } + + if (!this.hasUnaligned) { + boolean opposingAlignedNormals = this.alignedFacingBitmap == ModelQuadFacing.OPPOSING_X + || this.alignedFacingBitmap == ModelQuadFacing.OPPOSING_Y + || this.alignedFacingBitmap == ModelQuadFacing.OPPOSING_Z; + + // special case B + // if there are just two normals, they are exact opposites of each other and they + // each only have one distance, there is no way to see through one face to the + // other. + if (planeCount == 2 && opposingAlignedNormals) { + return SortType.NONE; + } + + // special case C + // the more complex test that checks for distances aligned with the bounding box + if (!this.alignedExtentsMultiple) { + boolean passesBoundingBoxTest = true; + for (int direction = 0; direction < ModelQuadFacing.DIRECTIONS; direction++) { + var extreme = this.alignedExtremes[direction]; + if (Float.isInfinite(extreme)) { + continue; + } + + // check the distance against the bounding box + var sign = direction < 3 ? 1 : -1; + if (sign * extreme != this.extents[direction]) { + passesBoundingBoxTest = false; + break; + } + } + if (passesBoundingBoxTest) { + return SortType.NONE; + } + } + + // special case D + // there are up to two normals that are opposing, this means no dynamic sorting + // is necessary. Without static sorting, the geometry to trigger on could be + // reduced but this isn't done here as we assume static sorting is possible. + if (opposingAlignedNormals || alignedNormalCount == 1) { + return SortType.STATIC_NORMAL_RELATIVE; + } + } else if (alignedNormalCount == 0) { + // special case D but for one normal or two opposing unaligned normals + if (unalignedNormalCount == 1 + || unalignedNormalCount == 2 && NormI8.isOpposite(this.unalignedANormal, this.unalignedBNormal)) { + return SortType.STATIC_NORMAL_RELATIVE; + } + } else if (planeCount == 2) { // implies normalCount == 2 + // special case D with mixed aligned and unaligned normals + int alignedDirection = Integer.numberOfTrailingZeros(this.alignedFacingBitmap); + if (NormI8.isOpposite(this.unalignedANormal, ModelQuadFacing.PACKED_ALIGNED_NORMALS[alignedDirection])) { + return SortType.STATIC_NORMAL_RELATIVE; + } + } + + // use the given set of quad count limits to determine if a static topo sort + // should be attempted + + var attemptLimitIndex = Math.max(Math.min(normalCount, STATIC_TOPO_SORT_ATTEMPT_LIMITS.length - 1), 2); + if (this.quads.length <= STATIC_TOPO_SORT_ATTEMPT_LIMITS[attemptLimitIndex]) { + return SortType.STATIC_TOPO; + } + + return SortType.DYNAMIC; + } + + private int getPlaneCount(int alignedNormalCount) { + int alignedPlaneCount = alignedNormalCount; + if (this.alignedExtentsMultiple) { + alignedPlaneCount = 100; + } + + int unalignedPlaneCount = 0; + if (!Float.isNaN(this.unalignedADistance1)) { + unalignedPlaneCount++; + } + if (!Float.isNaN(this.unalignedADistance2)) { + unalignedPlaneCount++; + } + if (!Float.isNaN(this.unalignedBDistance1)) { + unalignedPlaneCount++; + } + if (!Float.isNaN(this.unalignedBDistance2)) { + unalignedPlaneCount++; + } + + return alignedPlaneCount + unalignedPlaneCount; + } + + public SortType finishRendering() { + // combine the quads into one array + int totalQuadCount = 0; + for (var quadList : this.quadLists) { + if (quadList != null) { + totalQuadCount += quadList.size(); + } + } + this.quads = new TQuad[totalQuadCount]; + int quadIndex = 0; + for (int direction = 0; direction < ModelQuadFacing.COUNT; direction++) { + var quadList = this.quadLists[direction]; + if (quadList != null) { + for (var quad : quadList) { + this.quads[quadIndex++] = quad; + } + if (direction < ModelQuadFacing.DIRECTIONS) { + this.alignedFacingBitmap |= 1 << direction; + } + } + } + this.quadLists = null; // not needed anymore + + this.sortType = filterSortType(sortTypeHeuristic()); + return this.sortType; + } + + private TranslucentData makeNewTranslucentData(BuiltSectionMeshParts translucentMesh, CombinedCameraPos cameraPos, + TranslucentData oldData) { + if (this.sortType == SortType.NONE) { + return AnyOrderData.fromMesh(translucentMesh, this.quads, this.sectionPos, null); + } + + if (this.sortType == SortType.STATIC_NORMAL_RELATIVE) { + var isDoubleUnaligned = this.alignedFacingBitmap == 0; + return StaticNormalRelativeData.fromMesh(translucentMesh, this.quads, this.sectionPos, isDoubleUnaligned); + } + + // from this point on we know the estimated sort type requires direction mixing + // (no backface culling) and all vertices are in the UNASSIGNED direction. + NativeBuffer buffer = PresentTranslucentData.nativeBufferForQuads(this.quads); + if (this.sortType == SortType.STATIC_TOPO) { + var result = StaticTopoAcyclicData.fromMesh(translucentMesh, this.quads, this.sectionPos, buffer); + if (result != null) { + return result; + } + this.sortType = SortType.DYNAMIC; + } + + // filter the sort type with the user setting and re-evaluate + this.sortType = filterSortType(this.sortType); + + if (this.sortType == SortType.NONE) { + return AnyOrderData.fromMesh(translucentMesh, this.quads, this.sectionPos, buffer); + } + + if (this.sortType == SortType.DYNAMIC) { + try { + return BSPDynamicData.fromMesh( + translucentMesh, cameraPos, this.quads, this.sectionPos, + buffer, oldData); + } catch (BSPBuildFailureException e) { + var geometryPlanes = GeometryPlanes.fromQuadLists(this.sectionPos, this.quads); + return TopoSortDynamicData.fromMesh( + translucentMesh, cameraPos, this.quads, this.sectionPos, + geometryPlanes, buffer); + } + } + + throw new IllegalStateException("Unknown sort type: " + this.sortType); + } + + private int getQuadHash(TQuad[] quads) { + if (this.quadHashPresent) { + return this.quadHash; + } + + for (int i = 0; i < quads.length; i++) { + var quad = quads[i]; + this.quadHash = this.quadHash * 31 + quad.getQuadHash() + i * 3; + } + this.quadHashPresent = true; + return this.quadHash; + } + + public TranslucentData getTranslucentData( + TranslucentData oldData, BuiltSectionMeshParts translucentMesh, CombinedCameraPos cameraPos) { + // means there is no translucent geometry + if (translucentMesh == null) { + return NoData.forNoTranslucent(this.sectionPos); + } + + // re-use the original translucent data if it's the same. This reduces the + // amount of generated and uploaded index data when sections are rebuilt without + // relevant changes to translucent geometry. Rebuilds happen when any part of + // the section changes, including the here irrelevant cases of changes to opaque + // geometry or light levels. + if (oldData != null) { + // for the NONE sort type the ranges need to be the same, the actual geometry + // doesn't matter + if (this.sortType == SortType.NONE && oldData instanceof AnyOrderData oldAnyData + && oldAnyData.getLength() == this.quads.length + && Arrays.equals(oldAnyData.getVertexRanges(), translucentMesh.getVertexRanges())) { + oldAnyData.setReuseUploadedData(); + return oldAnyData; + } + + // for the other sort types the geometry needs to be the same (checked with + // length and hash) + if (oldData instanceof PresentTranslucentData oldPresentData) { + if (oldPresentData.getLength() == this.quads.length + && oldPresentData.getQuadHash() == getQuadHash(this.quads)) { + oldPresentData.setReuseUploadedData(); + return oldPresentData; + } + } + } + + var newData = makeNewTranslucentData(translucentMesh, cameraPos, oldData); + if (newData instanceof PresentTranslucentData presentData) { + presentData.setQuadHash(getQuadHash(this.quads)); + } + return newData; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPBuildFailureException.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPBuildFailureException.java new file mode 100644 index 0000000000..af192ed352 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPBuildFailureException.java @@ -0,0 +1,7 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +public class BSPBuildFailureException extends RuntimeException { + BSPBuildFailureException(String message) { + super(message); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPNode.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPNode.java new file mode 100644 index 0000000000..20a48780d1 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPNode.java @@ -0,0 +1,113 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import org.joml.Vector3fc; + +import it.unimi.dsi.fastutil.ints.IntArrayList; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TopoGraphSorting; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.caffeinemc.mods.sodium.api.util.NormI8; +import net.minecraft.core.SectionPos; + +/** + * A node in the BSP tree. The BSP tree is made up of nodes that split quads + * into groups on either side of a plane and those that lie on the plane. + * There's also leaf nodes that contain one or more quads. + * + * Implementation note: + * - Doing a convex box test doesn't seem to bring a performance boost, even if + * it does trigger sometimes with man-made structures. The multi partition node + * probably does most of the work already. + * - Checking if the given quads are all coplanar doesn't recoup the cost of + * iterating through all the quads. It also doesn't significantly reduce the + * number of triggering planes (which would have a performance and memory usage + * benefit). + */ +public abstract class BSPNode { + + abstract void collectSortedQuads(BSPSortState sortState, Vector3fc cameraPos); + + public void collectSortedQuads(NativeBuffer nativeBuffer, Vector3fc cameraPos) { + this.collectSortedQuads(new BSPSortState(nativeBuffer), cameraPos); + } + + public static BSPResult buildBSP(TQuad[] quads, SectionPos sectionPos, BSPNode oldRoot, + boolean prepareNodeReuse) { + // throw if there's too many quads + InnerPartitionBSPNode.validateQuadCount(quads.length); + + // create a workspace and then the nodes figure out the recursive building. + // throws if the BSP can't be built, null if none is necessary + var workspace = new BSPWorkspace(quads, sectionPos, prepareNodeReuse); + + // initialize the indexes to all quads + int[] initialIndexes = new int[quads.length]; + for (int i = 0; i < quads.length; i++) { + initialIndexes[i] = i; + } + var allIndexes = new IntArrayList(initialIndexes); + + var rootNode = BSPNode.build(workspace, allIndexes, -1, oldRoot); + var result = workspace.result; + result.setRootNode(rootNode); + return result; + } + + private static boolean doubleLeafPossible(TQuad quadA, TQuad quadB) { + // check for coplanar or mutually invisible quads + var facingA = quadA.getFacing(); + var facingB = quadB.getFacing(); + + // coplanar not aligned + if (!facingA.isAligned() || !facingB.isAligned()) { + var packedNormalA = quadA.getPackedNormal(); + var packedNormalB = quadB.getPackedNormal(); + // opposite normal (distance irrelevant) + if (NormI8.isOpposite(packedNormalA, packedNormalB) + // same normal and same distance + || packedNormalA == packedNormalB && quadA.getDotProduct() == quadB.getDotProduct()) { + return true; + } + } + + // coplanar aligned + else if (quadA.getExtents()[facingA.ordinal()] == quadB.getExtents()[facingB.ordinal()]) { + return true; + } + + // aligned facing away from each other + else if (facingA == facingB.getOpposite()) { + return true; + } + + // aligned otherwise mutually invisible + else { + return !TopoGraphSorting.orthogonalQuadVisibleThrough(quadA, quadB) + && !TopoGraphSorting.orthogonalQuadVisibleThrough(quadB, quadA); + } + + return false; + } + + static BSPNode build(BSPWorkspace workspace, IntArrayList indexes, int depth, BSPNode oldNode) { + depth++; + + // pick which type of node to create for the given workspace + if (indexes.isEmpty()) { + return null; + } else if (indexes.size() == 1) { + return new LeafSingleBSPNode(indexes.getInt(0)); + } else if (indexes.size() == 2) { + var quadIndexA = indexes.getInt(0); + var quadIndexB = indexes.getInt(1); + var quadA = workspace.quads[quadIndexA]; + var quadB = workspace.quads[quadIndexB]; + + if (doubleLeafPossible(quadA, quadB)) { + return new LeafDoubleBSPNode(quadIndexA, quadIndexB); + } + } + + return InnerPartitionBSPNode.build(workspace, indexes, depth, oldNode); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPResult.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPResult.java new file mode 100644 index 0000000000..44911007b9 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPResult.java @@ -0,0 +1,19 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.GeometryPlanes; + +/** + * The result of a BSP building operation. Building a BSP returns the root node + * along with the partition planes that need to be added to the trigger system. + */ +public class BSPResult extends GeometryPlanes { + private BSPNode rootNode; + + public BSPNode getRootNode() { + return this.rootNode; + } + + public void setRootNode(BSPNode rootNode) { + this.rootNode = rootNode; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPSortState.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPSortState.java new file mode 100644 index 0000000000..8dec551663 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPSortState.java @@ -0,0 +1,320 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import java.nio.IntBuffer; +import java.lang.Math; + +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntConsumer; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TranslucentData; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; + +/** + * The sort state is passed around the tree (similar to visitor pattern) and + * contains the index buffer being written to alongside additional state for + * remapping indexes when traversing the subtree of a reused node. + */ +class BSPSortState { + static final int NO_FIXED_OFFSET = Integer.MIN_VALUE; + + private IntBuffer indexBuffer; + + private int indexModificationsRemaining; + private int[] indexMap; + private int fixedIndexOffset = NO_FIXED_OFFSET; + + BSPSortState(NativeBuffer nativeBuffer) { + this.indexBuffer = nativeBuffer.getDirectBuffer().asIntBuffer(); + } + + void startNode(InnerPartitionBSPNode node) { + if (node.indexMap != null) { + if (this.indexMap != null || this.fixedIndexOffset != NO_FIXED_OFFSET) { + throw new IllegalStateException("Index modification already in progress"); + } + + this.indexMap = node.indexMap; + this.indexModificationsRemaining = node.reuseData.indexCount(); + } else if (node.fixedIndexOffset != NO_FIXED_OFFSET) { + if (this.indexMap != null || this.fixedIndexOffset != NO_FIXED_OFFSET) { + throw new IllegalStateException("Index modification already in progress"); + } + + this.fixedIndexOffset = node.fixedIndexOffset; + this.indexModificationsRemaining = node.reuseData.indexCount(); + } + } + + private void checkModificationCounter(int reduceBy) { + this.indexModificationsRemaining -= reduceBy; + if (this.indexModificationsRemaining <= 0) { + this.indexMap = null; + this.fixedIndexOffset = NO_FIXED_OFFSET; + } + } + + void writeIndex(int index) { + if (this.indexMap != null) { + TranslucentData.writeQuadVertexIndexes(this.indexBuffer, this.indexMap[index]); + checkModificationCounter(1); + } else if (this.fixedIndexOffset != NO_FIXED_OFFSET) { + TranslucentData.writeQuadVertexIndexes(this.indexBuffer, this.fixedIndexOffset + index); + checkModificationCounter(1); + } else { + TranslucentData.writeQuadVertexIndexes(this.indexBuffer, index); + } + } + + /** + * The minimum size of an index array that will be compressed. This value is + * non-zero to avoid wasting work on compressing arrays that won't benefit from + * it and the overhead in setting up the compression. Empirically, the + * compression ratio is only high for the very largest arrays and largely + * useless for smaller ones. + */ + private static final int INDEX_COMPRESSION_MIN_LENGTH = 32; + + private static final int HEADER_LENGTH = 2; + private static final int[] WIDTHS = new int[] { 1, 2, 3, 4, 5, 6, 8, 10, 16, 32 }; + private static final int CONSTANT_DELTA_WIDTH_INDEX = 15; + + /** + * ceilDiv was introduced to the JDK in Java 18 but is not available in the here + * used Java 17. + */ + private static int ceilDiv(int x, int y) { + return -Math.floorDiv(-x, y); + } + + private static boolean isOutOfBounds(int size) { + return size < INDEX_COMPRESSION_MIN_LENGTH || size > 1 << 10; + } + + static int[] compressIndexesInPlace(int[] indexes, boolean doSort) { + if (isOutOfBounds(indexes.length)) { + return indexes; + } + return compressIndexes(IntArrayList.wrap(indexes), doSort); + } + + static int[] compressIndexes(IntArrayList indexes) { + return compressIndexes(indexes, true); + } + + /** + * Compress a list of quad indexes by applying run length encoding or bit + * packing to their deltas. + * + * Format: 32 bits, elements described as [length in bits: description] + * header at position 0: 0b1[4: width index][10: delta count][17: first index] + * header at position 1: 0b[32: base delta] + * deltas at position 2..n: 0b[width: delta]... + * + * delta bit widths: + * 1x32b, 2x16b, 3x10b, 4x8b, 5x6b, + * 6x5b, 8x4b, 10x3b, 16x2b, 32x1b + */ + static int[] compressIndexes(IntArrayList indexes, boolean doSort) { + // bail on short lists + if (isOutOfBounds(indexes.size())) { + return indexes.toIntArray(); + } + + IntArrayList workingList = new IntArrayList(indexes); + + // sort for better compression, this also ensures that deltas are positive but + // as small as possible. + if (doSort) { + workingList.sort(null); + } + + // replace indexes with deltas + int last = workingList.getInt(0); + int minDelta = Integer.MAX_VALUE; + int maxDelta = 0; + for (int i = 1; i < workingList.size(); i++) { + int current = workingList.getInt(i); + int delta = current - last; + workingList.set(i, delta); + last = current; + if (delta < minDelta) { + minDelta = delta; + } + if (delta > maxDelta) { + maxDelta = delta; + } + } + int deltaRangeWidth = Integer.SIZE - Integer.numberOfLeadingZeros(maxDelta - minDelta); + + // stop if the first index is too large + int firstIndex = workingList.getInt(0); + if (firstIndex > 1 << 17) { + return indexes.toIntArray(); + } + + int deltaCount = workingList.size() - 1; + + // special case 0 bit delta + if (deltaRangeWidth == 0) { + // this means there's just a sequential list of indexes with each one +1 + var compressed = new int[HEADER_LENGTH]; + + // signal with special width index + compressed[0] = 1 << 31 | CONSTANT_DELTA_WIDTH_INDEX << 27 | deltaCount << 17 | firstIndex; + compressed[1] = minDelta; + + return compressed; + } + + // stop if the width is too large (and compression would make no sense) + if (deltaRangeWidth > 16) { + return indexes.toIntArray(); + } + + // find the smallest bit width that can represent the deltas + int widthIndex = 0; + while (WIDTHS[widthIndex] < deltaRangeWidth) { + widthIndex++; + } + int width = WIDTHS[widthIndex]; + int countPerInt = WIDTHS[WIDTHS.length - widthIndex - 1]; + + // figure out the size of the compressed index array + int size = HEADER_LENGTH + ceilDiv(deltaCount, countPerInt); + int[] compressed = new int[size]; + + // write the header + compressed[0] = 1 << 31 | widthIndex << 27 | deltaCount << 17 | firstIndex; + compressed[1] = minDelta; + + // write the deltas + final int positionLimit = Integer.SIZE - width; + int outputIndex = HEADER_LENGTH; + int gatherInt = 0; + int bitPosition = 0; + for (int i = 1; i < workingList.size(); i++) { + int shiftedDelta = workingList.getInt(i) - minDelta; + gatherInt |= shiftedDelta << bitPosition; + bitPosition += width; + if (bitPosition > positionLimit) { + compressed[outputIndex++] = gatherInt; + gatherInt = 0; + bitPosition = 0; + } + } + + // flush the last int if it hasn't been written yet + if (bitPosition > 0) { + compressed[outputIndex++] = gatherInt; + } + + return compressed; + } + + static int decompressOrRead(int[] indexes, IntConsumer consumer) { + if (isCompressed(indexes)) { + return decompress(indexes, consumer); + } else { + for (int i = 0; i < indexes.length; i++) { + consumer.accept(indexes[i]); + } + return indexes.length; + } + } + + private static int decompress(int[] indexes, IntConsumer consumer) { + return decompressWithOffset(indexes, 0, consumer); + } + + private static int decompressWithOffset(int[] indexes, int fixedIndexOffset, IntConsumer consumer) { + // read compression header + int header1 = indexes[0]; + int widthIndex = (header1 >> 27) & 0b1111; + int currentValue = header1 & 0b11111111111111111 + fixedIndexOffset; + int valueCount = ((header1 >> 17) & 0b1111111111) + 1; + int baseDelta = indexes[1]; + + // handle special case of width index 0, this means there's no delta data + if (widthIndex == CONSTANT_DELTA_WIDTH_INDEX) { + for (int i = 0; i < valueCount; i++) { + consumer.accept(currentValue); + currentValue += baseDelta; + } + + return valueCount; + } + + int width = WIDTHS[widthIndex]; + int mask = (1 << width) - 1; + + // write value (optionally map), read deltas, apply base delta and loop + final int positionLimit = Integer.SIZE - width; + int readIndex = HEADER_LENGTH; + int splitInt = indexes[readIndex++]; + int splitIntBitPosition = 0; + int totalValueCount = valueCount; + while (valueCount-- > 0) { + consumer.accept(currentValue); + + // read the next delta if there is one + if (valueCount == 0) { + break; + } + + int delta = (splitInt >> splitIntBitPosition) & mask; + splitIntBitPosition += width; + if (splitIntBitPosition > positionLimit && valueCount > 1) { + splitInt = indexes[readIndex++]; + splitIntBitPosition = 0; + } + + // update the current value with the delta and base delta + currentValue += baseDelta + delta; + } + + return totalValueCount; + } + + static boolean isCompressed(int[] indexes) { + return indexes[0] < 0; + } + + private IntConsumer indexConsumer = (int index) -> TranslucentData.writeQuadVertexIndexes( + this.indexBuffer, index); + + private IntConsumer indexMapConsumer = (int index) -> TranslucentData.writeQuadVertexIndexes( + this.indexBuffer, this.indexMap[index]); + + void writeIndexes(int[] indexes) { + boolean useIndexMap = this.indexMap != null; + boolean useFixedIndexOffset = this.fixedIndexOffset != NO_FIXED_OFFSET; + + int valueCount; + if (isCompressed(indexes)) { + if (useFixedIndexOffset) { + valueCount = decompressWithOffset(indexes, this.fixedIndexOffset, this.indexConsumer); + } else { + valueCount = decompress(indexes, useIndexMap ? this.indexMapConsumer : this.indexConsumer); + } + } else { + // uncompressed indexes + if (useIndexMap) { + for (int i = 0; i < indexes.length; i++) { + TranslucentData.writeQuadVertexIndexes(this.indexBuffer, this.indexMap[indexes[i]]); + } + } else if (useFixedIndexOffset) { + for (int i = 0; i < indexes.length; i++) { + TranslucentData.writeQuadVertexIndexes(this.indexBuffer, this.fixedIndexOffset + indexes[i]); + } + } else { + TranslucentData.writeQuadVertexIndexes(this.indexBuffer, indexes); + } + valueCount = indexes.length; + } + + // check if the index modification session is over. this is very important or + // there's an exception + if (useIndexMap || useFixedIndexOffset) { + checkModificationCounter(valueCount); + } + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPWorkspace.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPWorkspace.java new file mode 100644 index 0000000000..48e30bacc4 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/BSPWorkspace.java @@ -0,0 +1,44 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.GeometryPlanes; +import net.minecraft.core.SectionPos; + +/** + * The BSP workspace holds the state during the BSP building process. (see also + * BSPSortState) It brings a number of fixed parameters and receives partition + * planes to return as part of the final result. + * + * Implementation note: Storing the multi partition node's interval points in a + * global array instead of making a new one at each tree level doesn't appear to + * have any performance benefit. + */ +class BSPWorkspace { + /** + * All the quads in the section. + */ + final TQuad[] quads; + + final SectionPos sectionPos; + + final BSPResult result = new BSPResult(); + + final boolean prepareNodeReuse; + + BSPWorkspace(TQuad[] quads, SectionPos sectionPos, boolean prepareNodeReuse) { + this.quads = quads; + this.sectionPos = sectionPos; + this.prepareNodeReuse = prepareNodeReuse; + } + + // TODO: better bidirectional triggering: integrate bidirectionality in GFNI if + // top-level topo sorting isn't used anymore (and only use half as much memory + // by not storing it double) + void addAlignedPartitionPlane(int axis, float distance) { + result.addDoubleSidedPlane(this.sectionPos, axis, distance); + } + + GeometryPlanes getGeometryPlanes() { + return result; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerBinaryPartitionBSPNode.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerBinaryPartitionBSPNode.java new file mode 100644 index 0000000000..4151684714 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerBinaryPartitionBSPNode.java @@ -0,0 +1,102 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import org.joml.Vector3fc; + +import it.unimi.dsi.fastutil.ints.IntArrayList; + +/** + * Partitions quads into two sides, each its own BSP node, of a partition plane + * and a set of quads that lie on the plane. + */ +class InnerBinaryPartitionBSPNode extends InnerPartitionBSPNode { + private final float planeDistance; + + // side towards which the normal points + private final BSPNode inside; // nullable + private final BSPNode outside; // nullable + private final int[] onPlaneQuads; + + InnerBinaryPartitionBSPNode(NodeReuseData reuseData, float planeDistance, int axis, + BSPNode inside, BSPNode outside, int[] onPlaneQuads) { + super(reuseData, axis); + this.planeDistance = planeDistance; + this.inside = inside; + this.outside = outside; + this.onPlaneQuads = onPlaneQuads; + } + + @Override + void addPartitionPlanes(BSPWorkspace workspace) { + workspace.addAlignedPartitionPlane(this.axis, this.planeDistance); + + // also add the planes of the children + if (this.inside instanceof InnerPartitionBSPNode insideChild) { + insideChild.addPartitionPlanes(workspace); + } + if (this.outside instanceof InnerPartitionBSPNode outsideChild) { + outsideChild.addPartitionPlanes(workspace); + } + } + + private void collectInside(BSPSortState sortState, Vector3fc cameraPos) { + if (this.inside != null) { + this.inside.collectSortedQuads(sortState, cameraPos); + } + } + + private void collectOutside(BSPSortState sortState, Vector3fc cameraPos) { + if (this.outside != null) { + this.outside.collectSortedQuads(sortState, cameraPos); + } + } + + @Override + void collectSortedQuads(BSPSortState sortState, Vector3fc cameraPos) { + sortState.startNode(this); + + var cameraInside = this.planeNormal.dot(cameraPos) < this.planeDistance; + if (cameraInside) { + this.collectOutside(sortState, cameraPos); + } else { + this.collectInside(sortState, cameraPos); + } + if (this.onPlaneQuads != null) { + sortState.writeIndexes(this.onPlaneQuads); + } + if (cameraInside) { + this.collectInside(sortState, cameraPos); + } else { + this.collectOutside(sortState, cameraPos); + } + } + + static BSPNode buildFromPartitions(BSPWorkspace workspace, IntArrayList indexes, int depth, BSPNode oldNode, + Partition inside, Partition outside, int axis) { + var partitionDistance = inside.distance(); + workspace.addAlignedPartitionPlane(axis, partitionDistance); + + BSPNode oldInsideNode = null; + BSPNode oldOutsideNode = null; + if (oldNode instanceof InnerBinaryPartitionBSPNode binaryNode + && binaryNode.axis == axis + && binaryNode.planeDistance == partitionDistance) { + oldInsideNode = binaryNode.inside; + oldOutsideNode = binaryNode.outside; + } + + BSPNode insideNode = null; + BSPNode outsideNode = null; + if (inside.quadsBefore() != null) { + insideNode = BSPNode.build(workspace, inside.quadsBefore(), depth, oldInsideNode); + } + if (outside != null) { + outsideNode = BSPNode.build(workspace, outside.quadsBefore(), depth, oldOutsideNode); + } + var onPlane = inside.quadsOn() == null ? null : BSPSortState.compressIndexes(inside.quadsOn()); + + return new InnerBinaryPartitionBSPNode( + prepareNodeReuse(workspace, indexes, depth), + partitionDistance, axis, + insideNode, outsideNode, onPlane); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerMultiPartitionBSPNode.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerMultiPartitionBSPNode.java new file mode 100644 index 0000000000..8bc3d0beeb --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerMultiPartitionBSPNode.java @@ -0,0 +1,159 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import org.joml.Vector3fc; + +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.objects.ReferenceArrayList; + +/** + * Partitions quads into multiple child BSP nodes with multiple parallel + * partition planes. This is uses less memory and time than constructing a + * binary BSP tree through more partitioning passes. + * + * Implementation note: Detecting and avoiding the double array when possible + * brings no performance benefit in sorting speed, only a building speed + * detriment. + */ +class InnerMultiPartitionBSPNode extends InnerPartitionBSPNode { + private final float[] planeDistances; // one less than there are partitions + + private final BSPNode[] partitions; + private final int[][] onPlaneQuads; + + InnerMultiPartitionBSPNode(NodeReuseData reuseData, int axis, float[] planeDistances, + BSPNode[] partitions, int[][] onPlaneQuads) { + super(reuseData, axis); + this.planeDistances = planeDistances; + this.partitions = partitions; + this.onPlaneQuads = onPlaneQuads; + } + + @Override + void addPartitionPlanes(BSPWorkspace workspace) { + for (int i = 0; i < this.planeDistances.length; i++) { + workspace.addAlignedPartitionPlane(this.axis, this.planeDistances[i]); + } + + // recurse on children to also add their planes + for (var partition : this.partitions) { + if (partition instanceof InnerPartitionBSPNode inner) { + inner.addPartitionPlanes(workspace); + } + } + } + + private void collectPlaneQuads(BSPSortState sortState, int planeIndex) { + if (this.onPlaneQuads[planeIndex] != null) { + sortState.writeIndexes(this.onPlaneQuads[planeIndex]); + } + } + + private void collectPartitionQuads(BSPSortState sortState, int partitionIndex, Vector3fc cameraPos) { + if (this.partitions[partitionIndex] != null) { + this.partitions[partitionIndex].collectSortedQuads(sortState, cameraPos); + } + } + + @Override + void collectSortedQuads(BSPSortState sortState, Vector3fc cameraPos) { + sortState.startNode(this); + + // calculate the camera's distance. Then render the partitions in order of + // distance to the partition the camera is in. + var cameraDistance = this.planeNormal.dot(cameraPos); + + // forward sweep: collect quads until the camera is in the partition + for (int i = 0; i < this.planeDistances.length; i++) { + if (cameraDistance <= this.planeDistances[i]) { + // collect the plane the camera is in + var isOnPlane = cameraDistance == this.planeDistances[i]; + if (isOnPlane) { + this.collectPartitionQuads(sortState, i, cameraPos); + } + + // backwards sweep: collect all partitions backwards until the camera is reached + for (int j = this.planeDistances.length; j > i; j--) { + this.collectPartitionQuads(sortState, j, cameraPos); + this.collectPlaneQuads(sortState, j - 1); + } + + if (!isOnPlane) { + this.collectPartitionQuads(sortState, i, cameraPos); + } + + return; + } + + // collect the quads in the partition and on the plane + this.collectPartitionQuads(sortState, i, cameraPos); + this.collectPlaneQuads(sortState, i); + } + + // collect the last partition + this.collectPartitionQuads(sortState, this.planeDistances.length, cameraPos); + } + + static BSPNode buildFromPartitions(BSPWorkspace workspace, IntArrayList indexes, int depth, BSPNode oldNode, + ReferenceArrayList partitions, int axis, boolean endsWithPlane) { + int planeCount = endsWithPlane ? partitions.size() : partitions.size() - 1; + float[] planeDistances = new float[planeCount]; + BSPNode[] partitionNodes = new BSPNode[planeCount + 1]; + int[][] onPlaneQuads = new int[planeCount][]; + + BSPNode[] oldPartitionNodes = null; + float[] oldPlaneDistances = null; + int oldChildIndex = 0; + float oldPartitionDistance = 0; + if (oldNode instanceof InnerMultiPartitionBSPNode multiNode + && multiNode.axis == axis && multiNode.partitions.length > 0) { + oldPartitionNodes = multiNode.partitions; + oldPlaneDistances = multiNode.planeDistances; + oldPartitionDistance = multiNode.planeDistances[0]; + } + + // write the partition planes and nodes + for (int i = 0, count = partitions.size(); i < count; i++) { + var partition = partitions.get(i); + + // if the partition actually has a plane + float partitionDistance = -1; + if (endsWithPlane || i < count - 1) { + partitionDistance = partition.distance(); + workspace.addAlignedPartitionPlane(axis, partitionDistance); + + // NOTE: sanity check + if (partitionDistance == -1) { + throw new IllegalStateException("partition distance not set"); + } + + planeDistances[i] = partitionDistance; + } + + if (partition.quadsBefore() != null) { + BSPNode oldChild = null; + + if (oldPartitionNodes != null) { + // if there's a node that matches the partition's distance, use it as the old + // node. Search forwards through the old plane distances to find a candidate + while (oldChildIndex < oldPartitionNodes.length && oldPartitionDistance < partitionDistance) { + oldChildIndex++; + oldPartitionDistance = oldChildIndex < oldPlaneDistances.length + ? oldPlaneDistances[oldChildIndex] + : -1; + } + if (oldChildIndex < oldPartitionNodes.length && oldPartitionDistance == partitionDistance) { + oldChild = oldPartitionNodes[oldChildIndex]; + } + } + + partitionNodes[i] = BSPNode.build(workspace, partition.quadsBefore(), depth, oldChild); + } + if (partition.quadsOn() != null) { + onPlaneQuads[i] = BSPSortState.compressIndexes(partition.quadsOn()); + } + } + + return new InnerMultiPartitionBSPNode(prepareNodeReuse(workspace, indexes, depth), + axis, planeDistances, partitionNodes, onPlaneQuads); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerPartitionBSPNode.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerPartitionBSPNode.java new file mode 100644 index 0000000000..ce76d939a6 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/InnerPartitionBSPNode.java @@ -0,0 +1,437 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntConsumer; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.objects.ReferenceArrayList; +import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TopoGraphSorting; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.joml.Vector3fc; + +import java.util.Arrays; + +/** + * Performs aligned BSP partitioning of many nodes and constructs appropriate + * BSP nodes based on the result. + *

+ * Implementation notes: + * - Presorting the points in block-sized buckets doesn't help. It seems the + * sort algorithm is just fast enough to handle this. + * - Eliminating the use of partition objects doesn't help. Since there's + * usually just very few partitions, it's not worth it, it seems. + * - Using fastutil's LongArrays sorting options (radix and quicksort) is slower + * than using Arrays.sort (which uses DualPivotQuicksort internally), even on + * worlds with player-built structures. + * - A simple attempt at lazily writing index data to the buffer didn't yield a + * performance improvement. Maybe applying it to the multi partition node would + * be more effective (but also much more complex and slower). + *

+ * The encoding doesn't currently support negative distances (nor does such + * support appear to be required). Their ordering is wrong when sorting them by + * their binary representation. To fix this: "XOR all positive numbers with + * 0x8000... and negative numbers with 0xffff... This should flip the sign bit + * on both (so negative numbers go first), and then reverse the ordering on + * negative numbers." from StackOverflow + *

+ * When aligned partitioning fails the geometry is checked for intersection. If + * there is intersection it means the section is unsortable and an approximation + * is used instead. When it doesn't intersect but is not aligned partitionable, + * it either requires unaligned partitioning (a hard problem not solved here) + * or it's unpartitionable. It would be possible to insert a topo sorting node + * here, but it's not worth the implementation effort unless it's found to be a + * reasonable and common use case (I haven't been able to determine that it is). + */ +abstract class InnerPartitionBSPNode extends BSPNode { + private static final Logger LOGGER = LogManager.getLogger(InnerPartitionBSPNode.class); + + private static final int NODE_REUSE_THRESHOLD = 30; + + final Vector3fc planeNormal; + final int axis; + + int[] indexMap; + int fixedIndexOffset = BSPSortState.NO_FIXED_OFFSET; + final NodeReuseData reuseData; // nullable + + /** + * Stores data required for testing if the node can be re-used. This data is + * only generated for select candidate nodes. + *

+ * It only stores the set of indexes that this node was constructed from and + * their extents since the BSP construction only cares about the "opaque" quad + * geometry and not the normal or facing. + *

+ * Since the indexes might be compressed, the count needs to be stored + * separately from before compression. + */ + record NodeReuseData(float[][] quadExtents, int[] indexes, int indexCount, int maxIndex) { + } + + InnerPartitionBSPNode(NodeReuseData reuseData, int axis) { + this.planeNormal = ModelQuadFacing.ALIGNED_NORMALS[axis]; + this.axis = axis; + this.reuseData = reuseData; + } + + abstract void addPartitionPlanes(BSPWorkspace workspace); + + static NodeReuseData prepareNodeReuse(BSPWorkspace workspace, IntArrayList indexes, int depth) { + // if node reuse is enabled, only enable on the first level of children (not the + // root node and not anything deeper than its children) + if (workspace.prepareNodeReuse && depth == 1 && indexes.size() > NODE_REUSE_THRESHOLD) { + // collect the extents of the indexed quads and hash them + var quadExtents = new float[indexes.size()][]; + int maxIndex = -1; + for (int i = 0; i < indexes.size(); i++) { + var index = indexes.getInt(i); + var quad = workspace.quads[index]; + var extents = quad.getExtents(); + quadExtents[i] = extents; + maxIndex = Math.max(maxIndex, index); + } + + // compress indexes but without sorting them, as the order needs to be the same + // for the extents comparison loop to work + return new NodeReuseData( + quadExtents, + BSPSortState.compressIndexes(indexes, false), + indexes.size(), + maxIndex); + } + return null; + } + + private static class IndexRemapper implements IntConsumer { + private final int[] indexMap; + private final IntArrayList newIndexes; + private int index = 0; + private int firstOffset = 0; + + private static final int OFFSET_CHANGED = Integer.MIN_VALUE; + + IndexRemapper(int length, IntArrayList newIndexes) { + this.indexMap = new int[length]; + this.newIndexes = newIndexes; + } + + @Override + public void accept(int oldIndex) { + var newIndex = this.newIndexes.getInt(this.index); + this.indexMap[oldIndex] = newIndex; + var newOffset = newIndex - oldIndex; + if (this.index == 0) { + this.firstOffset = newOffset; + } else if (this.firstOffset != newOffset) { + this.firstOffset = OFFSET_CHANGED; + } + this.index++; + } + + boolean hasFixedOffset() { + return this.firstOffset != OFFSET_CHANGED; + } + } + + static InnerPartitionBSPNode attemptNodeReuse(BSPWorkspace workspace, IntArrayList newIndexes, InnerPartitionBSPNode oldNode) { + if (oldNode == null) { + return null; + } + + oldNode.indexMap = null; + oldNode.fixedIndexOffset = BSPSortState.NO_FIXED_OFFSET; + + var reuseData = oldNode.reuseData; + if (reuseData == null) { + return null; + } + + var oldExtents = reuseData.quadExtents; + if (oldExtents.length != newIndexes.size()) { + return null; + } + + for (int i = 0; i < newIndexes.size(); i++) { + if (!workspace.quads[newIndexes.getInt(i)].extentsEqual(oldExtents[i])) { + return null; + } + } + + // reuse old node and either apply a fixed offset or calculate an index map to + // map from old to new indices + var remapper = new IndexRemapper(reuseData.maxIndex + 1, newIndexes); + BSPSortState.decompressOrRead(reuseData.indexes, remapper); + + // use a fixed offset if possible (if all old indices differ from the new ones + // by the same amount) + if (remapper.hasFixedOffset()) { + oldNode.fixedIndexOffset = remapper.firstOffset; + } else { + oldNode.indexMap = remapper.indexMap; + } + + // import the triggering data from the old node to ensure it still triggers at + // the right time + oldNode.addPartitionPlanes(workspace); + + return oldNode; + } + + private static long encodeIntervalPoint(float distance, int quadIndex, int type) { + return ((long) Float.floatToRawIntBits(distance) << 32) | ((long) type << 30) | quadIndex; + } + + private static float decodeDistance(long encoded) { + return Float.intBitsToFloat((int) (encoded >>> 32)); + } + + private static int decodeQuadIndex(long encoded) { + return (int) (encoded & 0x3FFFFFFF); + } + + private static int decodeType(long encoded) { + return (int) (encoded >>> 30) & 0b11; + } + + public static void validateQuadCount(int quadCount) { + if (quadCount * 2 > 0x3FFFFFFF) { + throw new IllegalArgumentException("Too many quads: " + quadCount); + } + } + + // the indices of the type are chosen such that tie-breaking items that have the + // same distance with the type ascending yields a beneficial sort order + // (END of the current interval, on-edge quads, then the START of the next + // interval) + + // the start of a quad's extent in this direction + private static final int INTERVAL_START = 2; + + // end end of a quad's extent in this direction + private static final int INTERVAL_END = 0; + + // looking at a quad from the side where it has zero thickness + private static final int INTERVAL_SIDE = 1; + + static BSPNode build(BSPWorkspace workspace, IntArrayList indexes, int depth, BSPNode oldNode) { + // attempt reuse of the old node if possible + if (oldNode instanceof InnerPartitionBSPNode oldInnerNode) { + var reusedNode = InnerPartitionBSPNode.attemptNodeReuse(workspace, indexes, oldInnerNode); + if (reusedNode != null) { + return reusedNode; + } + } + + ReferenceArrayList partitions = new ReferenceArrayList<>(); + LongArrayList points = new LongArrayList((int) (indexes.size() * 1.5)); + + // find any aligned partition, search each axis + for (int axisCount = 0; axisCount < 3; axisCount++) { + int axis = (axisCount + depth + 1) % 3; + var oppositeDirection = axis + 3; + + // collect all the geometry's start and end points in this direction + points.clear(); + for (int quadIndex : indexes) { + var quad = workspace.quads[quadIndex]; + var extents = quad.getExtents(); + var posExtent = extents[axis]; + var negExtent = extents[oppositeDirection]; + if (posExtent == negExtent) { + points.add(encodeIntervalPoint(posExtent, quadIndex, INTERVAL_SIDE)); + } else { + points.add(encodeIntervalPoint(posExtent, quadIndex, INTERVAL_END)); + points.add(encodeIntervalPoint(negExtent, quadIndex, INTERVAL_START)); + } + } + + // sort interval points by distance ascending and then by type. Sorting the + // longs directly has the same effect because of the encoding. + Arrays.sort(points.elements(), 0, points.size()); + + // find gaps + partitions.clear(); + float distance = -1; + IntArrayList quadsBefore = null; + IntArrayList quadsOn = null; + int thickness = 0; + for (long point : points) { + switch (decodeType(point)) { + case INTERVAL_START -> { + // unless at the start, flush if there's a gap + if (thickness == 0 && (quadsBefore != null || quadsOn != null)) { + partitions.add(new Partition(distance, quadsBefore, quadsOn)); + distance = -1; + quadsBefore = null; + quadsOn = null; + } + + thickness++; + + // flush to partition if still writing last partition + if (quadsOn != null) { + if (distance == -1) { + throw new IllegalStateException("distance not set"); + } + partitions.add(new Partition(distance, quadsBefore, quadsOn)); + distance = -1; + quadsOn = null; + } + if (quadsBefore == null) { + quadsBefore = new IntArrayList(); + } + quadsBefore.add(decodeQuadIndex(point)); + } + case INTERVAL_END -> { + thickness--; + if (quadsOn == null) { + distance = decodeDistance(point); + } + } + case INTERVAL_SIDE -> { + // if this point in a gap, it can be put on the plane itself + int pointQuadIndex = decodeQuadIndex(point); + if (thickness == 0) { + float pointDistance = decodeDistance(point); + if (quadsOn == null) { + // no partition end created yet, set here + quadsOn = new IntArrayList(); + distance = pointDistance; + } else if (distance != pointDistance) { + // partition end has passed already, flush for new partition plane distance + partitions.add(new Partition(distance, quadsBefore, quadsOn)); + distance = pointDistance; + quadsBefore = null; + quadsOn = new IntArrayList(); + } + quadsOn.add(pointQuadIndex); + } else { + if (quadsBefore == null) { + throw new IllegalStateException("there must be started intervals here"); + } + quadsBefore.add(pointQuadIndex); + if (quadsOn == null) { + distance = decodeDistance(point); + } + } + } + } + } + + // check a different axis if everything is in one quadsBefore, + // which means there are no gaps + if (quadsBefore != null && quadsBefore.size() == indexes.size()) { + continue; + } + + // check if there's a trailing plane. Otherwise, the last plane has distance -1 + // since it just holds the trailing quads + boolean endsWithPlane = quadsOn != null; + + // flush the last partition, use the -1 distance to indicate the end if it + // doesn't use quadsOn (which requires a certain distance to be given) + if (quadsBefore != null || quadsOn != null) { + partitions.add(new Partition(endsWithPlane ? distance : -1, quadsBefore, quadsOn)); + } + + // check if this can be turned into a binary partition node + // (if there's at most two partitions and one plane) + if (partitions.size() <= 2) { + // get the two partitions + var inside = partitions.get(0); + var outside = partitions.size() == 2 ? partitions.get(1) : null; + if (outside == null || !endsWithPlane) { + return InnerBinaryPartitionBSPNode.buildFromPartitions(workspace, indexes, depth, oldNode, + inside, outside, axis); + } + } + + // create a multi-partition node + return InnerMultiPartitionBSPNode.buildFromPartitions(workspace, indexes, depth, oldNode, + partitions, axis, endsWithPlane); + } + + // test if there is intersecting geometry to avoid logging an error when it's not necessary. It just uses teh fallback for the rare cases of intersecting geometry. + int testsRemaining = 100; + for (int quadAIndex = 0; quadAIndex < indexes.size(); quadAIndex++) { + var quadA = workspace.quads[indexes.getInt(quadAIndex)]; + + for (int quadBIndex = quadAIndex + 1; quadBIndex < indexes.size(); quadBIndex++) { + var quadB = workspace.quads[indexes.getInt(quadBIndex)]; + + // aligned quads intersect if their bounding boxes intersect + if (TQuad.extentsIntersect(quadA, quadB)) { + var multiLeafNode = buildTopoMultiLeafNode(workspace, indexes); + if (multiLeafNode == null) { + throw new BSPBuildFailureException("Geometry is self-intersecting and can't be statically topo sorted"); + } + return multiLeafNode; + } + + if (--testsRemaining <= 0) { + break; + } + } + + if (testsRemaining == 0) { + break; + } + } + + // At this point we know the geometry is (probably) not intersecting, and it + // can't be partitioned along an aligned axis. This means that either the + // geometry is unpartitionable with free cuts (partitions that don't fragment + // geometry) or it required unaligned partitioning. Unaligned partitioning is a + // hard problem and solving it here isn't really necessary. Fully aligned + // unpartitonable constructions exist but not in normal Minecraft and are also + // not likely in any other normal scenario. + + // Throwing this exception will cause the entire section to be either topo + // sorted or the distance sorting fallback to be used. + // TODO: investigate BSP build failures if they happen, then remove this logging + LOGGER.warn( + "BSP build failure at {}. Please report this to douira for evaluation alongside with some way of reproducing the geometry in this section. (coordinates, and world file or seed)", + workspace.sectionPos); + + // unpartitionable non-intersecting geometry is warned about, but then we attempt to topo sort it anyway + var multiLeafNode = buildTopoMultiLeafNode(workspace, indexes); + if (multiLeafNode == null) { + throw new BSPBuildFailureException("No partition found but not intersecting and can't be statically topo sorted"); + } + return multiLeafNode; + } + + private static class QuadIndexConsumerIntoArray implements IntConsumer { + final int[] indexes; + private int index = 0; + + QuadIndexConsumerIntoArray(int size) { + this.indexes = new int[size]; + } + + @Override + public void accept(int value) { + this.indexes[this.index++] = value; + } + } + + static private BSPNode buildTopoMultiLeafNode(BSPWorkspace workspace, IntArrayList indexes) { + var quadCount = indexes.size(); + var quads = new TQuad[quadCount]; + var activeToRealIndex = new int[quadCount]; + for (int i = 0; i < indexes.size(); i++) { + var quadIndex = indexes.getInt(i); + quads[i] = workspace.quads[quadIndex]; + activeToRealIndex[i] = quadIndex; + } + + var indexWriter = new QuadIndexConsumerIntoArray(quadCount); + if (!TopoGraphSorting.topoGraphSort(indexWriter, quads, quads.length, activeToRealIndex, null, null)) { + return null; + } + + return new LeafMultiBSPNode(BSPSortState.compressIndexesInPlace(indexWriter.indexes, false)); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafDoubleBSPNode.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafDoubleBSPNode.java new file mode 100644 index 0000000000..19f8b1a2d2 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafDoubleBSPNode.java @@ -0,0 +1,22 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import org.joml.Vector3fc; + +/** + * A leaf node of a BSP tree that contains two quads. + */ +public class LeafDoubleBSPNode extends BSPNode { + private final int quadA; + private final int quadB; + + LeafDoubleBSPNode(int quadA, int quadB) { + this.quadA = quadA; + this.quadB = quadB; + } + + @Override + void collectSortedQuads(BSPSortState sortState, Vector3fc cameraPos) { + sortState.writeIndex(this.quadA); + sortState.writeIndex(this.quadB); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafMultiBSPNode.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafMultiBSPNode.java new file mode 100644 index 0000000000..0c2ebcc764 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafMultiBSPNode.java @@ -0,0 +1,19 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import org.joml.Vector3fc; + +/** + * A leaf node of a BSP tree that contains a set of quads. + */ +class LeafMultiBSPNode extends BSPNode { + private final int[] quads; + + LeafMultiBSPNode(int[] quads) { + this.quads = quads; + } + + @Override + void collectSortedQuads(BSPSortState sortState, Vector3fc cameraPos) { + sortState.writeIndexes(this.quads); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafSingleBSPNode.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafSingleBSPNode.java new file mode 100644 index 0000000000..68af251ec8 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/LeafSingleBSPNode.java @@ -0,0 +1,19 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import org.joml.Vector3fc; + +/** + * A leaf node of a BSP tree that contains a single quad. + */ +class LeafSingleBSPNode extends BSPNode { + private final int quad; + + LeafSingleBSPNode(int quad) { + this.quad = quad; + } + + @Override + void collectSortedQuads(BSPSortState sortState, Vector3fc cameraPos) { + sortState.writeIndex(this.quad); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/Partition.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/Partition.java new file mode 100644 index 0000000000..6a6d69e940 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/bsp_tree/Partition.java @@ -0,0 +1,11 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree; + +import it.unimi.dsi.fastutil.ints.IntArrayList; + +/** + * Models a partition of the space into a set of quads that lie inside or on the + * plane with the specified distance. If the distance is -1 this is the "end" + * partition after the last partition plane. + */ +record Partition(float distance, IntArrayList quadsBefore, IntArrayList quadsOn) { +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/AnyOrderData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/AnyOrderData.java new file mode 100644 index 0000000000..ce29e3a46d --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/AnyOrderData.java @@ -0,0 +1,55 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortType; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.minecraft.core.SectionPos; + +/** + * With this sort type the section's translucent quads can be rendered in any + * order. However, they do need to be rendered with some index buffer, so that + * vertices are assembled into quads. Since the sort order doesn't matter, all + * sections with this sort type can share the same data in the index buffer. + * + * NOTE: A possible optimization would be to share the buffer for unordered + * translucent sections on the CPU and on the GPU. It would essentially be the + * same as SharedQuadIndexBuffer, but it has to be compatible with sections in + * the same region using custom index buffers which makes the management + * complicated. The shared buffer would be a member amongst the other non-shared + * buffer segments and would need to be resized when a larger section wants to + * use it. + */ +public class AnyOrderData extends SplitDirectionData { + AnyOrderData(SectionPos sectionPos, NativeBuffer buffer, VertexRange[] ranges) { + super(sectionPos, buffer, ranges); + } + + @Override + public SortType getSortType() { + return SortType.NONE; + } + + /** + * Important: The vertex indexes must start at zero for each facing. + */ + public static AnyOrderData fromMesh(BuiltSectionMeshParts translucentMesh, + TQuad[] quads, SectionPos sectionPos, NativeBuffer buffer) { + buffer = PresentTranslucentData.nativeBufferForQuads(buffer, quads); + var indexBuffer = buffer.getDirectBuffer().asIntBuffer(); + + var ranges = translucentMesh.getVertexRanges(); + for (var range : ranges) { + if (range == null) { + continue; + } + + int count = TranslucentData.vertexCountToQuadCount(range.vertexCount()); + for (int i = 0; i < count; i++) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, i); + } + } + return new AnyOrderData(sectionPos, buffer, ranges); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/BSPDynamicData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/BSPDynamicData.java new file mode 100644 index 0000000000..c2fe7f67d4 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/BSPDynamicData.java @@ -0,0 +1,72 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import org.joml.Vector3dc; +import org.joml.Vector3fc; + +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree.BSPNode; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.bsp_tree.BSPResult; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.minecraft.core.SectionPos; + +/** + * Constructs a BSP tree of the quads and sorts them dynamically. + * + * Triggering is performed when the BSP tree's partition planes are crossed in + * any direction (bidirectional). + */ +public class BSPDynamicData extends DynamicData { + private static final int NODE_REUSE_MIN_GENERATION = 1; + + private final BSPNode rootNode; + private final int generation; + + private BSPDynamicData(SectionPos sectionPos, + NativeBuffer buffer, VertexRange range, BSPResult result, Vector3dc cameraPos, int generation) { + super(sectionPos, buffer, range, result, cameraPos); + this.rootNode = result.getRootNode(); + this.generation = generation; + } + + @Override + public void sortOnTrigger(Vector3fc cameraPos) { + this.sort(cameraPos); + } + + private void sort(Vector3fc cameraPos) { + this.unsetReuseUploadedData(); + + this.rootNode.collectSortedQuads(getBuffer(), cameraPos); + } + + public static BSPDynamicData fromMesh(BuiltSectionMeshParts translucentMesh, + CombinedCameraPos cameraPos, TQuad[] quads, SectionPos sectionPos, + NativeBuffer buffer, TranslucentData oldData) { + BSPNode oldRoot = null; + int generation = 0; + boolean prepareNodeReuse = false; + if (oldData instanceof BSPDynamicData oldBSPData) { + generation = oldBSPData.generation + 1; + oldRoot = oldBSPData.rootNode; + + // only enable partial updates after a certain number of generations + // (times the section has been built) + prepareNodeReuse = generation >= NODE_REUSE_MIN_GENERATION; + } + var result = BSPNode.buildBSP(quads, sectionPos, oldRoot, prepareNodeReuse); + + VertexRange range = TranslucentData.getUnassignedVertexRange(translucentMesh); + buffer = PresentTranslucentData.nativeBufferForQuads(buffer, quads); + + var dynamicData = new BSPDynamicData(sectionPos, buffer, range, result, + cameraPos.getAbsoluteCameraPos(), generation); + dynamicData.sort(cameraPos.getRelativeCameraPos()); + + // prepare geometry planes for integration into GFNI triggering + result.prepareIntegration(); + + return dynamicData; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/CombinedCameraPos.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/CombinedCameraPos.java new file mode 100644 index 0000000000..c18980a089 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/CombinedCameraPos.java @@ -0,0 +1,10 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import org.joml.Vector3dc; +import org.joml.Vector3fc; + +public interface CombinedCameraPos { + Vector3fc getRelativeCameraPos(); + + Vector3dc getAbsoluteCameraPos(); +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/DynamicData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/DynamicData.java new file mode 100644 index 0000000000..401ba9a1a4 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/DynamicData.java @@ -0,0 +1,42 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import org.joml.Vector3dc; + +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortType; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.GeometryPlanes; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.minecraft.core.SectionPos; + +public abstract class DynamicData extends MixedDirectionData { + private GeometryPlanes geometryPlanes; + private final Vector3dc initialCameraPos; + + DynamicData(SectionPos sectionPos, NativeBuffer buffer, VertexRange range, GeometryPlanes geometryPlanes, Vector3dc initialCameraPos) { + super(sectionPos, buffer, range); + this.geometryPlanes = geometryPlanes; + this.initialCameraPos = initialCameraPos; + } + + @Override + public SortType getSortType() { + return SortType.DYNAMIC; + } + + @Override + public boolean retainAfterUpload() { + return true; + } + + public GeometryPlanes getGeometryPlanes() { + return this.geometryPlanes; + } + + public void clearGeometryPlanes() { + this.geometryPlanes = null; + } + + public Vector3dc getInitialCameraPos() { + return this.initialCameraPos; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/MixedDirectionData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/MixedDirectionData.java new file mode 100644 index 0000000000..535ec27688 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/MixedDirectionData.java @@ -0,0 +1,20 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.minecraft.core.SectionPos; + +public abstract class MixedDirectionData extends PresentTranslucentData { + private final VertexRange[] ranges = new VertexRange[ModelQuadFacing.COUNT]; + + MixedDirectionData(SectionPos sectionPos, NativeBuffer buffer, VertexRange range) { + super(sectionPos, buffer); + this.ranges[ModelQuadFacing.UNASSIGNED.ordinal()] = range; + } + + @Override + public VertexRange[] getVertexRanges() { + return ranges; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/NoData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/NoData.java new file mode 100644 index 0000000000..12a98ee7e1 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/NoData.java @@ -0,0 +1,33 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortType; +import net.minecraft.core.SectionPos; + +/** + * This class means there is no translucent data and is used to signal that the + * section should be removed from triggering data structures. + * + * If translucent sorting is disabled, not even this class is used, but null is + * passed instead. + */ +public class NoData extends TranslucentData { + private final SortType reason; + + private NoData(SectionPos sectionPos, SortType reason) { + super(sectionPos); + this.reason = reason; + } + + @Override + public SortType getSortType() { + return reason; + } + + public static NoData forEmptySection(SectionPos sectionPos) { + return new NoData(sectionPos, SortType.EMPTY_SECTION); + } + + public static NoData forNoTranslucent(SectionPos sectionPos) { + return new NoData(sectionPos, SortType.NO_TRANSLUCENT); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/PresentTranslucentData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/PresentTranslucentData.java new file mode 100644 index 0000000000..6ae4d9b253 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/PresentTranslucentData.java @@ -0,0 +1,72 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.minecraft.core.SectionPos; + +/** + * Super class for translucent data that contains an actual buffer. + */ +public abstract class PresentTranslucentData extends TranslucentData { + private NativeBuffer buffer; + private boolean reuseUploadedData; + private int quadHash; + private int length; + + PresentTranslucentData(SectionPos sectionPos, NativeBuffer buffer) { + super(sectionPos); + this.buffer = buffer; + this.length = TranslucentData.indexBytesToQuadCount(buffer.getLength()); + } + + public abstract VertexRange[] getVertexRanges(); + + @Override + public void delete() { + super.delete(); + if (this.buffer != null) { + this.buffer.free(); + this.buffer = null; + } + } + + public void setQuadHash(int hash) { + this.quadHash = hash; + } + + public int getQuadHash() { + return this.quadHash; + } + + public int getLength() { + return this.length; + } + + public NativeBuffer getBuffer() { + return this.buffer; + } + + public boolean isReusingUploadedData() { + return this.reuseUploadedData; + } + + public void setReuseUploadedData() { + this.reuseUploadedData = true; + } + + public void unsetReuseUploadedData() { + this.reuseUploadedData = false; + } + + public static NativeBuffer nativeBufferForQuads(TQuad[] quads) { + return new NativeBuffer(TranslucentData.quadCountToIndexBytes(quads.length)); + } + + public static NativeBuffer nativeBufferForQuads(NativeBuffer existing, TQuad[] quads) { + if (existing != null) { + return existing; + } + return nativeBufferForQuads(quads); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/SplitDirectionData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/SplitDirectionData.java new file mode 100644 index 0000000000..320f0c70e5 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/SplitDirectionData.java @@ -0,0 +1,24 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.minecraft.core.SectionPos; + +/** + * Super class for translucent data that is rendered separately for each facing. + * (block face culling is possible) It's important that the indices are inserted + * starting at zero for each facing. + */ +public abstract class SplitDirectionData extends PresentTranslucentData { + private final VertexRange[] ranges; + + public SplitDirectionData(SectionPos sectionPos, NativeBuffer buffer, VertexRange[] ranges) { + super(sectionPos, buffer); + this.ranges = ranges; + } + + @Override + public VertexRange[] getVertexRanges() { + return this.ranges; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/StaticNormalRelativeData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/StaticNormalRelativeData.java new file mode 100644 index 0000000000..b7b6df5db4 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/StaticNormalRelativeData.java @@ -0,0 +1,149 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import java.nio.IntBuffer; +import java.util.Arrays; + +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortType; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.util.MathUtil; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.caffeinemc.mods.sodium.client.util.sorting.RadixSort; +import net.minecraft.core.SectionPos; + +/** + * Static normal relative sorting orders quads by the dot product of their + * normal and position. (referred to as "distance" throughout the code) + * + * Unlike sorting by distance, which is descending for translucent rendering to + * be correct, sorting by dot product is ascending instead. + */ +public class StaticNormalRelativeData extends SplitDirectionData { + public StaticNormalRelativeData(SectionPos sectionPos, NativeBuffer buffer, VertexRange[] ranges) { + super(sectionPos, buffer, ranges); + } + + @Override + public SortType getSortType() { + return SortType.STATIC_NORMAL_RELATIVE; + } + + private static StaticNormalRelativeData fromDoubleUnaligned(BuiltSectionMeshParts translucentMesh, + TQuad[] quads, SectionPos sectionPos) { + var buffer = PresentTranslucentData.nativeBufferForQuads(quads); + IntBuffer indexBuffer = buffer.getDirectBuffer().asIntBuffer(); + + if (quads.length <= 1) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, 0); + } else if (RadixSort.useRadixSort(quads.length)) { + final var keys = new int[quads.length]; + + for (int q = 0; q < quads.length; q++) { + keys[q] = MathUtil.floatToComparableInt(quads[q].getDotProduct()); + } + + var indices = RadixSort.sort(keys); + + for (int i = 0; i < quads.length; i++) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, indices[i]); + } + } else { + final var sortData = new long[quads.length]; + + for (int q = 0; q < quads.length; q++) { + int dotProductComponent = MathUtil.floatToComparableInt(quads[q].getDotProduct()); + sortData[q] = (long) dotProductComponent << 32 | q; + } + + Arrays.sort(sortData); + + for (int i = 0; i < quads.length; i++) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, (int) sortData[i]); + } + } + + return new StaticNormalRelativeData(sectionPos, buffer, translucentMesh.getVertexRanges()); + } + + /** + * Important: The vertex indexes must start at zero for each facing. + */ + private static StaticNormalRelativeData fromMixed(BuiltSectionMeshParts translucentMesh, + TQuad[] quads, SectionPos sectionPos) { + var buffer = PresentTranslucentData.nativeBufferForQuads(quads); + IntBuffer indexBuffer = buffer.getDirectBuffer().asIntBuffer(); + + var ranges = translucentMesh.getVertexRanges(); + var maxQuadCount = 0; + boolean anyNeedsSortData = false; + for (var range : ranges) { + if (range != null) { + var quadCount = TranslucentData.vertexCountToQuadCount(range.vertexCount()); + maxQuadCount = Math.max(maxQuadCount, quadCount); + anyNeedsSortData |= !RadixSort.useRadixSort(quadCount) && quadCount > 1; + } + } + + long[] sortData = null; + if (anyNeedsSortData) { + sortData = new long[maxQuadCount]; + } + + int quadIndex = 0; + for (var range : ranges) { + if (range == null) { + continue; + } + + int vertexCount = range.vertexCount(); + if (vertexCount == 0) { + continue; + } + + int count = TranslucentData.vertexCountToQuadCount(vertexCount); + + if (count == 1) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, 0); + quadIndex++; + } else if (RadixSort.useRadixSort(count)) { + final var keys = new int[count]; + + for (int q = 0; q < count; q++) { + keys[q] = MathUtil.floatToComparableInt(quads[quadIndex++].getDotProduct()); + } + + var indices = RadixSort.sort(keys); + + for (int i = 0; i < count; i++) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, indices[i]); + } + } else { + for (int i = 0; i < count; i++) { + var quad = quads[quadIndex++]; + int dotProductComponent = MathUtil.floatToComparableInt(quad.getDotProduct()); + sortData[i] = (long) dotProductComponent << 32 | i; + } + + if (count > 1) { + Arrays.sort(sortData, 0, count); + } + + for (int i = 0; i < count; i++) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, (int) sortData[i]); + } + } + } + + return new StaticNormalRelativeData(sectionPos, buffer, ranges); + } + + public static StaticNormalRelativeData fromMesh(BuiltSectionMeshParts translucentMesh, + TQuad[] quads, SectionPos sectionPos, boolean isDoubleUnaligned) { + if (isDoubleUnaligned) { + return fromDoubleUnaligned(translucentMesh, quads, sectionPos); + } else { + return fromMixed(translucentMesh, quads, sectionPos); + } + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/StaticTopoAcyclicData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/StaticTopoAcyclicData.java new file mode 100644 index 0000000000..64565520b3 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/StaticTopoAcyclicData.java @@ -0,0 +1,47 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortType; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.minecraft.core.SectionPos; + +import java.nio.IntBuffer; +import java.util.function.IntConsumer; + +/** + * Static topo acyclic sorting uses the topo sorting algorithm but only if it's + * possible to sort without dynamic triggering, meaning the sort order never + * needs to change. + */ +public class StaticTopoAcyclicData extends MixedDirectionData { + StaticTopoAcyclicData(SectionPos sectionPos, NativeBuffer buffer, VertexRange range) { + super(sectionPos, buffer, range); + } + + @Override + public SortType getSortType() { + return SortType.STATIC_TOPO; + } + + private record QuadIndexConsumerIntoBuffer(IntBuffer buffer) implements IntConsumer { + @Override + public void accept(int value) { + TranslucentData.writeQuadVertexIndexes(this.buffer, value); + } + } + + + public static StaticTopoAcyclicData fromMesh(BuiltSectionMeshParts translucentMesh, + TQuad[] quads, SectionPos sectionPos, NativeBuffer buffer) { + VertexRange range = TranslucentData.getUnassignedVertexRange(translucentMesh); + var indexWriter = new QuadIndexConsumerIntoBuffer(buffer.getDirectBuffer().asIntBuffer()); + + if (!TopoGraphSorting.topoGraphSort(indexWriter, quads, null, null)) { + return null; + } + + return new StaticTopoAcyclicData(sectionPos, buffer, range); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TopoGraphSorting.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TopoGraphSorting.java new file mode 100644 index 0000000000..d45af2c756 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TopoGraphSorting.java @@ -0,0 +1,331 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import it.unimi.dsi.fastutil.objects.Object2ReferenceOpenHashMap; +import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.AlignableNormal; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.util.collections.BitArray; +import org.joml.Vector3fc; + +import java.util.function.IntConsumer; + +/** + * This class contains the sorting algorithms that do topological or distance + * sorting. The algorithms are combined in this class to separate them from the + * general management code in other classes. + *

+ * Rough attempts at underapproximation of the visibility graph where the + * conditions for visibility between quads are made more strict did not yield + * significantly more robust topo sorting. + */ +public class TopoGraphSorting { + private TopoGraphSorting() { + } + + /** + * Test if the given point is within the half space defined by the plane anchor + * and the plane normal. The normal points away from the space considered to be + * inside. + * + * @param planeDistance dot product of the plane + * @param planeNormal the normal of the plane + * @param point the point to test + */ + private static boolean pointOutsideHalfSpace(float planeDistance, Vector3fc planeNormal, Vector3fc point) { + return planeNormal.dot(point) > planeDistance; + } + + private static boolean pointInsideHalfSpace(float planeDistance, Vector3fc planeNormal, Vector3fc point) { + return planeNormal.dot(point) < planeDistance; + } + + public static boolean orthogonalQuadVisibleThrough(TQuad quadA, TQuad quadB) { + var aDirection = quadA.getFacing().ordinal(); + var aOpposite = quadA.getFacing().getOpposite().ordinal(); + var bDirection = quadB.getFacing().ordinal(); + var aSign = quadA.getFacing().getSign(); + var bSign = quadB.getFacing().getSign(); + + var aExtents = quadA.getExtents(); + var bExtents = quadB.getExtents(); + + // test that B has an extent within A's half space and that A is not fully within B's half space + float BIntoADescent = aSign * aExtents[aDirection] - aSign * bExtents[aOpposite]; + float AOutsideBAscent = bSign * aExtents[bDirection] - bSign * bExtents[bDirection]; + + var vis = BIntoADescent > 0 && AOutsideBAscent > 0; + + // if they're visible and their bounding boxes intersect and apply a heuristic to resolve + if (vis && TQuad.extentsIntersect(aExtents, bExtents)) { + return BIntoADescent + AOutsideBAscent > 1; + } + return vis; + } + + private static boolean testSeparatorRange(Object2ReferenceOpenHashMap distancesByNormal, + Vector3fc normal, float start, float end) { + var distances = distancesByNormal.get(normal); + if (distances == null) { + return false; + } + return AlignableNormal.queryRange(distances, start, end); + } + + private static boolean visibilityWithSeparator(TQuad quadA, TQuad quadB, + Object2ReferenceOpenHashMap distancesByNormal, Vector3fc cameraPos) { + // check if there is an aligned separator + for (int direction = 0; direction < ModelQuadFacing.DIRECTIONS; direction++) { + var facing = ModelQuadFacing.VALUES[direction]; + var oppositeFacing = facing.getOpposite(); + var oppositeDirection = oppositeFacing.ordinal(); + var sign = facing.getSign(); + + // test that they're not overlapping in this direction. Multiplication with the + // sign makes the > work in the other direction which is necessary since the + // facing turns the whole space around. The start and end are ordered along the + // < relation as is the normal. The normal always points in the direction of + // greater values, even if all the geometry has negative values. + var separatorRangeStart = sign * quadB.getExtents()[direction]; + var separatorRangeEnd = sign * quadA.getExtents()[oppositeDirection]; + if (separatorRangeStart > separatorRangeEnd) { + continue; + } + + // test that the camera doesn't exclude all separators + var facingNormal = ModelQuadFacing.ALIGNED_NORMALS[direction]; + var cameraDistance = facingNormal.dot(cameraPos); + if (cameraDistance > separatorRangeEnd) { + continue; + } + + // use camera distance as the start because even if there's no geometry that + // generates such separator plane itself, if there's any plane that triggers the + // section before the camera can see B through A, this is enough. The separator + // doesn't need to be between B and A if the camera will cross another separator + // before any separator that could be between B and A. + separatorRangeStart = cameraDistance; + + // swapping the start and end is not necessary since the start is always smaller + // than the end value. + + // test if there is a separator plane that is outside/on the surface of the + // current trigger volume. + if (testSeparatorRange(distancesByNormal, facingNormal, separatorRangeStart, separatorRangeEnd)) { + return false; + } + } + + // NOTE: unaligned normals for separators are not checked because doing so is a + // hard problem and this is an approximation. The fully correct topo sort would need + // to be much more complicated. + + // visibility not disproven + return true; + } + + /** + * Checks if one quad is visible through the other quad. This accepts arbitrary + * quads, even unaligned ones. + * + * @param quad the quad through which the other quad is being + * tested + * @param other the quad being tested + * @param distancesByNormal a map of normals to sorted arrays of face plane + * distances for disproving that the quads are visible + * through each other, null to disable + * @return true if the other quad is visible through the first quad + */ + private static boolean quadVisibleThrough(TQuad quad, TQuad other, + Object2ReferenceOpenHashMap distancesByNormal, Vector3fc cameraPos) { + if (quad == other) { + return false; + } + + // aligned quads + var quadFacing = quad.useQuantizedFacing(); + var otherFacing = other.useQuantizedFacing(); + boolean result; + if (quadFacing != ModelQuadFacing.UNASSIGNED && otherFacing != ModelQuadFacing.UNASSIGNED) { + // opposites never see each other + if (quadFacing.getOpposite() == otherFacing) { + return false; + } + + // parallel quads, coplanar quads are not visible to each other + if (quadFacing == otherFacing) { + var sign = quadFacing.getSign(); + var direction = quadFacing.ordinal(); + result = sign * quad.getExtents()[direction] > sign * other.getExtents()[direction]; + } else { + // orthogonal quads + result = orthogonalQuadVisibleThrough(quad, other); + } + } else { + // at least one unaligned quad + // this is an approximation since our quads don't store all their vertices. + // check that other center is within the half space of quad and that quad isn't + // in the half space of other + result = pointInsideHalfSpace(quad.getDotProduct(), quad.getQuantizedNormal(), other.getCenter()) + && !pointInsideHalfSpace(other.getDotProduct(), other.getQuantizedNormal(), quad.getCenter()); + } + + // if enabled and necessary, try to disprove this see-through relationship with + // a separator plane + if (result && distancesByNormal != null) { + return visibilityWithSeparator(quad, other, distancesByNormal, cameraPos); + } + + return result; + } + + /** + * Performs a topological sort without constructing the full graph in memory by + * doing a DFS on the implicit graph. Edges are tested as they are searched for + * and if necessary separator planes are used to disprove visibility. + * + * @param indexConsumer the consumer to write the topo sort result to + * @param allQuads the quads to sort + * @param distancesByNormal a map of normals to sorted arrays of face plane + * distances, null to disable + * @param cameraPos the camera position, or null to disable the + * visibility check + */ + public static boolean topoGraphSort( + IntConsumer indexConsumer, TQuad[] allQuads, + Object2ReferenceOpenHashMap distancesByNormal, + Vector3fc cameraPos) { + // if enabled, check for visibility and produce a mapping of indices + TQuad[] quads; + int[] activeToRealIndex = null; + + // keep track of the number of quads to be processed, this is possibly less than quads.length + int quadCount = 0; + + if (cameraPos != null) { + // allocate the working quads and index map at the full size to avoid needing to + // iterate the quads again after checking visibility + quads = new TQuad[allQuads.length]; + activeToRealIndex = new int[allQuads.length]; + + for (int i = 0; i < allQuads.length; i++) { + TQuad quad = allQuads[i]; + // NOTE: This approximation may introduce wrong sorting if the real and the + // quantized normal aren't the same. A quad may be ignored with the quantized + // normal, but it's actually visible in camera. + if (pointOutsideHalfSpace(quad.getDotProduct(), quad.getQuantizedNormal(), cameraPos)) { + activeToRealIndex[quadCount] = i; + quads[quadCount] = quad; + quadCount++; + } else { + // write the invisible quads right away + indexConsumer.accept(i); + } + } + } else { + quads = allQuads; + quadCount = allQuads.length; + } + + return topoGraphSort(indexConsumer, quads, quadCount, activeToRealIndex, distancesByNormal, cameraPos); + } + + public static boolean topoGraphSort(IntConsumer indexConsumer, TQuad[] quads, int quadCount, int[] activeToRealIndex, Object2ReferenceOpenHashMap distancesByNormal, Vector3fc cameraPos) { + // special case for 0 to 2 quads + if (quadCount == 0) { + return true; + } + if (quadCount == 1) { + if (activeToRealIndex != null) { + indexConsumer.accept(activeToRealIndex[0]); + } else { + indexConsumer.accept(0); + } + return true; + } + + // special case 2 quads for performance + if (quadCount == 2) { + var a = 0; + var b = 1; + if (quadVisibleThrough(quads[a], quads[b], null, null)) { + a = 1; + b = 0; + } + if (activeToRealIndex != null) { + indexConsumer.accept(activeToRealIndex[a]); + indexConsumer.accept(activeToRealIndex[b]); + } else { + indexConsumer.accept(a); + indexConsumer.accept(b); + } + return true; + } + + BitArray unvisited = new BitArray(quadCount); + unvisited.set(0, quadCount); + int visitedCount = 0; + BitArray onStack = new BitArray(quadCount); + int[] stack = new int[quadCount]; + int[] nextEdge = new int[quadCount]; + + // start dfs searches until all quads are visited + while (visitedCount < quadCount) { + int stackPos = 0; + var root = unvisited.nextSetBit(0); + stack[stackPos] = root; + onStack.set(root); + nextEdge[stackPos] = 0; + + while (stackPos >= 0) { + // start at next edge and find an unvisited quad + var currentQuadIndex = stack[stackPos]; + var nextEdgeTest = unvisited.nextSetBit(nextEdge[stackPos]); + if (nextEdgeTest != -1) { + if (currentQuadIndex != nextEdgeTest) { + var currentQuad = quads[currentQuadIndex]; + var nextQuad = quads[nextEdgeTest]; + if (quadVisibleThrough(currentQuad, nextQuad, distancesByNormal, cameraPos)) { + // if the visible quad is on the stack, there is a cycle + if (onStack.getAndSet(nextEdgeTest)) { + return false; + } + + // set the next edge + nextEdge[stackPos] = nextEdgeTest + 1; + + // visit the next quad, onStack is already set + stackPos++; + stack[stackPos] = nextEdgeTest; + nextEdge[stackPos] = 0; + continue; + } + } + + // go to the next edge + nextEdgeTest++; + + // if we haven't reached the end of the edges yet + if (nextEdgeTest < quadCount) { + nextEdge[stackPos] = nextEdgeTest; + continue; + } + } + + // no more edges left, pop the stack + onStack.unset(currentQuadIndex); + visitedCount++; + unvisited.unset(currentQuadIndex); + stackPos--; + + // write to the index buffer since the order is now correct + if (activeToRealIndex != null) { + indexConsumer.accept(activeToRealIndex[currentQuadIndex]); + } else { + indexConsumer.accept(currentQuadIndex); + } + } + } + + return true; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TopoSortDynamicData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TopoSortDynamicData.java new file mode 100644 index 0000000000..0f3701f96d --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TopoSortDynamicData.java @@ -0,0 +1,250 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import java.nio.IntBuffer; +import java.util.Arrays; +import java.util.function.IntConsumer; + +import org.joml.Vector3dc; +import org.joml.Vector3fc; + +import it.unimi.dsi.fastutil.objects.Object2ReferenceOpenHashMap; +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.GeometryPlanes; +import net.caffeinemc.mods.sodium.client.util.NativeBuffer; +import net.caffeinemc.mods.sodium.client.util.sorting.RadixSort; +import net.minecraft.core.SectionPos; + +/** + * Performs dynamic topo sorting and falls back to distance sorting as + * necessary. This class implements a number of heuristics to attempt to upgrade + * distance-based sorting back to topo sorting when possible as topo sorting + * generally needs to happen far less often. + * + * Triggering is performed when the quads' planes crossed along their normal + * direction (unidirectional). + * + * Implementation note: + * - Reusing the output of previous distance sorting job doesn't make a + * difference or makes things slower in some cases. It's unclear why exactly + * this happens, I suspect weird memory behavior or the reuse is not actually + * that helpful to the sorting algorithm. + */ +public class TopoSortDynamicData extends DynamicData implements IntConsumer { + private final TQuad[] quads; + private boolean GFNITrigger = true; + private boolean directTrigger = false; + private boolean turnGFNITriggerOff = false; + private boolean turnDirectTriggerOn = false; + private boolean turnDirectTriggerOff = false; + private double directTriggerKey = -1; + private int consecutiveTopoSortFailures = 0; + private boolean pendingTriggerIsDirect; + private final Object2ReferenceOpenHashMap distancesByNormal; + private IntBuffer intBuffer; + + private static final int MAX_TOPO_SORT_QUADS = 1000; + private static final int MAX_TOPO_SORT_TIME_NS = 1_000_000; + private static final int MAX_FAILING_TOPO_SORT_TIME_NS = 750_000; + private static final int MAX_TOPO_SORT_PATIENT_TIME_NS = 250_000; + private static final int PATIENT_TOPO_ATTEMPTS = 5; + private static final int REGULAR_TOPO_ATTEMPTS = 2; + + private TopoSortDynamicData(SectionPos sectionPos, + NativeBuffer buffer, VertexRange range, TQuad[] quads, + GeometryPlanes geometryPlanes, Vector3dc cameraPos, + Object2ReferenceOpenHashMap distancesByNormal) { + super(sectionPos, buffer, range, geometryPlanes, cameraPos); + this.quads = quads; + this.distancesByNormal = distancesByNormal; + } + + public boolean GFNITriggerEnabled() { + return this.GFNITrigger; + } + + public boolean directTriggerEnabled() { + return this.directTrigger; + } + + public void clearTriggerChanges() { + this.turnGFNITriggerOff = false; + this.turnDirectTriggerOn = false; + this.turnDirectTriggerOff = false; + } + + private void turnGFNITriggerOff() { + if (this.GFNITrigger) { + this.GFNITrigger = false; + this.turnGFNITriggerOff = true; + } + } + + private void turnDirectTriggerOn() { + if (!this.directTrigger) { + this.directTrigger = true; + this.turnDirectTriggerOn = true; + } + } + + private void turnDirectTriggerOff() { + if (this.directTrigger) { + this.directTrigger = false; + this.turnDirectTriggerOff = true; + } + } + + public boolean getAndFlushTurnGFNITriggerOff() { + var result = this.turnGFNITriggerOff; + this.turnGFNITriggerOff = false; + return result; + } + + public boolean getAndFlushTurnDirectTriggerOn() { + var result = this.turnDirectTriggerOn; + this.turnDirectTriggerOn = false; + return result; + } + + public boolean getAndFlushTurnDirectTriggerOff() { + var result = this.turnDirectTriggerOff; + this.turnDirectTriggerOff = false; + return result; + } + + public double getDirectTriggerKey() { + return this.directTriggerKey; + } + + public void setDirectTriggerKey(double key) { + this.directTriggerKey = key; + } + + @Override + public void prepareTrigger(boolean isDirectTrigger) { + this.pendingTriggerIsDirect = isDirectTrigger; + } + + @Override + public void sortOnTrigger(Vector3fc cameraPos) { + this.sort(cameraPos, this.pendingTriggerIsDirect, false); + } + + private static int getAttemptsForTime(long ns) { + return ns <= MAX_TOPO_SORT_PATIENT_TIME_NS ? PATIENT_TOPO_ATTEMPTS : REGULAR_TOPO_ATTEMPTS; + } + + private void sort(Vector3fc cameraPos, boolean isDirectTrigger, boolean initial) { + // mark as not being reused to ensure the updated buffer is actually uploaded + this.unsetReuseUploadedData(); + + // uses a topo sort or a distance sort depending on what is enabled + IntBuffer indexBuffer = this.getBuffer().getDirectBuffer().asIntBuffer(); + + if (this.quads.length > MAX_TOPO_SORT_QUADS) { + this.turnGFNITriggerOff(); + this.turnDirectTriggerOn(); + } + + if (this.GFNITrigger && !isDirectTrigger) { + var sortStart = initial ? 0 : System.nanoTime(); + + this.intBuffer = indexBuffer; + var result = TopoGraphSorting.topoGraphSort(this, this.quads, this.distancesByNormal, cameraPos); + this.intBuffer = null; + + var sortTime = initial ? 0 : System.nanoTime() - sortStart; + + // if we've already failed, there's reduced patience for sorting since the + // probability of failure and wasted compute time is higher. Initial sorting is + // often very slow when the cpu is loaded and the JIT isn't ready yet, so it's + // ignored here. + if (!initial && sortTime > (this.consecutiveTopoSortFailures > 0 + ? MAX_FAILING_TOPO_SORT_TIME_NS + : MAX_TOPO_SORT_TIME_NS)) { + this.turnGFNITriggerOff(); + this.turnDirectTriggerOn(); + } else if (result) { + // disable distance sorting because topo sort seems to be possible. + this.turnDirectTriggerOff(); + this.consecutiveTopoSortFailures = 0; + return; + } else { + // topo sort failure, the topo sort algorithm doesn't work on all cases + + // gives up after a certain number of failures. it keeps GFNI triggering with + // topo sort on while the angle triggering is also active to maybe get a topo + // sort success from a different angle. + this.consecutiveTopoSortFailures++; + if (this.consecutiveTopoSortFailures >= getAttemptsForTime(sortTime)) { + this.turnGFNITriggerOff(); + } + this.turnDirectTriggerOn(); + } + } + + if (this.directTrigger) { + indexBuffer.rewind(); + distanceSortDirect(indexBuffer, this.quads, cameraPos); + } + } + + /** + * Sorts the given quads by descending center distance to the camera and writes + * the resulting order to the given index buffer. + */ + private static void distanceSortDirect(IntBuffer indexBuffer, TQuad[] quads, Vector3fc cameraPos) { + if (quads.length <= 1) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, 0); + } else if (RadixSort.useRadixSort(quads.length)) { + final var keys = new int[quads.length]; + + for (int q = 0; q < quads.length; q++) { + keys[q] = ~Float.floatToRawIntBits(quads[q].getCenter().distanceSquared(cameraPos)); + } + + var indices = RadixSort.sort(keys); + + for (int i = 0; i < quads.length; i++) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, indices[i]); + } + } else { + final var data = new long[quads.length]; + for (int q = 0; q < quads.length; q++) { + float distance = quads[q].getCenter().distanceSquared(cameraPos); + data[q] = (long) ~Float.floatToRawIntBits(distance) << 32 | q; + } + + Arrays.sort(data); + + for (int i = 0; i < quads.length; i++) { + TranslucentData.writeQuadVertexIndexes(indexBuffer, (int) data[i]); + } + } + } + + public static TopoSortDynamicData fromMesh(BuiltSectionMeshParts translucentMesh, + CombinedCameraPos cameraPos, TQuad[] quads, SectionPos sectionPos, + GeometryPlanes geometryPlanes, + NativeBuffer buffer) { + var distancesByNormal = geometryPlanes.prepareAndGetDistances(); + + VertexRange range = TranslucentData.getUnassignedVertexRange(translucentMesh); + if (buffer == null) { + buffer = PresentTranslucentData.nativeBufferForQuads(quads); + } + + var dynamicData = new TopoSortDynamicData(sectionPos, buffer, range, quads, geometryPlanes, + cameraPos.getAbsoluteCameraPos(), distancesByNormal); + + dynamicData.sort(cameraPos.getRelativeCameraPos(), false, true); + + return dynamicData; + } + + @Override + public void accept(int value) { + TranslucentData.writeQuadVertexIndexes(this.intBuffer, value); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TranslucentData.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TranslucentData.java new file mode 100644 index 0000000000..52fa90d90e --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/data/TranslucentData.java @@ -0,0 +1,91 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data; + +import java.nio.IntBuffer; + +import org.joml.Vector3fc; + +import net.caffeinemc.mods.sodium.client.gl.util.VertexRange; +import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; +import net.caffeinemc.mods.sodium.client.render.chunk.data.BuiltSectionMeshParts; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortType; +import net.minecraft.core.SectionPos; + +/** + * The base class for all types of translucent data. Subclasses are generated by + * the geometry collector after the section is built. + */ +public abstract class TranslucentData { + public static final int INDICES_PER_QUAD = 6; + public static final int VERTICES_PER_QUAD = 4; + public static final int BYTES_PER_INDEX = 4; + public static final int BYTES_PER_QUAD = INDICES_PER_QUAD * BYTES_PER_INDEX; + + public final SectionPos sectionPos; + + TranslucentData(SectionPos sectionPos) { + this.sectionPos = sectionPos; + } + + public abstract SortType getSortType(); + + public boolean retainAfterUpload() { + return false; + } + + public void delete() { + } + + public void sortOnTrigger(Vector3fc cameraPos) { + // no-op for other translucent data than dynamic + } + + /** + * Prepares the translucent data for triggering of the given type. This is run + * on the main thread before a sort task is scheduled. + * + * @param isAngleTrigger Whether the trigger is an angle trigger + */ + public void prepareTrigger(boolean isAngleTrigger) { + // no-op for other translucent data than GFNI dynamic + } + + public static int vertexCountToQuadCount(int vertexCount) { + return vertexCount / VERTICES_PER_QUAD; + } + + public static int quadCountToIndexBytes(int quadCount) { + return quadCount * BYTES_PER_QUAD; + } + + public static int indexBytesToQuadCount(int indexBytes) { + return indexBytes / BYTES_PER_QUAD; + } + + public static void writeQuadVertexIndexes(IntBuffer intBuffer, int quadIndex) { + int vertexOffset = quadIndex * VERTICES_PER_QUAD; + + intBuffer.put(vertexOffset + 0); + intBuffer.put(vertexOffset + 1); + intBuffer.put(vertexOffset + 2); + + intBuffer.put(vertexOffset + 2); + intBuffer.put(vertexOffset + 3); + intBuffer.put(vertexOffset + 0); + } + + public static void writeQuadVertexIndexes(IntBuffer intBuffer, int[] quadIndexes) { + for (int quadIndexPos = 0; quadIndexPos < quadIndexes.length; quadIndexPos++) { + writeQuadVertexIndexes(intBuffer, quadIndexes[quadIndexPos]); + } + } + + static VertexRange getUnassignedVertexRange(BuiltSectionMeshParts translucentMesh) { + VertexRange range = translucentMesh.getVertexRanges()[ModelQuadFacing.UNASSIGNED.ordinal()]; + + if (range == null) { + throw new IllegalStateException("No unassigned data in mesh"); + } + + return range; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/CameraMovement.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/CameraMovement.java new file mode 100644 index 0000000000..dd4eae5b8a --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/CameraMovement.java @@ -0,0 +1,9 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger; + +import org.joml.Vector3dc; + +public record CameraMovement(Vector3dc start, Vector3dc end) { + public boolean hasChanged() { + return !this.start.equals(this.end); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/DirectTriggers.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/DirectTriggers.java new file mode 100644 index 0000000000..6341652792 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/DirectTriggers.java @@ -0,0 +1,233 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger; + +import org.joml.Vector3d; +import org.joml.Vector3dc; + +import it.unimi.dsi.fastutil.doubles.Double2ObjectRBTreeMap; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TopoSortDynamicData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TranslucentData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.SortTriggering.SectionTriggers; +import net.minecraft.core.SectionPos; + +/** + * Performs direct triggering for sections that are sorted by distance. Direct + * triggering means the section is not triggered based on its geometry but + * rather the movement of the camera relative to the last position the camera + * was in when the section was sorted last. + * + * There are two types of direct triggering: Distance triggering is used when + * the camera is close to or inside the section. Angle triggering is used + * otherwise. Distance triggering sorts the section when the camera has moved at + * least a certain distance from the last sort position while angle triggering + * sorts it when a certain angle between the current and last sort position is + * exceeded (relative to the section center). + */ +class DirectTriggers implements SectionTriggers { + /** + * A tree map of the directly triggered sections, indexed by their + * minimum required camera movement. When the given camera movement is exceeded, + * they are tested for triggering the angle or distance condition. + * + * The accumulated distance is monotonically increasing and is never reset. This + * only becomes a problem when the camera moves more than 10^15 blocks in total. + * There will be precision issues at around 10^10 maybe, but it's still not a + * concern. + */ + private Double2ObjectRBTreeMap directTriggerSections = new Double2ObjectRBTreeMap<>(); + private double accumulatedDistance = 0; + + /** + * The factor by which the trigger distance and angle are multiplied to get a + * smaller threshold that is used for the actual trigger check but not the + * remaining distance calculation. This prevents curved movements from taking + * sections out of the tree and re-inserting without triggering many times near + * the actual trigger distance. It cuts the repeating unsuccessful trigger + * attempts off early. + */ + private static final double EARLY_TRIGGER_FACTOR = 0.9; + + /** + * Degrees of movement from last sort position before the section is sorted + * again. + */ + private static final double TRIGGER_ANGLE = Math.toRadians(10); + private static final double EARLY_TRIGGER_ANGLE_COS = Math.cos(TRIGGER_ANGLE * EARLY_TRIGGER_FACTOR); + private static final double SECTION_CENTER_DIST_SQUARED = 3 * Math.pow(16 / 2, 2) + 1; + private static final double SECTION_CENTER_DIST = Math.sqrt(SECTION_CENTER_DIST_SQUARED); + + /** + * How far the player must travel in blocks from the last position at which a + * section was sorted for it to be sorted again, if direct distance triggering + * is used (for close sections). + */ + private static final double DIRECT_TRIGGER_DISTANCE = 1; + private static final double EARLY_DIRECT_TRIGGER_DISTANCE_SQUARED = Math + .pow(DIRECT_TRIGGER_DISTANCE * EARLY_TRIGGER_FACTOR, 2); + + int getDirectTriggerCount() { + return this.directTriggerSections.size(); + } + + private class DirectTriggerData { + final SectionPos sectionPos; + private Vector3dc sectionCenter; + final TopoSortDynamicData dynamicData; + DirectTriggerData next; + + /** + * Absolute camera position at the time of the last trigger. + */ + Vector3dc triggerCameraPos; + + DirectTriggerData(TopoSortDynamicData dynamicData, SectionPos sectionPos, Vector3dc triggerCameraPos) { + this.dynamicData = dynamicData; + this.sectionPos = sectionPos; + this.triggerCameraPos = triggerCameraPos; + } + + Vector3dc getSectionCenter() { + if (this.sectionCenter == null) { + this.sectionCenter = new Vector3d( + sectionPos.minBlockX() + 8, + sectionPos.minBlockY() + 8, + sectionPos.minBlockZ() + 8); + } + return this.sectionCenter; + } + + double centerRelativeAngleCos(Vector3dc a, Vector3dc b) { + Vector3dc sectionCenter = this.getSectionCenter(); + return angleCos( + sectionCenter.x() - a.x(), + sectionCenter.y() - a.y(), + sectionCenter.z() - a.z(), + sectionCenter.x() - b.x(), + sectionCenter.y() - b.y(), + sectionCenter.z() - b.z()); + } + + /** + * Returns the distance between the sort camera pos and the center of the + * section. + */ + double getSectionCenterTriggerCameraDist() { + return Math.sqrt(getSectionCenterDistSquared(this.triggerCameraPos)); + } + + double getSectionCenterDistSquared(Vector3dc vector) { + Vector3dc sectionCenter = getSectionCenter(); + return sectionCenter.distanceSquared(vector); + } + + boolean isAngleTriggering(Vector3dc vector) { + return getSectionCenterDistSquared(vector) > SECTION_CENTER_DIST_SQUARED; + } + } + + private static double angleCos(double ax, double ay, double az, double bx, double by, double bz) { + double lengthA = Math.sqrt(Math.fma(ax, ax, Math.fma(ay, ay, az * az))); + double lengthB = Math.sqrt(Math.fma(bx, bx, Math.fma(by, by, bz * bz))); + double dot = Math.fma(ax, bx, Math.fma(ay, by, az * bz)); + return dot / (lengthA * lengthB); + } + + private void insertDirectAngleTrigger(DirectTriggerData data, Vector3dc cameraPos, double remainingAngle) { + double triggerCameraSectionCenterDist = data.getSectionCenterTriggerCameraDist(); + double centerMinDistance = Math.tan(remainingAngle) * (triggerCameraSectionCenterDist - SECTION_CENTER_DIST); + this.insertTrigger(this.accumulatedDistance + centerMinDistance, data); + } + + private void insertDirectDistanceTrigger(DirectTriggerData data, Vector3dc cameraPos, double remainingDistance) { + this.insertTrigger(this.accumulatedDistance + remainingDistance, data); + } + + private void insertTrigger(double key, DirectTriggerData data) { + data.dynamicData.setDirectTriggerKey(key); + + // attach the previous value after the current one in the list. if there is none + // it's just null + data.next = this.directTriggerSections.put(key, data); + } + + @Override + public void processTriggers(SortTriggering ts, CameraMovement movement) { + Vector3dc lastCamera = movement.start(); + Vector3dc camera = movement.end(); + this.accumulatedDistance += lastCamera.distance(camera); + + // iterate all elements with a key of at most accumulatedDistance + var head = this.directTriggerSections.headMap(this.accumulatedDistance); + for (var entry : head.double2ObjectEntrySet()) { + this.directTriggerSections.remove(entry.getDoubleKey()); + var data = entry.getValue(); + while (data != null) { + // get the next element before it's modified by the data being re-inserted into + // the tree + var next = data.next; + this.processSingleTrigger(data, ts, camera); + data = next; + } + } + } + + private void processSingleTrigger(DirectTriggerData data, SortTriggering ts, Vector3dc camera) { + if (data.isAngleTriggering(camera)) { + double remainingAngle = TRIGGER_ANGLE; + + // check if the angle since the last sort exceeds the threshold + double angleCos = data.centerRelativeAngleCos(data.triggerCameraPos, camera); + + // compare angles inverted because cosine flips it + if (angleCos <= EARLY_TRIGGER_ANGLE_COS) { + ts.triggerSectionDirect(data.sectionPos); + data.triggerCameraPos = camera; + } else { + remainingAngle -= Math.acos(angleCos); + } + + this.insertDirectAngleTrigger(data, camera, remainingAngle); + } else { + double remainingDistance = DIRECT_TRIGGER_DISTANCE; + double lastTriggerCurrentCameraDistSquared = data.triggerCameraPos.distanceSquared(camera); + + if (lastTriggerCurrentCameraDistSquared >= EARLY_DIRECT_TRIGGER_DISTANCE_SQUARED) { + ts.triggerSectionDirect(data.sectionPos); + data.triggerCameraPos = camera; + } else { + remainingDistance -= Math.sqrt(lastTriggerCurrentCameraDistSquared); + } + + this.insertDirectDistanceTrigger(data, camera, remainingDistance); + } + } + + @Override + public void removeSection(long sectionPos, TranslucentData data) { + if (data instanceof TopoSortDynamicData triggerable) { + var key = triggerable.getDirectTriggerKey(); + if (key != -1) { + this.directTriggerSections.remove(key); + triggerable.setDirectTriggerKey(-1); + } + } + } + + @Override + public void integrateSection(SortTriggering ts, SectionPos sectionPos, TopoSortDynamicData data, + CameraMovement movement) { + // create data with last camera position + var cameraPos = movement.start(); + var newData = new DirectTriggerData(data, sectionPos, cameraPos); + + if (movement.hasChanged()) { + // process it with the current camera position, this also initially inserts it + this.processSingleTrigger(newData, ts, movement.end()); + } else { + if (newData.isAngleTriggering(cameraPos)) { + this.insertDirectAngleTrigger(newData, cameraPos, TRIGGER_ANGLE); + } else { + this.insertDirectDistanceTrigger(newData, cameraPos, DIRECT_TRIGGER_DISTANCE); + } + } + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/GFNITriggers.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/GFNITriggers.java new file mode 100644 index 0000000000..b613243284 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/GFNITriggers.java @@ -0,0 +1,118 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger; + +import org.joml.Vector3fc; + +import it.unimi.dsi.fastutil.objects.Object2ReferenceOpenHashMap; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.DynamicData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TranslucentData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger.SortTriggering.SectionTriggers; +import net.minecraft.core.SectionPos; + +/** + * Performs Global Face Normal Indexing-based triggering as described in + * https://hackmd.io/@douira100/sodium-sl-gfni + * + * Note on precision: Global distances are stored as doubles while + * section-relative distances are stored as floats. The distances of the camera + * are calculated as doubles, but using float normal vectors (furthermore normal + * vectors are heavily quantized, so angular precision is not a concern). + */ +class GFNITriggers implements SectionTriggers { + /** + * A map of all the normal lists, indexed by their normal. + */ + private Object2ReferenceOpenHashMap normalLists = new Object2ReferenceOpenHashMap<>(); + + int getUniqueNormalCount() { + return this.normalLists.size(); + } + + @Override + public void processTriggers(SortTriggering ts, CameraMovement movement) { + for (var normalList : this.normalLists.values()) { + normalList.processMovement(ts, movement); + } + } + + private void addSectionInNewNormalLists(DynamicData dynamicData, NormalPlanes normalPlanes) { + var normal = normalPlanes.normal; + var normalList = this.normalLists.get(normal); + if (normalList == null) { + normalList = new NormalList(normal); + this.normalLists.put(normal, normalList); + normalList.addSection(normalPlanes, normalPlanes.sectionPos.asLong()); + } + } + + /** + * Removes the section from the normal list and returns whether the normal list + * is now empty and should itself be removed from the normal lists map. This is + * done with a return value so that the iterator can be used to remove it safely + * without a concurrent modification. + */ + private boolean removeSectionFromList(NormalList normalList, long sectionPos) { + normalList.removeSection(sectionPos); + return normalList.isEmpty(); + } + + @Override + public void removeSection(long sectionPos, TranslucentData data) { + this.normalLists.values().removeIf(normalList -> this.removeSectionFromList(normalList, sectionPos)); + } + + @Override + public void integrateSection(SortTriggering ts, SectionPos pos, DynamicData data, CameraMovement movement) { + long sectionPos = pos.asLong(); + var geometryPlanes = data.getGeometryPlanes(); + + // go through all normal lists and check against the normals that the group + // builder has. if the normal list has data for the section, but the group + // builder doesn't, the group is removed. otherwise, the group is updated. + var iterator = this.normalLists.values().iterator(); + while (iterator.hasNext()) { + var normalList = iterator.next(); + + // check if the geometry collector includes data for this normal. + var normalPlanes = geometryPlanes.getPlanesForNormal(normalList); + if (normalList.hasSection(sectionPos)) { + if (normalPlanes == null) { + if (this.removeSectionFromList(normalList, sectionPos)) { + iterator.remove(); + } + } else { + normalList.updateSection(normalPlanes, sectionPos); + } + } else if (normalPlanes != null) { + normalList.addSection(normalPlanes, sectionPos); + } + } + + // go through the data of the geometry collector to check for data of new + // normals + // for which there are no normal lists yet. This only checks for new normal + // lists since new data for existing normal lists is handled above. + var aligned = geometryPlanes.getAligned(); + if (aligned != null) { + for (var normalPlane : aligned) { + if (normalPlane != null) { + this.addSectionInNewNormalLists(data, normalPlane); + } + } + } + var unaligned = geometryPlanes.getUnaligned(); + if (unaligned != null) { + for (var normalPlane : unaligned) { + this.addSectionInNewNormalLists(data, normalPlane); + } + } + + data.clearGeometryPlanes(); + + // check if catchup trigger is necessary + if (movement.hasChanged()) { + for (var normalList : this.normalLists.values()) { + normalList.processCatchup(ts, movement, sectionPos); + } + } + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/GeometryPlanes.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/GeometryPlanes.java new file mode 100644 index 0000000000..8f75c2db7d --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/GeometryPlanes.java @@ -0,0 +1,133 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger; + +import java.util.Collection; + +import org.joml.Vector3fc; + +import it.unimi.dsi.fastutil.objects.Object2ReferenceOpenHashMap; +import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.TQuad; +import net.minecraft.core.SectionPos; + +/** + * GeometryPlanes stores the NormalPlanes for different normals, both aligned + * and unaligned. + */ +public class GeometryPlanes { + private NormalPlanes[] alignedPlanes; + private Object2ReferenceOpenHashMap unalignedPlanes; + + public NormalPlanes[] getAligned() { + return this.alignedPlanes; + } + + public NormalPlanes[] getAlignedOrCreate() { + if (this.alignedPlanes == null) { + this.alignedPlanes = new NormalPlanes[ModelQuadFacing.DIRECTIONS]; + } + return this.alignedPlanes; + } + + public Collection getUnaligned() { + if (this.unalignedPlanes == null) { + return null; + } + return this.unalignedPlanes.values(); + } + + public Object2ReferenceOpenHashMap getUnalignedOrCreate() { + if (this.unalignedPlanes == null) { + this.unalignedPlanes = new Object2ReferenceOpenHashMap<>(); + } + return this.unalignedPlanes; + } + + public Collection getUnalignedNormals() { + if (this.unalignedPlanes == null) { + return null; + } + return this.unalignedPlanes.keySet(); + } + + NormalPlanes getPlanesForNormal(NormalList normalList) { + var normal = normalList.getNormal(); + if (normal.isAligned()) { + if (this.alignedPlanes == null) { + return null; + } + return this.alignedPlanes[normal.getAlignedDirection()]; + } else { + if (this.unalignedPlanes == null) { + return null; + } + return this.unalignedPlanes.get(normal); + } + } + + public void addAlignedPlane(SectionPos sectionPos, int direction, float distance) { + var alignedDistances = this.getAlignedOrCreate(); + var normalPlanes = alignedDistances[direction]; + if (normalPlanes == null) { + normalPlanes = new NormalPlanes(sectionPos, direction); + alignedDistances[direction] = normalPlanes; + } + normalPlanes.addPlaneMember(distance); + } + + public void addDoubleSidedPlane(SectionPos sectionPos, int axis, float distance) { + this.addAlignedPlane(sectionPos, axis, distance); + this.addAlignedPlane(sectionPos, axis + 3, -distance); + } + + public void addUnalignedPlane(SectionPos sectionPos, Vector3fc normal, float distance) { + var unalignedDistances = this.getUnalignedOrCreate(); + var normalPlanes = unalignedDistances.get(normal); + if (normalPlanes == null) { + normalPlanes = new NormalPlanes(sectionPos, normal); + unalignedDistances.put(normal, normalPlanes); + } + normalPlanes.addPlaneMember(distance); + } + + public void addQuadPlane(SectionPos sectionPos, TQuad quad) { + var facing = quad.useQuantizedFacing(); + if (facing.isAligned()) { + this.addAlignedPlane(sectionPos, facing.ordinal(), quad.getDotProduct()); + } else { + this.addUnalignedPlane(sectionPos, quad.getQuantizedNormal(), quad.getDotProduct()); + } + } + + private void prepareAndInsert(Object2ReferenceOpenHashMap distancesByNormal) { + if (this.alignedPlanes != null) { + for (var normalPlanes : this.alignedPlanes) { + if (normalPlanes != null) { + normalPlanes.prepareAndInsert(distancesByNormal); + } + } + } + if (this.unalignedPlanes != null) { + for (var normalPlanes : this.unalignedPlanes.values()) { + normalPlanes.prepareAndInsert(distancesByNormal); + } + } + } + + public void prepareIntegration() { + this.prepareAndInsert(null); + } + + public Object2ReferenceOpenHashMap prepareAndGetDistances() { + var distancesByNormal = new Object2ReferenceOpenHashMap(10); + this.prepareAndInsert(distancesByNormal); + return distancesByNormal; + } + + public static GeometryPlanes fromQuadLists(SectionPos sectionPos, TQuad[] quads) { + var geometryPlanes = new GeometryPlanes(); + for (var quad : quads) { + geometryPlanes.addQuadPlane(sectionPos, quad); + } + return geometryPlanes; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/Group.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/Group.java new file mode 100644 index 0000000000..9be130c7ab --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/Group.java @@ -0,0 +1,81 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger; + +import com.lodborg.intervaltree.DoubleInterval; + +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.AlignableNormal; + +/** + * A group represents a set of face planes of the same normal within a section. + */ +class Group { + /** + * The section this group is for + */ + long sectionPos; + + /** + * A sorted list of all the face plane distances in this group. Relative to the + * base distance. + */ + float[] facePlaneDistances; + + /** + * A hash of all the face plane distances in this group (before adding the base + * distance) + */ + long relDistanceHash; + + /** + * The closed (inclusive of both boundaries) minimum and maximum distances. + * Absolute values, not relative to the base distance. + */ + DoubleInterval distances; + + double baseDistance; + + AlignableNormal normal; + + Group(NormalPlanes normalPlanes) { + this.replaceWith(normalPlanes); + } + + void replaceWith(NormalPlanes normalPlanes) { + this.sectionPos = normalPlanes.sectionPos.asLong(); + this.distances = normalPlanes.distanceRange; + this.relDistanceHash = normalPlanes.relDistanceHash; + this.facePlaneDistances = normalPlanes.relativeDistances; + this.baseDistance = normalPlanes.baseDistance; + this.normal = normalPlanes.normal; + } + + private boolean planeTriggered(double start, double end) { + return start < this.distances.getEnd() && end > this.distances.getStart() + && AlignableNormal.queryRange(this.facePlaneDistances, + (float) (start - this.baseDistance), (float) (end - this.baseDistance)); + } + + void triggerRange(SortTriggering ts, double start, double end) { + // trigger self on the section if the query range overlaps with the group + // testing for strict inequality because if the two intervals just touch at the + // start/end, there can be no overlap + if (this.planeTriggered(start, end)) { + ts.triggerSectionGFNI(this.sectionPos, this.normal); + } + } + + /** + * A pretty good heuristic for equality of captured translucent geometry data. + * + * It assumes that if the size, bounds, and hash are equal, they are most likely + * the same. We also know that the existing and new data is for the same section + * position since the group was retrieved from the map for the right position. + * + * TODO: how common are collisions and are they bad? + * If they are common, use second or different hash + */ + boolean normalPlanesEquals(NormalPlanes normalPlanes) { + return this.facePlaneDistances.length == normalPlanes.relativeDistancesSet.size() + && this.distances.equals(normalPlanes.distanceRange) + && this.relDistanceHash == normalPlanes.relDistanceHash; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/NormalList.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/NormalList.java new file mode 100644 index 0000000000..5c08e996ac --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/NormalList.java @@ -0,0 +1,176 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger; + +import java.util.Collection; + +import org.joml.Vector3dc; + +import com.lodborg.intervaltree.DoubleInterval; +import com.lodborg.intervaltree.Interval; +import com.lodborg.intervaltree.Interval.Bounded; +import com.lodborg.intervaltree.IntervalTree; + +import it.unimi.dsi.fastutil.longs.Long2ReferenceOpenHashMap; +import it.unimi.dsi.fastutil.objects.Object2ReferenceOpenHashMap; +import it.unimi.dsi.fastutil.objects.ReferenceArraySet; +import it.unimi.dsi.fastutil.objects.ReferenceLinkedOpenHashSet; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.AlignableNormal; + +/** + * A normal list contains all the face planes that have the same normal. + */ +public class NormalList { + /** + * Size threshold after which group sets in {@link #groupsByInterval} are + * replaced with hash sets to improve update performance. + */ + private static final int HASH_SET_THRESHOLD = 20; + + /** + * Size threshold under which group sets in {@link #groupsByInterval} are + * downgraded to array sets to reduce memory usage. + */ + private static final int ARRAY_SET_THRESHOLD = 10; + + /** + * The normal of this normal list. + */ + private final AlignableNormal normal; + + /** + * An interval tree of group intervals. Since this only stores intervals, the + * stored intervals are mapped to groups in a separate hashmap. + */ + private final IntervalTree intervalTree = new IntervalTree<>(); + + /** + * A separate hashmap of groups. This is what actually stores the groups since + * the interval tree just contains intervals. + */ + private final Object2ReferenceOpenHashMap> groupsByInterval = new Object2ReferenceOpenHashMap<>(); + + /** + * A hashmap from chunk sections to groups. This is for finding groups during + * updates. + */ + private final Long2ReferenceOpenHashMap groupsBySection = new Long2ReferenceOpenHashMap<>(); + + /** + * Constructs a new normal list with the given unit normal vector and aligned + * normal index. + * + * @param normal The unit normal vector + */ + NormalList(AlignableNormal normal) { + this.normal = normal; + } + + public AlignableNormal getNormal() { + return this.normal; + } + + private double normalDotDouble(Vector3dc v) { + return Math.fma((double) this.normal.x, v.x(), + Math.fma((double) this.normal.y, v.y(), + (double) this.normal.z * v.z())); + } + + void processMovement(SortTriggering ts, CameraMovement movement) { + // calculate the distance range of the movement with respect to the normal + double start = this.normalDotDouble(movement.start()); + double end = this.normalDotDouble(movement.end()); + + // stop if the movement is reverse with regards to the normal + // since this means it's moving against the normal + if (start >= end) { + return; + } + + // perform the interval query on the group intervals and resolve each interval + // to the collection of groups it maps to + var interval = new DoubleInterval(start, end, Bounded.CLOSED); + for (Interval groupInterval : intervalTree.query(interval)) { + for (Group group : groupsByInterval.get(groupInterval)) { + group.triggerRange(ts, start, end); + } + } + } + + void processCatchup(SortTriggering ts, CameraMovement movement, long sectionPos) { + double start = this.normalDotDouble(movement.start()); + double end = this.normalDotDouble(movement.end()); + if (start >= end) { + return; + } + var group = this.groupsBySection.get(sectionPos); + if (group != null) { + group.triggerRange(ts, start, end); + } + } + + private void removeGroupInterval(Group group) { + var groups = this.groupsByInterval.get(group.distances); + if (groups != null) { + groups.remove(group); + if (groups.isEmpty()) { + this.groupsByInterval.remove(group.distances); + + // only remove from the interval tree if no other sections are also using it + this.intervalTree.remove(group.distances); + } else if (groups.size() <= ARRAY_SET_THRESHOLD) { + groups = new ReferenceArraySet<>(groups); + this.groupsByInterval.put(group.distances, groups); + } + } + } + + private void addGroupInterval(Group group) { + var groups = this.groupsByInterval.get(group.distances); + if (groups == null) { + groups = new ReferenceArraySet<>(); + this.groupsByInterval.put(group.distances, groups); + + // only add to the interval tree if it's a new interval + this.intervalTree.add(group.distances); + } else if (groups.size() >= HASH_SET_THRESHOLD) { + groups = new ReferenceLinkedOpenHashSet<>(groups); + this.groupsByInterval.put(group.distances, groups); + } + groups.add(group); + } + + boolean hasSection(long sectionPos) { + return this.groupsBySection.containsKey(sectionPos); + } + + boolean isEmpty() { + return this.groupsBySection.isEmpty(); + } + + void addSection(NormalPlanes normalPlanes, long sectionPos) { + var group = new Group(normalPlanes); + + this.groupsBySection.put(sectionPos, group); + this.addGroupInterval(group); + } + + void removeSection(long sectionPos) { + Group group = this.groupsBySection.remove(sectionPos); + if (group != null) { + this.removeGroupInterval(group); + } + } + + void updateSection(NormalPlanes normalPlanes, long sectionPos) { + Group group = this.groupsBySection.get(sectionPos); + + // only update on changes to translucent geometry + if (group.normalPlanesEquals(normalPlanes)) { + // don't update if they are the same + return; + } + + this.removeGroupInterval(group); + group.replaceWith(normalPlanes); + this.addGroupInterval(group); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/NormalPlanes.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/NormalPlanes.java new file mode 100644 index 0000000000..ecd542b619 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/NormalPlanes.java @@ -0,0 +1,83 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger; + +import org.joml.Vector3fc; +import java.util.Arrays; + +import com.lodborg.intervaltree.DoubleInterval; +import com.lodborg.intervaltree.Interval.Bounded; + +import it.unimi.dsi.fastutil.floats.FloatOpenHashSet; +import it.unimi.dsi.fastutil.objects.Object2ReferenceOpenHashMap; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.AlignableNormal; +import net.minecraft.core.SectionPos; + +/** + * NormalPlanes represents planes by a normal and a list of distances. Initially they're + * stored in a hash set and later sorted for range queries. + */ +public class NormalPlanes { + final FloatOpenHashSet relativeDistancesSet = new FloatOpenHashSet(16); + final AlignableNormal normal; + final SectionPos sectionPos; + + float[] relativeDistances; // relative to the base distance + DoubleInterval distanceRange; + long relDistanceHash; + double baseDistance; + + private NormalPlanes(SectionPos sectionPos, AlignableNormal normal) { + this.sectionPos = sectionPos; + this.normal = normal; + } + + public NormalPlanes(SectionPos sectionPos, Vector3fc normal) { + this(sectionPos, AlignableNormal.fromUnaligned(normal)); + } + + public NormalPlanes(SectionPos sectionPos, int alignedDirection) { + this(sectionPos, AlignableNormal.fromAligned(alignedDirection)); + } + + boolean addPlaneMember(float vertexX, float vertexY, float vertexZ) { + return this.addPlaneMember(this.normal.dot(vertexX, vertexY, vertexZ)); + } + + public boolean addPlaneMember(float distance) { + return this.relativeDistancesSet.add(distance); + } + + public void prepareIntegration() { + // stop if already prepared + if (this.relativeDistances != null) { + throw new IllegalStateException("Already prepared"); + } + + // store the absolute face plane distances in an array + var size = this.relativeDistancesSet.size(); + this.relativeDistances = new float[this.relativeDistancesSet.size()]; + int i = 0; + for (float relDistance : this.relativeDistancesSet) { + this.relativeDistances[i++] = relDistance; + + long distanceBits = Double.doubleToLongBits(relDistance); + this.relDistanceHash ^= this.relDistanceHash * 31L + distanceBits; + } + + // sort the array ascending + Arrays.sort(relativeDistances); + + this.baseDistance = this.normal.dot( + sectionPos.minBlockX(), sectionPos.minBlockY(), sectionPos.minBlockZ()); + this.distanceRange = new DoubleInterval( + this.relativeDistances[0] + this.baseDistance, + this.relativeDistances[size - 1] + this.baseDistance, + Bounded.CLOSED); + } + + public void prepareAndInsert(Object2ReferenceOpenHashMap distancesByNormal) { + this.prepareIntegration(); + if (distancesByNormal != null) { + distancesByNormal.put(this.normal, this.relativeDistances); + } + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/SortTriggering.java b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/SortTriggering.java new file mode 100644 index 0000000000..587542ff54 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/render/chunk/translucent_sorting/trigger/SortTriggering.java @@ -0,0 +1,243 @@ +package net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.trigger; + +import java.util.List; +import java.util.function.BiConsumer; + +import org.joml.Vector3dc; + +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; +import net.caffeinemc.mods.sodium.client.SodiumClientMod; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.AlignableNormal; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortBehavior; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.SortType; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.DynamicData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TopoSortDynamicData; +import net.caffeinemc.mods.sodium.client.render.chunk.translucent_sorting.data.TranslucentData; +import net.minecraft.core.SectionPos; + +/** + * This class is a central point in translucency sorting. It counts the number + * of translucent data objects for each sort type and delegates triggering of + * sections for dynamic sorting to the trigger components. + * + * TODO: + * - investigate why there's a similar number of STA and DYN sections. This might be normal, the counters might be broken or the heuristic is actually wrong. + * + * @author douira (the translucent_sorting package) + */ +public class SortTriggering { + /** + * To avoid generating a collection of the triggered sections, this callback is + * used to process the triggered sections directly as they are queried from the + * normal lists' interval trees. The callback is given the section coordinates, + * and a boolean indicating if the trigger was an direct trigger. + */ + private BiConsumer triggerSectionCallback; + + /** + * The dynamic data being caught up. When a section is rebuilt (initially or + * later) it might not have the required trigger data registered yet so that it + * might miss being triggered between being scheduled for rebuild and being + * integrated. This is solved by catching up the section being integrated with + * the movement that has happened in the mean time. + */ + private DynamicData catchupData = null; + + /** + * The number of triggered sections and normals. The normals are kept in a + * hashmap to count them, triggered sections are not deduplicated. + */ + private int gfniTriggerCount = 0; + private int directTriggerCount = 0; + private final ObjectOpenHashSet triggeredNormals = new ObjectOpenHashSet<>(); + private int triggeredNormalCount = 0; + + /** + * A map of the number of times each sort type is currently in use. + */ + private final int[] sortTypeCounters = new int[SortType.values().length]; + + private final GFNITriggers gfni = new GFNITriggers(); + private final DirectTriggers direct = new DirectTriggers(); + + interface SectionTriggers { + void processTriggers(SortTriggering ts, CameraMovement movement); + + void removeSection(long sectionPos, TranslucentData data); + + void integrateSection(SortTriggering ts, SectionPos sectionPos, T data, CameraMovement movement); + } + + /** + * Triggers the sections that the given camera movement crosses face planes of. + * + * @param triggerSectionCallback called for each section that is triggered + * @param movement the camera movement to trigger for + */ + public void triggerSections(BiConsumer triggerSectionCallback, CameraMovement movement) { + triggeredNormals.clear(); + this.triggerSectionCallback = triggerSectionCallback; + var oldGfniTriggerCount = this.gfniTriggerCount; + var oldDirectTriggerCount = this.directTriggerCount; + this.gfniTriggerCount = 0; + this.directTriggerCount = 0; + + this.gfni.processTriggers(this, movement); + this.direct.processTriggers(this, movement); + + if (this.gfniTriggerCount > 0 || this.directTriggerCount > 0) { + this.triggeredNormalCount = this.triggeredNormals.size(); + } else { + this.gfniTriggerCount = oldGfniTriggerCount; + this.directTriggerCount = oldDirectTriggerCount; + } + + this.triggerSectionCallback = null; + } + + private boolean isCatchingUp() { + return this.catchupData != null; + } + + void triggerSectionGFNI(long sectionPos, AlignableNormal normal) { + if (this.isCatchingUp()) { + this.triggerSectionCatchup(sectionPos, false); + return; + } + + this.triggeredNormals.add(normal); + this.triggerSectionCallback.accept(sectionPos, false); + this.gfniTriggerCount++; + } + + void triggerSectionDirect(SectionPos sectionPos) { + if (this.isCatchingUp()) { + this.triggerSectionCatchup(sectionPos.asLong(), true); + return; + } + + this.triggerSectionCallback.accept(sectionPos.asLong(), true); + this.directTriggerCount++; + } + + private void triggerSectionCatchup(long sectionPos, boolean isDirectTrigger) { + // catchup triggering might be disabled + if (this.triggerSectionCallback != null) { + // do prepare triggere here since it can't be done through the render section as + // it hasn't been put there yet or it contains an old data object + this.catchupData.prepareTrigger(isDirectTrigger); + + // schedule the section to be re-sorted + this.triggerSectionCallback.accept(sectionPos, isDirectTrigger); + } + } + + public void applyTriggerChanges(TopoSortDynamicData data, SectionPos pos, Vector3dc cameraPos) { + if (data.getAndFlushTurnGFNITriggerOff()) { + this.gfni.removeSection(pos.asLong(), data); + } + if (data.getAndFlushTurnDirectTriggerOn()) { + // use dummy camera movement since there's no risk of the camera moving between + // the section being scheduled and integrated (there's no building going on + // here) + this.direct.integrateSection(this, pos, data, new CameraMovement(cameraPos, cameraPos)); + } + if (data.getAndFlushTurnDirectTriggerOff()) { + this.direct.removeSection(pos.asLong(), data); + } + } + + private void decrementSortTypeCounter(TranslucentData oldData) { + if (oldData != null) { + this.sortTypeCounters[oldData.getSortType().ordinal()]--; + } + } + + private void incrementSortTypeCounter(TranslucentData newData) { + this.sortTypeCounters[newData.getSortType().ordinal()]++; + } + + /** + * Removes a section from direct and GFNI triggering. This removes all its face + * planes. + * + * @param oldData the data of the section to remove + * @param sectionPos the section to remove + */ + public void removeSection(TranslucentData oldData, long sectionPos) { + if (oldData == null) { + return; + } + this.gfni.removeSection(sectionPos, oldData); + this.direct.removeSection(sectionPos, oldData); + this.decrementSortTypeCounter(oldData); + } + + /** + * Integrates the data from a geometry collector into GFNI. The geometry + * collector contains the translucent face planes of a single section. This + * method may also remove the section if it has become irrelevant. + */ + public void integrateTranslucentData(TranslucentData oldData, TranslucentData newData, Vector3dc cameraPos, + BiConsumer triggerSectionCallback) { + if (oldData == newData) { + return; + } + + var pos = newData.sectionPos; + + this.incrementSortTypeCounter(newData); + + if (newData instanceof DynamicData dynamicData) { + this.direct.removeSection(pos.asLong(), oldData); + this.decrementSortTypeCounter(oldData); + this.triggerSectionCallback = triggerSectionCallback; + this.catchupData = dynamicData; + var movement = new CameraMovement(dynamicData.getInitialCameraPos(), cameraPos); + + if (dynamicData instanceof TopoSortDynamicData topoSortData) { + if (topoSortData.GFNITriggerEnabled()) { + this.gfni.integrateSection(this, pos, topoSortData, movement); + } else { + // remove the trigger data since this section is never going to get gfni + // triggering (there's no option to add sections to GFNI later currently) + topoSortData.clearGeometryPlanes(); + } + if (topoSortData.directTriggerEnabled()) { + this.direct.integrateSection(this, pos, topoSortData, movement); + } + + // clear trigger changes on data change because the current state of trigger + // types was just applied + topoSortData.clearTriggerChanges(); + } else { + this.gfni.integrateSection(this, pos, dynamicData, movement); + } + + this.triggerSectionCallback = null; + this.catchupData = null; + } else { + this.removeSection(oldData, pos.asLong()); + } + } + + public void addDebugStrings(List list) { + var sortBehavior = SodiumClientMod.options().performance.getSortBehavior(); + if (sortBehavior.getSortMode() == SortBehavior.SortMode.NONE) { + list.add("TS OFF"); + } else { + list.add("TS (%s) NL=%02d TrN=%02d TrS=G%03d/D%03d".formatted( + sortBehavior.getShortName(), + this.gfni.getUniqueNormalCount(), + this.triggeredNormalCount, + this.gfniTriggerCount, + this.directTriggerCount)); + list.add("N=%05d SNR=%05d STA=%05d DYN=%05d (DIR=%02d)".formatted( + this.sortTypeCounters[SortType.NONE.ordinal()], + this.sortTypeCounters[SortType.STATIC_NORMAL_RELATIVE.ordinal()], + this.sortTypeCounters[SortType.STATIC_TOPO.ordinal()], + this.sortTypeCounters[SortType.DYNAMIC.ordinal()], + this.direct.getDirectTriggerCount())); + } + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/util/MathUtil.java b/src/main/java/net/caffeinemc/mods/sodium/client/util/MathUtil.java index e764e27f72..8c41de06ec 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/util/MathUtil.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/util/MathUtil.java @@ -11,4 +11,22 @@ public static boolean isPowerOfTwo(int n) { public static long toMib(long bytes) { return bytes / (1024L * 1024L); // 1 MiB = 1048576 (2^20) bytes } + + private static final int BIT_COUNT = 32; + private static final int FLIP_SIGN_MASK = 1 << (BIT_COUNT - 1); + + /** + * Converts a float to a comparable integer value. This is used to compare + * floating point values by their int bits (for example packed in a long). + * + * The resulting integer can be treated as if it's unsigned and numbers the + * floats from the smallest negative to the largest positive value. + */ + public static int floatToComparableInt(float f) { + // // uses Float.compare to avoid issues comparing -0.0f and 0.0f + // return Float.floatToRawIntBits(f) ^ (Float.compare(f, 0f) > 0 ? 0x80000000 : 0xffffffff); + + var bits = Float.floatToRawIntBits(f); + return bits ^ ((bits >> (BIT_COUNT - 1)) | FLIP_SIGN_MASK); + } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/util/ModelQuadUtil.java b/src/main/java/net/caffeinemc/mods/sodium/client/util/ModelQuadUtil.java index f15e5c46f7..a2cd31a62d 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/util/ModelQuadUtil.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/util/ModelQuadUtil.java @@ -1,11 +1,5 @@ package net.caffeinemc.mods.sodium.client.util; -import net.caffeinemc.mods.sodium.client.model.quad.ModelQuadView; -import net.caffeinemc.mods.sodium.client.model.quad.properties.ModelQuadFacing; -import net.caffeinemc.mods.sodium.api.util.NormI8; -import net.minecraft.core.Direction; -import net.minecraft.util.Mth; -import org.joml.Vector3f; /** * Provides some utilities and constants for interacting with vanilla's model quad vertex format. @@ -39,73 +33,4 @@ public class ModelQuadUtil { public static int vertexOffset(int vertexIndex) { return vertexIndex * VERTEX_SIZE; } - - public static ModelQuadFacing findNormalFace(float x, float y, float z) { - Vector3f normal = new Vector3f(x, y, z); - - if (!normal.isFinite()) { - return ModelQuadFacing.UNASSIGNED; - } - - float maxDot = 0; - Direction closestFace = null; - - for (Direction face : DirectionUtil.ALL_DIRECTIONS) { - float dot = normal.dot(face.step()); - - if (dot > maxDot) { - maxDot = dot; - closestFace = face; - } - } - - if (closestFace != null && Mth.equal(maxDot, 1.0f)) { - return ModelQuadFacing.fromDirection(closestFace); - } - - return ModelQuadFacing.UNASSIGNED; - } - - public static ModelQuadFacing findNormalFace(int normal) { - return findNormalFace(NormI8.unpackX(normal), NormI8.unpackY(normal), NormI8.unpackZ(normal)); - } - - public static int calculateNormal(ModelQuadView quad) { - final float x0 = quad.getX(0); - final float y0 = quad.getY(0); - final float z0 = quad.getZ(0); - - final float x1 = quad.getX(1); - final float y1 = quad.getY(1); - final float z1 = quad.getZ(1); - - final float x2 = quad.getX(2); - final float y2 = quad.getY(2); - final float z2 = quad.getZ(2); - - final float x3 = quad.getX(3); - final float y3 = quad.getY(3); - final float z3 = quad.getZ(3); - - final float dx0 = x2 - x0; - final float dy0 = y2 - y0; - final float dz0 = z2 - z0; - final float dx1 = x3 - x1; - final float dy1 = y3 - y1; - final float dz1 = z3 - z1; - - float normX = dy0 * dz1 - dz0 * dy1; - float normY = dz0 * dx1 - dx0 * dz1; - float normZ = dx0 * dy1 - dy0 * dx1; - - float l = (float) Math.sqrt(normX * normX + normY * normY + normZ * normZ); - - if (l != 0) { - normX /= l; - normY /= l; - normZ /= l; - } - - return NormI8.pack(normX, normY, normZ); - } } diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/util/collections/BitArray.java b/src/main/java/net/caffeinemc/mods/sodium/client/util/collections/BitArray.java new file mode 100644 index 0000000000..333282a930 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/util/collections/BitArray.java @@ -0,0 +1,270 @@ +package net.caffeinemc.mods.sodium.client.util.collections; + +import java.util.Arrays; + +public class BitArray { + private static final int ADDRESS_BITS_PER_WORD = 6; + private static final int BITS_PER_WORD = 1 << ADDRESS_BITS_PER_WORD; + private static final int BIT_INDEX_MASK = BITS_PER_WORD - 1; + private static final long WORD_MASK = 0xFFFFFFFFFFFFFFFFL; + + private final long[] words; + private final int count; + + /** + * Returns {@param num} aligned to the next multiple of {@param alignment}. + * + * Taken from https://github.com/CaffeineMC/sodium-fabric/blob/1.19.x/next/components/gfx-utils/src/main/java/net/caffeinemc/gfx/util/misc/MathUtil.java + * + * @param num The number that will be rounded if needed + * @param alignment The multiple that the output will be rounded to (must be a + * power-of-two) + * @return The aligned position, either equal to or greater than {@param num} + */ + private static int align(int num, int alignment) { + int additive = alignment - 1; + int mask = ~additive; + return (num + additive) & mask; + } + + public BitArray(int count) { + this.words = new long[(align(count, BITS_PER_WORD) >> ADDRESS_BITS_PER_WORD)]; + this.count = count; + } + + public boolean get(int index) { + return (this.words[wordIndex(index)] & 1L << bitIndex(index)) != 0; + } + + public void set(int index) { + this.words[wordIndex(index)] |= 1L << bitIndex(index); + } + + public void unset(int index) { + this.words[wordIndex(index)] &= ~(1L << bitIndex(index)); + } + + public void put(int index, boolean value) { + int wordIndex = wordIndex(index); + int bitIndex = bitIndex(index); + long intValue = value ? 1 : 0; + this.words[wordIndex] = (this.words[wordIndex] & ~(1L << bitIndex)) | (intValue << bitIndex); + } + + /** + * Sets the bits from startIdx (inclusive) to endIdx (exclusive) to 1 + */ + public void set(int startIdx, int endIdx) { + int startWordIndex = wordIndex(startIdx); + int endWordIndex = wordIndex(endIdx - 1); + + long firstWordMask = WORD_MASK << startIdx; + long lastWordMask = WORD_MASK >>> -endIdx; + if (startWordIndex == endWordIndex) { + this.words[startWordIndex] |= (firstWordMask & lastWordMask); + } else { + this.words[startWordIndex] |= firstWordMask; + + for (int i = startWordIndex + 1; i < endWordIndex; i++) { + this.words[i] = 0xFFFFFFFFFFFFFFFFL; + } + + this.words[endWordIndex] |= lastWordMask; + } + } + + /** + * Sets the bits from startIdx (inclusive) to endIdx (exclusive) to 0 + */ + public void unset(int startIdx, int endIdx) { + int startWordIndex = wordIndex(startIdx); + int endWordIndex = wordIndex(endIdx - 1); + + long firstWordMask = ~(WORD_MASK << startIdx); + long lastWordMask = ~(WORD_MASK >>> -endIdx); + if (startWordIndex == endWordIndex) { + this.words[startWordIndex] &= (firstWordMask & lastWordMask); + } else { + this.words[startWordIndex] &= firstWordMask; + + for (int i = startWordIndex + 1; i < endWordIndex; i++) { + this.words[i] = 0x0000000000000000L; + } + + this.words[endWordIndex] &= lastWordMask; + } + } + + // FIXME + /* public boolean checkUnset(int startIdx, int endIdx) { + int startWordIndex = wordIndex(startIdx); + int endWordIndex = wordIndex(endIdx - 1); + + long firstWordMask = ~(WORD_MASK << startIdx); + long lastWordMask = ~(WORD_MASK >>> -endIdx); + if (startWordIndex == endWordIndex) { + return (this.words[startWordIndex] & firstWordMask & lastWordMask) == 0x0000000000000000L; + } else { + if ((this.words[startWordIndex] & firstWordMask) != 0x0000000000000000L) { + return false; + } + + for (int i = startWordIndex + 1; i < endWordIndex; i++) { + if (this.words[i] != 0x0000000000000000L) { + return false; + } + } + + return (this.words[endWordIndex] & lastWordMask) == 0x0000000000000000L; + } + }*/ + + public void copy(BitArray src, int startIdx, int endIdx) { + int startWordIndex = wordIndex(startIdx); + int endWordIndex = wordIndex(endIdx - 1); + + long firstWordMask = WORD_MASK << startIdx; + long lastWordMask = WORD_MASK >>> -endIdx; + if (startWordIndex == endWordIndex) { + long combinedMask = firstWordMask & lastWordMask; + long invCombinedMask = ~combinedMask; + this.words[startWordIndex] = (this.words[startWordIndex] & invCombinedMask) + | (src.words[startWordIndex] & combinedMask); + } else { + long invFirstWordMask = ~firstWordMask; + long invLastWordMask = ~lastWordMask; + + this.words[startWordIndex] = (this.words[startWordIndex] & invFirstWordMask) + | (src.words[startWordIndex] & firstWordMask); + + int length = endWordIndex - (startWordIndex + 1); + if (length > 0) { + System.arraycopy( + src.words, + startWordIndex + 1, + this.words, + startWordIndex + 1, + length); + } + + this.words[endWordIndex] = (this.words[endWordIndex] & invLastWordMask) + | (src.words[endWordIndex] & lastWordMask); + } + } + + public void copy(BitArray src, int index) { + int wordIndex = wordIndex(index); + long invBitMask = 1L << bitIndex(index); + long bitMask = ~invBitMask; + this.words[wordIndex] = (this.words[wordIndex] & bitMask) | (src.words[wordIndex] & invBitMask); + } + + public void and(BitArray src, int startIdx, int endIdx) { + int startWordIndex = wordIndex(startIdx); + int endWordIndex = wordIndex(endIdx - 1); + + long firstWordMask = WORD_MASK << startIdx; + long lastWordMask = WORD_MASK >>> -endIdx; + if (startWordIndex == endWordIndex) { + long combinedMask = firstWordMask & lastWordMask; + long invCombinedMask = ~combinedMask; + this.words[startWordIndex] &= (src.words[startWordIndex] | invCombinedMask); + } else { + long invFirstWordMask = ~firstWordMask; + long invLastWordMask = ~lastWordMask; + + this.words[startWordIndex] &= (src.words[startWordIndex] | invFirstWordMask); + + for (int i = startWordIndex + 1; i < endWordIndex; i++) { + this.words[i] &= src.words[i]; + } + + this.words[endWordIndex] &= (src.words[endWordIndex] | invLastWordMask); + } + } + + private static int wordIndex(int index) { + return index >> ADDRESS_BITS_PER_WORD; + } + + private static int bitIndex(int index) { + return index & BIT_INDEX_MASK; + } + + public void fill(boolean value) { + Arrays.fill(this.words, value ? 0xFFFFFFFFFFFFFFFFL : 0x0000000000000000L); + } + + public void unset() { + this.fill(false); + } + + public void set() { + this.fill(true); + } + + public int count() { + int sum = 0; + + for (long word : this.words) { + sum += Long.bitCount(word); + } + + return sum; + } + + public int capacity() { + return this.count; + } + + public boolean getAndSet(int index) { + int wordIndex = wordIndex(index); + long bit = 1L << bitIndex(index); + + long word = this.words[wordIndex]; + this.words[wordIndex] = word | bit; + + return (word & bit) != 0; + } + + public boolean getAndUnset(int index) { + var wordIndex = wordIndex(index); + var bit = 1L << bitIndex(index); + + var word = this.words[wordIndex]; + this.words[wordIndex] = word & ~bit; + + return (word & bit) != 0; + } + + public int nextSetBit(int fromIndex) { + int u = wordIndex(fromIndex); + + if (u >= this.words.length) { + return -1; + } + long word = this.words[u] & (WORD_MASK << fromIndex); + + while (true) { + if (word != 0) { + return (u * BITS_PER_WORD) + Long.numberOfTrailingZeros(word); + } + + if (++u == this.words.length) { + return -1; + } + + word = this.words[u]; + } + } + + public String toBitString() { + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < this.count; i++) { + sb.append(this.get(i) ? '1' : '0'); + } + + return sb.toString(); + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/util/sorting/RadixSort.java b/src/main/java/net/caffeinemc/mods/sodium/client/util/sorting/RadixSort.java new file mode 100644 index 0000000000..00aedcb426 --- /dev/null +++ b/src/main/java/net/caffeinemc/mods/sodium/client/util/sorting/RadixSort.java @@ -0,0 +1,82 @@ +package net.caffeinemc.mods.sodium.client.util.sorting; + +public class RadixSort extends AbstractSort { + public static final int RADIX_SORT_THRESHOLD = 64; + + private static final int DIGIT_BITS = 8; + private static final int RADIX_KEY_BITS = Integer.BYTES * 8; + private static final int BUCKET_COUNT = 1 << DIGIT_BITS; + private static final int DIGIT_COUNT = (RADIX_KEY_BITS + DIGIT_BITS - 1) / DIGIT_BITS; + private static final int DIGIT_MASK = (1 << DIGIT_BITS) - 1; + + public static int[] sort(int[] keys) { + if (keys.length <= 1) { + return new int[keys.length]; + } + + return radixSort(keys, createHistogram(keys)); + } + + private static int[][] createHistogram(int[] keys) { + var histogram = new int[DIGIT_COUNT][BUCKET_COUNT]; + + for (final int key : keys) { + for (int digit = 0; digit < DIGIT_COUNT; digit++) { + histogram[digit][extractDigit(key, digit)] += 1; + } + } + + return histogram; + } + + private static void prefixSum(int[][] offsets) { + for (int digit = 0; digit < DIGIT_COUNT; digit++) { + final var buckets = offsets[digit]; + var sum = 0; + + for (int bucket_idx = 0; bucket_idx < BUCKET_COUNT; bucket_idx++) { + final var offset = sum; + sum += buckets[bucket_idx]; + buckets[bucket_idx] = offset; + } + } + } + + private static int[] radixSort(int[] keys, int[][] offsets) { + prefixSum(offsets); + + final var length = keys.length; + + int[] cur = createIndexBuffer(length); + int[] next = new int[length]; + + for (int digit = 0; digit < DIGIT_COUNT; digit++) { + final var buckets = offsets[digit]; + + for (int pos = 0; pos < length; pos++) { + final var index = cur[pos]; + final var bucket_idx = extractDigit(keys[index], digit); + + next[buckets[bucket_idx]] = index; + buckets[bucket_idx] += 1; + } + + { + // (cur, next) = (next, cur) + var temp = next; + next = cur; + cur = temp; + } + } + + return cur; + } + + private static int extractDigit(int key, int digit) { + return ((key >>> (digit * DIGIT_BITS)) & DIGIT_MASK); + } + + public static boolean useRadixSort(int length) { + return length >= RADIX_SORT_THRESHOLD; + } +} diff --git a/src/main/java/net/caffeinemc/mods/sodium/client/util/sorting/VertexSorters.java b/src/main/java/net/caffeinemc/mods/sodium/client/util/sorting/VertexSorters.java index eead01365d..5a36dd1783 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/client/util/sorting/VertexSorters.java +++ b/src/main/java/net/caffeinemc/mods/sodium/client/util/sorting/VertexSorters.java @@ -21,6 +21,9 @@ protected float getKey(Vector3f position) { } } + /** + * Sorts the keys given by the subclass by descending value. + */ private static abstract class AbstractVertexSorter implements VertexSorting { @Override public final int[] sort(Vector3f[] positions) { diff --git a/src/main/java/net/caffeinemc/mods/sodium/mixin/core/model/quad/BakedQuadMixin.java b/src/main/java/net/caffeinemc/mods/sodium/mixin/core/model/quad/BakedQuadMixin.java index 72a95185b3..bfbe01de72 100644 --- a/src/main/java/net/caffeinemc/mods/sodium/mixin/core/model/quad/BakedQuadMixin.java +++ b/src/main/java/net/caffeinemc/mods/sodium/mixin/core/model/quad/BakedQuadMixin.java @@ -48,8 +48,8 @@ public class BakedQuadMixin implements BakedQuadView { @Inject(method = "", at = @At("RETURN")) private void init(int[] vertexData, int colorIndex, Direction face, TextureAtlasSprite sprite, boolean shade, CallbackInfo ci) { - this.normal = ModelQuadUtil.calculateNormal(this); - this.normalFace = ModelQuadUtil.findNormalFace(this.normal); + this.normal = this.calculateNormal(); + this.normalFace = ModelQuadFacing.fromPackedNormal(this.normal); this.flags = ModelQuadFlags.getQuadFlags(this, face); } @@ -104,6 +104,11 @@ public ModelQuadFacing getNormalFace() { return this.normalFace; } + @Override + public int getNormal() { + return this.normal; + } + @Override public Direction getLightFace() { return this.direction; diff --git a/src/main/resources/assets/sodium/lang/en_us.json b/src/main/resources/assets/sodium/lang/en_us.json index 888b5634e7..79a6be914a 100644 --- a/src/main/resources/assets/sodium/lang/en_us.json +++ b/src/main/resources/assets/sodium/lang/en_us.json @@ -46,9 +46,11 @@ "sodium.options.use_persistent_mapping.name": "Use Persistent Mapping", "sodium.options.use_persistent_mapping.tooltip": "For debugging only. If enabled, persistent memory mappings will be used for the staging buffer so that unnecessary memory copies can be avoided. Disabling this can be useful for narrowing down the cause of graphical corruption.\n\nRequires OpenGL 4.4 or ARB_buffer_storage.", "sodium.options.chunk_update_threads.name": "Chunk Update Threads", - "sodium.options.chunk_update_threads.tooltip": "Specifies the number of threads to use for chunk building. Using more threads can speed up chunk loading and update speed, but may negatively impact frame times. The default value is usually good enough for all situations.", + "sodium.options.chunk_update_threads.tooltip": "Specifies the number of threads to use for chunk building and sorting. Using more threads can speed up chunk loading and update speed, but may negatively impact frame times. The default value is usually good enough for all situations.", "sodium.options.always_defer_chunk_updates.name": "Always Defer Chunk Updates", "sodium.options.always_defer_chunk_updates.tooltip": "If enabled, rendering will never wait for chunk updates to finish, even if they are important. This can greatly improve frame rates in some scenarios, but it may create significant visual lag where blocks take a while to appear or disappear.", + "sodium.options.sort_behavior.name": "Translucency Sorting", + "sodium.options.sort_behavior.tooltip": "Enables translucency sorting. This avoids glitches in translucent blocks like water and glass when enabled and attempts to correctly present them even when the camera is in motion. This has a small performance impact on chunk loading and update speeds, but is usually not noticeable in frame rates.", "sodium.options.use_no_error_context.name": "Use No Error Context", "sodium.options.use_no_error_context.tooltip": "When enabled, the OpenGL context will be created with error checking disabled. This slightly improves rendering performance, but it can make debugging sudden unexplained crashes much harder.", "sodium.options.buttons.undo": "Undo",