Very compact dungeon representation in RAM, but it is quite slow

This commit is contained in:
DBotThePony 2024-04-08 14:06:12 +07:00
parent 53bb3bd843
commit c2e5b32c94
Signed by: DBot
GPG Key ID: DCC23B5715498507
5 changed files with 385 additions and 51 deletions

View File

@ -0,0 +1,245 @@
package ru.dbotthepony.kstarbound.defs.dungeon;
import java.util.BitSet;
// TODO: actually make it vectorized, some unsafe API maybe?
// currently it is quite slow, but achieves desired memory efficiency.
public final class VectorizedBitSet {
public final int bits;
public final int width;
public final int height;
private final BitSet data;
public VectorizedBitSet(int bits, int width, int height) {
if (bits <= 0 || bits >= 13)
throw new IllegalArgumentException("Too many or no bits: " + bits + ". Maximum 12 supported");
this.bits = bits;
this.width = width;
this.height = height;
this.data = new BitSet(bits * width * height);
}
private static int bit(boolean value, int order) {
return (value ? 1 : 0) << order;
}
private void bit(int index, int value, int order) {
if (((value >>> order) & 1) == 0) {
data.clear(index);
} else {
data.set(index);
}
}
public void set(int x, int y, int value) {
int bitIndex = (x + y * width) * bits;
switch (this.bits) {
case 0:
case 1:
bit(bitIndex, value, 0);
break;
case 2:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
break;
case 3:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
break;
case 4:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
bit(bitIndex + 3, value, 3);
break;
case 5:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
bit(bitIndex + 3, value, 3);
bit(bitIndex + 4, value, 4);
break;
case 6:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
bit(bitIndex + 3, value, 3);
bit(bitIndex + 4, value, 4);
bit(bitIndex + 5, value, 5);
break;
case 7:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
bit(bitIndex + 3, value, 3);
bit(bitIndex + 4, value, 4);
bit(bitIndex + 5, value, 5);
bit(bitIndex + 6, value, 6);
break;
case 8:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
bit(bitIndex + 3, value, 3);
bit(bitIndex + 4, value, 4);
bit(bitIndex + 5, value, 5);
bit(bitIndex + 6, value, 6);
bit(bitIndex + 7, value, 7);
break;
case 9:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
bit(bitIndex + 3, value, 3);
bit(bitIndex + 4, value, 4);
bit(bitIndex + 5, value, 5);
bit(bitIndex + 6, value, 6);
bit(bitIndex + 7, value, 7);
bit(bitIndex + 8, value, 8);
break;
case 10:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
bit(bitIndex + 3, value, 3);
bit(bitIndex + 4, value, 4);
bit(bitIndex + 5, value, 5);
bit(bitIndex + 6, value, 6);
bit(bitIndex + 7, value, 7);
bit(bitIndex + 8, value, 8);
bit(bitIndex + 9, value, 9);
break;
case 11:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
bit(bitIndex + 3, value, 3);
bit(bitIndex + 4, value, 4);
bit(bitIndex + 5, value, 5);
bit(bitIndex + 6, value, 6);
bit(bitIndex + 7, value, 7);
bit(bitIndex + 8, value, 8);
bit(bitIndex + 9, value, 9);
bit(bitIndex + 10, value, 10);
break;
case 12:
bit(bitIndex, value, 0);
bit(bitIndex + 1, value, 1);
bit(bitIndex + 2, value, 2);
bit(bitIndex + 3, value, 3);
bit(bitIndex + 4, value, 4);
bit(bitIndex + 5, value, 5);
bit(bitIndex + 6, value, 6);
bit(bitIndex + 7, value, 7);
bit(bitIndex + 8, value, 8);
bit(bitIndex + 9, value, 9);
bit(bitIndex + 10, value, 10);
bit(bitIndex + 11, value, 11);
break;
}
}
public int get(int x, int y) {
int bitIndex = (x + y * width) * bits;
BitSet data = this.data;
return switch (this.bits) {
case 0, 1 -> bit(data.get(bitIndex), 0);
case 2 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1);
case 3 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2);
case 4 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2) |
bit(data.get(bitIndex + 3), 3);
case 5 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2) |
bit(data.get(bitIndex + 3), 3) |
bit(data.get(bitIndex + 4), 4);
case 6 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2) |
bit(data.get(bitIndex + 3), 3) |
bit(data.get(bitIndex + 4), 4) |
bit(data.get(bitIndex + 5), 5);
case 7 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2) |
bit(data.get(bitIndex + 3), 3) |
bit(data.get(bitIndex + 4), 4) |
bit(data.get(bitIndex + 5), 5) |
bit(data.get(bitIndex + 6), 6);
case 8 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2) |
bit(data.get(bitIndex + 3), 3) |
bit(data.get(bitIndex + 4), 4) |
bit(data.get(bitIndex + 5), 5) |
bit(data.get(bitIndex + 6), 6) |
bit(data.get(bitIndex + 7), 7);
case 9 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2) |
bit(data.get(bitIndex + 3), 3) |
bit(data.get(bitIndex + 4), 4) |
bit(data.get(bitIndex + 5), 5) |
bit(data.get(bitIndex + 6), 6) |
bit(data.get(bitIndex + 7), 7) |
bit(data.get(bitIndex + 8), 8);
case 10 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2) |
bit(data.get(bitIndex + 3), 3) |
bit(data.get(bitIndex + 4), 4) |
bit(data.get(bitIndex + 5), 5) |
bit(data.get(bitIndex + 6), 6) |
bit(data.get(bitIndex + 7), 7) |
bit(data.get(bitIndex + 8), 8) |
bit(data.get(bitIndex + 9), 9);
case 11 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2) |
bit(data.get(bitIndex + 3), 3) |
bit(data.get(bitIndex + 4), 4) |
bit(data.get(bitIndex + 5), 5) |
bit(data.get(bitIndex + 6), 6) |
bit(data.get(bitIndex + 7), 7) |
bit(data.get(bitIndex + 8), 8) |
bit(data.get(bitIndex + 9), 9) |
bit(data.get(bitIndex + 10), 10);
case 12 -> bit(data.get(bitIndex), 0) |
bit(data.get(bitIndex + 1), 1) |
bit(data.get(bitIndex + 2), 2) |
bit(data.get(bitIndex + 3), 3) |
bit(data.get(bitIndex + 4), 4) |
bit(data.get(bitIndex + 5), 5) |
bit(data.get(bitIndex + 6), 6) |
bit(data.get(bitIndex + 7), 7) |
bit(data.get(bitIndex + 8), 8) |
bit(data.get(bitIndex + 9), 9) |
bit(data.get(bitIndex + 10), 10) |
bit(data.get(bitIndex + 11), 11);
default -> 0;
};
}
}

