424 lines
13 KiB
Kotlin
424 lines
13 KiB
Kotlin
package ru.dbotthepony.kstarbound.defs.image
|
|
|
|
import com.github.benmanes.caffeine.cache.Cache
|
|
import com.github.benmanes.caffeine.cache.Caffeine
|
|
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.stream.JsonReader
|
|
import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap
|
|
import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap
|
|
import org.apache.logging.log4j.LogManager
|
|
import org.lwjgl.stb.STBImage
|
|
import org.lwjgl.system.MemoryUtil
|
|
import ru.dbotthepony.kstarbound.PIXELS_IN_STARBOUND_UNIT
|
|
import ru.dbotthepony.kstarbound.PIXELS_IN_STARBOUND_UNITi
|
|
import ru.dbotthepony.kstarbound.Starbound
|
|
import ru.dbotthepony.kstarbound.api.IStarboundFile
|
|
import ru.dbotthepony.kstarbound.io.stream2STBIO
|
|
import ru.dbotthepony.kstarbound.util.contains
|
|
import ru.dbotthepony.kstarbound.util.get
|
|
import ru.dbotthepony.kstarbound.util.getObject
|
|
import ru.dbotthepony.kvector.vector.Vector2i
|
|
import ru.dbotthepony.kvector.vector.Vector4i
|
|
import java.io.BufferedInputStream
|
|
import java.io.FileNotFoundException
|
|
import java.lang.ref.Cleaner
|
|
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.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,
|
|
sprites: List<DataSprite>?
|
|
) {
|
|
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 = Object2ObjectLinkedOpenHashMap<String, Sprite>()
|
|
private var dataRef: WeakReference<ByteBuffer>? = null
|
|
private val lock = ReentrantLock()
|
|
|
|
init {
|
|
if (sprites == null) {
|
|
this.spritesInternal["default"] = Sprite("default", 0, 0, width, height)
|
|
} else {
|
|
for (data in sprites) {
|
|
this.spritesInternal[data.name] = Sprite(
|
|
data.name,
|
|
data.coordinates.x,
|
|
data.coordinates.y,
|
|
data.coordinates.z - data.coordinates.x,
|
|
data.coordinates.w - data.coordinates.y)
|
|
}
|
|
}
|
|
}
|
|
|
|
val data: ByteBuffer get() {
|
|
var get = dataRef?.get()
|
|
|
|
if (get != null)
|
|
return get
|
|
|
|
lock.lock()
|
|
|
|
try {
|
|
get = dataRef?.get()
|
|
|
|
if (get != null)
|
|
return get
|
|
|
|
get = dataCache.get(path) {
|
|
val getWidth = intArrayOf(0)
|
|
val getHeight = intArrayOf(0)
|
|
val components = intArrayOf(0)
|
|
|
|
val data = STBImage.stbi_load_from_memory(
|
|
source.readDirect(),
|
|
getWidth, getHeight,
|
|
components, 0
|
|
) ?: throw IllegalArgumentException("File $source is not an image or it is corrupted")
|
|
|
|
val address = MemoryUtil.memAddress(data)
|
|
cleaner.register(data) { STBImage.nstbi_image_free(address) }
|
|
|
|
check(getWidth[0] == width && getHeight[0] == height && components[0] == amountOfChannels) {
|
|
"Actual loaded image differs from constructed (this $width x $height with $amountOfChannels channels; loaded ${getWidth[0]} x ${getHeight[0]} with ${components[0]} channels)"
|
|
}
|
|
|
|
data
|
|
}
|
|
|
|
dataRef = WeakReference(get)
|
|
return get
|
|
} finally {
|
|
lock.unlock()
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
operator fun get(x: Int, y: Int): Int {
|
|
return whole[x, y]
|
|
}
|
|
|
|
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
|
|
|
|
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
|
|
|
|
when (amountOfChannels) {
|
|
4 -> return data[offset].toInt() or
|
|
data[offset + 1].toInt().shl(8) or
|
|
data[offset + 2].toInt().shl(16) or
|
|
data[offset + 3].toInt().shl(24)
|
|
|
|
3 -> return data[offset].toInt() or
|
|
data[offset + 1].toInt().shl(8) or
|
|
data[offset + 2].toInt().shl(16)
|
|
|
|
2 -> return data[offset].toInt() or
|
|
data[offset + 1].toInt().shl(8)
|
|
|
|
1 -> return data[offset].toInt()
|
|
|
|
else -> throw IllegalStateException()
|
|
}
|
|
}
|
|
|
|
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 xDivL = pixelOffset.x % PIXELS_IN_STARBOUND_UNITi
|
|
val yDivB = pixelOffset.y % PIXELS_IN_STARBOUND_UNITi
|
|
|
|
val xDivR = (pixelOffset.x + width) % PIXELS_IN_STARBOUND_UNITi
|
|
val yDivT = (pixelOffset.y + height) % PIXELS_IN_STARBOUND_UNITi
|
|
|
|
val leftMostX = pixelOffset.x / PIXELS_IN_STARBOUND_UNITi - (if (xDivL != 0) 1 else 0)
|
|
val bottomMostY = pixelOffset.y / PIXELS_IN_STARBOUND_UNITi - (if (yDivB != 0) 1 else 0)
|
|
|
|
val rightMostX = (pixelOffset.x + width) / PIXELS_IN_STARBOUND_UNITi + (if (xDivR != 0) 1 else 0)
|
|
val topMostY = (pixelOffset.y + height) / PIXELS_IN_STARBOUND_UNITi + (if (yDivT != 0) 1 else 0)
|
|
|
|
val result = ArrayList<Vector2i>()
|
|
|
|
for (y in bottomMostY .. topMostY) {
|
|
for (x in leftMostX .. rightMostX) {
|
|
val left = x * PIXELS_IN_STARBOUND_UNITi
|
|
val bottom = y * PIXELS_IN_STARBOUND_UNITi
|
|
|
|
var transparentPixels = 0
|
|
|
|
for (sX in 0 until PIXELS_IN_STARBOUND_UNITi) {
|
|
for (sY in 0 until PIXELS_IN_STARBOUND_UNITi) {
|
|
if (isTransparent(xDivL + sX + left, yDivB + sY + bottom, flip)) {
|
|
transparentPixels++
|
|
}
|
|
}
|
|
}
|
|
|
|
if (transparentPixels * FILL_RATIO >= spaceScan) {
|
|
result.add(Vector2i(x, y))
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
}
|
|
|
|
companion object {
|
|
const val FILL_RATIO = 1 / (PIXELS_IN_STARBOUND_UNIT * PIXELS_IN_STARBOUND_UNIT)
|
|
|
|
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 cache = ConcurrentHashMap<String, Optional<List<DataSprite>>>()
|
|
private val imageCache = ConcurrentHashMap<String, Optional<Image>>()
|
|
private val logger = LogManager.getLogger()
|
|
private val cleaner = Cleaner.create { Thread(it, "STB Image Cleaner") }
|
|
|
|
private val dataCache: Cache<String, ByteBuffer> = Caffeine.newBuilder()
|
|
.softValues()
|
|
.expireAfterAccess(Duration.ofMinutes(20))
|
|
.weigher<String, ByteBuffer> { key, value -> value.capacity() }
|
|
.maximumWeight(1_024L * 1_024L * 256L /* 256 МиБ */)
|
|
.build()
|
|
|
|
@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 status = STBImage.stbi_info_from_callbacks(
|
|
stream2STBIO(BufferedInputStream(file.open())), 0L,
|
|
getWidth, getHeight,
|
|
components
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
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<List<DataSprite>> {
|
|
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 })))
|
|
}
|
|
}
|
|
|
|
private fun getConfig(path: String): List<DataSprite>? {
|
|
var folder = path.substringBefore(':').substringBeforeLast('/')
|
|
val name = path.substringBefore(':').substringAfterLast('/').substringBefore('.')
|
|
|
|
while (true) {
|
|
val find = cache.computeIfAbsent("$folder/$name", ::compute).or { cache.computeIfAbsent("$folder/default", ::compute) }
|
|
|
|
if (find.isPresent)
|
|
return find.get()
|
|
|
|
folder = folder.substringBeforeLast('/')
|
|
|
|
if (folder.isEmpty()) return null
|
|
}
|
|
}
|
|
}
|
|
}
|