Skip to content

MegaTextureCatalog.kt

src/jvmMain/kotlin/afield/graphics/texture/mega/MegaTextureCatalog.kt · 496 lines

package afield.graphics.texture.mega

import afield.app.diagnostics.logging.lprintln
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.channels.FileChannel
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.security.MessageDigest
import kotlin.io.path.extension

The stable image-to-index catalog behind a MegaTexture, and the on-disk pyramid cache keyed to it.

MegaTextureCatalog assigns each source file an append-only index and detects files that changed since the last run; MegaTexturePyramidCache stores decoded pages so a later run can skip decoding them.

class MegaTextureCatalog private constructor

The set of source images a MegaTexture can show, and the index each one answers to.

An index is the only name the GPU ever knows: the feedback buffer, the page table and every megaTexture(id, uv) call in a shader are all indexed by it. So the mapping has to be stable across runs — otherwise the on-disk pyramid cache from the last run describes different pictures than the catalog it is keyed to, and the wall silently rearranges itself.

Stability comes from append-only ordering, not from sorting: index = order of first appearance in catalogFile. A file added to the directory later is appended and gets a new index; a file deleted leaves its index behind as a hole (resolving to the same path, which will simply fail to decode). Re-sorting on every scan would have been tidier to read and would have invalidated the cache every time the directory changed.

Each entry records the source's mtime and size as it was when the cache was written. A mismatch on load means that file changed under us, and changedIndices reports it so the pyramid cache can drop the stale pages for exactly those indices rather than for everything.