View File

@ -1,51 +1,101 @@
package ru.dbotthepony.kstarbound.defs.dungeon package ru.dbotthepony.kstarbound.defs.dungeon
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import it.unimi.dsi.fastutil.ints.IntAVLTreeSet
import it.unimi.dsi.fastutil.ints.IntArrayList
import it.unimi.dsi.fastutil.objects.ObjectArrayList
import org.lwjgl.stb.STBImage
import org.lwjgl.system.MemoryUtil
import ru.dbotthepony.kommons.arrays.Object2DArray import ru.dbotthepony.kommons.arrays.Object2DArray
import ru.dbotthepony.kommons.math.RGBAColor import ru.dbotthepony.kommons.math.RGBAColor
import ru.dbotthepony.kommons.util.KOptional import ru.dbotthepony.kommons.util.KOptional
import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kommons.vector.Vector2i
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.defs.image.Image import ru.dbotthepony.kstarbound.defs.image.Image
import java.lang.ref.Reference
class ImagePartReader(part: DungeonPart, val images: ImmutableList<Image>) : PartReader(part) { class ImagePartReader(part: DungeonPart, val images: ImmutableList<Image>) : PartReader(part) {
override val size: Vector2i override val size: Vector2i
get() = if (images.isEmpty()) Vector2i.ZERO else images.first().size get() = if (images.isEmpty()) Vector2i.ZERO else images.first().size
// it is much cheaper to just read all images and store 2D array // ObjectArrayList doesn't check for concurrent modifications
// of references than loading / keeping images themselves around private val layers = ObjectArrayList<Layer>()
// `Image` class doesn't actually keep pixel data around for too long, private class Layer(val palette: Array<DungeonTile>, val data: VectorizedBitSet)
// if it doesn't get accessed in some time it gets purged from ram
private val layers = Array(images.size) {
Object2DArray.nulls<DungeonTile>(images[it].width, images[it].height)
} as Array<Object2DArray<DungeonTile>>
override fun bind(def: DungeonDefinition) { override fun bind(def: DungeonDefinition) {
check(def.tiles.isNotEmpty) { "Image parts require 'tiles' palette to be present in .dungeon definition" } check(def.tiles.isNotEmpty) { "Image parts require 'tiles' palette to be present in .dungeon definition" }
for ((i, image) in images.withIndex()) { for ((i, image) in images.withIndex()) {
val layer = layers[i] // go around image cache, since image will be loaded exactly once
// and then forgotten
val (bytes, width, height, channels) = Image.readImageDirect(image.source)
val tileData = IntArray(width * height)
if (channels == 3) {
// RGB
for (x in 0 until image.width) {
for (y in 0 until image.height) {
val offset = (x + y * image.width) * channels
tileData[x + y * image.width] = bytes[offset].toInt().and(0xFF) or
bytes[offset + 1].toInt().and(0xFF).shl(8) or
bytes[offset + 2].toInt().and(0xFF).shl(16) or -0x1000000 // leading alpha as 255
}
}
} else if (channels == 4) {
// RGBA
for (x in 0 until image.width) {
for (y in 0 until image.height) {
val offset = (x + y * image.width) * channels
tileData[x + y * image.width] = bytes[offset].toInt().and(0xFF) or
bytes[offset + 1].toInt().and(0xFF).shl(8) or
bytes[offset + 2].toInt().and(0xFF).shl(16) or
bytes[offset + 3].toInt().and(0xFF).shl(24)
}
}
}
// determine unique tiles
val uniqueTiles = IntAVLTreeSet()
for (y in 0 until image.height) { for (y in 0 until image.height) {
for (x in 0 until image.width) { for (x in 0 until image.width) {
val color = image[x, y] val color = tileData[x + y * image.width]
val tile = part.dungeon.tiles[color]
if (tile == null) { if (uniqueTiles.add(color)) {
val parse = RGBAColor.abgr(color) if (part.dungeon.tiles[color] == null) {
throw IllegalStateException("Unknown tile on ${image.path} at $x, $y: [${parse.redInt}, ${parse.greenInt}, ${parse.blueInt}, ${parse.alphaInt}] (index $color)") val parse = RGBAColor.abgr(color)
throw IllegalStateException("Unknown tile inside ${image.path} at $x, $y with color [${parse.redInt}, ${parse.greenInt}, ${parse.blueInt}, ${parse.alphaInt}] (index $color)")
}
} }
layer[x, y] = tile
} }
} }
// construct palette
val indices = IntArrayList(uniqueTiles)
val palette = Array(indices.size) { part.dungeon.tiles[indices.getInt(it)]!! }
val data = VectorizedBitSet(32 - Integer.numberOfLeadingZeros(uniqueTiles.size), image.width, image.height)
// fill data
for (y in 0 until image.height) {
for (x in 0 until image.width) {
val index = indices.indexOf(tileData[x + y * image.width])
check(index != -1)
data[x, y] = index
}
}
layers.add(Layer(palette, data))
} }
} }
override fun <T> walkTiles(callback: TileCallback<T>): KOptional<T> { override fun <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
for (layer in layers) { for (layer in layers) {
for (y in 0 until layer.rows) { for (y in 0 until layer.data.height) {
for (x in 0 until layer.columns) { for (x in 0 until layer.data.width) {
val get = callback(x, y, layer[x, y]) val get = callback(x, y, layer.palette[layer.data[x, y]])
if (get.isPresent) return get if (get.isPresent) return get
} }
} }
@ -56,8 +106,8 @@ class ImagePartReader(part: DungeonPart, val images: ImmutableList<Image>) : Par
override fun <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> { override fun <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
for (layer in layers) { for (layer in layers) {
if (x in 0 until layer.columns && y in 0 until layer.rows) { if (x in 0 until layer.data.width && y in 0 until layer.data.height) {
val get = callback(x, y, layer[x, y]) val get = callback(x, y, layer.palette[layer.data[x, y]])
if (get.isPresent) return get if (get.isPresent) return get
} }
} }

View File

@ -5,11 +5,12 @@ import com.google.gson.JsonElement
import com.google.gson.JsonNull import com.google.gson.JsonNull
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive import com.google.gson.JsonPrimitive
import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap
import it.unimi.dsi.fastutil.ints.IntAVLTreeSet
import it.unimi.dsi.fastutil.ints.IntArrayList
import it.unimi.dsi.fastutil.io.FastByteArrayInputStream import it.unimi.dsi.fastutil.io.FastByteArrayInputStream
import ru.dbotthepony.kommons.gson.contains import ru.dbotthepony.kommons.gson.contains
import ru.dbotthepony.kommons.gson.get
import ru.dbotthepony.kommons.gson.set import ru.dbotthepony.kommons.gson.set
import ru.dbotthepony.kommons.util.AABBi
import ru.dbotthepony.kommons.util.Either import ru.dbotthepony.kommons.util.Either
import ru.dbotthepony.kommons.util.KOptional import ru.dbotthepony.kommons.util.KOptional
import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kommons.vector.Vector2i
@ -48,7 +49,7 @@ class TiledMap(data: JsonData) : TileMap() {
val size = Vector2i(data.width, data.height) val size = Vector2i(data.width, data.height)
val tileSets = TiledTileSets(data.tilesets) private val tileSets = TiledTileSets(data.tilesets)
var frontLayer: TileLayer? = null var frontLayer: TileLayer? = null
private set private set
@ -93,6 +94,8 @@ class TiledMap(data: JsonData) : TileMap() {
} }
this.objectLayers = ImmutableList.copyOf(objectLayers) this.objectLayers = ImmutableList.copyOf(objectLayers)
tileSets.free()
} }
override fun <T> walkTiles(callback: TileCallback<T>): KOptional<T> { override fun <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
@ -145,10 +148,13 @@ class TiledMap(data: JsonData) : TileMap() {
val x: Int = data.x val x: Int = data.x
val y: Int = data.y val y: Int = data.y
// this eats ram, need to use cache or huffman encoding with bitset private val frontPalette: Array<DungeonTile>
private val tileData: IntArray private val backPalette: Array<DungeonTile>
private val data: VectorizedBitSet
init { init {
val tileData: IntArray
if (data.compression == "zlib") { if (data.compression == "zlib") {
val stream = BufferedInputStream(InflaterInputStream(FastByteArrayInputStream(Base64.getDecoder().decode(data.data.asString)))) val stream = BufferedInputStream(InflaterInputStream(FastByteArrayInputStream(Base64.getDecoder().decode(data.data.asString))))
@ -176,17 +182,39 @@ class TiledMap(data: JsonData) : TileMap() {
} else { } else {
throw IllegalArgumentException("Unsupported compression mode: ${data.compression}") throw IllegalArgumentException("Unsupported compression mode: ${data.compression}")
} }
// determine unique tiles
val countUniqueTiles = IntAVLTreeSet()
for (x in 0 until width) {
for (y in 0 until height) {
countUniqueTiles.add(tileData[x + y * width])
}
}
// construct backing memory
this.data = VectorizedBitSet(32 - Integer.numberOfLeadingZeros(countUniqueTiles.size), width, height)
// determine palette
val gidIndices = IntArrayList(countUniqueTiles)
frontPalette = Array(gidIndices.size) { tileSets.getFront(gidIndices.getInt(it)) }
backPalette = Array(gidIndices.size) { tileSets.getBack(gidIndices.getInt(it)) }
// fill backing memory with paletted indices
for (x in 0 until width) {
for (y in 0 until height) {
val index = gidIndices.indexOf(tileData[x + y * width])
check(index != -1)
this.data[x, this.height - y - 1] = index
}
}
} }
private fun get0(x: Int, y: Int): DungeonTile { private fun get0(x: Int, y: Int): DungeonTile {
val actualX = x - this.x
var actualY = y - this.y
actualY = this.height - actualY - 1
if (isBackground) if (isBackground)
return tileSets.getBack(tileData[actualX + actualY * width]) return backPalette[data[x - this.x, y - this.y]]
else else
return tileSets.getFront(tileData[actualX + actualY * width]) return frontPalette[data[x - this.x, y - this.y]]
} }
operator fun get(x: Int, y: Int): DungeonTile { operator fun get(x: Int, y: Int): DungeonTile {

View File

@ -14,8 +14,8 @@ class TiledTileSets(entries: List<Entry>) {
val source: String, val source: String,
) )
private val front = Int2ObjectOpenHashMap<Pair<DungeonTile, JsonObject>>() private var front = Int2ObjectOpenHashMap<Pair<DungeonTile, JsonObject>>()
private val back = Int2ObjectOpenHashMap<Pair<DungeonTile, JsonObject>>() private var back = Int2ObjectOpenHashMap<Pair<DungeonTile, JsonObject>>()
init { init {
for ((firstgid, source) in entries) { for ((firstgid, source) in entries) {
@ -60,4 +60,9 @@ class TiledTileSets(entries: List<Entry>) {
fun getBackData(gid: Int): JsonObject { fun getBackData(gid: Int): JsonObject {
return back[gid]?.second ?: JsonObject() return back[gid]?.second ?: JsonObject()
} }
fun free() {
front = Int2ObjectOpenHashMap()
back = Int2ObjectOpenHashMap()
}
} }

View File

@ -341,6 +341,29 @@ class Image private constructor(
private val imageCache = ConcurrentHashMap<String, Optional<Image>>() private val imageCache = ConcurrentHashMap<String, Optional<Image>>()
private val logger = LogManager.getLogger() private val logger = LogManager.getLogger()
data class ReadDirectData(val data: ByteBuffer, val width: Int, val height: Int, val channels: Int)
fun readImageDirect(file: IStarboundFile): ReadDirectData {
val getWidth = intArrayOf(0)
val getHeight = intArrayOf(0)
val components = intArrayOf(0)
val idata = file.readDirect()
val data = STBImage.stbi_load_from_memory(
idata,
getWidth, getHeight,
components, 0
) ?: throw IllegalArgumentException("File $file is not an image or it is corrupted")
Reference.reachabilityFence(idata)
val address = MemoryUtil.memAddress(data)
Starbound.CLEANER.register(data) { STBImage.nstbi_image_free(address) }
return ReadDirectData(data, getWidth[0], getHeight[0], components[0])
}
private val dataCache: AsyncLoadingCache<IStarboundFile, ByteBuffer> = Caffeine.newBuilder() private val dataCache: AsyncLoadingCache<IStarboundFile, ByteBuffer> = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(1)) .expireAfterAccess(Duration.ofMinutes(1))
.weigher<IStarboundFile, ByteBuffer> { key, value -> value.capacity() } .weigher<IStarboundFile, ByteBuffer> { key, value -> value.capacity() }
@ -348,24 +371,7 @@ class Image private constructor(
.scheduler(Starbound) .scheduler(Starbound)
.executor(Starbound.EXECUTOR) .executor(Starbound.EXECUTOR)
.buildAsync(CacheLoader { .buildAsync(CacheLoader {
val getWidth = intArrayOf(0) readImageDirect(it).data
val getHeight = intArrayOf(0)
val components = intArrayOf(0)
val idata = it.readDirect()
val data = STBImage.stbi_load_from_memory(
idata,
getWidth, getHeight,
components, 0
) ?: throw IllegalArgumentException("File $it is not an image or it is corrupted")
Reference.reachabilityFence(idata)
val address = MemoryUtil.memAddress(data)
Starbound.CLEANER.register(data) { STBImage.nstbi_image_free(address) }
data
}) })
@JvmStatic @JvmStatic