KStarbound/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt

562 lines
17 KiB
Kotlin

package ru.dbotthepony.kstarbound.defs.image
import com.github.benmanes.caffeine.cache.AsyncLoadingCache
import com.github.benmanes.caffeine.cache.CacheLoader
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.Scheduler
import com.google.common.collect.ImmutableList
import com.google.gson.JsonArray
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.gson.JsonSyntaxException
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap
import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap
import org.apache.logging.log4j.LogManager
import org.lwjgl.opengl.GL45
import org.lwjgl.stb.STBIEOFCallback
import org.lwjgl.stb.STBIIOCallbacks
import org.lwjgl.stb.STBIReadCallback
import org.lwjgl.stb.STBIReadCallbackI
import org.lwjgl.stb.STBISkipCallback
import org.lwjgl.stb.STBImage
import org.lwjgl.system.MemoryUtil
import ru.dbotthepony.kommons.vector.Vector2i
import ru.dbotthepony.kommons.vector.Vector4i
import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNIT
import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNITi
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.IStarboundFile
import ru.dbotthepony.kstarbound.client.StarboundClient
import ru.dbotthepony.kstarbound.client.gl.GLTexture2D
import ru.dbotthepony.kommons.gson.consumeNull
import ru.dbotthepony.kommons.gson.contains
import ru.dbotthepony.kommons.gson.get
import ru.dbotthepony.kommons.gson.getObject
import java.io.BufferedInputStream
import java.io.FileNotFoundException
import java.lang.ref.Reference
import java.lang.ref.WeakReference
import java.nio.ByteBuffer
import java.time.Duration
import java.util.Collections
import java.util.Optional
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock
class Image private constructor(
val source: IStarboundFile,
val path: String,
val width: Int,
val height: Int,
val amountOfChannels: Int,
spritesData: Pair<List<DataSprite>, IStarboundFile>?
) {
init {
check(width >= 0) { "Invalid width $width" }
check(height >= 0) { "Invalid height $height" }
check(amountOfChannels in 1 .. 4) { "Unknown number of channels $amountOfChannels" }
}
private val spritesInternal = LinkedHashMap<String, Sprite>()
private var dataRef: WeakReference<ByteBuffer>? = null
private val lock = Any()
//private val _texture = ThreadLocal<WeakReference<GLTexture2D>>()
init {
if (spritesData == null) {
this.spritesInternal["default"] = Sprite("default", 0, 0, width, height)
} else {
val (sprites, origin) = spritesData
for (data in sprites) {
var sX = data.coordinates.x % width
var sY = data.coordinates.y % height
if (sX !in 0 .. width) {
//LOGGER.warn("Sprite X offset ${data.name} is out of bounds: $sX, clamping to 0 .. $width. (image: $source; frames: $origin)")
sX = sX.coerceIn(0, width)
}
if (sY !in 0 .. height) {
//LOGGER.warn("Sprite Y offset ${data.name} is out of bounds: $sY, clamping to 0 .. $height. (image: $source; frames: $origin)")
sY = sY.coerceIn(0, height)
}
var sWidth = data.coordinates.z - sX
var sHeight = data.coordinates.w - sY
if (sWidth !in 0 .. width) {
//LOGGER.warn("Sprite width ${data.name} is out of bounds: $sWidth, clamping to 0 .. $width. (image: $source; frames: $origin)")
sWidth = sWidth.coerceIn(0, width)
}
if (sHeight !in 0 .. height) {
//LOGGER.warn("Sprite height ${data.name} is out of bounds: $sHeight, clamping to 0 .. $height. (image: $source; frames: $origin)")
sHeight = sHeight.coerceIn(0, height)
}
this.spritesInternal[data.name] = Sprite(data.name, sX, sY, sWidth, sHeight)
}
}
}
val data: CompletableFuture<ByteBuffer> get() {
var get = dataRef?.get()
if (get != null)
return CompletableFuture.completedFuture(get)
synchronized(lock) {
get = dataRef?.get()
if (get != null)
return CompletableFuture.completedFuture(get)
val f = dataCache.get(source)
if (f.isDone)
dataRef = WeakReference(f.get())
return f.copy()
}
}
val texture: GLTexture2D get() {
//val get = _texture.get()?.get()
val client = StarboundClient.current()
/*if (get != null) {
// update access time
client.named2DTextures0.getIfPresent(this)
client.named2DTextures1.getIfPresent(this)
return get
}*/
val value = client.named2DTextures0.get(this) {
client.named2DTextures1.get(this) {
val (memFormat, fileFormat) = when (amountOfChannels) {
1 -> GL45.GL_R8 to GL45.GL_RED
3 -> GL45.GL_RGB8 to GL45.GL_RGB
4 -> GL45.GL_RGBA8 to GL45.GL_RGBA
else -> throw IllegalArgumentException("Unknown amount of channels in $it: $amountOfChannels")
}
val tex = GLTexture2D(width, height, memFormat)
data.thenApplyAsync({
tex.upload(fileFormat, GL45.GL_UNSIGNED_BYTE, it)
tex.textureMinFilter = GL45.GL_NEAREST
tex.textureMagFilter = GL45.GL_NEAREST
}, client)
tex
}
}
//_texture.set(WeakReference(value))
return value
}
val size = Vector2i(width, height)
val sprites: Map<String, Sprite> = Collections.unmodifiableMap(this.spritesInternal)
val first: Sprite = this.spritesInternal.values.first()
val whole = Sprite("this", 0, 0, width, height)
val nonEmptyRegion get() = whole.nonEmptyRegion
/**
* returns integer in ABGR format
*/
operator fun get(x: Int, y: Int): Int {
return whole[x, y]
}
/**
* returns integer in ABGR format
*/
operator fun get(x: Int, y: Int, flip: Boolean): Int {
return whole[x, y, flip]
}
operator fun get(name: String): Sprite? {
return spritesInternal[name]
}
fun isTransparent(x: Int, y: Int, flip: Boolean): Boolean {
return whole.isTransparent(x, y, flip)
}
fun worldSpaces(pixelOffset: Vector2i, spaceScan: Double, flip: Boolean): List<Vector2i> {
return whole.worldSpaces(pixelOffset, spaceScan, flip)
}
private data class DataSprite(val name: String, val coordinates: Vector4i)
inner class Sprite(val name: String, val x: Int, val y: Int, val width: Int, val height: Int) : IUVCoordinates {
// flip coordinates to account for opengl
override val u0: Float = x.toFloat() / this@Image.width
override val v1: Float = y.toFloat() / this@Image.height
override val u1: Float = (x.toFloat() + this.width.toFloat()) / this@Image.width
override val v0: Float = (y.toFloat() + this.height.toFloat()) / this@Image.height
/**
* returns integer in ABGR format if it is RGB or RGBA picture,
* otherwise returns pixels as-is
*/
operator fun get(x: Int, y: Int): Int {
require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" }
val offset = (this.y + y) * this@Image.width * amountOfChannels + (this.x + x) * amountOfChannels
val data = data.join()
when (amountOfChannels) {
4 -> return data[offset].toInt().and(0xFF) or
data[offset + 1].toInt().and(0xFF).shl(8) or
data[offset + 2].toInt().and(0xFF).shl(16) or
data[offset + 3].toInt().and(0xFF).shl(24)
3 -> return data[offset].toInt().and(0xFF) or
data[offset + 1].toInt().and(0xFF).shl(8) or
data[offset + 2].toInt().and(0xFF).shl(16) or -0x1000000 // leading alpha as 255
2 -> return data[offset].toInt().and(0xFF) or
data[offset + 1].toInt().and(0xFF).shl(8)
1 -> return data[offset].toInt()
else -> throw IllegalStateException()
}
}
/**
* returns integer in ABGR format
*/
operator fun get(x: Int, y: Int, flip: Boolean): Int {
if (flip) {
require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" }
return this[width - x - 1, y]
} else {
return this[x, y]
}
}
fun isTransparent(x: Int, y: Int, flip: Boolean): Boolean {
if (x !in 0 until width) return true
if (y !in 0 until height) return true
if (amountOfChannels != 4) return false
return this[x, y, flip] and 0xFF != 0x0
}
val nonEmptyRegion by lazy {
if (amountOfChannels == 4) {
var x0 = 0
var y0 = 0
search@for (y in 0 until height) {
for (x in 0 until width) {
if (this[x, y] and 0xFF != 0x0) {
x0 = x
y0 = y
break@search
}
}
}
var x1 = x0
var y1 = y0
search@for (y in height - 1 downTo y0) {
for (x in width - 1 downTo x0) {
if (this[x, y] and 0xFF != 0x0) {
x1 = x
y1 = y
break@search
}
}
}
return@lazy Vector4i(x0, y0, x1, y1)
}
Vector4i(0, 0, width, height)
}
fun worldSpaces(pixelOffset: Vector2i, spaceScan: Double, flip: Boolean): List<Vector2i> {
if (amountOfChannels != 3 && amountOfChannels != 4) throw IllegalStateException("Can not check world space taken by image with $amountOfChannels color channels")
val minX = pixelOffset.x / PIXELS_IN_STARBOUND_UNITi
val minY = pixelOffset.y / PIXELS_IN_STARBOUND_UNITi
val maxX = (width + pixelOffset.x + PIXELS_IN_STARBOUND_UNITi - 1) / PIXELS_IN_STARBOUND_UNITi
val maxY = (height + pixelOffset.y + PIXELS_IN_STARBOUND_UNITi - 1) / PIXELS_IN_STARBOUND_UNITi
val result = ArrayList<Vector2i>()
// this is weird, but that's how original game handles this
// also we don't cache this info since that's a waste of precious ram
for (yspace in minY until maxY) {
for (xspace in minX until maxX) {
var fillRatio = 0.0
for (y in 0 until PIXELS_IN_STARBOUND_UNITi) {
val ypixel = (yspace * PIXELS_IN_STARBOUND_UNITi + y - pixelOffset.y)
if (ypixel !in 0 until height)
continue
for (x in 0 until PIXELS_IN_STARBOUND_UNITi) {
val xpixel = (xspace * PIXELS_IN_STARBOUND_UNITi + x - pixelOffset.x)
if (xpixel !in 0 until width)
continue
if (isTransparent(xpixel, ypixel, flip)) {
fillRatio += 1.0 / (PIXELS_IN_STARBOUND_UNIT * PIXELS_IN_STARBOUND_UNIT)
}
}
}
if (fillRatio >= spaceScan) {
result.add(Vector2i(xspace, yspace))
}
}
}
return result
}
}
companion object : TypeAdapter<Image>() {
private val LOGGER = LogManager.getLogger()
private val objects by lazy { Starbound.gson.getAdapter(JsonObject::class.java) }
private val vectors by lazy { Starbound.gson.getAdapter(Vector4i::class.java) }
private val vectors2 by lazy { Starbound.gson.getAdapter(Vector2i::class.java) }
private val configCache = ConcurrentHashMap<String, Optional<Pair<List<DataSprite>, IStarboundFile>>>()
private val imageCache = ConcurrentHashMap<String, Optional<Image>>()
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()
.expireAfterAccess(Duration.ofMinutes(1))
.weigher<IStarboundFile, ByteBuffer> { key, value -> value.capacity() }
.maximumWeight((Runtime.getRuntime().maxMemory() / 4L).coerceIn(1_024L * 1_024L * 32L /* 32 МиБ */, 1_024L * 1_024L * 256L /* 256 МиБ */))
.scheduler(Starbound)
.executor(Starbound.EXECUTOR)
.buildAsync(CacheLoader {
readImageDirect(it).data
})
@JvmStatic
fun get(path: String): Image? {
return imageCache.computeIfAbsent(path) {
try {
val file = Starbound.locate(it)
if (!file.exists) {
throw FileNotFoundException("No such file $it")
}
if (!file.isFile) {
throw FileNotFoundException("File $it is a directory")
}
val getWidth = intArrayOf(0)
val getHeight = intArrayOf(0)
val components = intArrayOf(0)
val stream = BufferedInputStream(file.open())
val callback = STBIIOCallbacks.malloc()
val readCallback = STBIReadCallback.create(STBIReadCallbackI { _, buf, size ->
val readBuf = ByteArray(size)
val read = stream.read(readBuf)
for (i in 0 until read) {
MemoryUtil.memPutByte(buf + i, readBuf[i])
}
return@STBIReadCallbackI read
})
val skipCallback = STBISkipCallback.create { _, n -> stream.skip(n.toLong()) }
val eofCallback = STBIEOFCallback.create {
stream.mark(1)
val empty = stream.read() == -1
stream.reset()
if (empty) 1 else 0
}
callback.set(readCallback, skipCallback, eofCallback)
val status = STBImage.stbi_info_from_callbacks(
callback, 0L,
getWidth, getHeight,
components
)
readCallback.free()
skipCallback.free()
eofCallback.free()
callback.free()
if (!status)
throw IllegalArgumentException("File $file is not an image or it is corrupted")
Optional.of(Image(file, it, getWidth[0], getHeight[0], components[0], getConfig(it)))
} catch (err: Exception) {
logger.error("Failed to load image at path $it", err)
Optional.empty()
}
}.orElse(null)
}
override fun write(out: JsonWriter, value: Image?) {
if (value == null)
out.nullValue()
else
out.value(value.path)
}
override fun read(`in`: JsonReader): Image? {
if (`in`.consumeNull())
return null
else
return get(`in`.nextString())
}
private fun generateFakeNames(dimensions: Vector2i): JsonArray {
return JsonArray(dimensions.y).also {
var stripElem = 0
for (stripNum in 0 until dimensions.y) {
val strip = JsonArray(dimensions.x)
for (i in 0 until dimensions.x) {
strip.add(stripElem.toString())
stripElem++
}
it.add(strip)
}
}
}
private fun parseFrames(read: JsonObject): List<DataSprite> {
val begin = read.get("begin", vectors2) { Vector2i.ZERO }
val sprites = LinkedHashMap<String, DataSprite>()
if ("frameGrid" in read) {
val frameGrid = read.getObject("frameGrid")
val size = vectors2.fromJsonTree(frameGrid["size"] ?: throw JsonSyntaxException("Missing frameGrid.size"))
val dimensions = vectors2.fromJsonTree(frameGrid["dimensions"] ?: throw JsonSyntaxException("Missing frameGrid.dimensions"))
require(size.x >= 0 && size.y >= 0) { "Invalid size: $size" }
require(dimensions.x > 0 && dimensions.y > 0) { "Invalid dimensions: $dimensions" }
val names = (frameGrid.get("names") { generateFakeNames(dimensions) })
.map { (it as? JsonArray)?.map { if (it == JsonNull.INSTANCE) null else it.asString } }.toList()
for ((y, strip) in names.withIndex()) {
// разрешаем вставлять null как ленту кадров, что означает что мы должны пропустить её полностью
if (strip == null) continue
for ((x, spriteName) in strip.withIndex()) {
// если кадр не имеет имени...
if (spriteName == null) continue
require(y < dimensions.y && x < dimensions.x) { "Sprite at $x $y is out of bounds for frame grid with dimensions of $dimensions" }
sprites[spriteName] = DataSprite(spriteName, Vector4i(begin.x + x * size.x, begin.y + y * size.y, begin.x + (x + 1) * size.x, begin.y + (y + 1) * size.y))
}
}
}
if ("frameList" in read) {
for ((spriteName, coords) in read.getObject("frameList").entrySet()) {
sprites[spriteName] = DataSprite(spriteName, vectors.fromJsonTree(coords))
}
}
val aliases = Object2ObjectArrayMap<String, String>()
for ((k, v) in read.get("aliases") { JsonObject() }.entrySet())
aliases[k] = v.asString
var changes = true
while (aliases.isNotEmpty() && changes) {
changes = false
val i = aliases.entries.iterator()
for ((k, v) in i) {
require(k !in sprites) { "Sprite with name '$k' already defined" }
val sprite = sprites[v] ?: continue
sprites[k] = sprite.copy(name = k)
changes = true
i.remove()
}
}
for ((k, v) in aliases.entries)
throw JsonSyntaxException("Alias '$k' want to refer to sprite '$v', but it does not exist")
return ImmutableList.copyOf(sprites.values)
}
private fun compute(it: String): Optional<Pair<List<DataSprite>, IStarboundFile>> {
val find = Starbound.locate("$it.frames")
if (!find.exists) {
return Optional.empty()
} else {
return Optional.of(parseFrames(objects.read(JsonReader(find.reader()).also { it.isLenient = true })) to find)
}
}
private fun getConfig(path: String): Pair<List<DataSprite>, IStarboundFile>? {
var folder = path.substringBefore(':').substringBeforeLast('/')
val name = path.substringBefore(':').substringAfterLast('/').substringBefore('.')
while (true) {
val find = configCache.computeIfAbsent("$folder/$name", ::compute).or { configCache.computeIfAbsent("$folder/default", ::compute) }
if (find.isPresent)
return find.get()
folder = folder.substringBeforeLast('/')
if (folder.isEmpty()) return null
}
}
}
}