class MegaTextureCatalog private constructor(
    /** Where this catalog and its pyramid cache live. */
    val cacheDir: Path,
    private val entries: MutableList<Entry>,
    private val indexByPath: MutableMap<String, Int>,
    /**
     * Indices whose source file's mtime/size differ from what the catalog recorded — their cached
     * pages describe an older version of the picture and must be discarded.
     */
    val changedIndices: IntArray
) {

class Entry(val path: Path, var mtime: Long, var size: Long)

val count: Int get() = entries.size

fun path(index: Int): Path? = entries.getOrNull(index)?.path

fun indexOf(path: Path): Int? = indexByPath[path.toAbsolutePath().normalize().toString()]

fun forEachIndexed(block: (Int, Path) -> Unit) {

Every (index, path) pair, in index order. Useful for a warm-up pass over the whole set.

    fun forEachIndexed(block: (Int, Path) -> Unit) {
        entries.forEachIndexed { index, entry -> block(index, entry.path) }
    }

    private val catalogFile: Path get() = cacheDir.resolve(CATALOG_NAME)

Rewrite catalogFile from the current entries. Called once after a scan; the format is deliberately plain text (mtime, size, path — tab separated) so a wrong cache is diagnosable with head, which mattered more here than the few hundred milliseconds a binary form would save on a million-line file.

    private fun save() {
        val tmp = cacheDir.resolve("$CATALOG_NAME.tmp")
        Files.newBufferedWriter(tmp).use { out ->
            out.write("$CATALOG_MAGIC\n")
            for (entry in entries) {
                out.write(entry.mtime.toString())
                out.write("\t")
                out.write(entry.size.toString())
                out.write("\t")
                out.write(entry.path.toString().replace('\n', ' '))
                out.write("\n")
            }
        }
        Files.move(tmp, catalogFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
    }

companion object

    companion object {
        private const val CATALOG_NAME = "catalog.txt"
        private const val CATALOG_MAGIC = "#afield megatexture catalog v1"

val DEFAULT_EXTENSIONS = setOf("jpg", "jpeg", "png", "tga", "bmp", "gif", "psd", "hdr")

fun defaultCacheRoot(): Path

Where a source spec's cache lives when the caller does not name one.

        fun defaultCacheRoot(): Path =
            Path.of(System.getProperty("user.home") ?: ".", ".afield", "megatexture")

fun scan

Scan roots for images and load-or-create the catalog that names them.

cacheRoot holds one subdirectory per source spec (the roots plus the extension filter), so two different walls do not fight over one index space. Key by the spec and not by the file list: the whole point is that adding a picture to the folder keeps the indices — and therefore the cached pyramids — of everything already there.

body — 66 lines, from line 106
        fun scan(
            roots: List<Path>,
            extensions: Set<String> = DEFAULT_EXTENSIONS,
            recursive: Boolean = true,
            cacheRoot: Path = defaultCacheRoot(),
            maxImages: Int = Int.MAX_VALUE
        ): MegaTextureCatalog {
            val specKey = buildString {
                roots.forEach { append(it.toAbsolutePath().normalize().toString()).append('\n') }
                append("ext=").append(extensions.sorted().joinToString(",")).append('\n')
                append("recursive=").append(recursive).append('\n')
            }
            val cacheDir = cacheRoot.resolve(shortHash(specKey))
            Files.createDirectories(cacheDir)

            val entries = mutableListOf<Entry>()
            val indexByPath = mutableMapOf<String, Int>()
            loadExisting(cacheDir.resolve(CATALOG_NAME), entries, indexByPath)
            val knownBefore = entries.size

            val found = mutableListOf<Path>()
            for (root in roots) {
                if (!Files.isDirectory(root)) {
                    lprintln("warning,megatexture", "megatexture source '$root' is not a directory")
                    continue
                }
                val stream = if (recursive) Files.walk(root) else Files.list(root)
                stream.use { s ->
                    s.filter { Files.isRegularFile(it) }
                        .filter { it.extension.lowercase() in extensions }
                        .forEach { found.add(it.toAbsolutePath().normalize()) }
                }
            }
            // Sort only the *newly discovered* files. Existing indices keep their order, so the
            // cache stays valid, while a first scan is still in a sensible (path) order.
            found.sort()

            val changed = mutableListOf<Int>()
            for (path in found) {
                val key = path.toString()
                val mtime = runCatching { Files.getLastModifiedTime(path).toMillis() }.getOrDefault(0L)
                val size = runCatching { Files.size(path) }.getOrDefault(0L)
                val existing = indexByPath[key]
                if (existing == null) {
                    if (entries.size >= maxImages) break
                    indexByPath[key] = entries.size
                    entries.add(Entry(path, mtime, size))
                } else {
                    val entry = entries[existing]
                    if (entry.mtime != mtime || entry.size != size) {
                        entry.mtime = mtime
                        entry.size = size
                        changed.add(existing)
                    }
                }
            }

            val catalog = MegaTextureCatalog(cacheDir, entries, indexByPath, changed.toIntArray())
            if (entries.size != knownBefore || changed.isNotEmpty()) catalog.save()
            lprintln(
                "info,megatexture",
                "megatexture catalog $cacheDir: ${entries.size} images " +
                        "(${entries.size - knownBefore} new, ${changed.size} changed)"
            )
            return catalog
        }

fun of(paths: List<Path>, cacheDir: Path): MegaTextureCatalog

An explicit, ordered list of files — for tests and for callers that build their own set.

body — 50 lines, from line 174
        fun of(paths: List<Path>, cacheDir: Path): MegaTextureCatalog {
            Files.createDirectories(cacheDir)
            val entries = mutableListOf<Entry>()
            val indexByPath = mutableMapOf<String, Int>()
            paths.forEach { raw ->
                val path = raw.toAbsolutePath().normalize()
                indexByPath[path.toString()] = entries.size
                entries.add(
                    Entry(
                        path,
                        runCatching { Files.getLastModifiedTime(path).toMillis() }.getOrDefault(0L),
                        runCatching { Files.size(path) }.getOrDefault(0L)
                    )
                )
            }
            return MegaTextureCatalog(cacheDir, entries, indexByPath, IntArray(0))
        }

        private fun loadExisting(
            file: Path,
            entries: MutableList<Entry>,
            indexByPath: MutableMap<String, Int>
        ) {
            if (!Files.isRegularFile(file)) return
            try {
                Files.newBufferedReader(file).use { reader ->
                    val header = reader.readLine() ?: return
                    if (header != CATALOG_MAGIC) {
                        lprintln("warning,megatexture", "ignoring catalog with unexpected header: $file")
                        return
                    }
                    while (true) {
                        val line = reader.readLine() ?: break
                        if (line.isEmpty()) continue
                        val a = line.indexOf('\t')
                        val b = line.indexOf('\t', a + 1)
                        if (a < 0 || b < 0) continue
                        val mtime = line.substring(0, a).toLongOrNull() ?: continue
                        val size = line.substring(a + 1, b).toLongOrNull() ?: continue
                        val path = Path.of(line.substring(b + 1))
                        indexByPath[path.toString()] = entries.size
                        entries.add(Entry(path, mtime, size))
                    }
                }
            } catch (e: IOException) {
                lprintln("warning,megatexture", "could not read catalog $file: ${e.message}")
                entries.clear()
                indexByPath.clear()
            }
        }

internal fun shortHash(text: String): String

        internal fun shortHash(text: String): String {
            val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray())
            return digest.take(10).joinToString("") { "%02x".format(it) }
        }
    }
}

class MegaTexturePyramidCache

The on-disk store of downsampled pages, one sparse file per level.

Every page at a level is the same size, so a page's offset in level<L>.bin is just index * stride — no index, no allocator, one pread to load. The files are left sparse, so level3.bin for a million images is 16 GB of address space and only as many bytes as have actually been written. That is why there is a separate presence bitmap rather than a "non-zero means present" test: a legitimately black page is indistinguishable from a hole.

A level is only cached at all if its fully-populated size fits maxVirtualBytes; above that the pages are small in number and cheap to re-derive, and a 64 GB sparse file is a liability on any filesystem or backup tool that does not understand holes.

Presence bits are written back periodically, not on every page. Because a page's bytes are written before its bit is set, an out-of-date bitmap can only ever under-report — which costs a re-decode and never shows the wrong picture. The reverse ordering would have been a bug.

class MegaTexturePyramidCache(
    val cacheDir: Path,
    val imageCount: Int,
    /** Where page sizes come from. Taken whole so the cache's stride cannot drift from the atlas's. */
    val layout: MegaTextureLayout,
    val maxLevelCached: Int,
    val maxVirtualBytes: Long = 32L shl 30
) : AutoCloseable {

val levels: Int get() = layout.levels

    private class LevelStore(
        val level: Int,
        val stride: Int,
        val channel: FileChannel,
        val presentFile: Path,
        val present: LongArray
    ) {
        @Volatile
        var presentDirty = false
    }

    private val stores = arrayOfNulls<LevelStore>(levels)
    private val lock = Any()
    private var lastFlushNanos = System.nanoTime()

Source pixel dimensions, two ints per image, or 0 for "never decoded".

A cached page is a fixed-size rectangle with the picture fitted into its top-left corner, so the bytes alone do not say how much of the page is picture. Without this the aspect ratio would be knowable only on a cache miss, and every warm start would show the page's shape rather than the picture's.

    private val dims = IntArray(maxOf(imageCount, 1) * 2)
    private val dimsFile = cacheDir.resolve("dims.bin")
    private var dimsDirty = false

init

body — 41 lines, from line 287
    init {
        Files.createDirectories(cacheDir)
        loadDims()
        for (level in 0 until minOf(levels, maxLevelCached + 1)) {
            val stride = layout.pageBytes(level)
            val virtual = stride.toLong() * imageCount
            if (virtual > maxVirtualBytes) {
                lprintln(
                    "info,megatexture",
                    "megatexture cache skipping level $level " +
                            "(${layout.pageWidth(level)}x${layout.pageHeight(level)}): would need " +
                            "${virtual shr 30} GiB of sparse file, over the ${maxVirtualBytes shr 30} GiB cap"
                )
                continue
            }
            try {
                val blob = cacheDir.resolve("level$level.bin")
                val channel = FileChannel.open(
                    blob,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.READ,
                    StandardOpenOption.WRITE
                )
                val presentFile = cacheDir.resolve("level$level.present")
                stores[level] = LevelStore(level, stride, channel, presentFile, loadPresent(presentFile))
            } catch (e: IOException) {
                lprintln("warning,megatexture", "megatexture cache level $level unavailable: ${e.message}")
            }
        }
    }

    private fun loadDims() {
        if (!Files.isRegularFile(dimsFile)) return
        try {
            val buffer = ByteBuffer.wrap(Files.readAllBytes(dimsFile)).order(ByteOrder.LITTLE_ENDIAN)
            var i = 0
            while (buffer.remaining() >= 4 && i < dims.size) dims[i++] = buffer.int
        } catch (e: IOException) {
            lprintln("warning,megatexture", "could not read $dimsFile: ${e.message}")
        }
    }

fun sourceSize(index: Int): Pair<Int, Int>?

The source image's pixel size, or null if it has never been decoded.

    fun sourceSize(index: Int): Pair<Int, Int>? {
        if (index < 0 || index * 2 + 1 >= dims.size) return null
        val w = dims[index * 2]
        val h = dims[index * 2 + 1]
        return if (w > 0 && h > 0) w to h else null
    }

fun setSourceSize(index: Int, width: Int, height: Int)

Record the source image's pixel size, learned from a decode.

    fun setSourceSize(index: Int, width: Int, height: Int) {
        if (index < 0 || index * 2 + 1 >= dims.size) return
        synchronized(lock) {
            if (dims[index * 2] == width && dims[index * 2 + 1] == height) return
            dims[index * 2] = width
            dims[index * 2 + 1] = height
            dimsDirty = true
        }
    }

    private fun loadPresent(file: Path): LongArray {
        val words = (imageCount + 63) / 64
        val bits = LongArray(maxOf(words, 1))
        if (!Files.isRegularFile(file)) return bits
        try {
            val bytes = Files.readAllBytes(file)
            val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
            var i = 0
            while (buffer.remaining() >= 8 && i < bits.size) bits[i++] = buffer.long
        } catch (e: IOException) {
            lprintln("warning,megatexture", "could not read $file: ${e.message}")
        }
        return bits
    }

fun has(index: Int, level: Int): Boolean

True if level level has a page for index on disk.

    fun has(index: Int, level: Int): Boolean {
        val store = stores.getOrNull(level) ?: return false
        if (index < 0 || index >= imageCount) return false
        return (store.present[index ushr 6] ushr (index and 63)) and 1L != 0L
    }

fun cachedLevels(): List<Int>

Which levels this cache stores at all, cheapest first.

    fun cachedLevels(): List<Int> = stores.mapIndexedNotNull { level, store -> level.takeIf { store != null } }

fun read(index: Int, level: Int, into: ByteBuffer): Boolean

Read the cached page for (image, level) into into, or return false if it is not there. into must have stride bytes remaining; its position is left at the start of the page.

    fun read(index: Int, level: Int, into: ByteBuffer): Boolean {
        val store = stores.getOrNull(level) ?: return false
        if (!has(index, level)) return false
        val start = into.position()
        require(into.remaining() >= store.stride) {
            "megatexture cache read needs ${store.stride} bytes, buffer has ${into.remaining()}"
        }
        val slice = into.duplicate().limit(start + store.stride) as ByteBuffer
        var read = 0
        val offset = store.stride.toLong() * index
        try {
            while (read < store.stride) {
                val n = store.channel.read(slice, offset + read)
                if (n <= 0) return false
                read += n
            }
        } catch (e: IOException) {
            lprintln("warning,megatexture", "megatexture cache read failed at level $level/$index: ${e.message}")
            return false
        }
        return true
    }

fun write(index: Int, level: Int, page: ByteBuffer)

Store the page for (image, level). Silently does nothing for an uncached level.

    fun write(index: Int, level: Int, page: ByteBuffer) {
        val store = stores.getOrNull(level) ?: return
        if (index < 0 || index >= imageCount) return
        val source = page.duplicate()
        require(source.remaining() >= store.stride) {
            "megatexture cache write needs ${store.stride} bytes, got ${source.remaining()}"
        }
        source.limit(source.position() + store.stride)
        val offset = store.stride.toLong() * index
        try {
            var written = 0
            while (source.hasRemaining()) {
                val n = store.channel.write(source, offset + written)
                if (n <= 0) break
                written += n
            }
            if (written < store.stride) return
        } catch (e: IOException) {
            lprintln("warning,megatexture", "megatexture cache write failed at level $level/$index: ${e.message}")
            return
        }
        synchronized(lock) {
            store.present[index ushr 6] = store.present[index ushr 6] or (1L shl (index and 63))
            store.presentDirty = true
            val now = System.nanoTime()
            if (now - lastFlushNanos > 2_000_000_000L) {
                lastFlushNanos = now
                flushPresent()
            }
        }
    }

fun invalidate(indices: IntArray)

Forget every cached level for these indices — their source files changed.

    fun invalidate(indices: IntArray) {
        if (indices.isEmpty()) return
        synchronized(lock) {
            for (store in stores) {
                if (store == null) continue
                for (index in indices) {
                    if (index < 0 || index >= imageCount) continue
                    store.present[index ushr 6] = store.present[index ushr 6] and (1L shl (index and 63)).inv()
                }
                store.presentDirty = true
            }
            flushPresent()
        }
    }

fun residentCounts(): IntArray

How many pages each cached level holds — the number reported by stats.

    fun residentCounts(): IntArray = IntArray(levels) { level ->
        val store = stores.getOrNull(level) ?: return@IntArray 0
        store.present.sumOf { java.lang.Long.bitCount(it) }
    }

    private fun flushPresent() {
        if (dimsDirty) {
            try {
                val buffer = ByteBuffer.allocate(dims.size * 4).order(ByteOrder.LITTLE_ENDIAN)
                dims.forEach { buffer.putInt(it) }
                Files.write(
                    dimsFile,
                    buffer.array(),
                    StandardOpenOption.CREATE,
                    StandardOpenOption.WRITE,
                    StandardOpenOption.TRUNCATE_EXISTING
                )
                dimsDirty = false
            } catch (e: IOException) {
                lprintln("warning,megatexture", "could not write $dimsFile: ${e.message}")
            }
        }
        for (store in stores) {
            if (store == null || !store.presentDirty) continue
            try {
                val buffer = ByteBuffer.allocate(store.present.size * 8).order(ByteOrder.LITTLE_ENDIAN)
                store.present.forEach { buffer.putLong(it) }
                Files.write(
                    store.presentFile,
                    buffer.array(),
                    StandardOpenOption.CREATE,
                    StandardOpenOption.WRITE,
                    StandardOpenOption.TRUNCATE_EXISTING
                )
                store.presentDirty = false
            } catch (e: IOException) {
                lprintln("warning,megatexture", "could not write ${store.presentFile}: ${e.message}")
            }
        }
    }

override fun close()

    override fun close() {
        synchronized(lock) { flushPresent() }
        stores.forEach { runCatching { it?.channel?.close() } }
    }
}