Skip to content

SplatCloudEdit.kt

src/jvmMain/kotlin/afield/graphics/render/SplatCloudEdit.kt · 545 lines

@file:Suppress("MemberVisibilityCanBePrivate", "unused")

package afield.graphics.render

import afield.graphics.scene.CompressedSplatPly
import afield.graphics.scene.CompressedSplatPlyWriter
import afield.graphics.scene.PlyFloatTable
import afield.graphics.scene.readCompressedSplatPly
import afield.graphics.scene.readPlyAsFloatTable
import afield.math.linalg.Matrix4f
import afield.math.linalg.Vector3f
import java.io.File
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt

Editable CPU-side Gaussian splat clouds: SplatCloud holds splats in world-unit scales, 0..1 opacity and linear colour, so cropping, filtering, decimating, transforming and merging are plain arithmetic.

A cloud converts from the table readCompressedSplatPly reads and writes back out as a .compressed.ply that CompressedSplatPly reads again.

class SplatCloud

A Gaussian splat cloud as editable CPU data: read it, ask it questions, crop it, filter it, decimate it, move it, merge it, and write it back out as a .compressed.ply that CompressedSplatPly reads again.

Why a fourth representation

The tree already has three. PlyFloatTable is the file's own conventions — log scales, logit opacity, spherical-harmonic DC colour — which is right for a reader and hostile to a person: the number you would type to make a splat half as wide is not 0.5 but a subtraction of ln 2. CompressedSplatPly.RawSplats is the quantized payload, deliberately undecoded. And afield.graphics.scene.fabricated.GaussianSplatRenderer's 16-float layout is a rendering form — the covariance is already baked into six numbers from which the scale and rotation cannot be cleanly recovered.

SplatCloud is the form you can edit. Scales are world units, opacity is 0..1, colour is linear 0..1, rotations are unit quaternions. Every editing operation below is expressible as arithmetic a person would recognise, and none of them needs to know that the file spells any of it differently.

It does not add a fourth decoder. of converts from the table readCompressedSplatPly already produced; the bit-level unpacking happens once, in CompressedSplatPly, as it did before.

The shape worth reaching for

val cloud = SplatCloud.read("frame_000060.compressed.ply")
println(cloud.stats())                                   // query
val trimmed = cloud.filtered(minAlpha = 0.15f)           // edit
    .cropped(Vector3f(-0.5f, -1f, 0f), Vector3f(0.5f, 1f, 2f))
    .decimated(0.5f)
val png = SplatRaster.rasterize(                          // preview, no GPU
    listOf(GaussianSplatRenderer.SplatSource(trimmed.toRawSplats())), view
).image.toPng()
trimmed.write(File("trimmed.compressed.ply"))             // write

Read, query, edit, preview, write — and the written file goes back through the same reader, which SplatCloudEditTest checks by round-tripping and comparing.

