diff --git a/chunky/src/java/se/llbit/chunky/block/AbstractModelBlock.java b/chunky/src/java/se/llbit/chunky/block/AbstractModelBlock.java index 62b38ede33..6c7341c91c 100644 --- a/chunky/src/java/se/llbit/chunky/block/AbstractModelBlock.java +++ b/chunky/src/java/se/llbit/chunky/block/AbstractModelBlock.java @@ -45,4 +45,9 @@ public BlockModel getModel() { public boolean intersect(Ray ray, Scene scene) { return model.intersect(ray, scene); } + + @Override + public boolean isBiomeDependant() { + return super.isBiomeDependant() || model.isBiomeDependant(); + } } diff --git a/chunky/src/java/se/llbit/chunky/block/Block.java b/chunky/src/java/se/llbit/chunky/block/Block.java index c8eb19c23d..f7b507f4f1 100644 --- a/chunky/src/java/se/llbit/chunky/block/Block.java +++ b/chunky/src/java/se/llbit/chunky/block/Block.java @@ -146,4 +146,11 @@ public boolean isModifiedByBlockEntity() { public Tag getNewTagWithBlockEntity(Tag blockTag, CompoundTag entityTag) { return null; } + + /** + * Does this block use biome tint for its rendering + */ + public boolean isBiomeDependant() { + return isWaterFilled(); + } } diff --git a/chunky/src/java/se/llbit/chunky/block/legacy/blocks/LegacyFlowerPot.java b/chunky/src/java/se/llbit/chunky/block/legacy/blocks/LegacyFlowerPot.java index 8f6dbf5a9e..a4cd43e1bd 100644 --- a/chunky/src/java/se/llbit/chunky/block/legacy/blocks/LegacyFlowerPot.java +++ b/chunky/src/java/se/llbit/chunky/block/legacy/blocks/LegacyFlowerPot.java @@ -83,4 +83,9 @@ public Tag getNewTagWithBlockEntity(Tag blockTag, CompoundTag entityTag) { return tag; // keep empty } + + @Override + public boolean isBiomeDependant() { + return true; // (in reality it is only used for fern) + } } diff --git a/chunky/src/java/se/llbit/chunky/block/legacy/blocks/LegacyVine.java b/chunky/src/java/se/llbit/chunky/block/legacy/blocks/LegacyVine.java index a73b5f5518..6dfb530765 100644 --- a/chunky/src/java/se/llbit/chunky/block/legacy/blocks/LegacyVine.java +++ b/chunky/src/java/se/llbit/chunky/block/legacy/blocks/LegacyVine.java @@ -27,4 +27,9 @@ public void finalizeBlock(FinalizationState state) { // otherwise just unwrap the block state.replaceCurrentBlock(tag); } + + @Override + public boolean isBiomeDependant() { + return true; + } } diff --git a/chunky/src/java/se/llbit/chunky/model/AABBModel.java b/chunky/src/java/se/llbit/chunky/model/AABBModel.java index a78da34c31..90ca47f55e 100644 --- a/chunky/src/java/se/llbit/chunky/model/AABBModel.java +++ b/chunky/src/java/se/llbit/chunky/model/AABBModel.java @@ -9,6 +9,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.Random; /** @@ -181,4 +182,16 @@ private boolean intersectFace(Ray ray, Scene scene, Texture texture, UVMapping m } return false; } + + @Override + public boolean isBiomeDependant() { + Tint[][] tints = getTints(); + if(tints == null) + return false; + + return Arrays.stream(tints) + .filter(Objects::nonNull) + .flatMap(Arrays::stream) + .anyMatch(Tint::isBiomeTint); + } } diff --git a/chunky/src/java/se/llbit/chunky/model/BlockModel.java b/chunky/src/java/se/llbit/chunky/model/BlockModel.java index 95c54075b7..5fd4786a83 100644 --- a/chunky/src/java/se/llbit/chunky/model/BlockModel.java +++ b/chunky/src/java/se/llbit/chunky/model/BlockModel.java @@ -18,4 +18,6 @@ public interface BlockModel { void sample(int face, Vector3 loc, Random rand); double faceSurfaceArea(int face); + + boolean isBiomeDependant(); } diff --git a/chunky/src/java/se/llbit/chunky/model/QuadModel.java b/chunky/src/java/se/llbit/chunky/model/QuadModel.java index 31b070b142..e8bdcfb9c4 100644 --- a/chunky/src/java/se/llbit/chunky/model/QuadModel.java +++ b/chunky/src/java/se/llbit/chunky/model/QuadModel.java @@ -28,6 +28,8 @@ import se.llbit.math.Vector3; import se.llbit.math.Vector4; +import java.util.Arrays; +import java.util.Objects; import java.util.Random; /** @@ -148,4 +150,14 @@ public boolean intersect(Ray ray, Scene scene) { } return hit; } + + @Override + public boolean isBiomeDependant() { + Tint[] tints = getTints(); + if(tints == null) + return false; + + return Arrays.stream(tints) + .anyMatch(Tint::isBiomeTint); + } } diff --git a/chunky/src/java/se/llbit/chunky/model/Tint.java b/chunky/src/java/se/llbit/chunky/model/Tint.java index 604ddf41c8..a3164f6caf 100644 --- a/chunky/src/java/se/llbit/chunky/model/Tint.java +++ b/chunky/src/java/se/llbit/chunky/model/Tint.java @@ -98,4 +98,8 @@ public void tint(Vector4 color, Ray ray, Scene scene) { color.z *= tintColor[2]; } } + + public boolean isBiomeTint() { + return type == TintType.BIOME_FOLIAGE || type == TintType.BIOME_GRASS || type == TintType.BIOME_WATER; + } } diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index 8efb175f6b..53ffa7996d 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -41,7 +41,9 @@ import se.llbit.chunky.renderer.projection.ParallelProjector; import se.llbit.chunky.renderer.projection.ProjectionMode; import se.llbit.chunky.renderer.renderdump.RenderDump; +import se.llbit.chunky.renderer.scene.biome.BiomeBlendingUtility; import se.llbit.chunky.renderer.scene.biome.BiomeStructure; +import se.llbit.chunky.renderer.scene.biome.ChunkBiomeBlendingHelper; import se.llbit.chunky.renderer.scene.sky.Sky; import se.llbit.chunky.renderer.scene.sky.Sun; import se.llbit.chunky.resources.BitmapImage; @@ -238,7 +240,7 @@ public class Scene implements JsonSerializable, Refreshable { public final Fog fog = new Fog(this); protected boolean biomeColors = true; - protected boolean biomeBlending = true; + protected int biomeBlendingRadius = 2; protected boolean transparentSky = false; protected Collection chunks = new ArrayList<>(); protected JsonObject cameraPresets = new JsonObject(); @@ -858,6 +860,7 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Collec Position2IntStructure biomePaletteIdxStructure = biomeStructureFactory.createIndexStructure(); boolean use3dBiomes = biomeStructureFactory.is3d(); + Map biomeBlendingHelper = new HashMap<>(); final Mutable loadingChunkData = new Mutable<>(null); // chunkData currently being used for loading from save final Mutable activeChunkData = new Mutable<>(null); // chunkData for loading into the octree @@ -926,14 +929,25 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Collec int wx0 = cp.x * 16; // Start of this chunk in world coordinates. int wz0 = cp.z * 16; BiomeData biomeData = chunkData.getBiomeData(); + ChunkBiomeBlendingHelper chunkBiomeHelper = new ChunkBiomeBlendingHelper(); + biomeBlendingHelper.put(cp, chunkBiomeHelper); if (use3dBiomes) { - for (int y = chunkData.minY(); y < chunkData.maxY(); y++) { + // We need to load biome data for the full height of the chunk + // and not only limited to the sections where there are blocks + // because it is possible that a neighboring chunks has blocks + // that will observe the biome color (via biome blending) + for (int y = yMin; y < yMax; y++) { for (int cz = 0; cz < 16; ++cz) { int wz = cz + wz0; for (int cx = 0; cx < 16; ++cx) { int wx = cx + wx0; int biomePaletteIdx = biomeData.getBiome(cx, y, cz); + if(y != yMin) { + int biomeUnder = biomeData.getBiome(cx, y-1, cz); + if(biomeUnder != biomePaletteIdx) + chunkBiomeHelper.addTransition(y); + } biomePaletteIdxStructure.set(wx, y, wz, biomePaletteIdx); } } @@ -985,6 +999,9 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Collec int octNode = currentBlock; Block block = palette.get(currentBlock); + if(block.isBiomeDependant()) + chunkBiomeHelper.makeBiomeRelevant(y); + if(block.isEntity()) { Vector3 position = new Vector3(cx + cp.x * 16, y, cz + cp.z * 16); Entity entity = block.toEntity(position); @@ -1216,169 +1233,158 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Collec int done = 0; int target = nonEmptyChunks.size(); + for (ChunkPosition cp : nonEmptyChunks) { // TODO: make this less special cased in some way, having 2 ifs for biomeBlending and use3dBiomes is quite awful to read and maintain // Finalize grass and foliage textures. // 3x3 box blur. - if (biomeBlending) { - if (use3dBiomes) { - for (int sectionY = yMin >> 4; sectionY < (yMax - 1 >> 4) + 1; sectionY++) { - for (int x = 0; x < 16; ++x) { - for (int z = 0; z < 16; ++z) { - for (int y = 0; y < 16; y++) { - int nsum = 0; - - float[] grassMix = {0, 0, 0}; - float[] foliageMix = {0, 0, 0}; - float[] waterMix = {0, 0, 0}; - for (int sx = x - 1; sx <= x + 1; ++sx) { - int wx = cp.x * 16 + sx; - for (int sz = z - 1; sz <= z + 1; ++sz) { - int wz = cp.z * 16 + sz; - for (int sy = y - 1; sy < y + 1; sy++) { - int wy = sectionY * 16 + sy; - ChunkPosition ccp = new ChunkPosition(wx >> 4, wz >> 4); - if (nonEmptyChunks.contains(ccp)) { - nsum += 1; - Integer id = biomePaletteIdxStructure.get(wx, wy, wz); - if (id == null) { - continue; - } - Biome biome = biomePalette.get(id); - float[] grassColor = biome.grassColorLinear; - grassMix[0] += grassColor[0]; - grassMix[1] += grassColor[1]; - grassMix[2] += grassColor[2]; - float[] foliageColor = biome.foliageColorLinear; - foliageMix[0] += foliageColor[0]; - foliageMix[1] += foliageColor[1]; - foliageMix[2] += foliageColor[2]; - float[] waterColor = biome.waterColorLinear; - waterMix[0] += waterColor[0]; - waterMix[1] += waterColor[1]; - waterMix[2] += waterColor[2]; - } - } - } - } - grassMix[0] /= nsum; - grassMix[1] /= nsum; - grassMix[2] /= nsum; - grassTexture.set(cp.x * 16 + x - origin.x, sectionY * 16 + y - origin.y, cp.z * 16 + z - origin.z, grassMix); - - foliageMix[0] /= nsum; - foliageMix[1] /= nsum; - foliageMix[2] /= nsum; - foliageTexture.set(cp.x * 16 + x - origin.x, sectionY * 16 + y - origin.y, cp.z * 16 + z - origin.z, foliageMix); - - waterMix[0] /= nsum; - waterMix[1] /= nsum; - waterMix[2] /= nsum; - waterTexture.set(cp.x * 16 + x - origin.x, sectionY * 16 + y - origin.y, cp.z * 16 + z - origin.z, waterMix); - } + ChunkBiomeBlendingHelper chunkBiomeHelper = biomeBlendingHelper.get(cp); + if(chunkBiomeHelper.isBiomeUsed()) { + if(biomeBlendingRadius > 0) { + if(use3dBiomes) { + ChunkBiomeBlendingHelper[] neighboringChunks = new ChunkBiomeBlendingHelper[]{ + biomeBlendingHelper.get(new ChunkPosition(cp.x - 1, cp.z - 1)), + biomeBlendingHelper.get(new ChunkPosition(cp.x - 1, cp.z)), + biomeBlendingHelper.get(new ChunkPosition(cp.x - 1, cp.z + 1)), + biomeBlendingHelper.get(new ChunkPosition(cp.x, cp.z - 1)), + biomeBlendingHelper.get(new ChunkPosition(cp.x, cp.z + 1)), + biomeBlendingHelper.get(new ChunkPosition(cp.x + 1, cp.z - 1)), + biomeBlendingHelper.get(new ChunkPosition(cp.x + 1, cp.z)), + biomeBlendingHelper.get(new ChunkPosition(cp.x + 1, cp.z + 1)) + }; + + int[] combinedBiomeTransitions = chunkBiomeHelper.combineAndTrimTransitions(neighboringChunks, biomeBlendingRadius); + + // When doing 3D blur we use the list of (vertical) biome transition + // in the chunk or in neighboring ones + // If there is no transition, a 2D blur is enough, otherwise we only + // need to compute the colors around the transitions + + // For example, if loading from y=0 to y=200 with a biome transition at y=20 + // and another one at y=50 and with a blur radius of 2 (5*5*5 box) + // We can compute a 2D blur at y=0 and use those color for up to y=17 + // For y in [18, 21] we need to compute the real 3D blur (because of the biome transition + // at y=20 and the blur radius of 2) + // Then we can compute the 2D blur at y=22 and use those colors for up to y=47 + // And so on, 3D blur for y in [48, 51] and 2D blur for y in [52,200] + + // As such, in spirit every transition make us compute an additional 16*16*(2*biomeBlendingRadius) 3D blur + // and a 16*16 2D blur (that can be combined in a 16*16*(2*biomeBlendingRadius+1) 3D blur) + // (ignoring cases where transition are close to one another which are handled by the code) + + // Note that having a single (x, y) column that effectively has a biome transition + // in the chunk are a neighboring chunk causes us to compute the 3D blur for the whole 16*16 + // vertical slice of the chunk. Because vertical biome transition are pretty rare, + // that's probably ok. + int nextY = chunkBiomeHelper.getyMinBiomeRelevant(); + + for(int i = 0; i < combinedBiomeTransitions.length; ++i) { + int transition = combinedBiomeTransitions[i]; + if(nextY < transition - biomeBlendingRadius) { + // Do a 2d blur to fill up to the height affected by the transition + BiomeBlendingUtility.chunk2DBlur( + cp, + biomeBlendingRadius, + nextY, + transition - biomeBlendingRadius, + origin, + biomePaletteIdxStructure, + biomePalette, + nonEmptyChunks, + grassTexture, + foliageTexture, + waterTexture); + nextY = transition - biomeBlendingRadius; + } + + // Do a 3D blur to fill the next 2*biomeBlendingRadius layers + // or more if the next transition is close by, in which case + // both transition (or even more) are handled by a bigger 3D blur + int maxYWorkedOn = transition + biomeBlendingRadius; + while(i < combinedBiomeTransitions.length - 1 && maxYWorkedOn >= combinedBiomeTransitions[i + 1] - biomeBlendingRadius) { + // Extends the 3D blur to enclose the next transition as well + maxYWorkedOn = combinedBiomeTransitions[i + 1] + biomeBlendingRadius; + ++i; } + int maxYWorkedOnClamped = Math.min(maxYWorkedOn, chunkBiomeHelper.getyMaxBiomeRelevant()); + BiomeBlendingUtility.chunk3DBlur( + cp, + biomeBlendingRadius, + nextY, + maxYWorkedOnClamped + 1, + origin, + biomePaletteIdxStructure, + biomePalette, + nonEmptyChunks, + grassTexture, + foliageTexture, + waterTexture); + nextY = maxYWorkedOnClamped + 1; } + + // Last 2D blur that extent up to the top + if(nextY <= chunkBiomeHelper.getyMaxBiomeRelevant()) { + BiomeBlendingUtility.chunk2DBlur( + cp, + biomeBlendingRadius, + nextY, + chunkBiomeHelper.getyMaxBiomeRelevant() + 1, + origin, + biomePaletteIdxStructure, + biomePalette, + nonEmptyChunks, + grassTexture, + foliageTexture, + waterTexture); + } + } else { + BiomeBlendingUtility.chunk2DBlur(cp, biomeBlendingRadius, 0, 1, origin, biomePaletteIdxStructure, biomePalette, nonEmptyChunks, grassTexture, foliageTexture, waterTexture); } } else { - for (int x = 0; x < 16; ++x) { - for (int z = 0; z < 16; ++z) { - - int nsum = 0; - float[] grassMix = {0, 0, 0}; - float[] foliageMix = {0, 0, 0}; - float[] waterMix = {0, 0, 0}; - for (int sx = x - 1; sx <= x + 1; ++sx) { - int wx = cp.x * 16 + sx; - for (int sz = z - 1; sz <= z + 1; ++sz) { - int wz = cp.z * 16 + sz; - - ChunkPosition ccp = new ChunkPosition(wx >> 4, wz >> 4); - if (nonEmptyChunks.contains(ccp)) { - nsum += 1; - Biome biome = biomePalette.get(biomePaletteIdxStructure.get(wx, 0, wz)); - float[] grassColor = biome.grassColorLinear; - grassMix[0] += grassColor[0]; - grassMix[1] += grassColor[1]; - grassMix[2] += grassColor[2]; - float[] foliageColor = biome.foliageColorLinear; - foliageMix[0] += foliageColor[0]; - foliageMix[1] += foliageColor[1]; - foliageMix[2] += foliageColor[2]; - float[] waterColor = biome.waterColorLinear; - waterMix[0] += waterColor[0]; - waterMix[1] += waterColor[1]; - waterMix[2] += waterColor[2]; + if(use3dBiomes) { + for(int sectionY = yMin >> 4; sectionY < (yMax - 1 >> 4) + 1; sectionY++) { + for(int y = 0; y < 16; y++) { + int wy = sectionY * 16 + y; + for(int x = 0; x < 16; ++x) { + int wx = cp.x * Chunk.X_MAX + x; + for(int z = 0; z < 16; ++z) { + int wz = cp.z * Chunk.Z_MAX + z; + + int id = biomePaletteIdxStructure.get(wx, wy, wz); + + Biome biome = biomePalette.get(id); + grassTexture.set(cp.x * 16 + x - origin.x, sectionY * 16 + y - origin.y, cp.z * 16 + z - origin.z, biome.grassColorLinear); + foliageTexture.set(cp.x * 16 + x - origin.x, sectionY * 16 + y - origin.y, cp.z * 16 + z - origin.z, biome.foliageColorLinear); + waterTexture.set(cp.x * 16 + x - origin.x, sectionY * 16 + y - origin.y, cp.z * 16 + z - origin.z, biome.waterColorLinear); } } } - grassMix[0] /= nsum; - grassMix[1] /= nsum; - grassMix[2] /= nsum; - grassTexture.set(cp.x * 16 + x - origin.x, 0, cp.z * 16 + z - origin.z, grassMix); - - foliageMix[0] /= nsum; - foliageMix[1] /= nsum; - foliageMix[2] /= nsum; - foliageTexture.set(cp.x * 16 + x - origin.x, 0, cp.z * 16 + z - origin.z, foliageMix); - - waterMix[0] /= nsum; - waterMix[1] /= nsum; - waterMix[2] /= nsum; - waterTexture.set(cp.x * 16 + x - origin.x, 0, cp.z * 16 + z - origin.z, waterMix); } - } - } - } else { - if (use3dBiomes) { - for (int sectionY = yMin >> 4; sectionY < (yMax - 1 >> 4) + 1; sectionY++) { - for (int y = 0; y < 16; y++) { - int wy = sectionY * 16 + y; - for (int x = 0; x < 16; ++x) { - int wx = cp.x * Chunk.X_MAX + x; - for (int z = 0; z < 16; ++z) { - int wz = cp.z * Chunk.Z_MAX + z; - int nsum = 0; - - Integer id = biomePaletteIdxStructure.get(wx, wy, wz); - if (id == null) { - continue; - } - if(id != 0) { - int asd = 0; - } - - Biome biome = biomePalette.get(id); - grassTexture.set(cp.x * 16 + x - origin.x, sectionY * 16 + y - origin.y, cp.z * 16 + z - origin.z, biome.grassColorLinear); - foliageTexture.set(cp.x * 16 + x - origin.x, sectionY * 16 + y - origin.y, cp.z * 16 + z - origin.z, biome.foliageColorLinear); - waterTexture.set(cp.x * 16 + x - origin.x, sectionY * 16 + y - origin.y, cp.z * 16 + z - origin.z, biome.waterColorLinear); - } + } else { + for(int x = 0; x < 16; ++x) { + int wx = cp.x * 16 + x; + for(int z = 0; z < 16; ++z) { + int wz = cp.z * 16 + z; + + int id = biomePaletteIdxStructure.get(wx, 0, wz); + Biome biome = biomePalette.get(id); + + grassTexture.set(cp.x * 16 + x - origin.x, 0, cp.z * 16 + z - origin.z, biome.grassColorLinear); + foliageTexture.set(cp.x * 16 + x - origin.x, 0, cp.z * 16 + z - origin.z, biome.foliageColorLinear); + waterTexture.set(cp.x * 16 + x - origin.x, 0, cp.z * 16 + z - origin.z, biome.waterColorLinear); } } } - } else { - for (int x = 0; x < 16; ++x) { - int wx = cp.x * 16 + x; - for (int z = 0; z < 16; ++z) { - int wz = cp.z * 16 + z; - - int id = biomePaletteIdxStructure.get(wx, 0, wz); - Biome biome = biomePalette.get(id); - - grassTexture.set(cp.x * 16 + x - origin.x, 0, cp.z * 16 + z - origin.z, biome.grassColorLinear); - foliageTexture.set(cp.x * 16 + x - origin.x, 0, cp.z * 16 + z - origin.z, biome.foliageColorLinear); - waterTexture.set(cp.x * 16 + x - origin.x, 0, cp.z * 16 + z - origin.z, biome.waterColorLinear); - } - } } } - task.updateEta(target, done); - done += 1; OctreeFinalizer.finalizeChunk(worldOctree, waterOctree, palette, origin, cp, yMin, yMax); if (legacyChunks.contains(cp)) { LegacyBlocksFinalizer .finalizeChunk(worldOctree, waterOctree, palette, origin, cp, yMin, yMax); } + task.updateEta(target, done); + done += 1; } worldOctree.endFinalization(); @@ -1539,9 +1545,9 @@ public void setBiomeColorsEnabled(boolean value) { } } - public void setBiomeBlendingEnabled(boolean value) { - if (value != biomeBlending) { - biomeBlending = value; + public void setBiomeBlendingRadius(int value) { + if (value != biomeBlendingRadius) { + biomeBlendingRadius = value; refresh(); } } @@ -1620,8 +1626,8 @@ public boolean biomeColorsEnabled() { return biomeColors; } - public boolean biomeBlendingEnabled() { - return biomeBlending; + public int biomeBlendingRadius() { + return biomeBlendingRadius; } /** diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/biome/BiomeBlendingUtility.java b/chunky/src/java/se/llbit/chunky/renderer/scene/biome/BiomeBlendingUtility.java new file mode 100644 index 0000000000..e8e1b2a6ea --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/biome/BiomeBlendingUtility.java @@ -0,0 +1,337 @@ +package se.llbit.chunky.renderer.scene.biome; + +import se.llbit.chunky.world.ChunkPosition; +import se.llbit.chunky.world.biome.Biome; +import se.llbit.chunky.world.biome.BiomePalette; +import se.llbit.math.Vector3i; +import se.llbit.math.structures.Position2IntStructure; + +import java.util.Set; + +public class BiomeBlendingUtility { + public static void addEqual(float[] result, float[] addend) { + result[0] += addend[0]; + result[1] += addend[1]; + result[2] += addend[2]; + } + + /** + * Helper class to compute average of subrectangle of a 2D table in O(n²) init time + * and O(1) use time. Based on summed-area table https://en.wikipedia.org/wiki/Summed-area_table + */ + private static class SummedAreaTable { + // (16+2*r)² Summed area table of 9 entries vector (3 components for 3 colors) + float[] satData; + // Other summed area table to count the number of summed color to get denominator for average + int[] satN; + final int blurRadius; + final int xStride; + + public SummedAreaTable(int blurRadius) { + satData = new float[(16 + 2*blurRadius) * (16 + 2*blurRadius) * 9 + 9]; + satN = new int[(16 + 2*blurRadius) * (16 + 2*blurRadius) + 1]; + // (a bit more space is allocated at the end and left with 0 to simplify implmeentation of getAverageFor + this.blurRadius = blurRadius; + xStride = 16 + 2*blurRadius; + } + + int indexFor(int x, int z) { + int offsetedX = x + blurRadius; + int offsetedZ = z + blurRadius; + if(offsetedX < 0 || offsetedZ < 0) { + // if requesting before the table, redirect to the space allocated at the end + return satN.length - 1; + } + return offsetedX * xStride + offsetedZ; + } + + void init(int x, int z, Biome biome) { + int idx = indexFor(x, z); + satData[idx*9] = biome.grassColorLinear[0]; + satData[idx*9+1] = biome.grassColorLinear[1]; + satData[idx*9+2] = biome.grassColorLinear[2]; + satData[idx*9+3] = biome.foliageColorLinear[0]; + satData[idx*9+4] = biome.foliageColorLinear[1]; + satData[idx*9+5] = biome.foliageColorLinear[2]; + satData[idx*9+6] = biome.waterColorLinear[0]; + satData[idx*9+7] = biome.waterColorLinear[1]; + satData[idx*9+8] = biome.waterColorLinear[2]; + satN[idx] = 1; + } + + void computeSum() { + // Compute first line + for(int offsetedX = 1; offsetedX < 16 + 2*blurRadius; ++offsetedX) { + for(int i = 0; i < 9; ++i) { + satData[(offsetedX * xStride) * 9 + i] += satData[((offsetedX - 1) * xStride) * 9 + i]; + } + satN[offsetedX * xStride] += satN[(offsetedX - 1) * xStride]; + } + + // Compute first column + for(int offsetedZ = 1; offsetedZ < 16 + 2*blurRadius; ++offsetedZ) { + for(int i = 0; i < 9; ++i) { + satData[offsetedZ * 9 + i] += satData[(offsetedZ - 1) * 9 + i]; + } + satN[offsetedZ] += satN[offsetedZ - 1]; + } + + // Compute rest + for(int offsetedX = 1; offsetedX < 16 + 2*blurRadius; ++offsetedX) { + for(int offsetedZ = 1; offsetedZ < 16 + 2 * blurRadius; ++offsetedZ) { + for(int i = 0; i < 9; ++i) { + // current += top + left - topleft + satData[(offsetedX * xStride + offsetedZ) * 9 + i] += + satData[((offsetedX - 1) * xStride + offsetedZ) * 9 + i] + + satData[(offsetedX * xStride + (offsetedZ - 1)) * 9 + i] + - satData[((offsetedX - 1) * xStride + (offsetedZ - 1)) * 9 + i]; + } + satN[offsetedX * xStride + offsetedZ] += + satN[(offsetedX - 1) * xStride + offsetedZ] + + satN[offsetedX * xStride + (offsetedZ - 1)] + - satN[(offsetedX - 1) * xStride + (offsetedZ - 1)]; + } + } + } + + public float[] getAverageFor(int x, int z) { + int topLeft = indexFor(x - blurRadius - 1, z - blurRadius - 1); + int topRight = indexFor(x - blurRadius - 1, z + blurRadius); + int bottomLeft = indexFor(x + blurRadius, z - blurRadius - 1); + int bottomRight = indexFor(x + blurRadius, z + blurRadius); + + int n = satN[topLeft] + satN[bottomRight] - satN[topRight] - satN[bottomLeft]; + + float[] result = new float[9]; + for(int i = 0; i < 9; ++i) { + result[i] = satData[topLeft*9+i] + satData[bottomRight*9+i] - satData[topRight*9+i] - satData[bottomLeft*9+i]; + result[i] /= n; + } + + return result; + } + } + + /** + * Compute the blended biome colors for a chunk by doing a 2D blur and store the result in the given biome structures + * Sample the biome at a given y level and writes the result for y levels going from samplingY (inclusive) to maxFillY (exclusive) + */ + static public void chunk2DBlur(ChunkPosition cp, int blurRadius, int samplingY, int maxFillY, Vector3i origin, Position2IntStructure biomeIdx, BiomePalette biomePalette, Set nonEmptyChunks, BiomeStructure grassTexture, BiomeStructure foliageTexture, BiomeStructure waterTexture) { + + SummedAreaTable table = new SummedAreaTable(blurRadius); + for(int x = -blurRadius; x < 16 + blurRadius; ++x) { + for(int z = -blurRadius; z < 16 + blurRadius; ++z) { + ChunkPosition ccp = new ChunkPosition(Math.floorDiv(cp.x * 16 + x, 16), Math.floorDiv(cp.z * 16 + z, 16)); + if (nonEmptyChunks.contains(ccp)) { + int biomeId = biomeIdx.get(cp.x * 16 + x, samplingY, cp.z * 16 + z); + if(biomeId != -1) { + Biome biome = biomePalette.get(biomeId); + table.init(x, z, biome); + } else { + // Not having the biome data loaded at this point is + // a bug earlier in the loading process. + // In case it happens, ignore the block to get a somewhat sensible result + assert false; + } + } + } + } + table.computeSum(); + + for (int x = 0; x < 16; ++x) { + for (int z = 0; z < 16; ++z) { + float[] data = table.getAverageFor(x, z); + float[] grassMix = { + data[0], data[1], data[2] + }; + float[] foliageMix = { + data[3], data[4], data[5] + }; + float[] waterMix = { + data[6], data[7], data[8] + }; + + for(int y = samplingY; y < maxFillY; ++y) { + // TODO Introduce additional API to BiomeStructure to make them aware of the vertical repetition so they can optimize if wanted + grassTexture.set(cp.x * 16 + x - origin.x, y - origin.y, cp.z * 16 + z - origin.z, grassMix); + foliageTexture.set(cp.x * 16 + x - origin.x, y - origin.y, cp.z * 16 + z - origin.z, foliageMix); + waterTexture.set(cp.x * 16 + x - origin.x, y - origin.y, cp.z * 16 + z - origin.z, waterMix); + } + } + } + } + + /** + * Same as SummedAreaTable but in 3D + * https://stackoverflow.com/questions/20445084/3d-variant-for-summed-area-table-sat + */ + private static class SummedVolumeTable { + // (16+2*r)^3 Summed area table of 9 entries vector (3 components for 3 colors) + float[] satData; + // Other summed area table to count the number of summed color to get denominator for average + int[] satN; + final int blurRadius; + final int yMin; + final int yMax; + final int yStride; + final int xStride; + + public SummedVolumeTable(int blurRadius, int yMin, int yMax) { + satData = new float[(16 + 2*blurRadius) * (16 + 2*blurRadius) * (yMax - yMin + 2*blurRadius) * 9 + 9]; + satN = new int[(16 + 2*blurRadius) * (16 + 2*blurRadius) * (yMax - yMin + 2*blurRadius) + 1]; + // (a bit more space is allocated at the end and left with 0 to simplify implmeentation of getAverageFor + this.blurRadius = blurRadius; + this.yMin = yMin; + this.yMax = yMax; + yStride = (16 + 2*blurRadius) * (16 + 2*blurRadius); + xStride = 16 + 2*blurRadius; + } + + int indexFor(int x, int y, int z) { + int offsetedY = y - yMin + blurRadius; + int offsetedX = x + blurRadius; + int offsetedZ = z + blurRadius; + if(offsetedY < 0 || offsetedX < 0 || offsetedZ < 0) { + // if requesting before the table, redirect to the space allocated at the end + return satN.length - 1; + } + return offsetedY * yStride + offsetedX * xStride + offsetedZ; + } + + void init(int x, int y, int z, Biome biome) { + int idx = indexFor(x, y, z); + satData[idx*9] = biome.grassColorLinear[0]; + satData[idx*9+1] = biome.grassColorLinear[1]; + satData[idx*9+2] = biome.grassColorLinear[2]; + satData[idx*9+3] = biome.foliageColorLinear[0]; + satData[idx*9+4] = biome.foliageColorLinear[1]; + satData[idx*9+5] = biome.foliageColorLinear[2]; + satData[idx*9+6] = biome.waterColorLinear[0]; + satData[idx*9+7] = biome.waterColorLinear[1]; + satData[idx*9+8] = biome.waterColorLinear[2]; + satN[idx] = 1; + } + + void computeSum() { + // Because of the trick of redirecting out of bounds index to the end + // (that we only read and never write, so full of zeros) + // there is no need to special case the start + + for(int y = yMin - blurRadius; y < yMax + blurRadius; ++y) { + for(int x = -blurRadius; x < 16 + blurRadius; ++x) { + for(int z = -blurRadius; z < 16 + blurRadius; ++z) { + int xyz = indexFor(x, y, z); + int xyz1 = indexFor(x, y, z - 1); + int xy1z = indexFor(x, y - 1, z); + int xy1z1 = indexFor(x, y - 1, z - 1); + int x1yz = indexFor(x - 1, y, z); + int x1yz1 = indexFor(x - 1, y, z - 1); + int x1y1z = indexFor(x - 1, y - 1, z); + int x1y1z1 = indexFor(x - 1, y - 1, z - 1); + + for(int i = 0; i < 9; ++i) { + satData[xyz*9 + i] += + satData[x1yz*9 + i] + + satData[xy1z*9 + i] + + satData[xyz1*9 + i] + - satData[x1y1z*9 + i] + - satData[x1yz1*9 + i] + - satData[xy1z1*9 + i] + + satData[x1y1z1*9 + i]; + } + satN[xyz] += + satN[x1yz] + + satN[xy1z] + + satN[xyz1] + - satN[x1y1z] + - satN[x1yz1] + - satN[xy1z1] + + satN[x1y1z1]; + } + } + } + } + + public float[] getAverageFor(int x, int y, int z) { + int x1y1z1 = indexFor(x - blurRadius - 1, y - blurRadius - 1, z - blurRadius - 1); + int x1y1z2 = indexFor(x - blurRadius - 1, y - blurRadius - 1, z + blurRadius); + int x1y2z1 = indexFor(x - blurRadius - 1, y + blurRadius, z - blurRadius - 1); + int x1y2z2 = indexFor(x - blurRadius - 1, y + blurRadius, z + blurRadius); + int x2y1z1 = indexFor(x + blurRadius, y - blurRadius - 1, z - blurRadius - 1); + int x2y1z2 = indexFor(x + blurRadius, y - blurRadius - 1, z + blurRadius); + int x2y2z1 = indexFor(x + blurRadius, y + blurRadius, z - blurRadius - 1); + int x2y2z2 = indexFor(x + blurRadius, y + blurRadius, z + blurRadius); + + int n = + satN[x2y2z2] + - satN[x2y2z1] + - satN[x2y1z2] + - satN[x1y2z2] + + satN[x2y1z1] + + satN[x1y2z1] + + satN[x1y1z2] + - satN[x1y1z1]; + + float[] result = new float[9]; + for(int i = 0; i < 9; ++i) { + result[i] = + satData[x2y2z2*9 + i] + - satData[x2y2z1*9 + i] + - satData[x2y1z2*9 + i] + - satData[x1y2z2*9 + i] + + satData[x2y1z1*9 + i] + + satData[x1y2z1*9 + i] + + satData[x1y1z2*9 + i] + - satData[x1y1z1*9 + i]; + result[i] /= n; + } + + return result; + } + } + + /** + * Compute the blended biome colors for a portion of chunk by doing a 3D blur and store the result in the given biome structures + */ + static public void chunk3DBlur(ChunkPosition cp, int blurRadius, int minY, int maxY, Vector3i origin, Position2IntStructure biomeIdx, BiomePalette biomePalette, Set nonEmptyChunks, BiomeStructure grassTexture, BiomeStructure foliageTexture, BiomeStructure waterTexture) { + SummedVolumeTable table = new SummedVolumeTable(blurRadius, minY, maxY); + for(int y = minY - blurRadius; y < maxY + blurRadius; ++y) { + for(int x = -blurRadius; x < 16 + blurRadius; ++x) { + for(int z = -blurRadius; z < 16 + blurRadius; ++z) { + ChunkPosition ccp = new ChunkPosition(Math.floorDiv(cp.x * 16 + x, 16), Math.floorDiv(cp.z * 16 + z, 16)); + if (nonEmptyChunks.contains(ccp)) { + int biomeId = biomeIdx.get(cp.x * 16 + x, y, cp.z * 16 + z); + if(biomeId != -1) { + Biome biome = biomePalette.get(biomeId); + table.init(x, y, z, biome); + } + // if biomeId is -1, either y is outside of the loaded interval + // or there is a bug in loading similar to chunk2DBlur + } + } + } + } + table.computeSum(); + + for(int y = minY; y < maxY; ++y) { + for (int x = 0; x < 16; ++x) { + for (int z = 0; z < 16; ++z) { + float[] data = table.getAverageFor(x, y, z); + float[] grassMix = { + data[0], data[1], data[2] + }; + float[] foliageMix = { + data[3], data[4], data[5] + }; + float[] waterMix = { + data[6], data[7], data[8] + }; + + grassTexture.set(cp.x * 16 + x - origin.x, y - origin.y, cp.z * 16 + z - origin.z, grassMix); + foliageTexture.set(cp.x * 16 + x - origin.x, y - origin.y, cp.z * 16 + z - origin.z, foliageMix); + waterTexture.set(cp.x * 16 + x - origin.x, y - origin.y, cp.z * 16 + z - origin.z, waterMix); + } + } + } + } +} diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/biome/ChunkBiomeBlendingHelper.java b/chunky/src/java/se/llbit/chunky/renderer/scene/biome/ChunkBiomeBlendingHelper.java new file mode 100644 index 0000000000..6bea7c2a88 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/biome/ChunkBiomeBlendingHelper.java @@ -0,0 +1,73 @@ +package se.llbit.chunky.renderer.scene.biome; + +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntRBTreeSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import it.unimi.dsi.fastutil.ints.IntSortedSet; + +/** + * Holds information relative to biome in a a chunk to help with biome blending at load time + */ +public class ChunkBiomeBlendingHelper { + /// List of the y values at which a vertical biome transition occurs. + /// A value of y means that the biome between height y-1 and height y is different + /// (if null, no transition vertical biome transition exist for this chunk) + /// Should stay sorted, but not checked by this class itself + private IntArrayList transitions; + /// The interval of y values for which the biome is relevant + /// ie some block tint depend on the biome + /// (both inclusive) + private int yMinBiomeRelevant = Integer.MAX_VALUE; + private int yMaxBiomeRelevant = Integer.MIN_VALUE; + + /** + * Add a transition. Transitions should be inserted in sorted order + * @param y The transition to add + */ + public void addTransition(int y) { + if(transitions == null) + transitions = new IntArrayList(); + if(transitions.size() > 0 && transitions.getInt(transitions.size() - 1) == y) + return; + transitions.add(y); + } + + public void makeBiomeRelevant(int y) { + yMinBiomeRelevant = Math.min(yMinBiomeRelevant, y); + yMaxBiomeRelevant = Math.max(yMaxBiomeRelevant, y); + } + + public int[] combineAndTrimTransitions(ChunkBiomeBlendingHelper[] neighboringChunks, int blurRadius) { + // merge sorted arrays and deduplication + // Simple implementation for now, probably enough even later + IntSortedSet set = new IntRBTreeSet(); + if(transitions != null) { + for(int y : transitions) { + if(y > yMinBiomeRelevant - blurRadius && y <= yMaxBiomeRelevant + blurRadius) + set.add(y); + } + } + for(ChunkBiomeBlendingHelper other : neighboringChunks) { + if(other != null && other.transitions != null) { + for(int y : other.transitions){ + if(y > yMinBiomeRelevant - blurRadius && y <= yMaxBiomeRelevant + blurRadius) + set.add(y); + } + } + } + return set.toIntArray(); // sorted merge and deduplication done by set + } + + public int getyMinBiomeRelevant() { + return yMinBiomeRelevant; + } + + public int getyMaxBiomeRelevant() { + return yMaxBiomeRelevant; + } + + public boolean isBiomeUsed() { + // Has makeBiomeRelevant been called at least once + return yMaxBiomeRelevant >= yMinBiomeRelevant; + } +} diff --git a/chunky/src/java/se/llbit/chunky/ui/render/tabs/TexturesTab.java b/chunky/src/java/se/llbit/chunky/ui/render/tabs/TexturesTab.java index 5023257fd7..f0d9d474c7 100644 --- a/chunky/src/java/se/llbit/chunky/ui/render/tabs/TexturesTab.java +++ b/chunky/src/java/se/llbit/chunky/ui/render/tabs/TexturesTab.java @@ -33,6 +33,7 @@ import se.llbit.chunky.renderer.RenderController; import se.llbit.chunky.renderer.scene.Scene; import se.llbit.chunky.renderer.scene.SceneManager; +import se.llbit.chunky.ui.IntegerAdjuster; import se.llbit.chunky.ui.controller.RenderControlsFxController; import se.llbit.chunky.ui.dialogs.ResourcePackChooser; import se.llbit.chunky.ui.render.RenderControlsTab; @@ -51,7 +52,7 @@ public class TexturesTab extends ScrollPane implements RenderControlsTab, Initia @FXML private CheckBox biomeColors; @FXML - private CheckBox biomeBlending; + private IntegerAdjuster biomeBlendingRadiusInput; @FXML private CheckBox singleColorBtn; @FXML @@ -99,7 +100,7 @@ public void initialize(URL location, ResourceBundle resources) { boolean enabled = scene.biomeColorsEnabled(); scene.setBiomeColorsEnabled(newValue); - biomeBlending.setDisable(!newValue); + biomeBlendingRadiusInput.setDisable(!newValue); if(!scene.haveLoadedChunks()) { return; @@ -109,14 +110,16 @@ public void initialize(URL location, ResourceBundle resources) { } }); - biomeBlending.setTooltip(new Tooltip("Blend edges of biomes (looks better but loads slower).")); - biomeBlending.selectedProperty().addListener((observable, oldValue, newValue) -> { + biomeBlendingRadiusInput.setTooltip("Set the radius used for the blending of biome colors. 0 to disable. (1 is what minecraft calls 3×3, 2 is 5×5 and so on)"); + biomeBlendingRadiusInput.setRange(0, 16); + biomeBlendingRadiusInput.clampBoth(); + biomeBlendingRadiusInput.onValueChange(value -> { Scene scene = sceneManager.getScene(); - boolean enabled = scene.biomeBlendingEnabled(); + int previous = scene.biomeBlendingRadius(); - scene.setBiomeBlendingEnabled(newValue); + scene.setBiomeBlendingRadius(value); - if(enabled != newValue) { + if(previous != value) { alertIfReloadNeeded("biome blending"); } }); @@ -147,8 +150,8 @@ public void setController(RenderControlsFxController fxController) { @Override public void update(Scene scene) { biomeColors.setSelected(scene.biomeColorsEnabled()); - biomeBlending.setDisable(!scene.biomeColorsEnabled()); - biomeBlending.setSelected(scene.biomeBlendingEnabled()); + biomeBlendingRadiusInput.setDisable(!scene.biomeColorsEnabled()); + biomeBlendingRadiusInput.set(scene.biomeBlendingRadius()); } @Override diff --git a/chunky/src/java/se/llbit/math/structures/Position3d2IntPackedArray.java b/chunky/src/java/se/llbit/math/structures/Position3d2IntPackedArray.java index 443c2d42ac..5ac7bf4b04 100644 --- a/chunky/src/java/se/llbit/math/structures/Position3d2IntPackedArray.java +++ b/chunky/src/java/se/llbit/math/structures/Position3d2IntPackedArray.java @@ -3,6 +3,7 @@ import it.unimi.dsi.fastutil.objects.Object2ReferenceMap; import it.unimi.dsi.fastutil.objects.Object2ReferenceOpenHashMap; +import java.util.Arrays; import java.util.Objects; public class Position3d2IntPackedArray implements Position2IntStructure { @@ -28,7 +29,7 @@ public void set(int x, int y, int z, int data) { if(xSection == this.lastX && ySection == this.lastY && zSection == this.lastZ) { arr = this.lastData; } else { - arr = this.structure.computeIfAbsent(new XYZTriple(xSection, ySection, zSection), sectionPos -> new int[16 * 16 * 16]); + arr = this.structure.computeIfAbsent(new XYZTriple(xSection, ySection, zSection), sectionPos -> newSection()); this.lastX = xSection; this.lastY = ySection; this.lastZ = zSection; @@ -39,6 +40,12 @@ public void set(int x, int y, int z, int data) { } } + private static int[] newSection() { + int[] section = new int[16*16*16]; + Arrays.fill(section, -1); + return section; + } + @Override public int get(int x, int y, int z) { int xSection = x >> 4; @@ -48,7 +55,7 @@ public int get(int x, int y, int z) { if(xSection == this.lastX && ySection == this.lastY && zSection == this.lastZ) { arr = this.lastData; } else { - arr = this.structure.computeIfAbsent(new XYZTriple(xSection, ySection, zSection), sectionPos -> new int[16 * 16 * 16]); + arr = this.structure.computeIfAbsent(new XYZTriple(xSection, ySection, zSection), sectionPos -> newSection()); this.lastX = xSection; this.lastY = ySection; this.lastZ = zSection; @@ -57,7 +64,7 @@ public int get(int x, int y, int z) { if(arr != null) { return arr[packedIndex(x, y, z)]; } - return 0; + return -1; } protected static class XYZTriple { diff --git a/chunky/src/res/se/llbit/chunky/ui/render/tabs/TexturesTab.fxml b/chunky/src/res/se/llbit/chunky/ui/render/tabs/TexturesTab.fxml index 0d25ead927..1e189299de 100644 --- a/chunky/src/res/se/llbit/chunky/ui/render/tabs/TexturesTab.fxml +++ b/chunky/src/res/se/llbit/chunky/ui/render/tabs/TexturesTab.fxml @@ -3,11 +3,15 @@ + + - + + +