Skip to content

GeometryGlb.kt

src/jvmMain/kotlin/afield/geometry/mesh/GeometryGlb.kt · 227 lines

@file:afield.runtime.completion.CompletionCategory("Graphics")

package afield.geometry.mesh

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder

The exit from GeometryBuilder to a file every other tool can open.

A GeometryScene becomes one .glb with a node per part; a recipe whose vertices move becomes an animated one. toGlb returns the bytes and writeGlb puts them in a file; geometryAnimationGlb and writeGlbAnimation do the same for a run of frames. Both writers compress by default, through Gltfpack when it is installed.

The animation is stored as morph targets rather than a skeleton: one target per frame after the first, played by a weights animation. That needs no rig, which is what makes it open anywhere, and it is why every frame must emit the same parts with the same vertex counts. A morph target is a vertex-for-vertex delta against frame 0, so there is nowhere to put a triangle that appeared halfway through.

A part carries its positions, face normals, linear vertex colours and a metallic-roughness material built from its GeometryMaterial. KHR_materials_emissive_strength is declared only when some channel of a part's emissive colour, times its intensity, exceeds 1.

fun GeometryScene.toGlb(): ByteArray = encodeGlb(listOf(this), fps = 30.0)

A GeometryScene as a binary glTF (.glb): one node per part, each with its positions, face normals, linear vertex colours and a metallic-roughness material made from its GeometryMaterial.

File("/tmp/vessel.glb").writeBytes(geometryScene { revolve(listOf(profile(1.0, 0.0), profile(0.6, 1.0)), 48) }.toGlb())

fun GeometryScene.writeGlb(file: File, compress: Boolean = true): File

Write toGlb to file, returning it. With compress, the GLB goes through Gltfpack when it is installed and makes the file smaller, as the toolbox's export does; otherwise it is written as it is.

fun GeometryScene.writeGlb(file: File, compress: Boolean = true): File =
    file.also { it.writeBytes(if (compress) Gltfpack.compressIfSmaller(toGlb()) else toGlb()) }

