simpleGLTFSupport.kt
src/jvmMain/kotlin/afield/graphics/scene/simpleGLTFSupport.kt · 1259 lines
package and imports — 47 lines, from line 1
@file:Suppress("MemberVisibilityCanBePrivate", "unused")
package afield.graphics.scene
import afield.graphics.render.MeshModule
import afield.graphics.render.Module
import afield.graphics.render.VertexData
import afield.graphics.render.main
import afield.document.context.fromContext
import afield.graphics.render.Mesh
import afield.graphics.render.ModulePointer
import afield.graphics.render.Parameters
import afield.graphics.render.parameters
import afield.document.graph.Box
import afield.runtime.lifecycle.RunsAt
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.buffers.defaultBuffer
import afield.graphics.scene.GltfSupport.TRS
import afield.graphics.scene.GltfSupport.TRSSample
import afield.graphics.scene.GltfSupport.gltVertexShader
import afield.graphics.scene.GltfSupport.gltfFragmentShader
import afield.graphics.scene.GltfSupport.gltfFragmentShaderTextured
import afield.graphics.texture.HasImageView
import afield.graphics.texture.LoadedTexture
import afield.graphics.texture.TextureSampler
import afield.graphics.vulkan.util.Camera
import afield.app.diagnostics.logging.lprintln
import afield.runtime.lifecycle.macroRegistration
import afield.runtime.macros.Insert
import afield.runtime.macros.MacroRegistry
import afield.runtime.macros.MacroRegistry.callRewrite
import afield.math.linalg.HasMatrix4f
import afield.graphics.shader._moduleHere
import afield.graphics.shader.fromViews
import afield.graphics.shader.macro_moduleHereWithDefaultShaders
import kotlinx.coroutines.DelicateCoroutinesApi
import afield.math.linalg.Matrix4f
import afield.math.linalg.Quaternionf
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import org.lwjgl.assimp.*
import org.lwjgl.system.MemoryStack
import org.lwjgl.system.MemoryUtil.*
import org.lwjgl.vulkan.VK10
import java.nio.file.Path
import java.nio.file.Paths
Loading a model file onto the GPU, with its materials and textures.
gltfModelHere("model.glb") { } in a box is the usual case: the call
expands the way moduleHere does, into a graphics module carrying the bundled glTF shaders at the
end of this file, and hands back that module together with the loaded GltfSupport.GltfModel.
GltfSupport.loadGltfBasic is the same load without a module, for code bringing its own shaders.
The name says glTF and the importer is Assimp, so the formats are Assimp's: glTF and GLB, and also
FBX, DAE, OBJ, STL and PLY. A load returns one GltfSupport.MeshInstance per mesh in the node tree,
each carrying host-side vertex and index buffers in the fixed 12-float layout those shaders read
(position, normal, uv, colour), its material, and the node's animation track resampled at a fixed
rate, which this file's sample extension interpolates at whatever time you ask for.
Two things catch callers out. GltfSupport.GltfLoadConfig's loadTextures is on by default and
that path asks the box context for a Renderer, so a load taking the default arguments cannot run
outside a live renderer; pass GltfLoadConfig(loadTextures = false) for geometry alone, as
assimpToTriMesh does. And a GltfSupport.MeshInstance's bindPose is a
translation/rotation/scale triple, which cannot hold the mirror or shear a glTF node transform is
allowed to carry; bindMatrix, documented at length below, is the full 4×4 the loader also keeps.
the idea here is that this module can be customized (at least the first time!) for the (first?) model inside the gltf
fun Box.gltfModelHere(p: Parameters.() -> Unit): Pair<ModulePointer, List<GltfSupport.GltfModel>> {
gltfModelHere("model.glb") { } in box code: a graphics module for that file, carrying the bundled
glTF shaders, together with the model the importer read.
The declared signature takes only the parameters block because the filename is a macro argument.
registerGLTFLoader registers the rewrite, which turns the call into _gltfModelHere plus the
preamble a module needs, so calling the function as declared throws instead.
gltfModelWithTextureHere is the same with the textured fragment shader, binding the first mesh's
material textures as its uTextures array.
fun Box.gltfModelHere(p: Parameters.() -> Unit): Pair<ModulePointer, List<GltfSupport.GltfModel>> {
throw MacroRegistry.MacroNotExpandedException("gltfModelHere should not be callable directly, but should be expanded as a macro")
}
fun Box.macro_modelHere(args: List<String>): Insert?
@OptIn(DelicateCoroutinesApi::class)
fun Box.macro_modelHere(args: List<String>): Insert? {
return macro_moduleHereWithDefaultShaders(
args, vertex = gltVertexShader, fragment = gltfFragmentShader, resolvesToCall = "_gltfModelHere"
)
}
fun Box.macro_modelWithTextureHere(args: List<String>): Insert?
@OptIn(DelicateCoroutinesApi::class)
fun Box.macro_modelWithTextureHere(args: List<String>): Insert? {
return macro_moduleHereWithDefaultShaders(
args,
vertex = gltVertexShader,
fragment = gltfFragmentShaderTextured,
resolvesToCall = "_gltfModelHereWithTexture"
)
}
fun Box._gltfModelHere
fun Box._gltfModelHere(
filename: String, p: Parameters.() -> Unit = {}
): Pair<ModulePointer, List<GltfSupport.GltfModel>> {
val renderer: Renderer = fromContext()
val module = _moduleHere(parameters(renderer = renderer, p))
val loader = GltfSupport.loadGltfBasic(filename)
return module to listOf(loader)
}
fun Box._gltfModelHereWithTexture
fun Box._gltfModelHereWithTexture(
filename: String, p: Parameters.() -> Unit = {}
): Pair<ModulePointer, List<GltfSupport.GltfModel>> {
val renderer: Renderer = fromContext()
val loader = GltfSupport.loadGltfBasic(filename, GltfSupport.GltfLoadConfig(loadTextures = true))
val module = _moduleHere(parameters(renderer = renderer) {
p()
val was = textureResolver
textureResolver = { tex ->
if (tex.name == "uTextures") {
// Exactly as many as the shader declares: a file with more images than the array binds the
// first ones, and one with fewer repeats its last, so any file links.
val all = loader.meshes[0].material.textures.map { slot -> slot.texture as HasImageView }
val count = tex.arrayCount.coerceAtLeast(1)
val textures = List(count) { i -> all[minOf(i, all.size - 1)] }
val maxMip = textures.maxOf { texture -> texture.imageView.mipLevels }
fromViews(textures, sampler = TextureSampler(renderer = fromContext(), maxMip, true))
} else was(tex)
}
})
return module to listOf(loader)
}
fun registerGLTFLoader()
@RunsAt(macroRegistration)
fun registerGLTFLoader() {
callRewrite["gltfModelHere"] = { box, id, args ->
box.macro_modelHere(args)
}
callRewrite["gltfModelWithTextureHere"] = { box, id, args ->
box.macro_modelWithTextureHere(args)
}
}
object GltfSupport
object GltfSupport {
Public data model
data class TRS
data class TRS(
val t: Vector3f, val r: Quaternionf, val s: Vector3f
) : HasMatrix4f {
fun toMatrix(): Matrix4f
fun toMatrix(): Matrix4f {
return Matrix4f()
.translate(t)
.rotate(r)
.scale(s)
}
override val m: Matrix4f
override val m: Matrix4f
get() = toMatrix()
}
data class TRSSample
data class TRSSample(
val time: Float, val trs: TRS
)
data class MeshBuffers
data class MeshBuffers(
val vertexData: java.nio.ByteBuffer, // tightly packed floats
val indexData: java.nio.IntBuffer, // 32-bit indices
val vertexCount: Int,
val indexCount: Int,
val tangentData: java.nio.ByteBuffer? = null // optional vec4 tangent frame; w is bitangent sign
)
data class TextureSlot
data class TextureSlot(
val type: Int, // Assimp aiTextureType_*
val uvSet: Int, // which UV channel to use
val texture: LoadedTexture, val valid: Boolean,
/** The texture's `KHR_texture_transform`, read from the glTF itself; null when it has none. */
val transform: TextureTransform? = null,
)
data class TextureTransform
A glTF KHR_texture_transform: uv' = offset + rotate(rotation) * (scale * uv), in glTF UV
space. Read off the file rather than Assimp, whose UV-transform pivot and order differ.
data class TextureTransform(
val offsetX: Float = 0f, val offsetY: Float = 0f,
val scaleX: Float = 1f, val scaleY: Float = 1f,
val rotation: Float = 0f,
val texCoord: Int? = null,
) {
val isIdentity: Boolean get()
val isIdentity: Boolean get() = offsetX == 0f && offsetY == 0f && scaleX == 1f && scaleY == 1f && rotation == 0f
}
data class MaterialTextured
data class MaterialTextured(
val baseColor: Vector4f, // RGBA (treat as linear in your pipeline)
val textures: List<TextureSlot> = emptyList(),
val name: String = "",
val metallicFactor: Float = 1f,
val roughnessFactor: Float = 1f,
val clearcoatFactor: Float = 0f,
val clearcoatRoughnessFactor: Float = 0f,
val transmissionFactor: Float = 0f,
val alphaMode: String = "OPAQUE",
val alphaCutoff: Float = 0.5f,
val doubleSided: Boolean = false
)
data class MeshInstance
data class MeshInstance(
val name: String,
val meshIndex: Int, // index into scene meshes as loaded
val buffers: MeshBuffers, // CPU-side; you decide how to pack into SSBOs
val bindPose: TRS, // node transform at import time (world from root)
val material: MaterialTextured,
val doTextures: Boolean = false,
val animationSamples: List<TRSSample>? // sampled TRS for this node, or null
) {
var bindMatrix: Matrix4f? = null
The node's true world matrix at import time — the thing bindPose is a lossy summary of.
bindPose is a translation/rotation/scale triple, and a glTF node transform is a full 4x4: a
mirror (negative determinant) or a shear does not fit in a TRS. Matrix4f.getScale
returns sqrt(...) per basis column and is therefore always positive, so
decomposeToTRS silently flattens a mirrored node to a pure translation — the recomposed
matrix comes back with determinant +1 and the geometry is placed un-mirrored. Measured on a
four-node test scene: Assimp hands over row 1 [-1 0 0 -3] with 3x3 determinant -1.0, and
bindPose.toMatrix() returns [1 0 0 -3] with determinant +1.0.
Deliberately a body property rather than a constructor parameter, so it is not a data class
component: adding it to equals / hashCode / copy would change the identity of every
MeshInstance, and Matrix4f does not define structural equality.
Set by the loader's node walk; null only for an instance built some other way, in which case
bindPose.toMatrix() is all there is. afield.graphics.scene.assimpToTriMesh prefers this.
The GPU paths read this too, as of 2026-09-05. FabricateScene.importGltf and
StandardRendererDocsHarness.gltfWorldBounds prefer it over bindPose; applyToMesh
defaults its model argument to it, having previously pushed only modelView and proj
from the camera and so dropped every node transform rather than only the mirror.
Two things deliberately not done there, both because they would be wrong or dead:
- No winding reversal. A reflection reverses triangle winding, which is why
afield.graphics.scene.assimpToTriMesh swaps two corners. On the GPU it does not matter:
every Field pipeline sets
VK_CULL_MODE_NONE(four/modules/MeshModule.kt,four/modules/Module.kt), so no face is ever culled for facing. A reflection is orthogonal, so it is also its own inverse transpose and the transformed normal still points outward. Reversing the winding here would be dead code until something starts culling — seeafield.geometry.path.TessellationFacingRenderTestfor the same observation. - Normals are still
mat3(modelView) * n, not the inverse transpose, in both the bundled glTF shader andstandardRenderer.kt's g-buffer. Exact for a rigid or uniformly-scaled node (ToyCar's1e-4) and for a mirror; skewed for a non-uniformly scaled one. That was unreachable while the model matrix was dropped and is reachable now, but fixing it needs a new field in a push-constant block that is already 176 bytes.
var skin: GltfSkinning.MeshSkin? = null
This mesh's skeleton and per-vertex bone weights, or null when the mesh is rigid.
A body property rather than a constructor parameter for the same reason as bindMatrix:
making it a data class component would put a scene-wide node tree into every
MeshInstance's equals and hashCode.
Note what this does not change: buffers still holds the bind-pose vertices, and
bindPose / bindMatrix still hold the mesh node's own transform. Deforming is a separate
step, GltfSkinning.skin, and its output is in scene space — glTF 2.0 §3.7.3.2 says a
skinned mesh node's own transform is ignored — so a caller that skins must not also apply
bindMatrix. afield.graphics.scene.skinnedGltfToTriMesh is the path that gets this right.
Populated by loadGltfBasic when GltfLoadConfig.loadSkins is on, which it is by default;
the cost when a file has no bones is one mNumBones() == 0 test per mesh.
The GPU buffers applyToMesh holds when it is called with reuseBuffers = true.
Body properties rather than constructor parameters for the same reason as bindMatrix:
a FrameBufferGroup has no business in a data class's equals. null until the first
reusing call, and never populated by the default path.
private var heldVertexBuffer: afield.graphics.vulkan.buffers.BufferParameters? = null
private var heldIndexBuffer: afield.graphics.vulkan.buffers.BufferParameters? = null
fun modelView(camera: Camera = fromContext()): Matrix4f
view * model for this primitive under camera, with its own node transform as the model:
what to assign to modelView each frame when a loop drives the camera, so a part positioned
by its glTF nodes stays where they put it.
fun modelView(camera: Camera = fromContext()): Matrix4f = camera.m().mul(bindMatrix ?: bindPose.toMatrix())
fun applyToMesh
Upload this instance's buffers and set the push constants the bundled shaders expect.
@param model the model matrix, composed into modelView as view * model. The default is
this instance's own node transform — bindMatrix when the loader recorded it, falling
back to bindPose.toMatrix() — so a multi-node file assembles where its nodes put it
instead of heaping every part at the origin. Pass Matrix4f() for the model-space draw
this used to do unconditionally, or your own matrix to place the part yourself.
modelView is a push constant, so a box that writes it again per frame writes
modelView(camera), which keeps this node transform, rather than camera.m() alone.
@param reuseBuffers keep this instance's GPU buffers between calls instead of allocating a fresh pair. Required for a per-frame call, and off by default so that no existing caller changes behaviour.
Without it, every call builds a new BufferParameters through defaultBuffer { … } —
and BufferParameters.result, the cache that makes toBuffer() hand back the same
allocation twice, lives on that object. So a call allocates a fresh FrameBufferGroup
and a fresh VulkanBuffer. Once at setup, which is what the documentation shows, that
is invisible. Sixty times a second — which is what a CPU-skinned character needs, since
afield.graphics.scene.CpuSkinner rewrites buffers in place and the new contents have
to be re-uploaded — it is a leak at frame rate.
With it on, the two BufferParameters are held on this instance, so toBuffer()
returns the same allocation and the write { } below simply overwrites it. One
consequence worth knowing: the buffers are bound to the renderer in scope at the first
such call, so do not share one instance across two renderers with this on.
body — 54 lines, from line 325
fun applyToMesh(
m: Mesh,
camera: Camera = fromContext(),
model: Matrix4f = bindMatrix ?: bindPose.toMatrix(),
reuseBuffers: Boolean = false,
) {
m.initializePushConstantsFromModule(m.inside)
// view * model, which is what the shader's `mat4 modelView` has always claimed to be.
m.push["modelView"] = camera.m().mul(model)
m.push["proj"] = camera.p()
m.push["vertexOffset"] = 0
m.push["indexOffset"] = 0
m.push["vertexStride"] = 12
m.push["materialBaseColor"] = material.baseColor
m.push["useVertexColor"] = 1
fun freshVertexParameters() = defaultBuffer { size = (buffers.vertexCount * 12).floats }
fun freshIndexParameters() = defaultBuffer { size = buffers.indexCount.ints }
// Allocated on the mesh's own renderer, so this also works from a `main`, where there is
// no box context to find one in.
val vertexData = if (!reuseBuffers) freshVertexParameters().toBuffer(m.renderer) else {
(heldVertexBuffer ?: freshVertexParameters().also { heldVertexBuffer = it }).toBuffer(m.renderer)
}
val indexData = if (!reuseBuffers) freshIndexParameters().toBuffer(m.renderer) else {
(heldIndexBuffer ?: freshIndexParameters().also { heldIndexBuffer = it }).toBuffer(m.renderer)
}
m.push["vertexBuffer"] = vertexData
m.push["indexBuffer"] = indexData
vertexData.write { this.put(buffers.vertexData.asFloatBuffer()) }
val f = buffers.vertexData.asFloatBuffer()
for (n in 0 until f.capacity()) {
val x = f.get(n)
// lprint("debug,gltf", "$x ")
// if (n % 12 == 11) lprintln("debug,gltf", "")
}
indexData.write {
// TODO!
for (n in 0 until buffers.indexCount) {
val x = buffers.indexData.get(n)
// lprint("debug,gltf", "$x ")
this.putInt(x)
// if (n % 3 == 2) lprintln("debug,gltf", "")
}
}
m.resize(buffers.indexCount) // not buffers.vertexCound, because we're not using any vertex / index buffers at all
}
}
data class GltfModel
data class GltfModel(
val meshes: List<MeshInstance>
)
data class GltfLoadConfig
Config: - sampleRate: Hz for animation sampling (if any) - includeVertexColors: when true, always writes RGBA per-vertex. - loadTextures: when true, collect material textures (diffuse/baseColor/normal/etc.)
NOTE: The default shaders below assume a fixed layout:
pos.xyzuv.xy = 12 floats per vertex.
For that path, keep includeVertexColors = true so color is present
(we fill white if source has no COLOR_0).
data class GltfLoadConfig(
val sampleRate: Float = 60f,
val includeVertexColors: Boolean = true,
val loadTextures: Boolean = true,
/**
* Read `aiMesh.mBones()` into [MeshInstance.skin]. On by default and close to free for a rigid
* file — the node tree is walked once per import and every mesh short-circuits on
* `mNumBones() == 0`. Turn it off to load a rigged file as its undeformed bind pose.
*/
val loadSkins: Boolean = true,
)
Assimp loading
private const val POST_FLAGS: Int =
Assimp.aiProcess_Triangulate or Assimp.aiProcess_GenNormals or Assimp.aiProcess_CalcTangentSpace or Assimp.aiProcess_JoinIdenticalVertices or Assimp.aiProcess_ImproveCacheLocality or Assimp.aiProcess_SortByPType
fun loadModel(path: String)
fun loadModel(path: String) = Assimp.aiImportFile(path, POST_FLAGS)
?: error("Assimp import failed for '$path': ${Assimp.aiGetErrorString()}")
fun loadGltfBasic(path: String, config: GltfLoadConfig = GltfLoadConfig()): GltfModel
body — 81 lines, from line 417
fun loadGltfBasic(path: String, config: GltfLoadConfig = GltfLoadConfig()): GltfModel {
val scene = loadModel(path)
val baseDir: Path? = try {
Paths.get(path).parent
} catch (_: Exception) {
null
}
if (scene.mFlags() and Assimp.AI_SCENE_FLAGS_INCOMPLETE != 0 || scene.mRootNode() == null) {
Assimp.aiReleaseImport(scene)
error("Invalid or incomplete scene for '$path'")
}
val meshes = mutableListOf<MeshInstance>()
val root = scene.mRootNode()
val identity = Matrix4f()
// The node tree and the clips are scene-wide, so they are read once and shared by every
// MeshInstance's skin rather than rebuilt per mesh. `null` when skins are switched off, which
// is what makes the rigid path cost nothing.
val skeletonNodes = if (config.loadSkins) GltfSkinning.readNodes(scene) else null
val clips = if (skeletonNodes != null) GltfSkinning.readClips(scene, skeletonNodes, config.sampleRate) else null
val gltfMaterials = gltfMaterialsOf(path)
traverseNode(scene, root!!, identity, meshes, config, baseDir, skeletonNodes, clips, gltfMaterials)
Assimp.aiReleaseImport(scene)
return GltfModel(meshes)
}
private fun traverseNode(
scene: AIScene,
node: AINode,
parentTransform: Matrix4f,
outMeshes: MutableList<MeshInstance>,
config: GltfLoadConfig,
baseDir: Path?,
skeletonNodes: List<GltfSkinning.SkeletonNode>? = null,
clips: List<GltfSkinning.AnimationClip>? = null,
gltfMaterials: List<kotlinx.serialization.json.JsonObject?>? = null,
) {
val local = aiToMatrix4f(node.mTransformation())
val world = Matrix4f(parentTransform).mul(local)
val meshIndices = node.mMeshes()
if (meshIndices != null) {
for (i in 0 until meshIndices.remaining()) {
val meshIndex = meshIndices[i]
val aiMesh = getMesh(scene, meshIndex) ?: continue
val buffers = extractMeshBuffers(aiMesh, config.includeVertexColors)
val bindTrs = decomposeToTRS(world)
val material = extractMaterial(scene, aiMesh, baseDir, config, gltfMaterials)
val animSamples = buildAnimationSamplesForNode(scene, node, config.sampleRate)
outMeshes += MeshInstance(
name = node.mName().dataString(),
meshIndex = meshIndex,
buffers = buffers,
bindPose = bindTrs,
material = material,
animationSamples = animSamples.ifEmpty { null },
// The un-decomposed world matrix, kept because bindTrs cannot represent a mirror or
// a shear. See MeshInstance.bindMatrix.
).also {
it.bindMatrix = Matrix4f(world)
if (skeletonNodes != null) {
it.skin = GltfSkinning.readMeshSkin(aiMesh, skeletonNodes, clips ?: emptyList())
}
}
}
}
val children = node.mChildren()
if (children != null) {
for (i in 0 until children.remaining()) {
val child = AINode.create(children[i])
traverseNode(scene, child, world, outMeshes, config, baseDir, skeletonNodes, clips, gltfMaterials)
}
}
}
Mesh data extraction
source — 113 lines, from line 503
private fun extractMeshBuffers(
aiMesh: AIMesh, includeVertexColors: Boolean
): MeshBuffers {
val vertexCount = aiMesh.mNumVertices()
val posBuf = aiMesh.mVertices()
val normalBuf = aiMesh.mNormals()
val tangentBuf = aiMesh.mTangents()
val bitangentBuf = aiMesh.mBitangents()
val texBuf = aiMesh.mTextureCoords(0)
val colorBuf = if (includeVertexColors) aiMesh.mColors(0) else null
val hasNormals = normalBuf != null
val hasTangents = tangentBuf != null
val hasTex = texBuf != null
val hasColors = colorBuf != null
// Fixed layout to match default shaders:
// [pos.xyz][normal.xyz][uv.xy][color.rgba] = 12 floats
val componentsPerVertex = 12
val strideBytes = componentsPerVertex * 4
val vertexData = memAlloc(vertexCount * strideBytes)
val tangentData = if (hasTangents) memAlloc(vertexCount * 4 * 4) else null
for (i in 0 until vertexCount) {
// pos
val v = posBuf[i]
vertexData.putFloat(v.x())
vertexData.putFloat(v.y())
vertexData.putFloat(v.z())
// normal or default
if (hasNormals) {
val n = normalBuf[i]
vertexData.putFloat(n.x())
vertexData.putFloat(n.y())
vertexData.putFloat(n.z())
} else {
vertexData.putFloat(0f)
vertexData.putFloat(0f)
vertexData.putFloat(1f)
}
// uv or default
if (hasTex) {
val t = texBuf[i]
// lprintln("debug,gltf", "tc at load time is ${t} -> ${t.x()} ${t.y()} from ${i} / ${texBuf}")
vertexData.putFloat(t.x())
vertexData.putFloat(t.y())
} else {
vertexData.putFloat(0f)
vertexData.putFloat(0f)
}
// color: if includeVertexColors: actual or white; else: white
if (includeVertexColors && hasColors) {
val c = colorBuf[i]
vertexData.putFloat(c.r())
vertexData.putFloat(c.g())
vertexData.putFloat(c.b())
vertexData.putFloat(c.a())
} else {
vertexData.putFloat(1f)
vertexData.putFloat(1f)
vertexData.putFloat(1f)
vertexData.putFloat(1f)
}
if (hasTangents && tangentData != null) {
val t = tangentBuf[i]
val sign = if (hasNormals && bitangentBuf != null) {
val n = normalBuf[i]
val b = bitangentBuf[i]
val cx = n.y() * t.z() - n.z() * t.y()
val cy = n.z() * t.x() - n.x() * t.z()
val cz = n.x() * t.y() - n.y() * t.x()
if (cx * b.x() + cy * b.y() + cz * b.z() < 0f) -1f else 1f
} else {
1f
}
tangentData.putFloat(t.x())
tangentData.putFloat(t.y())
tangentData.putFloat(t.z())
tangentData.putFloat(sign)
}
}
vertexData.flip()
tangentData?.flip()
// Indices (uint32). You can compress to 16-bit in your own step if desired.
val faceBuf = aiMesh.mFaces()
var indexCount = 0
for (i in 0 until aiMesh.mNumFaces()) {
indexCount += faceBuf[i].mNumIndices()
}
val indexData = memAllocInt(indexCount)
for (i in 0 until aiMesh.mNumFaces()) {
val face = faceBuf[i]
val idx = face.mIndices()
for (j in 0 until face.mNumIndices()) {
indexData.put(idx[j])
}
}
indexData.flip()
return MeshBuffers(
vertexData = vertexData,
indexData = indexData,
vertexCount = vertexCount,
indexCount = indexCount,
tangentData = tangentData
)
}
Material extraction (colors + textures)
The materials of the glTF at path, in order, or null when it is not glTF or cannot be read.
Assimp keeps glTF material order, so an Assimp material index is an index into this.
private fun gltfMaterialsOf(path: String): List<kotlinx.serialization.json.JsonObject?>? = runCatching {
val file = java.io.File(path)
val text = when (file.extension.lowercase()) {
"glb" -> {
val bytes = file.readBytes()
val header = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN)
String(bytes, 20, header.getInt(12), Charsets.UTF_8)
}
"gltf" -> file.readText()
else -> return null
}
val json = kotlinx.serialization.json.Json.parseToJsonElement(text) as kotlinx.serialization.json.JsonObject
(json["materials"] as? kotlinx.serialization.json.JsonArray)?.map { it as? kotlinx.serialization.json.JsonObject }
}.getOrNull()
The glTF textureInfo an Assimp texture type of material was read from.
source — 287 lines, from line 641
private fun gltfTextureInfo(material: kotlinx.serialization.json.JsonObject?, type: Int): kotlinx.serialization.json.JsonObject? {
fun obj(o: kotlinx.serialization.json.JsonObject?, key: String) = o?.get(key) as? kotlinx.serialization.json.JsonObject
val pbr = obj(material, "pbrMetallicRoughness")
return when (type) {
Assimp.aiTextureType_BASE_COLOR, Assimp.aiTextureType_DIFFUSE -> obj(pbr, "baseColorTexture")
Assimp.aiTextureType_NORMALS -> obj(material, "normalTexture")
Assimp.aiTextureType_EMISSIVE -> obj(material, "emissiveTexture")
Assimp.aiTextureType_METALNESS, Assimp.aiTextureType_DIFFUSE_ROUGHNESS,
Assimp.aiTextureType_GLTF_METALLIC_ROUGHNESS -> obj(pbr, "metallicRoughnessTexture")
Assimp.aiTextureType_LIGHTMAP, Assimp.aiTextureType_AMBIENT_OCCLUSION -> obj(material, "occlusionTexture")
Assimp.aiTextureType_CLEARCOAT -> obj(obj(obj(material, "extensions"), "KHR_materials_clearcoat"), "clearcoatTexture")
else -> null
}
}
private fun textureTransformOf(info: kotlinx.serialization.json.JsonObject?): TextureTransform? {
val tx = ((info?.get("extensions") as? kotlinx.serialization.json.JsonObject)?.get("KHR_texture_transform")
as? kotlinx.serialization.json.JsonObject) ?: return null
fun number(element: kotlinx.serialization.json.JsonElement?) =
(element as? kotlinx.serialization.json.JsonPrimitive)?.content?.toFloatOrNull()
fun pair(key: String, index: Int, default: Float) =
number((tx[key] as? kotlinx.serialization.json.JsonArray)?.getOrNull(index)) ?: default
return TextureTransform(pair("offset", 0, 0f), pair("offset", 1, 0f), pair("scale", 0, 1f), pair("scale", 1, 1f),
number(tx["rotation"]) ?: 0f, number(tx["texCoord"])?.toInt())
}
private fun extractMaterial(
scene: AIScene, aiMesh: AIMesh, baseDir: Path?, config: GltfLoadConfig,
gltfMaterials: List<kotlinx.serialization.json.JsonObject?>? = null,
): MaterialTextured {
val defaultColor = Vector4f(1f, 1f, 1f, 1f)
val matIndex = aiMesh.mMaterialIndex()
if (matIndex < 0 || scene.mNumMaterials() == 0) {
return MaterialTextured(defaultColor)
}
if (scene.mMaterials() == null || matIndex >= scene.mNumMaterials()) {
return MaterialTextured(defaultColor)
}
val aiMat = getMaterial(scene, matIndex) ?: return MaterialTextured(defaultColor)
fun materialFloat(key: String, default: Float): Float {
val values = floatArrayOf(default)
val count = intArrayOf(1)
return if (Assimp.aiGetMaterialFloatArray(aiMat, key, 0, 0, values, count) == Assimp.aiReturn_SUCCESS && count[0] > 0) {
values[0]
} else {
default
}
}
fun materialInt(key: String, default: Int): Int {
val values = intArrayOf(default)
val count = intArrayOf(1)
return if (Assimp.aiGetMaterialIntegerArray(aiMat, key, 0, 0, values, count) == Assimp.aiReturn_SUCCESS && count[0] > 0) {
values[0]
} else {
default
}
}
fun materialString(key: String, default: String): String {
val value = AIString.calloc()
return try {
if (Assimp.aiGetMaterialString(aiMat, key, 0, 0, value) == Assimp.aiReturn_SUCCESS) {
value.dataString().ifBlank { default }
} else {
default
}
} finally {
value.free()
}
}
val color = AIColor4D.calloc()
val resultBase = Assimp.aiGetMaterialColor(aiMat, Assimp.AI_MATKEY_BASE_COLOR, 0, 0, color)
val resultDiffuse = if (resultBase != 0) {
Assimp.aiGetMaterialColor(aiMat, Assimp.AI_MATKEY_COLOR_DIFFUSE, 0, 0, color)
} else 0
val baseColor = when {
resultBase == 0 -> Vector4f(color.r(), color.g(), color.b(), color.a())
resultDiffuse == 0 -> Vector4f(color.r(), color.g(), color.b(), color.a())
else -> defaultColor
}
color.free()
baseColor.w *= materialFloat(Assimp.AI_MATKEY_OPACITY, 1f).coerceIn(0f, 1f)
val materialName = materialString(Assimp.AI_MATKEY_NAME, "material-$matIndex")
val metallicFactor = materialFloat(Assimp.AI_MATKEY_METALLIC_FACTOR, 1f)
val roughnessFactor = materialFloat(Assimp.AI_MATKEY_ROUGHNESS_FACTOR, 1f)
val clearcoatFactor = materialFloat(Assimp.AI_MATKEY_CLEARCOAT_FACTOR, 0f).coerceIn(0f, 1f)
val clearcoatRoughnessFactor = materialFloat(Assimp.AI_MATKEY_CLEARCOAT_ROUGHNESS_FACTOR, 0f).coerceIn(0f, 1f)
val transmissionFactor = materialFloat(Assimp.AI_MATKEY_TRANSMISSION_FACTOR, 0f).coerceIn(0f, 1f)
val alphaMode = materialString(
Assimp.AI_MATKEY_GLTF_ALPHAMODE,
if (baseColor.w < 0.999f) "BLEND" else "OPAQUE"
)
val alphaCutoff = materialFloat(Assimp.AI_MATKEY_GLTF_ALPHACUTOFF, 0.5f)
val doubleSided = materialInt(Assimp.AI_MATKEY_TWOSIDED, 0) != 0
if (!config.loadTextures) return MaterialTextured(
baseColor = baseColor,
name = materialName,
metallicFactor = metallicFactor,
roughnessFactor = roughnessFactor,
clearcoatFactor = clearcoatFactor,
clearcoatRoughnessFactor = clearcoatRoughnessFactor,
transmissionFactor = transmissionFactor,
alphaMode = alphaMode,
alphaCutoff = alphaCutoff,
doubleSided = doubleSided
)
val slots = mutableListOf<TextureSlot>()
val renderer: Renderer = fromContext()
val types = listOf(
Assimp.aiTextureType_BASE_COLOR,
Assimp.aiTextureType_DIFFUSE,
Assimp.aiTextureType_NORMALS,
Assimp.aiTextureType_EMISSIVE,
Assimp.aiTextureType_METALNESS,
Assimp.aiTextureType_DIFFUSE_ROUGHNESS,
Assimp.aiTextureType_GLTF_METALLIC_ROUGHNESS,
Assimp.aiTextureType_LIGHTMAP,
Assimp.aiTextureType_AMBIENT_OCCLUSION,
Assimp.aiTextureType_CLEARCOAT
)
val typeMapping = types.mapIndexed { i, type -> type to i }.toMap()
types.forEach {
slots += TextureSlot(
type = it,
uvSet = 0,
texture = LoadedTexture.stubTexture(renderer),
valid = false
)
}
fun textureFormatFor(type: Int): Int =
when (type) {
Assimp.aiTextureType_NORMALS,
Assimp.aiTextureType_METALNESS,
Assimp.aiTextureType_DIFFUSE_ROUGHNESS,
Assimp.aiTextureType_GLTF_METALLIC_ROUGHNESS,
Assimp.aiTextureType_LIGHTMAP,
Assimp.aiTextureType_AMBIENT_OCCLUSION,
Assimp.aiTextureType_CLEARCOAT -> VK10.VK_FORMAT_R8G8B8A8_UNORM
else -> VK10.VK_FORMAT_R8G8B8A8_SRGB
}
fun loadSlot(type: Int) {
val count = Assimp.aiGetMaterialTextureCount(aiMat, type)
if (count <= 0) return
MemoryStack.stackPush().use { stack ->
for (i in 0 until count) {
val path = AIString.calloc(stack)
val uvIndex = stack.mallocInt(1)
val res = Assimp.aiGetMaterialTexture(
aiMat, type, i, path, null, uvIndex, null, null, null, null
)
if (res != Assimp.aiReturn_SUCCESS) {
continue
}
val tex = loadTextureFromPathOrEmbedded(
scene,
path.dataString(),
baseDir,
renderer,
textureFormatFor(type)
)
if (tex != null) {
slots[typeMapping[type]!!] =
TextureSlot(type = type, uvSet = uvIndex[0], texture = tex, valid = true,
transform = textureTransformOf(gltfTextureInfo(gltfMaterials?.getOrNull(matIndex), type)))
} else {
}
}
}
}
types.forEach { loadSlot(it) }
return MaterialTextured(
baseColor = baseColor,
textures = slots,
name = materialName,
metallicFactor = metallicFactor,
roughnessFactor = roughnessFactor,
clearcoatFactor = clearcoatFactor,
clearcoatRoughnessFactor = clearcoatRoughnessFactor,
transmissionFactor = transmissionFactor,
alphaMode = alphaMode,
alphaCutoff = alphaCutoff,
doubleSided = doubleSided
)
}
private fun loadTextureFromPathOrEmbedded(
scene: AIScene, path: String, baseDir: Path?, renderer: Renderer, imageFormat: Int
): LoadedTexture? {
if (path.isEmpty()) return null
// Embedded texture reference: "*0", "*1", ...
if (path.startsWith("*")) {
val embedded = loadEmbeddedTexture(scene, path, renderer, imageFormat)
if (embedded == null) {
lprintln("warning,gltf", "Skipping embedded texture $path: unable to decode")
}
return embedded
} else {
val p = Paths.get(path)
val resolved = if (p.isAbsolute) p else baseDir?.resolve(p) ?: p
return try {
val t = LoadedTexture.fromFile(resolved.toString(), renderer, imageFormat)
t.transferNow()
t
} catch (_: Exception) {
null
}
}
}
private fun loadEmbeddedTexture(scene: AIScene, path: String, renderer: Renderer, imageFormat: Int): LoadedTexture? {
val index = path.drop(1).toIntOrNull()
if (index == null) {
lprintln("warning,gltf", "Embedded texture reference is not numeric: $path")
return null
}
val textures = scene.mTextures()
if (textures == null || index < 0 || index >= scene.mNumTextures()) {
lprintln("warning,gltf", "Embedded texture index out of range: $path")
return null
}
val aiTex = AITexture.create(textures[index])
val width = aiTex.mWidth()
val height = aiTex.mHeight()
if (width <= 0) {
lprintln("warning,gltf", "Embedded texture has invalid size: $path (w=$width h=$height)")
return null
}
return if (height == 0) {
// Compressed data (PNG/JPEG/etc) stored in pcData with byte length = width
val texels = aiTex.pcData()
val encoded = memByteBuffer(texels.address(), width)
try {
val t = LoadedTexture.fromEncodedMemory("embedded:$path", encoded, renderer, imageFormat)
t.transferNow()
t
} catch (_: Exception) {
null
}
} else {
// Raw texels stored as BGRA in pcData (width*height entries)
val texels = aiTex.pcData()
val pixelCount = width * height
if (texels.capacity() < pixelCount) {
lprintln("warning,gltf", "Embedded texture texel data is incomplete: $path")
return null
}
val rgba = memAlloc(pixelCount * 4)
try {
for (i in 0 until pixelCount) {
val t = texels[i]
rgba.put((t.r().toInt() and 0xFF).toByte())
rgba.put((t.g().toInt() and 0xFF).toByte())
rgba.put((t.b().toInt() and 0xFF).toByte())
rgba.put((t.a().toInt() and 0xFF).toByte())
}
rgba.flip()
val t = LoadedTexture.fromRawRGBA("embedded:$path", width, height, rgba, renderer, imageFormat)
t.transferNow()
t
} catch (_: Exception) {
null
} finally {
memFree(rgba)
}
}
}
Animation sampling (node-level TRS only)
source — 125 lines, from line 933
private fun buildAnimationSamplesForNode(
scene: AIScene, node: AINode, sampleRate: Float
): List<TRSSample> {
val animCount = scene.mNumAnimations()
if (animCount == 0) return emptyList()
// For now: just use the first animation.
val anim = AIAnimation.create(scene.mAnimations()!![0])
val nodeName = node.mName().dataString()
// Find the channel that targets this node
val numChannels = anim.mNumChannels()
val channels = anim.mChannels()!!
var target: AINodeAnim? = null
for (i in 0 until numChannels) {
val ch = AINodeAnim.create(channels[i])
if (ch.mNodeName().dataString() == nodeName) {
target = ch
break
}
}
val channel = target ?: return emptyList()
val ticksPerSecond = if (anim.mTicksPerSecond() != 0.0) {
anim.mTicksPerSecond()
} else {
25.0 // Assimp default fallback
}
val durationTicks = anim.mDuration()
if (durationTicks <= 0.0 || ticksPerSecond <= 0.0) return emptyList()
val durationSec = (durationTicks / ticksPerSecond).toFloat()
if (durationSec <= 0f) return emptyList()
val dt = 1f / sampleRate
val samples = ArrayList<TRSSample>()
var t = 0f
while (t <= durationSec + 1e-4f) {
val tickTime = t * ticksPerSecond
val pos = sampleVec3(channel.mPositionKeys(), channel.mNumPositionKeys(), tickTime)
val rot = sampleQuat(channel.mRotationKeys(), channel.mNumRotationKeys(), tickTime)
val scl = sampleVec3(channel.mScalingKeys(), channel.mNumScalingKeys(), tickTime)
samples += TRSSample(
time = t, trs = TRS(pos, rot.normalize(), scl)
)
t += dt
}
return samples
}
private fun sampleVec3(
keys: AIVectorKey.Buffer?, count: Int, time: Double
): Vector3f {
if (keys == null || count == 0) return Vector3f(0f, 0f, 0f)
if (count == 1) {
val v = keys[0].mValue()
return Vector3f(v.x(), v.y(), v.z())
}
var i = 0
while (i < count - 1 && time >= keys[i + 1].mTime()) {
i++
}
val a = keys[i]
if (i == count - 1) {
val v = a.mValue()
return Vector3f(v.x(), v.y(), v.z())
}
val b = keys[i + 1]
val denom = (b.mTime() - a.mTime()).coerceAtLeast(1e-8)
val f = ((time - a.mTime()) / denom).coerceIn(0.0, 1.0)
val av = a.mValue()
val bv = b.mValue()
return Vector3f(
(av.x() + (bv.x() - av.x()) * f).toFloat(),
(av.y() + (bv.y() - av.y()) * f).toFloat(),
(av.z() + (bv.z() - av.z()) * f).toFloat()
)
}
private fun sampleQuat(
keys: AIQuatKey.Buffer?, count: Int, time: Double
): Quaternionf {
if (keys == null || count == 0) return Quaternionf()
if (count == 1) {
val q = keys[0].mValue()
return Quaternionf(q.x(), q.y(), q.z(), q.w())
}
var i = 0
while (i < count - 1 && time >= keys[i + 1].mTime()) {
i++
}
val a = keys[i]
if (i == count - 1) {
val q = a.mValue()
return Quaternionf(q.x(), q.y(), q.z(), q.w())
}
val b = keys[i + 1]
val denom = (b.mTime() - a.mTime()).coerceAtLeast(1e-8)
val f = ((time - a.mTime()) / denom).coerceIn(0.0, 1.0).toFloat()
val qa = a.mValue()
val qb = b.mValue()
val qaJ = Quaternionf(qa.x(), qa.y(), qa.z(), qa.w())
val qbJ = Quaternionf(qb.x(), qb.y(), qb.z(), qb.w())
return qaJ.slerp(qbJ, f)
}
Utilities
private fun aiToMatrix4f(m: AIMatrix4x4): Matrix4f = Matrix4f(
m.a1(),
m.b1(),
m.c1(),
m.d1(),
m.a2(),
m.b2(),
m.c2(),
m.d2(),
m.a3(),
m.b3(),
m.c3(),
m.d3(),
m.a4(),
m.b4(),
m.c4(),
m.d4()
)
private fun decomposeToTRS(m: Matrix4f): TRS {
val t = Vector3f()
val r = Quaternionf()
val s = Vector3f()
m.getTranslation(t)
m.getUnnormalizedRotation(r)
m.getScale(s)
return TRS(t, r.normalize(), s)
}
private fun getMesh(scene: AIScene, index: Int): AIMesh? {
if (index < 0 || index >= scene.mNumMeshes()) return null
val meshes = scene.mMeshes() ?: return null
return AIMesh.create(meshes[index])
}
private fun getMaterial(scene: AIScene, index: Int): AIMaterial? {
if (index < 0 || index >= scene.mNumMaterials()) return null
val materials = scene.mMaterials() ?: return null
return AIMaterial.create(materials[index])
}
Default shaders (GLSL) for SSBO vertex pulling + push constants
Assumes: - Vertex SSBO at set=0,binding=0: float vertexData[] - Index SSBO at set=0,binding=1: uint indexData[] - Push constants: mat4 modelView; mat4 proj; vec4 materialBaseColor; uint vertexOffset; // in floats uint indexOffset; // in indices uint vertexStride; // in floats (12 for this layout) uint useVertexColor; // 0 or 1 VertexBuffer vertexBuffer; IndexBuffer indexBuffer;
- Vertex layout exactly: 12 floats 0..2: pos, 3..5: normal, 6..7: uv, 8..11: color
val gltVertexShader
body — 79 lines, from line 1126
val gltVertexShader = """
#version 450
#extension GL_EXT_buffer_reference : require
#extension GL_EXT_scalar_block_layout : require
layout(std430, buffer_reference) readonly buffer VertexBuffer {
float vertexData[];
};
layout(std430, buffer_reference) readonly buffer IndexBuffer {
uint indexData[];
};
layout(push_constant) uniform PushConstants {
mat4 modelView; // view * model
mat4 proj; // projection
vec4 materialBaseColor;
uint vertexOffset; // in floats
uint indexOffset; // in indices
uint vertexStride; // in floats
uint useVertexColor; // bool as uint
VertexBuffer vertexBuffer;
IndexBuffer indexBuffer;
} pc;
layout(location = 0) out vec3 vViewPos;
layout(location = 1) out vec3 vNormalVS;
layout(location = 2) out vec2 vUV;
layout(location = 3) out vec4 vColor;
void main()
{
uint idx = pc.indexBuffer.indexData[pc.indexOffset+uint(gl_VertexIndex)];
uint base = pc.vertexOffset + idx * pc.vertexStride;
vec3 pos = vec3(
pc.vertexBuffer.vertexData[base + 0],
pc.vertexBuffer.vertexData[base + 1],
pc.vertexBuffer.vertexData[base + 2]
);
vec3 normal = vec3(
pc.vertexBuffer.vertexData[base + 3],
pc.vertexBuffer.vertexData[base + 4],
pc.vertexBuffer.vertexData[base + 5]
);
vec2 uv = vec2(
pc.vertexBuffer.vertexData[base + 6],
pc.vertexBuffer.vertexData[base + 7]
);
vec4 color = vec4(
pc.vertexBuffer.vertexData[base + 8],
pc.vertexBuffer.vertexData[base + 9],
pc.vertexBuffer.vertexData[base + 10],
pc.vertexBuffer.vertexData[base + 11]
);
if (pc.useVertexColor == 0u) {
color = vec4(1.0);
}
vec4 viewPos = pc.proj * pc.modelView*vec4(pos, 1.0);
mat3 mv3 = mat3(pc.modelView);
vec3 normalVS = normalize(mv3 * normal);
vViewPos = viewPos.xyz;
vNormalVS = normalVS;
vUV = uv;
vColor = color * pc.materialBaseColor;
gl_Position = viewPos;
}
""".trimIndent()
val gltfFragmentShader
val gltfFragmentShader = """
#version 450
layout(location = 0) out vec4 uFragColor;
layout(location = 3) in vec4 vColor;
layout(location = 1) in vec3 vNormalVS;
void main()
{
float d = abs(dot(vNormalVS.xyz, normalize(vec3(1, 0.4, -0.2))));
uFragColor = vec4(vNormalVS.rgb*d,1);
}
""".trimIndent()
val gltfFragmentShaderTextured
Simple textured fragment: samples first available texture; falls back to vertex color if none bound. Expects descriptor array of combined image samplers at set=0, binding=1.
val gltfFragmentShaderTextured = """
#version 450
layout(location = 0) out vec4 uFragColor;
layout(location = 3) in vec4 vColor;
layout(location = 2) in vec2 vUV;
layout(set = 0, binding = 1) uniform sampler2D uTextures[6];
void main()
{
uFragColor = vColor*texture(uTextures[0], vUV);
}
""".trimIndent()
}
fun List<TRSSample>.sample(time: Float): TRS?
fun List<TRSSample>.sample(time: Float): TRS? {
if (isEmpty()) return null
if (size == 1) return this[0].trs
if (time <= this[0].time) return this[0].trs
if (time >= last().time) return last().trs
// Find this to interpolate between
var i = 0
while (i < size - 1 && time > this[i + 1].time) {
i++
}
val a = this[i]
val b = this[i + 1]
val t = ((time - a.time) / (b.time - a.time)).coerceIn(0f, 1f)
return TRS(
Vector3f(a.trs.t).lerp(b.trs.t, t),
Quaternionf(a.trs.r).slerp(b.trs.r, t),
Vector3f(a.trs.s).lerp(b.trs.s, t)
)
}