562 lines
17 KiB
Kotlin
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
|
|
}
|
|
}
|
|
}
|
|
}
|