fun geometryAnimationGlb(frames: Int = 90, fps: Double = 30.0, block: GeometryBuilder.() -> Unit): ByteArray {

Record a GeometryBuilder recipe as an animated glTF: frames frames at fps, from t = 0, stored as morph targets (one per frame after the first, positions and normals) played by a weights animation — what three.js, Blender and every glTF viewer play back.

Every frame must emit the same parts with the same vertex counts — move the vertices, do not add or remove triangles — or this throws, naming the first frame that changed.

fun geometryAnimationGlb(frames: Int = 90, fps: Double = 30.0, block: GeometryBuilder.() -> Unit): ByteArray {
    require(frames in 2..600) { "an animation needs 2 to 600 frames, got $frames" }
    require(fps.isFinite() && fps > 0) { "fps must be positive, got $fps" }
    val scenes = List(frames) { f -> geometryScene(t = f / fps, frame = f, dt = 1 / fps, block = block) }
    val first = scenes[0]
    require(first.parts.isNotEmpty()) { "frame 0 emitted no triangles" }
    for (f in 1 until frames) {
        val s = scenes[f]
        require(s.parts.size == first.parts.size) {
            "frame $f has ${s.parts.size} parts where frame 0 has ${first.parts.size}; an animation needs a fixed topology"
        }
        for (i in s.parts.indices) require(s.parts[i].mesh.vertexCount == first.parts[i].mesh.vertexCount) {
            "frame $f changed the vertex count of part ${first.parts[i].name} " +
                "(${first.parts[i].mesh.vertexCount} → ${s.parts[i].mesh.vertexCount}); an animation needs a fixed topology"
        }
    }
    return encodeGlb(scenes, fps)
}

fun writeGlbAnimation

Write geometryAnimationGlb to file, returning it; compress as in writeGlb.

body — 144 lines, from line 83
fun writeGlbAnimation(
    file: File, frames: Int = 90, fps: Double = 30.0, compress: Boolean = true, block: GeometryBuilder.() -> Unit,
): File = file.also {
    val glb = geometryAnimationGlb(frames, fps, block)
    it.writeBytes(if (compress) Gltfpack.compressIfSmaller(glb) else glb)
}

private class GlbBin {
    val bytes = ByteArrayOutputStream()
    val views = ArrayList<JsonObject>()
    val accessors = ArrayList<JsonObject>()

    /** Append [data] as a float accessor of [type] (`VEC3`, `SCALAR`) and return its index. */
    fun floats(data: FloatArray, type: String, minMax: Boolean): Int {
        val components = if (type == "VEC3") 3 else 1
        val offset = bytes.size()
        val buf = ByteBuffer.allocate(data.size * 4).order(ByteOrder.LITTLE_ENDIAN)
        data.forEach { buf.putFloat(it) }
        bytes.write(buf.array())
        views += buildJsonObject { put("buffer", 0); put("byteOffset", offset); put("byteLength", data.size * 4) }
        accessors += buildJsonObject {
            put("bufferView", views.size - 1)
            put("componentType", 5126)
            put("count", data.size / components)
            put("type", type)
            if (minMax) {
                val min = FloatArray(components) { Float.MAX_VALUE }
                val max = FloatArray(components) { -Float.MAX_VALUE }
                for (i in data.indices) { val c = i % components; min[c] = minOf(min[c], data[i]); max[c] = maxOf(max[c], data[i]) }
                if (data.isEmpty()) { min.fill(0f); max.fill(0f) }
                putJsonArray("min") { min.forEach { add(it) } }
                putJsonArray("max") { max.forEach { add(it) } }
            }
        }
        return accessors.size - 1
    }
}

private fun encodeGlb(scenes: List<GeometryScene>, fps: Double): ByteArray {
    val base = scenes[0]
    val bin = GlbBin()
    val animated = scenes.size > 1
    val meshes = ArrayList<JsonObject>()
    val materials = ArrayList<JsonObject>()
    val nodes = ArrayList<JsonObject>()
    var usesEmissiveStrength = false

    base.parts.forEachIndexed { i, part ->
        val m = part.mesh
        val position = bin.floats(m.positions, "VEC3", minMax = true)
        val normal = bin.floats(checkNotNull(m.normals), "VEC3", minMax = false)
        val color = bin.floats(checkNotNull(m.colors), "VEC3", minMax = false)
        val targets = if (!animated) emptyList() else (1 until scenes.size).map { f ->
            val later = scenes[f].parts[i].mesh
            val dp = FloatArray(m.positions.size) { later.positions[it] - m.positions[it] }
            val dn = FloatArray(m.positions.size) { later.normals!![it] - m.normals[it] }
            bin.floats(dp, "VEC3", minMax = true) to bin.floats(dn, "VEC3", minMax = false)
        }

        val mat = part.material
        val lc = mat.linearColor
        val le = mat.linearEmissive
        val glow = le.map { it * mat.emissiveIntensity.toFloat() }
        val strength = glow.maxOrNull()?.takeIf { it > 1f } ?: 1f
        if (strength > 1f) usesEmissiveStrength = true
        materials += buildJsonObject {
            put("name", "${part.name}-material")
            putJsonObject("pbrMetallicRoughness") {
                putJsonArray("baseColorFactor") { lc.forEach { add(it) }; add(mat.opacity.toFloat()) }
                put("metallicFactor", mat.metalness)
                put("roughnessFactor", mat.roughness)
            }
            putJsonArray("emissiveFactor") { glow.forEach { add(it / strength) } }
            if (strength > 1f) putJsonObject("extensions") {
                putJsonObject("KHR_materials_emissive_strength") { put("emissiveStrength", strength) }
            }
            put("doubleSided", mat.side == GeometrySide.DOUBLE)
            if (mat.opacity < 1.0) put("alphaMode", "BLEND")
        }
        meshes += buildJsonObject {
            put("name", part.name)
            putJsonArray("primitives") {
                addJsonObject {
                    putJsonObject("attributes") { put("POSITION", position); put("NORMAL", normal); put("COLOR_0", color) }
                    put("material", i)
                    put("mode", 4)
                    if (targets.isNotEmpty()) putJsonArray("targets") {
                        targets.forEach { (p, n) -> addJsonObject { put("POSITION", p); put("NORMAL", n) } }
                    }
                }
            }
            if (targets.isNotEmpty()) putJsonArray("weights") { repeat(targets.size) { add(0f) } }
        }
        nodes += buildJsonObject { put("name", part.name); put("mesh", i) }
    }

    val animations = if (!animated) null else {
        val frames = scenes.size
        val times = bin.floats(FloatArray(frames) { (it / fps).toFloat() }, "SCALAR", minMax = true)
        val targets = frames - 1
        // Frame f shows target f - 1 at full weight and every other at zero; frame 0 is the base mesh.
        val weights = bin.floats(FloatArray(frames * targets) { k -> if (k / targets == k % targets + 1) 1f else 0f }, "SCALAR", minMax = false)
        buildJsonArray {
            addJsonObject {
                put("name", "geometry-vertex-animation")
                putJsonArray("samplers") {
                    base.parts.indices.forEach { _ -> addJsonObject { put("input", times); put("output", weights); put("interpolation", "LINEAR") } }
                }
                putJsonArray("channels") {
                    base.parts.indices.forEach { i ->
                        addJsonObject { put("sampler", i); putJsonObject("target") { put("node", i); put("path", "weights") } }
                    }
                }
            }
        }
    }

    val binBytes = bin.bytes.toByteArray()
    val json = buildJsonObject {
        putJsonObject("asset") { put("version", "2.0"); put("generator", "Field GeometryBuilder") }
        if (usesEmissiveStrength) putJsonArray("extensionsUsed") { add("KHR_materials_emissive_strength") }
        put("scene", 0)
        putJsonArray("scenes") { addJsonObject { put("name", "geometry"); putJsonArray("nodes") { nodes.indices.forEach { add(it) } } } }
        put("nodes", JsonArray(nodes))
        put("meshes", JsonArray(meshes))
        put("materials", JsonArray(materials))
        put("accessors", JsonArray(bin.accessors))
        put("bufferViews", JsonArray(bin.views))
        putJsonArray("buffers") { addJsonObject { put("byteLength", binBytes.size) } }
        animations?.let { put("animations", it) }
    }.toString().toByteArray(Charsets.UTF_8)

    fun pad(n: Int) = (4 - n % 4) % 4
    val jsonLen = json.size + pad(json.size)
    val binLen = binBytes.size + pad(binBytes.size)
    val total = 12 + 8 + jsonLen + 8 + binLen
    val out = ByteBuffer.allocate(total).order(ByteOrder.LITTLE_ENDIAN)
    out.putInt(0x46546C67).putInt(2).putInt(total)
    out.putInt(jsonLen).putInt(0x4E4F534A).put(json)
    repeat(pad(json.size)) { out.put(' '.code.toByte()) }
    out.putInt(binLen).putInt(0x004E4942).put(binBytes)
    repeat(pad(binBytes.size)) { out.put(0) }
    return out.array()
}