FabricateScene.kt
src/jvmMain/kotlin/afield/graphics/scene/fabricated/FabricateScene.kt · 885 lines
package afield.graphics.scene.fabricated
import kotlinx.serialization.json.jsonObject
import afield.graphics.scene.GltfSupport
import afield.document.context.fromContext
import afield.graphics.scene.sample
import afield.graphics.texture.LoadedTexture
import afield.math.linalg.Matrix4f
import afield.math.linalg.Quaternionf
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import org.lwjgl.assimp.Assimp
import kotlin.math.abs
import kotlin.math.max
The scene graph: nodes, transforms, materials, lights, animation playback, and the glTF import that fills them.
This file owns no Vulkan resources and imports no device type. A FabricateNode carries a
transform and children; a renderable carries a FabricateMaterial and the intent to be drawn;
updateWorldTransforms composes the hierarchy into world matrices. None of that needs a GPU, so
the scene graph, the animation sampler and the draw-order planning in FabricateRenderPlan.kt
are all exercisable from a plain main, which FabricateSceneGraphTestMain and
FabricateSceneTestMain do without a device.
FabricateTransform is a position/rotation/scale triple rather than a matrix, so handing the
graph a composed Matrix4f is not enough on its own: FabricateTransform.set(Matrix4f)
decomposes it back into the triple. A transform that cannot be expressed as one, such as a
shear, will not survive the round trip.
Lights are a list, and an empty list is not the same as no lighting: it keeps the single default
directional light, so a scene that never touches lights is still lit. One shadow-casting
directional light is treated as the key light and drives the shadow map; the rest reach the
deferred lighting pass through a lights buffer.
FabricateAlphaMode is the field that decides which of the three draw queues a renderable joins,
and it is the same OPAQUE/MASK/BLEND distinction glTF makes, carried through rather than
collapsed.
class FabricateScene
A small Three.js-like scene layer for Fabricate, independent of Vulkan resources: nodes own transforms, renderables own material/rendering intent, and renderer bindings consume the ordered draw lists.
class FabricateScene(
val root: FabricateNode = FabricateNode("root")
) {
constructor(name: String) : this(FabricateNode(name))
init
init {
root.scene = this
}
var renderProfile: FabricateRenderProfile = FabricateRenderProfile.outOfBox()
var animationTime: Float = 0f
val lights: MutableList<FabricateLight> = mutableListOf()
Scene lights, Three.js style. The renderer treats one shadow-casting directional light as the
key light (it drives the shadow map and the built-in LightData uniform); every other light
is uploaded to the deferred lighting pass through a lights buffer. An empty list keeps the
historical single default directional light untouched, so existing scenes render unchanged.
fun addLight(light: FabricateLight): FabricateLight
fun addLight(light: FabricateLight): FabricateLight {
lights += light
return light
}
fun node(name: String, parent: FabricateNode = root, configure: FabricateNode.() -> Unit
fun node(name: String, parent: FabricateNode = root, configure: FabricateNode.() -> Unit = {}): FabricateNode {
val n = FabricateNode(name)
parent.add(n)
n.configure()
return n
}
fun mesh
fun mesh(
name: String,
parent: FabricateNode = root,
material: FabricateMaterial = FabricateMaterial(),
configure: FabricateNode.() -> Unit = {}
): FabricateNode = node(name, parent) {
renderable = FabricateRenderable(material = material)
configure()
}
fun updateWorldTransforms(time: Float = animationTime): FabricateScene
fun updateWorldTransforms(time: Float = animationTime): FabricateScene {
root.updateWorld(Matrix4f().identity(), time)
return this
}
fun updateWorld(): FabricateScene
fun updateWorld(): FabricateScene =
updateWorldTransforms()
fun traverse(visit: (FabricateNode) -> Unit) {
fun traverse(visit: (FabricateNode) -> Unit) {
root.walk().forEach(visit)
}
fun advance(seconds: Float, loop: Boolean = true): FabricateScene
fun advance(seconds: Float, loop: Boolean = true): FabricateScene {
val duration = animationDuration()
animationTime = if (loop && duration > 0f) {
positiveModulo(animationTime + seconds, duration)
} else {
animationTime + seconds
}
updateWorldTransforms(animationTime)
return this
}
fun animationDuration(): Float
fun animationDuration(): Float =
root.walk()
.mapNotNull { node -> node.animationSamples?.lastOrNull()?.time }
.maxOrNull() ?: 0f
fun drawLists(cameraPosition: Vector3f = Vector3f(0f, 0f, 0f), time: Float = animationTime): FabricateDrawLists
fun drawLists(cameraPosition: Vector3f = Vector3f(0f, 0f, 0f), time: Float = animationTime): FabricateDrawLists {
updateWorldTransforms(time)
val draws = root.walk()
.asSequence()
.filter { it.visible }
.mapNotNull { node ->
val renderable = node.renderable ?: return@mapNotNull null
val worldCenter = node.worldPoint(renderable.boundsCenter)
FabricateDraw(
node = node,
renderable = renderable,
worldMatrix = Matrix4f(node.worldMatrix),
worldCenter = worldCenter,
distanceToCamera = worldCenter.distance(cameraPosition),
sortKey = renderable.sortKey(node)
)
}
.toList()
val opaque = draws
.filter { it.renderable.material.alphaMode == FabricateAlphaMode.OPAQUE }
.sortedWith(compareBy<FabricateDraw> { it.sortKey }.thenBy { it.distanceToCamera })
val transparent = draws
.filter { it.renderable.material.alphaMode != FabricateAlphaMode.OPAQUE }
.sortedWith(compareBy<FabricateDraw> { it.sortKey }.thenByDescending { it.distanceToCamera })
val shadowCasters = draws
.filter { it.renderable.castShadow && it.renderable.material.alphaMode != FabricateAlphaMode.BLEND }
.sortedWith(compareBy<FabricateDraw> { it.sortKey }.thenBy { it.node.name })
return FabricateDrawLists(opaque = opaque, transparent = transparent, shadowCasters = shadowCasters)
}
fun importGltf
fun importGltf(
model: GltfSupport.GltfModel,
namePrefix: String = "gltf",
parent: FabricateNode = root,
material: (GltfSupport.MeshInstance) -> FabricateMaterial = { FabricateMaterial.fromGltf(it.material) }
): List<FabricateNode> {
return model.meshes.mapIndexed { index, instance ->
mesh(
name = "${namePrefix}-${instance.name.ifBlank { "mesh" }}-$index",
parent = parent,
material = material(instance)
) {
// The node's true world matrix, NOT bindPose.toMatrix(): a TRS cannot carry a mirror
// or a shear, and decomposeToTRS flattens a mirrored node to a pure translation with
// no error raised. See GltfSupport.MeshInstance.bindMatrix.
transform.set(instance.bindMatrix ?: instance.bindPose.toMatrix())
animationSamples = instance.animationSamples
source = instance
renderable?.boundsRadius = estimateRadius(instance)
}
}.also {
updateWorldTransforms(animationTime)
}
}
companion object
companion object {
private fun positiveModulo(value: Float, modulus: Float): Float {
val r = value % modulus
return if (r < 0f) r + modulus else r
}
private fun estimateRadius(instance: GltfSupport.MeshInstance): Float {
val floats = instance.buffers.vertexData.asFloatBuffer()
var radiusSquared = 0f
for (i in 0 until instance.buffers.vertexCount) {
val base = i * 12
val x = floats.get(base)
val y = floats.get(base + 1)
val z = floats.get(base + 2)
radiusSquared = max(radiusSquared, x * x + y * y + z * z)
}
return kotlin.math.sqrt(radiusSquared)
}
}
}
class FabricateNode(val name: String)
internal var scene: FabricateScene? = null
var parent: FabricateNode?
var parent: FabricateNode? = null
private set
val children: MutableList<FabricateNode> = mutableListOf()
val transform: FabricateTransform = FabricateTransform()
var worldMatrix: Matrix4f
var worldMatrix: Matrix4f = Matrix4f().identity()
private set
var previousWorldMatrix: Matrix4f
var previousWorldMatrix: Matrix4f = Matrix4f().identity()
private set
var renderable: FabricateRenderable? = null
var visible: Boolean = true
var animationSamples: List<GltfSupport.TRSSample>? = null
var source: Any? = null
fun add(child: FabricateNode): FabricateNode
fun add(child: FabricateNode): FabricateNode {
require(child !== this) { "Cannot parent a node to itself" }
require(!child.walk().contains(this)) { "Cannot create a FabricateScene cycle with ${child.name}" }
child.parent?.children?.remove(child)
child.parent = this
child.scene = scene
children += child
child.propagateScene(scene)
return child
}
operator fun plusAssign(child: FabricateNode)
operator fun plusAssign(child: FabricateNode) {
add(child)
}
fun remove(child: FabricateNode): Boolean
fun remove(child: FabricateNode): Boolean {
val removed = children.remove(child)
if (removed) {
child.parent = null
child.propagateScene(null)
}
return removed
}
fun walk(): Sequence<FabricateNode>
fun walk(): Sequence<FabricateNode> = sequence {
yield(this@FabricateNode)
children.forEach { child -> yieldAll(child.walk()) }
}
fun translate(x: Float, y: Float, z: Float): FabricateNode
fun translate(x: Float, y: Float, z: Float): FabricateNode {
transform.position.add(x, y, z)
return this
}
fun setTranslation(x: Float, y: Float, z: Float): FabricateNode
fun setTranslation(x: Float, y: Float, z: Float): FabricateNode {
transform.position.set(x, y, z)
return this
}
fun updateFromRoot(): FabricateNode
fun updateFromRoot(): FabricateNode {
scene?.updateWorldTransforms() ?: updateWorld(Matrix4f().identity(), 0f)
return this
}
fun updateWorld(parentWorld: Matrix4f? = parent?.worldMatrix, force: Boolean = true): FabricateNode
fun updateWorld(parentWorld: Matrix4f? = parent?.worldMatrix, force: Boolean = true): FabricateNode {
val base = parentWorld ?: Matrix4f().identity()
updateWorld(base, scene?.animationTime ?: 0f)
return this
}
internal fun updateWorld(parentWorld: Matrix4f, time: Float)
internal fun updateWorld(parentWorld: Matrix4f, time: Float) {
animationSamples?.sample(time)?.let { transform.set(it.toMatrix()) }
previousWorldMatrix = Matrix4f(worldMatrix)
worldMatrix = Matrix4f(parentWorld).mul(transform.matrix())
children.forEach { it.updateWorld(worldMatrix, time) }
}
fun worldPoint(local: Vector3f = Vector3f()): Vector3f
fun worldPoint(local: Vector3f = Vector3f()): Vector3f =
worldMatrix.transformPosition(Vector3f(local))
fun worldPosition(dest: Vector3f = Vector3f()): Vector3f
fun worldPosition(dest: Vector3f = Vector3f()): Vector3f =
worldMatrix.getTranslation(dest)
private fun propagateScene(nextScene: FabricateScene?) {
scene = nextScene
children.forEach { it.propagateScene(nextScene) }
}
}
data class FabricateTransform
data class FabricateTransform(
val position: Vector3f = Vector3f(),
val rotation: Quaternionf = Quaternionf(),
val scale: Vector3f = Vector3f(1f, 1f, 1f)
) {
var matrixOverride: Matrix4f?
The exact matrix set was handed, kept only when the TRS triple above cannot reproduce it.
A translation/rotation/scale triple is not a general 4x4. Matrix4f.getScale is sqrt(...)
per basis column and so never negative, which means a mirror — a negative-determinant
transform, scale (-1, 1, 1) in a glTF node — decomposes to a positive scale and recomposes
with determinant +1: the reflection is gone and the geometry is placed un-mirrored with no
error raised. A shear does not survive either. See GltfSupport.MeshInstance.bindMatrix, which
is the same defect one layer up, and afield.geometry.mesh.AssimpSceneBridgeTest.
So set measures its own decomposition: if recomposing the TRS does not return the matrix it was given, it keeps the original here and matrix returns it verbatim. For every transform that is a TRS — the overwhelming majority, and every scene that worked before this existed — this stays null and nothing changes.
The cost of that, stated plainly: while this is non-null the TRS fields are a lossy summary
and mutating them in place (transform.position.add(...), FabricateNode.translate) has no
effect on what is drawn. Call clearMatrix first, or go through the TRS set overload, which
clears it for you.
A body property rather than a constructor parameter, so it is not a data class component:
Matrix4f has no structural equality, and putting it in equals / copy would change the
identity of every transform in every scene. The consequence is that copy() drops it — no
caller does that today, and a node's transform is a val built with the node.
var matrixOverride: Matrix4f? = null
private set
fun clearMatrix(): FabricateTransform
Discard matrixOverride and go back to composing the TRS fields.
fun clearMatrix(): FabricateTransform {
matrixOverride = null
return this
}
fun matrix(): Matrix4f
fun matrix(): Matrix4f =
matrixOverride?.let { Matrix4f(it) }
?: Matrix4f().translate(position).rotate(rotation).scale(scale)
fun set(matrix: Matrix4f): FabricateTransform
fun set(matrix: Matrix4f): FabricateTransform {
matrix.getTranslation(position)
matrix.getUnnormalizedRotation(rotation)
rotation.normalize()
matrix.getScale(scale)
// Does the decomposition actually round-trip? A mirror or a shear does not, and silently
// placing that geometry somewhere plausible-but-wrong is the failure this guards.
val recomposed = Matrix4f().translate(position).rotate(rotation).scale(scale)
matrixOverride = if (matrix.approximatelyEquals(recomposed)) null else Matrix4f(matrix)
return this
}
fun set(position: Vector3f, rotation: Quaternionf = Quaternionf(), scale: Vector3f = Vector3f(1f, 1f, 1f)): FabricateTransform
fun set(position: Vector3f, rotation: Quaternionf = Quaternionf(), scale: Vector3f = Vector3f(1f, 1f, 1f)): FabricateTransform {
this.position.set(position)
this.rotation.set(rotation).normalize()
this.scale.set(scale)
matrixOverride = null
return this
}
companion object
companion object {
Element-wise comparison with a tolerance scaled to the magnitudes involved, because a
ToyCar node's 1e-4 scale and a metre-scale translation cannot share an absolute epsilon.
private fun Matrix4f.approximatelyEquals(other: Matrix4f, relative: Float = 1e-4f): Boolean {
for (i in 0 until 16) {
val a = this[i]
val b = other[i]
val tolerance = relative * max(1f, max(abs(a), abs(b)))
if (abs(a - b) > tolerance) return false
}
return true
}
}
}
enum class FabricateAlphaMode
enum class FabricateAlphaMode {
OPAQUE,
MASK,
BLEND
}
data class FabricateMaterial
data class FabricateMaterial(
val baseColor: Vector4f = Vector4f(1f, 0.56f, 0.24f, 1f),
var metallic: Float = 0.08f,
var roughness: Float = 0.42f,
var baseColorTexture: LoadedTexture? = null,
var normalTexture: LoadedTexture? = null,
var metallicRoughnessTexture: LoadedTexture? = null,
var occlusionTexture: LoadedTexture? = null,
var emissiveTexture: LoadedTexture? = null,
var clearcoatTexture: LoadedTexture? = null,
var emissiveFactor: Vector3f = Vector3f(0f, 0f, 0f),
var clearcoatFactor: Float = 0f,
var clearcoatRoughnessFactor: Float = 0f,
/** UV offset (xy) and scale (zw), applied to the maps in [uvTransformMaps]. */
var uvTransform: Vector4f = Vector4f(0f, 0f, 1f, 1f),
/** Which maps [uvTransform] applies to, [UV_BASE_COLOR] … [UV_CLEARCOAT]; the rest are sampled at their own UVs. */
var uvTransformMaps: Int = UV_ALL_MAPS,
var alphaMode: FabricateAlphaMode = FabricateAlphaMode.OPAQUE,
var alphaCutoff: Float = 0.5f,
var doubleSided: Boolean = false,
var receiveShadow: Boolean = true,
var receiveAo: Boolean = true,
var sortOrder: Int = 0
) {
fun transparent(opacity: Float = baseColor.w): FabricateMaterial
fun transparent(opacity: Float = baseColor.w): FabricateMaterial {
baseColor.w = opacity
alphaMode = FabricateAlphaMode.BLEND
return this
}
companion object
const val UV_BASE_COLOR = 1
const val UV_NORMAL = 2
const val UV_METALLIC_ROUGHNESS = 4
const val UV_OCCLUSION = 8
const val UV_EMISSIVE = 16
const val UV_CLEARCOAT = 32
const val UV_ALL_MAPS = 63
A glTF material's KHR_texture_transforms as one uvTransform and the maps it applies to.
The G-buffer has room for one offset-and-scale per material, so a material whose maps use a
rotation, or two different transforms, keeps the first and says which maps it got wrong.
private fun uvTransformOf(material: GltfSupport.MaterialTextured, roles: List<Pair<Int, GltfSupport.TextureSlot?>>): Pair<Vector4f, Int> {
val transformed = roles.mapNotNull { (bit, slot) -> slot?.transform?.takeUnless { it.isIdentity }?.let { bit to it } }
if (transformed.isEmpty()) return Vector4f(0f, 0f, 1f, 1f) to UV_ALL_MAPS
val chosen = transformed.first().second
val applies = transformed.filter { it.second == chosen }
val unhandled = transformed.filter { it.second != chosen } + (if (chosen.rotation != 0f) applies else emptyList())
if (unhandled.isNotEmpty()) {
afield.app.diagnostics.logging.lprintln("warning,gltf",
"material '${material.name}': the StandardRenderer applies one unrotated KHR_texture_transform per material; " +
"${unhandled.size} of its maps use another or a rotation and are sampled with the first")
}
return Vector4f(chosen.offsetX, chosen.offsetY, chosen.scaleX, chosen.scaleY) to applies.fold(0) { mask, (bit, _) -> mask or bit }
}
fun fromGltf(material: GltfSupport.MaterialTextured): FabricateMaterial
body — 63 lines, from line 425
fun fromGltf(material: GltfSupport.MaterialTextured): FabricateMaterial {
val emissiveTexture = material.textures.firstOrNull {
it.valid && it.type == Assimp.aiTextureType_EMISSIVE
}?.texture
val metallicRoughnessTexture = material.textures.firstOrNull {
it.valid && (it.type == Assimp.aiTextureType_GLTF_METALLIC_ROUGHNESS || it.type == Assimp.aiTextureType_DIFFUSE_ROUGHNESS || it.type == Assimp.aiTextureType_METALNESS)
}?.texture
val occlusionTexture = material.textures.firstOrNull {
it.valid && (it.type == Assimp.aiTextureType_AMBIENT_OCCLUSION || it.type == Assimp.aiTextureType_LIGHTMAP)
}?.texture ?: metallicRoughnessTexture
val clearcoatTexture = material.textures.firstOrNull {
it.valid && it.type == Assimp.aiTextureType_CLEARCOAT
}?.texture
fun slot(vararg types: Int) = material.textures.firstOrNull { it.valid && it.type in types }
val (uvTransform, uvTransformMaps) = uvTransformOf(material, listOf(
UV_BASE_COLOR to slot(Assimp.aiTextureType_BASE_COLOR, Assimp.aiTextureType_DIFFUSE),
UV_NORMAL to slot(Assimp.aiTextureType_NORMALS),
UV_METALLIC_ROUGHNESS to slot(Assimp.aiTextureType_GLTF_METALLIC_ROUGHNESS, Assimp.aiTextureType_DIFFUSE_ROUGHNESS, Assimp.aiTextureType_METALNESS),
UV_OCCLUSION to (slot(Assimp.aiTextureType_AMBIENT_OCCLUSION, Assimp.aiTextureType_LIGHTMAP)
?: slot(Assimp.aiTextureType_GLTF_METALLIC_ROUGHNESS, Assimp.aiTextureType_DIFFUSE_ROUGHNESS, Assimp.aiTextureType_METALNESS)),
UV_EMISSIVE to slot(Assimp.aiTextureType_EMISSIVE),
UV_CLEARCOAT to slot(Assimp.aiTextureType_CLEARCOAT),
))
val transmission = material.transmissionFactor.coerceIn(0f, 1f)
val base = Vector4f(material.baseColor)
if (transmission > 0.001f && base.w >= 0.999f) {
base.w = (1f - transmission * 0.72f).coerceIn(0.18f, 0.42f)
}
val alphaMode = when {
material.alphaMode.equals("MASK", ignoreCase = true) -> FabricateAlphaMode.MASK
material.alphaMode.equals("BLEND", ignoreCase = true) -> FabricateAlphaMode.BLEND
transmission > 0.001f -> FabricateAlphaMode.BLEND
base.w < 0.999f -> FabricateAlphaMode.BLEND
else -> FabricateAlphaMode.OPAQUE
}
return FabricateMaterial(
baseColor = base,
metallic = material.metallicFactor,
roughness = material.roughnessFactor.coerceIn(0.02f, 1f),
baseColorTexture = material.textures.firstOrNull {
it.valid && (it.type == Assimp.aiTextureType_BASE_COLOR || it.type == Assimp.aiTextureType_DIFFUSE)
}?.texture,
normalTexture = material.textures.firstOrNull {
it.valid && it.type == Assimp.aiTextureType_NORMALS
}?.texture,
metallicRoughnessTexture = metallicRoughnessTexture,
occlusionTexture = occlusionTexture,
emissiveTexture = emissiveTexture,
clearcoatTexture = clearcoatTexture,
emissiveFactor = if (emissiveTexture != null) Vector3f(1f, 1f, 1f) else Vector3f(0f, 0f, 0f),
clearcoatFactor = material.clearcoatFactor,
clearcoatRoughnessFactor = material.clearcoatRoughnessFactor,
uvTransform = uvTransform,
uvTransformMaps = uvTransformMaps,
alphaMode = alphaMode,
alphaCutoff = material.alphaCutoff,
doubleSided = material.doubleSided,
receiveShadow = transmission <= 0.001f,
receiveAo = transmission <= 0.001f
)
}
}
}
data class FabricateRenderable
data class FabricateRenderable(
val material: FabricateMaterial = FabricateMaterial(),
var castShadow: Boolean = true,
var boundsCenter: Vector3f = Vector3f(),
var boundsRadius: Float = 1f,
var userData: Any? = null
) {
fun sortKey(node: FabricateNode): Int
fun sortKey(node: FabricateNode): Int =
material.sortOrder * 31 + node.name.hashCode()
}
data class FabricateDraw
data class FabricateDraw(
val node: FabricateNode,
val renderable: FabricateRenderable,
val worldMatrix: Matrix4f,
val worldCenter: Vector3f,
val distanceToCamera: Float,
val sortKey: Int
)
data class FabricateDrawLists
data class FabricateDrawLists(
val opaque: List<FabricateDraw>,
val transparent: List<FabricateDraw>,
val shadowCasters: List<FabricateDraw>
) {
val allVisible: List<FabricateDraw>
val allVisible: List<FabricateDraw>
get() = opaque + transparent
}
data class FabricateRenderProfile
data class FabricateRenderProfile(
var ambientOcclusion: Boolean = true,
var aoStrength: Float = 0.6f,
var aoRadius: Float = 0.35f,
var softShadows: Boolean = true,
var shadowStrength: Float = 1f,
var shadowSoftness: Float = 1.75f,
var motionBlur: Boolean = true,
var antiAlias: Boolean = true
) {
fun applyTo(bundle: StandardRenderer.Bundle): StandardRenderer.Bundle
fun applyTo(bundle: StandardRenderer.Bundle): StandardRenderer.Bundle {
bundle.scene.aoStrength = if (ambientOcclusion) aoStrength else 0f
bundle.scene.aoRadius = aoRadius
bundle.scene.shadowStrength = if (softShadows) shadowStrength else 0f
bundle.scene.shadowSoftness = if (softShadows) shadowSoftness else 0f
bundle.scene.motionBlurStrength = if (motionBlur) bundle.scene.motionBlurStrength else 0f
bundle.scene.aaBlend = if (antiAlias) 1f else 0f
bundle.sceneGraph.renderProfile = this
return bundle
}
companion object
fun outOfBox(): FabricateRenderProfile = FabricateRenderProfile()
enum class FabricateLightType
enum class FabricateLightType {
DIRECTIONAL,
POINT,
SPOT
}
data class FabricateLight
A light source. Directional lights model the sun (parallel rays, no falloff); point lights radiate
from a position with inverse-square falloff clamped by range; spot lights add a cone defined by
innerConeDegrees/outerConeDegrees. Only one directional light with castShadow can drive the
shadow map at a time (the first such light wins); the rest are shaded without shadows.
data class FabricateLight(
var type: FabricateLightType = FabricateLightType.DIRECTIONAL,
var position: Vector3f = Vector3f(0f, 3f, 0f),
var direction: Vector3f = Vector3f(-0.35f, -1f, -0.25f),
var color: Vector3f = Vector3f(1f, 1f, 1f),
var intensity: Float = 1f,
var range: Float = 0f,
var innerConeDegrees: Float = 20f,
var outerConeDegrees: Float = 30f,
var castShadow: Boolean = false
) {
companion object
fun directional
fun directional(
direction: Vector3f,
color: Vector3f = Vector3f(1f, 1f, 1f),
intensity: Float = 3.5f,
castShadow: Boolean = true
): FabricateLight = FabricateLight(
type = FabricateLightType.DIRECTIONAL,
direction = Vector3f(direction),
color = Vector3f(color),
intensity = intensity,
castShadow = castShadow
)
fun point
fun point(
position: Vector3f,
color: Vector3f = Vector3f(1f, 1f, 1f),
intensity: Float = 6f,
range: Float = 8f
): FabricateLight = FabricateLight(
type = FabricateLightType.POINT,
position = Vector3f(position),
color = Vector3f(color),
intensity = intensity,
range = range
)
fun spot
fun spot(
position: Vector3f,
direction: Vector3f,
color: Vector3f = Vector3f(1f, 1f, 1f),
intensity: Float = 8f,
range: Float = 12f,
innerConeDegrees: Float = 16f,
outerConeDegrees: Float = 26f
): FabricateLight = FabricateLight(
type = FabricateLightType.SPOT,
position = Vector3f(position),
direction = Vector3f(direction),
color = Vector3f(color),
intensity = intensity,
range = range,
innerConeDegrees = innerConeDegrees,
outerConeDegrees = outerConeDegrees
)
}
}
data class FabricateRegisteredGltf
data class FabricateRegisteredGltf(
val nodes: List<FabricateNode>,
val opaqueMeshes: List<StandardRenderer.Bundle.PullingMeshPair>,
val transparentMeshes: List<StandardRenderer.PullingMesh>,
val meshBindings: List<FabricateGltfMeshBinding> = emptyList()
) {
fun animate(time: Float): FabricateRegisteredGltf
fun animate(time: Float): FabricateRegisteredGltf {
nodes.firstOrNull()?.scene?.updateWorldTransforms(time)
return this
}
val meshes: List<StandardRenderer.Bundle.PullingMeshPair>
val meshes: List<StandardRenderer.Bundle.PullingMeshPair>
get() = opaqueMeshes
}
data class FabricateGltfMeshBinding
data class FabricateGltfMeshBinding(
val index: Int,
val node: FabricateNode,
val opaque: StandardRenderer.Bundle.PullingMeshPair? = null,
val transparent: StandardRenderer.PullingMesh? = null
) {
fun applyMaterial(material: FabricateMaterial): FabricateGltfMeshBinding
fun applyMaterial(material: FabricateMaterial): FabricateGltfMeshBinding {
opaque?.applyFabricateMaterial(material)
transparent?.applyFabricateMaterial(material)
return this
}
}
fun StandardRenderer.Bundle.applyRenderProfile(profile: FabricateRenderProfile = FabricateRenderProfile.outOfBox()): StandardRenderer.Bundle
fun StandardRenderer.Bundle.applyRenderProfile(profile: FabricateRenderProfile = FabricateRenderProfile.outOfBox()): StandardRenderer.Bundle =
profile.applyTo(this)
fun StandardRenderer.Bundle.sceneDrawLists(cameraPosition: Vector3f = Vector3f(scene.camera.at)): FabricateDrawLists
fun StandardRenderer.Bundle.sceneDrawLists(cameraPosition: Vector3f = Vector3f(scene.camera.at)): FabricateDrawLists =
sceneGraph.drawLists(cameraPosition)
fun StandardRenderer.Bundle.planScene
fun StandardRenderer.Bundle.planScene(
cameraPosition: Vector3f = Vector3f(scene.camera.at),
planner: FabricateScenePlanner = FabricateScenePlanner()
): FabricateRenderPlan =
planner.plan(sceneGraph, cameraPosition)
fun StandardRenderer.Bundle.applyScenePlan(plan: FabricateRenderPlan = planScene()): FabricateRenderPlan
fun StandardRenderer.Bundle.applyScenePlan(plan: FabricateRenderPlan = planScene()): FabricateRenderPlan {
require(plan.allDrawItems.isEmpty() || plan.materials.isNotEmpty()) {
"Fabricate planner returned draw items without material records"
}
reorderFabricateMeshes(
gBufferModule.current.meshes,
(plan.opaque + plan.alphaMask).fabricateMeshNames()
)
reorderFabricateMeshes(
shadowModule.current.meshes,
plan.shadowCasters.fabricateMeshNames()
)
reorderFabricateMeshes(
transparentModule.current.meshes,
plan.transparent.fabricateMeshNames()
)
return plan
}
private fun List<FabricateRenderItem>.fabricateMeshNames(): List<String> =
mapNotNull { it.renderable.userData as? String }
private fun <T> reorderFabricateMeshes(meshes: MutableMap<String, T>, orderedNames: List<String>) {
if (orderedNames.isEmpty()) return
val reordered = linkedMapOf<String, T>()
orderedNames.forEach { name ->
meshes[name]?.let { reordered[name] = it }
}
meshes.forEach { (name, mesh) ->
if (name !in reordered) reordered[name] = mesh
}
meshes.clear()
meshes.putAll(reordered)
}
fun StandardRenderer.Bundle.registerGltf
fun StandardRenderer.Bundle.registerGltf(
path: String,
namePrefix: String = "gltf",
config: GltfSupport.GltfLoadConfig = GltfSupport.GltfLoadConfig(),
renderer: afield.graphics.vulkan.Renderer = fromContext()
): FabricateRegisteredGltf {
reportIgnoredGltfExtensions(path)
return registerGltf(GltfSupport.loadGltfBasic(path, config), namePrefix, renderer)
}
glTF extensions whose data the StandardRenderer material bridge reads.
private val STANDARD_RENDERER_GLTF_EXTENSIONS = setOf("KHR_materials_clearcoat", "KHR_materials_emissive_strength", "KHR_texture_transform")
internal fun reportIgnoredGltfExtensions(path: String)
Say, once per import, which of a file's extensionsUsed the StandardRenderer does not evaluate
(texture transforms, sheen, physical transmission…), so a model that looks plainer than it should
is explained in the log rather than left to look like a loader bug.
internal fun reportIgnoredGltfExtensions(path: String) {
val json = runCatching {
val file = java.io.File(path)
val text = if (file.extension.equals("glb", ignoreCase = true)) {
val bytes = file.readBytes()
val buffer = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN)
String(bytes, 20, buffer.getInt(12), Charsets.UTF_8)
} else file.readText()
kotlinx.serialization.json.Json.parseToJsonElement(text).jsonObject
}.getOrNull() ?: return
val used = (json["extensionsUsed"] as? kotlinx.serialization.json.JsonArray)
?.mapNotNull { (it as? kotlinx.serialization.json.JsonPrimitive)?.content }.orEmpty()
val ignored = used.filter { it !in STANDARD_RENDERER_GLTF_EXTENSIONS }
if (ignored.isNotEmpty()) {
afield.app.diagnostics.logging.lprintln(
"warning,gltf",
"${java.io.File(path).name} uses glTF extensions the StandardRenderer does not evaluate: " +
ignored.joinToString(", ") + "; those parts of its materials fall back to the base material",
)
}
}
fun StandardRenderer.Bundle.registerGltf
body — 47 lines, from line 731
fun StandardRenderer.Bundle.registerGltf(
model: GltfSupport.GltfModel,
namePrefix: String = "gltf",
renderer: afield.graphics.vulkan.Renderer = fromContext()
): FabricateRegisteredGltf {
val nodes = sceneGraph.importGltf(model, namePrefix)
val opaque = mutableListOf<StandardRenderer.Bundle.PullingMeshPair>()
val transparent = mutableListOf<StandardRenderer.PullingMesh>()
val bindings = mutableListOf<FabricateGltfMeshBinding>()
model.meshes.zip(nodes).forEachIndexed { index, (instance, node) ->
val material = node.renderable?.material ?: FabricateMaterial.fromGltf(instance.material)
val meshName = "${namePrefix}-${index}"
node.renderable?.userData = meshName
if (material.alphaMode == FabricateAlphaMode.BLEND) {
val mesh = registerTransparentPullingMesh(
name = meshName,
maxVertices = instance.buffers.indexCount.coerceAtLeast(3),
renderer = renderer
)
writeGltfMesh(instance, mesh)
mesh.bindModel { Matrix4f(node.worldMatrix) }
mesh.applyFabricateMaterial(material)
transparent += mesh
bindings += FabricateGltfMeshBinding(index, node, transparent = mesh)
} else {
val pair = material.baseColorTexture?.let {
registerTexturedPullingMesh(
name = meshName,
maxVertices = instance.buffers.indexCount.coerceAtLeast(3),
material = material,
renderer = renderer
)
} ?: registerPullingMesh(
name = meshName,
maxVertices = instance.buffers.indexCount.coerceAtLeast(3),
renderer = renderer
)
writeGltfMesh(instance, pair)
pair.bindModel { Matrix4f(node.worldMatrix) }
pair.applyFabricateMaterial(material)
opaque += pair
bindings += FabricateGltfMeshBinding(index, node, opaque = pair)
}
}
return FabricateRegisteredGltf(nodes, opaque, transparent, bindings)
}
The textured G-buffer shader's mapFlags: which of the material's maps are bound, one bit each.
Bits rather than a float per map so its push constants fit KosmicKrisp's 256 bytes; the untextured
shader, which fits anyway, keeps its named has…Map floats.
private fun FabricateMaterial.mapFlags(): Float =
((if (normalTexture != null) 1 else 0) or (if (metallicRoughnessTexture != null) 2 else 0) or
(if (occlusionTexture != null) 4 else 0) or (if (emissiveTexture != null) 8 else 0) or
(if (clearcoatTexture != null) 16 else 0) or
// Bits 5–10: which maps sample through uvTransform (FabricateMaterial.UV_BASE_COLOR … UV_CLEARCOAT).
((uvTransformMaps and FabricateMaterial.UV_ALL_MAPS) shl 5)).toFloat()
fun StandardRenderer.Bundle.PullingMeshPair.applyFabricateMaterial
fun StandardRenderer.Bundle.PullingMeshPair.applyFabricateMaterial(
material: FabricateMaterial
): StandardRenderer.Bundle.PullingMeshPair {
val push = gBuffer.mesh.push
if (push.offsets.containsKey("mapFlags")) push["mapFlags"] = material.mapFlags()
if (push.offsets.containsKey("baseColor")) push["baseColor"] = Vector4f(material.baseColor)
if (push.offsets.containsKey("metallic")) push["metallic"] = material.metallic
if (push.offsets.containsKey("roughness")) push["roughness"] = material.roughness
if (push.offsets.containsKey("ao")) push["ao"] = if (material.receiveAo) 1f else 0f
if (push.offsets.containsKey("hasNormalMap")) push["hasNormalMap"] = if (material.normalTexture != null) 1f else 0f
if (push.offsets.containsKey("hasMetallicRoughnessMap")) push["hasMetallicRoughnessMap"] = if (material.metallicRoughnessTexture != null) 1f else 0f
if (push.offsets.containsKey("hasOcclusionMap")) push["hasOcclusionMap"] = if (material.occlusionTexture != null) 1f else 0f
if (push.offsets.containsKey("hasEmissiveMap")) push["hasEmissiveMap"] = if (material.emissiveTexture != null) 1f else 0f
if (push.offsets.containsKey("hasClearcoatMap")) push["hasClearcoatMap"] = if (material.clearcoatTexture != null) 1f else 0f
if (push.offsets.containsKey("emissiveFactor")) push["emissiveFactor"] = Vector4f(material.emissiveFactor, 1f)
if (push.offsets.containsKey("clearcoatFactor")) push["clearcoatFactor"] = material.clearcoatFactor
if (push.offsets.containsKey("clearcoatRoughness")) push["clearcoatRoughness"] = material.clearcoatRoughnessFactor
if (push.offsets.containsKey("uvTransform")) push["uvTransform"] = Vector4f(material.uvTransform)
return this
}
fun StandardRenderer.PullingMesh.applyFabricateMaterial
body — 73 lines, from line 812
fun StandardRenderer.PullingMesh.applyFabricateMaterial(
material: FabricateMaterial
): StandardRenderer.PullingMesh {
val push = mesh.push
if (push.offsets.containsKey("mapFlags")) push["mapFlags"] = material.mapFlags()
if (push.offsets.containsKey("baseColor")) push["baseColor"] = Vector4f(material.baseColor)
if (push.offsets.containsKey("metallic")) push["metallic"] = material.metallic
if (push.offsets.containsKey("roughness")) push["roughness"] = material.roughness
if (push.offsets.containsKey("ao")) push["ao"] = if (material.receiveAo) 1f else 0f
if (push.offsets.containsKey("hasNormalMap")) push["hasNormalMap"] = if (material.normalTexture != null) 1f else 0f
if (push.offsets.containsKey("hasMetallicRoughnessMap")) push["hasMetallicRoughnessMap"] = if (material.metallicRoughnessTexture != null) 1f else 0f
if (push.offsets.containsKey("hasOcclusionMap")) push["hasOcclusionMap"] = if (material.occlusionTexture != null) 1f else 0f
if (push.offsets.containsKey("hasEmissiveMap")) push["hasEmissiveMap"] = if (material.emissiveTexture != null) 1f else 0f
if (push.offsets.containsKey("hasClearcoatMap")) push["hasClearcoatMap"] = if (material.clearcoatTexture != null) 1f else 0f
if (push.offsets.containsKey("emissiveFactor")) push["emissiveFactor"] = Vector4f(material.emissiveFactor, 1f)
if (push.offsets.containsKey("clearcoatFactor")) push["clearcoatFactor"] = material.clearcoatFactor
if (push.offsets.containsKey("clearcoatRoughness")) push["clearcoatRoughness"] = material.clearcoatRoughnessFactor
if (push.offsets.containsKey("uvTransform")) push["uvTransform"] = Vector4f(material.uvTransform)
return this
}
private fun writeGltfMesh(
instance: GltfSupport.MeshInstance,
pair: StandardRenderer.Bundle.PullingMeshPair
) {
writeGltfMesh(instance, pair.gBuffer)
pair.shadow.setVertexCount(pair.gBuffer.mesh.numVert)
}
private fun writeGltfMesh(
instance: GltfSupport.MeshInstance,
mesh: StandardRenderer.PullingMesh
) {
val vertexData = instance.buffers.vertexData.asFloatBuffer()
val tangentData = instance.buffers.tangentData?.asFloatBuffer()
val indexData = instance.buffers.indexData
val indexCount = instance.buffers.indexCount
mesh.position.write {
for (i in 0 until indexCount) {
val base = indexData.get(i) * 12
put(vertexData.get(base), vertexData.get(base + 1), vertexData.get(base + 2), 1f)
}
}
mesh.normal.write {
for (i in 0 until indexCount) {
val base = indexData.get(i) * 12
put(vertexData.get(base + 3), vertexData.get(base + 4), vertexData.get(base + 5), 0f)
}
}
mesh.uv?.write {
for (i in 0 until indexCount) {
val base = indexData.get(i) * 12
put(vertexData.get(base + 6), vertexData.get(base + 7), 0f, 0f)
}
}
mesh.tangent?.write {
for (i in 0 until indexCount) {
val base = indexData.get(i) * 4
if (tangentData != null) {
put(
tangentData.get(base),
tangentData.get(base + 1),
tangentData.get(base + 2),
tangentData.get(base + 3)
)
} else {
put(1f, 0f, 0f, 0f)
}
}
}
mesh.setVertexCount(indexCount)
}