class SplatCloud(
    /** `x, y, z` per splat, world space. */
    val positions: FloatArray,
    /** Linear `r, g, b` per splat in 0..1 — what the shader consumes, not the SH DC coefficient. */
    val colors: FloatArray,
    /** Opacity in 0..1, post-sigmoid. */
    val alphas: FloatArray,
    /** Unit quaternion per splat, `w, x, y, z` — `rot_0` first, as a 3DGS PLY spells it. */
    val rotations: FloatArray,
    /** `sx, sy, sz` per splat in **world units**, not logs. */
    val scales: FloatArray,
    val count: Int
) {

init

    init {
        require(count >= 0) { "count must be non-negative, got $count" }
        require(positions.size >= count * 3) { "positions holds ${positions.size / 3} of $count splats" }
        require(colors.size >= count * 3) { "colors holds ${colors.size / 3} of $count splats" }
        require(alphas.size >= count) { "alphas holds ${alphas.size} of $count splats" }
        require(rotations.size >= count * 4) { "rotations holds ${rotations.size / 4} of $count splats" }
        require(scales.size >= count * 3) { "scales holds ${scales.size / 3} of $count splats" }
    }

Query

data class CloudStats

What is actually in this cloud.

Percentiles rather than only means, because the questions a capture raises are about tails: a depth estimator that lost the plot leaves a few splats a hundred times too large, and a mean scale will not show it while a p99 will.

    data class CloudStats(
        val count: Int,
        val boundsMin: Vector3f,
        val boundsMax: Vector3f,
        val meanColor: Vector3f,
        /** Mean of `max(rgb) - min(rgb)`. Near zero means the capture is greyscale. */
        val meanSaturation: Float,
        val meanAlpha: Float,
        val alphaPercentiles: FloatArray,
        /** Geometric mean of the three world-space scales, per splat, then averaged. */
        val meanScale: Float,
        val scalePercentiles: FloatArray,
        val nonFinite: Int
    ) {

companion object

        companion object {
val PERCENTILES = floatArrayOf(1f, 25f, 50f, 75f, 99f)

The percentiles alphaPercentiles and scalePercentiles are reported at.

override fun toString(): String

        override fun toString(): String = buildString {
            append("$count splats\n")
            append("  bounds      $boundsMin .. $boundsMax\n")
            append("  size        (%.4f, %.4f, %.4f)\n".format(
                boundsMax.x - boundsMin.x, boundsMax.y - boundsMin.y, boundsMax.z - boundsMin.z
            ))
            // An em dash rather than "<-": this string lands in a box's output panel, which renders
            // HTML, and a literal '<' in text that a browser is going to parse is a hazard not worth
            // taking for the sake of an arrow.
            append("  colour      mean (%.4f, %.4f, %.4f), saturation %.4f%s\n".format(
                meanColor.x, meanColor.y, meanColor.z, meanSaturation,
                if (meanSaturation < 0.02f) "  — effectively greyscale" else ""
            ))
            append("  opacity     mean %.4f, p1/p25/p50/p75/p99 %s\n".format(
                meanAlpha, alphaPercentiles.joinToString(" / ") { "%.3f".format(it) }
            ))
            append("  scale (m)   mean %.5f, p1/p25/p50/p75/p99 %s\n".format(
                meanScale, scalePercentiles.joinToString(" / ") { "%.5f".format(it) }
            ))
            append(if (nonFinite == 0) "  non-finite  none" else "  non-finite  $nonFinite SPLATS")
        }
    }

fun stats(): CloudStats

body — 68 lines, from line 142
    fun stats(): CloudStats {
        val min = Vector3f(Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE)
        val max = Vector3f(-Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE)
        var sumR = 0.0; var sumG = 0.0; var sumB = 0.0
        var sumSaturation = 0.0
        var sumAlpha = 0.0
        var sumScale = 0.0
        var nonFinite = 0
        val alphaSamples = FloatArray(count)
        val scaleSamples = FloatArray(count)
        var good = 0

        for (splat in 0 until count) {
            val x = positions[splat * 3]
            val y = positions[splat * 3 + 1]
            val z = positions[splat * 3 + 2]
            val alpha = alphas[splat]
            val sx = scales[splat * 3]
            val sy = scales[splat * 3 + 1]
            val sz = scales[splat * 3 + 2]
            if (!x.isFinite() || !y.isFinite() || !z.isFinite() || !alpha.isFinite() ||
                !sx.isFinite() || !sy.isFinite() || !sz.isFinite()
            ) {
                nonFinite++
                continue
            }
            min.set(min(min.x, x), min(min.y, y), min(min.z, z))
            max.set(max(max.x, x), max(max.y, y), max(max.z, z))
            val r = colors[splat * 3]
            val g = colors[splat * 3 + 1]
            val b = colors[splat * 3 + 2]
            sumR += r; sumG += g; sumB += b
            sumSaturation += (max(r, max(g, b)) - min(r, min(g, b))).toDouble()
            sumAlpha += alpha
            val scale = Math.cbrt((sx * sy * sz).toDouble()).toFloat()
            sumScale += scale
            alphaSamples[good] = alpha
            scaleSamples[good] = scale
            good++
        }
        if (good == 0) {
            return CloudStats(
                count, Vector3f(), Vector3f(), Vector3f(), 0f, 0f,
                FloatArray(CloudStats.PERCENTILES.size), 0f,
                FloatArray(CloudStats.PERCENTILES.size), nonFinite
            )
        }
        java.util.Arrays.sort(alphaSamples, 0, good)
        java.util.Arrays.sort(scaleSamples, 0, good)
        fun percentiles(sorted: FloatArray) = FloatArray(CloudStats.PERCENTILES.size) { index ->
            val at = ((CloudStats.PERCENTILES[index] / 100f) * (good - 1)).toInt().coerceIn(0, good - 1)
            sorted[at]
        }
        return CloudStats(
            count = count,
            boundsMin = min,
            boundsMax = max,
            meanColor = Vector3f(
                (sumR / good).toFloat(), (sumG / good).toFloat(), (sumB / good).toFloat()
            ),
            meanSaturation = (sumSaturation / good).toFloat(),
            meanAlpha = (sumAlpha / good).toFloat(),
            alphaPercentiles = percentiles(alphaSamples),
            meanScale = (sumScale / good).toFloat(),
            scalePercentiles = percentiles(scaleSamples),
            nonFinite = nonFinite
        )
    }

Edit

fun where(keep: (Int) -> Boolean): SplatCloud {

A new cloud holding only the splats keep accepts, in the same order.

    fun where(keep: (Int) -> Boolean): SplatCloud {
        val chosen = IntArray(count)
        var kept = 0
        for (splat in 0 until count) if (keep(splat)) chosen[kept++] = splat
        return gather(chosen, kept)
    }

fun cropped(min: Vector3f, max: Vector3f): SplatCloud

Splats whose centres lie inside the axis-aligned box min..max, inclusive.

    fun cropped(min: Vector3f, max: Vector3f): SplatCloud = where { splat ->
        val x = positions[splat * 3]
        val y = positions[splat * 3 + 1]
        val z = positions[splat * 3 + 2]
        x >= min.x && x <= max.x && y >= min.y && y <= max.y && z >= min.z && z <= max.z
    }

fun filtered

Splats within the given opacity and world-scale windows.

The default maxScale of infinity and minAlpha of zero mean "no filter", so a caller can set one bound and leave the rest alone. Non-finite splats are dropped by any filter at all — that is the one place this is not purely a predicate, and it is deliberate: a filter is the natural place to get rid of them, and keeping them would make every downstream count a lie.

    fun filtered(
        minAlpha: Float = 0f,
        maxAlpha: Float = 1f,
        minScale: Float = 0f,
        maxScale: Float = Float.POSITIVE_INFINITY
    ): SplatCloud = where { splat ->
        val alpha = alphas[splat]
        val sx = scales[splat * 3]
        val sy = scales[splat * 3 + 1]
        val sz = scales[splat * 3 + 2]
        val largest = max(sx, max(sy, sz))
        alpha.isFinite() && sx.isFinite() && sy.isFinite() && sz.isFinite() &&
            positions[splat * 3].isFinite() && positions[splat * 3 + 1].isFinite() &&
            positions[splat * 3 + 2].isFinite() &&
            alpha >= minAlpha && alpha <= maxAlpha && largest >= minScale && largest <= maxScale
    }

fun decimated(fraction: Float): SplatCloud

A fraction of the splats, taken by a deterministic stride rather than at random.

A stride over a Morton-ordered file — which is what a .compressed.ply is — thins the cloud roughly uniformly in space, because neighbours in Morton order are neighbours in space. Random sampling would give the same expected density with more variance and no reproducibility, and reproducibility is what makes a decimated preview comparable between runs.

    fun decimated(fraction: Float): SplatCloud {
        require(fraction > 0f && fraction <= 1f) { "fraction must be in (0, 1], got $fraction" }
        if (fraction == 1f) return this
        val chosen = IntArray(count)
        var kept = 0
        var accumulator = 0f
        for (splat in 0 until count) {
            accumulator += fraction
            if (accumulator >= 1f) { accumulator -= 1f; chosen[kept++] = splat }
        }
        return gather(chosen, kept)
    }

fun transformed(transform: Matrix4f): SplatCloud

Every splat through transform — position by the full affine map, rotation and scale by the linear part.

The linear part is decomposed back into a rotation and three scales, because that is what a .compressed.ply can store. A rigid transform, or a rigid one with uniform scale, survives this exactly. Shear does not: the file format has no way to hold it, so a sheared covariance is approximated by the nearest scale-and-rotate and shearLoss on the result says how much was given up. If you need shear, bake it on the GPU side instead — GaussianSplatRenderer.SplatSource.transform carries a general linear map through the covariance congruence exactly and never has to write it down.

body — 61 lines, from line 289
    fun transformed(transform: Matrix4f): SplatCloud {
        val out = SplatCloud(
            positions = FloatArray(count * 3),
            colors = colors.copyOf(count * 3),
            alphas = alphas.copyOf(count),
            rotations = FloatArray(count * 4),
            scales = FloatArray(count * 3),
            count = count
        )
        var worstShear = 0f
        // Matrix4f is column-major, m<column><row>: L[row][col] is m<col><row>.
        val l = arrayOf(
            floatArrayOf(transform.m00, transform.m10, transform.m20),
            floatArrayOf(transform.m01, transform.m11, transform.m21),
            floatArrayOf(transform.m02, transform.m12, transform.m22)
        )
        for (splat in 0 until count) {
            val x = positions[splat * 3]
            val y = positions[splat * 3 + 1]
            val z = positions[splat * 3 + 2]
            out.positions[splat * 3] = l[0][0] * x + l[0][1] * y + l[0][2] * z + transform.m30
            out.positions[splat * 3 + 1] = l[1][0] * x + l[1][1] * y + l[1][2] * z + transform.m31
            out.positions[splat * 3 + 2] = l[2][0] * x + l[2][1] * y + l[2][2] * z + transform.m32

            // A = L * R, then A back to a rotation and three scales by Gram-Schmidt.
            val r = quaternionToMatrix(
                rotations[splat * 4], rotations[splat * 4 + 1],
                rotations[splat * 4 + 2], rotations[splat * 4 + 3]
            )
            val a = Array(3) { row ->
                FloatArray(3) { column ->
                    l[row][0] * r[0][column] + l[row][1] * r[1][column] + l[row][2] * r[2][column]
                }
            }
            // Columns of A are the splat's principal axes, scaled. Orthogonalise them.
            val axes = Array(3) { column -> floatArrayOf(a[0][column], a[1][column], a[2][column]) }
            val lengths = FloatArray(3)
            for (column in 0..2) {
                for (previous in 0 until column) {
                    val dot = axes[column][0] * axes[previous][0] +
                        axes[column][1] * axes[previous][1] + axes[column][2] * axes[previous][2]
                    worstShear = max(worstShear, abs(dot))
                    for (row in 0..2) axes[column][row] -= dot * axes[previous][row]
                }
                val length = sqrt(
                    axes[column][0] * axes[column][0] + axes[column][1] * axes[column][1] +
                        axes[column][2] * axes[column][2]
                )
                lengths[column] = length
                if (length > 1e-20f) for (row in 0..2) axes[column][row] /= length
                else { axes[column][0] = if (column == 0) 1f else 0f
                    axes[column][1] = if (column == 1) 1f else 0f
                    axes[column][2] = if (column == 2) 1f else 0f }
            }
            for (axis in 0..2) out.scales[splat * 3 + axis] = lengths[axis] * scales[splat * 3 + axis]
            val quaternion = matrixToQuaternion(axes)
            for (component in 0..3) out.rotations[splat * 4 + component] = quaternion[component]
        }
        out.shearLoss = worstShear
        return out
    }

var shearLoss: Float

How much shear transformed had to throw away: the largest absolute dot product it saw between two axes that should have been orthogonal, before it orthogonalised them. Zero after any transform the format can represent exactly.

    var shearLoss: Float = 0f
        private set

fun withAlphaScaled(factor: Float): SplatCloud

This cloud with every opacity multiplied by factor and clamped to 0..1.

    fun withAlphaScaled(factor: Float): SplatCloud = SplatCloud(
        positions = positions.copyOf(count * 3),
        colors = colors.copyOf(count * 3),
        alphas = FloatArray(count) { (alphas[it] * factor).coerceIn(0f, 1f) },
        rotations = rotations.copyOf(count * 4),
        scales = scales.copyOf(count * 3),
        count = count
    )

fun withScale(factor: Float): SplatCloud

This cloud with every world scale multiplied by factor.

    fun withScale(factor: Float): SplatCloud = SplatCloud(
        positions = positions.copyOf(count * 3),
        colors = colors.copyOf(count * 3),
        alphas = alphas.copyOf(count),
        rotations = rotations.copyOf(count * 4),
        scales = FloatArray(count * 3) { scales[it] * factor },
        count = count
    )

    private fun gather(chosen: IntArray, kept: Int): SplatCloud {
        val out = SplatCloud(
            FloatArray(kept * 3), FloatArray(kept * 3), FloatArray(kept),
            FloatArray(kept * 4), FloatArray(kept * 3), kept
        )
        for (slot in 0 until kept) {
            val from = chosen[slot]
            for (axis in 0..2) {
                out.positions[slot * 3 + axis] = positions[from * 3 + axis]
                out.colors[slot * 3 + axis] = colors[from * 3 + axis]
                out.scales[slot * 3 + axis] = scales[from * 3 + axis]
            }
            out.alphas[slot] = alphas[from]
            for (component in 0..3) out.rotations[slot * 4 + component] = rotations[from * 4 + component]
        }
        return out
    }

Back to something renderable, and to disk

fun toRawSplats(): CompressedSplatPly.RawSplats = CompressedSplatPlyWriter.quantize(this)

Re-quantize into CompressedSplatPly.RawSplats, so an edited cloud can go straight into GaussianSplatRenderer.packInto — and therefore into SplatRaster and into a live GPU Cloud.reupload — without a file in between.

This is lossy in exactly the way the file format is: 11/10/11 bits of position per chunk, 10 bits per rotation component, 8 bits of colour and opacity. CompressedSplatPlyWriter.quantize does the work and is the same code path write uses, so what you preview is what you save.

fun write(file: File, mortonSort: Boolean = true)

Write a valid PlayCanvas .compressed.ply. See CompressedSplatPlyWriter.

    fun write(file: File, mortonSort: Boolean = true) =
        CompressedSplatPlyWriter.write(file, this, mortonSort)

companion object

    companion object {

fun of(table: PlyFloatTable): SplatCloud

Convert a decoded splat table — the one readCompressedSplatPly and readPlyAsFloatTable produce — into editable conventions.

This changes spelling, not values: f_dc · SH_C0 + 0.5 for colour, a sigmoid for opacity, exp for the scales. It deliberately does not re-read or re-decode anything, so there is still exactly one bit-level decoder in the tree.

        fun of(table: PlyFloatTable): SplatCloud {
            table.requireColumns(
                "x", "y", "z", "f_dc_0", "f_dc_1", "f_dc_2", "opacity",
                "rot_0", "rot_1", "rot_2", "rot_3", "scale_0", "scale_1", "scale_2"
            )
            val count = table.rowCount
            val cloud = SplatCloud(
                FloatArray(count * 3), FloatArray(count * 3), FloatArray(count),
                FloatArray(count * 4), FloatArray(count * 3), count
            )
            for (splat in 0 until count) {
                cloud.positions[splat * 3] = table.floatAt(splat, "x")
                cloud.positions[splat * 3 + 1] = table.floatAt(splat, "y")
                cloud.positions[splat * 3 + 2] = table.floatAt(splat, "z")
                for (channel in 0..2) {
                    cloud.colors[splat * 3 + channel] =
                        table.floatAt(splat, "f_dc_$channel") * CompressedSplatPly.SH_C0 + 0.5f
                }
                cloud.alphas[splat] = sigmoid(table.floatAt(splat, "opacity"))
                // Normalise on the way in: the file's quaternion is unit to ten bits per component,
                // and every consumer below assumes exactly unit.
                var w = table.floatAt(splat, "rot_0")
                var x = table.floatAt(splat, "rot_1")
                var y = table.floatAt(splat, "rot_2")
                var z = table.floatAt(splat, "rot_3")
                val length = sqrt(w * w + x * x + y * y + z * z)
                if (length > 1e-20f) { w /= length; x /= length; y /= length; z /= length }
                else { w = 1f; x = 0f; y = 0f; z = 0f }
                cloud.rotations[splat * 4] = w
                cloud.rotations[splat * 4 + 1] = x
                cloud.rotations[splat * 4 + 2] = y
                cloud.rotations[splat * 4 + 3] = z
                for (axis in 0..2) {
                    cloud.scales[splat * 3 + axis] =
                        exp(table.floatAt(splat, "scale_$axis").toDouble()).toFloat()
                }
            }
            return cloud
        }

fun read(path: String): SplatCloud = of(readPlyAsFloatTable(path))

Read a .compressed.ply (or any splat PLY) into editable conventions.

fun readCompressed(path: String): SplatCloud = of(readCompressedSplatPly(path))

Read a .compressed.ply specifically, failing loudly if it is not one.

fun merge(clouds: List<SplatCloud>): SplatCloud

Several clouds as one, concatenated in order.

Concatenated rather than interleaved by depth: the order a cloud is written in is the order a SortMode.None draw composites in, and re-sorting is packInto's job, not this one's.

        fun merge(clouds: List<SplatCloud>): SplatCloud {
            require(clouds.isNotEmpty()) { "merge needs at least one cloud" }
            if (clouds.size == 1) return clouds.single()
            val total = clouds.sumOf { it.count }
            val out = SplatCloud(
                FloatArray(total * 3), FloatArray(total * 3), FloatArray(total),
                FloatArray(total * 4), FloatArray(total * 3), total
            )
            var at = 0
            for (cloud in clouds) {
                System.arraycopy(cloud.positions, 0, out.positions, at * 3, cloud.count * 3)
                System.arraycopy(cloud.colors, 0, out.colors, at * 3, cloud.count * 3)
                System.arraycopy(cloud.alphas, 0, out.alphas, at, cloud.count)
                System.arraycopy(cloud.rotations, 0, out.rotations, at * 4, cloud.count * 4)
                System.arraycopy(cloud.scales, 0, out.scales, at * 3, cloud.count * 3)
                at += cloud.count
            }
            return out
        }

internal fun sigmoid(value: Float): Float

        internal fun sigmoid(value: Float): Float =
            (1.0 / (1.0 + exp(-value.toDouble()))).toFloat()

internal fun quaternionToMatrix(w: Float, x: Float, y: Float, z: Float): Array<FloatArray>

A unit quaternion's rotation matrix, row, as emitSplat spells it.

        internal fun quaternionToMatrix(w: Float, x: Float, y: Float, z: Float): Array<FloatArray> {
            val xx = x * x; val yy = y * y; val zz = z * z
            val xy = x * y; val xz = x * z; val yz = y * z
            val wx = w * x; val wy = w * y; val wz = w * z
            return arrayOf(
                floatArrayOf(1f - 2f * (yy + zz), 2f * (xy - wz), 2f * (xz + wy)),
                floatArrayOf(2f * (xy + wz), 1f - 2f * (xx + zz), 2f * (yz - wx)),
                floatArrayOf(2f * (xz - wy), 2f * (yz + wx), 1f - 2f * (xx + yy))
            )
        }

internal fun matrixToQuaternion(axes: Array<FloatArray>): FloatArray

A rotation matrix's w, x, y, z quaternion, via the largest-diagonal branch so the square root never divides by something near zero. axes is column — the three orthonormal axes as transformed builds them, which is the transpose of quaternionToMatrix's convention.

        internal fun matrixToQuaternion(axes: Array<FloatArray>): FloatArray {
            val m00 = axes[0][0]; val m01 = axes[1][0]; val m02 = axes[2][0]
            val m10 = axes[0][1]; val m11 = axes[1][1]; val m12 = axes[2][1]
            val m20 = axes[0][2]; val m21 = axes[1][2]; val m22 = axes[2][2]
            val trace = m00 + m11 + m22
            val q: FloatArray
            if (trace > 0f) {
                val s = sqrt(trace + 1f) * 2f
                q = floatArrayOf(0.25f * s, (m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s)
            } else if (m00 > m11 && m00 > m22) {
                val s = sqrt(1f + m00 - m11 - m22) * 2f
                q = floatArrayOf((m21 - m12) / s, 0.25f * s, (m01 + m10) / s, (m02 + m20) / s)
            } else if (m11 > m22) {
                val s = sqrt(1f + m11 - m00 - m22) * 2f
                q = floatArrayOf((m02 - m20) / s, (m01 + m10) / s, 0.25f * s, (m12 + m21) / s)
            } else {
                val s = sqrt(1f + m22 - m00 - m11) * 2f
                q = floatArrayOf((m10 - m01) / s, (m02 + m20) / s, (m12 + m21) / s, 0.25f * s)
            }
            val length = sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3])
            return if (length > 1e-20f) FloatArray(4) { q[it] / length } else floatArrayOf(1f, 0f, 0f, 0f)
        }

internal fun logOf(scale: Float): Float

        internal fun logOf(scale: Float): Float =
            if (scale > 0f) ln(scale.toDouble()).toFloat() else -20f
    }
}