Projection.kt
src/jvmMain/kotlin/afield/graphics/vulkan/util/Projection.kt · 576 lines
package afield.graphics.vulkan.util
Projection matrices for the Vulkan renderer: a plain perspective Projection and a SuperProjection that blends perspective into orthographic.
SuperProjection is what Camera uses. Its orthoness runs from 0 (perspective) to 1 (orthographic) along a matrix-log path, with matchDepth (or the camera's distance to its target) as the depth where the two footprints agree, and ox/oy shifting the principal point.
The remaining functions are the matrix tools behind that blend: matrix4fToSimple and simpleToMatrix4f convert to EJML, expmEigen and logmEigen take the matrix exponential and logarithm, lieInterpolateProjectionEJML interpolates two projections, and renormDepth01 and renormVerticalFovAtDepth rescale a matrix's depth range and its field of view at a chosen depth.
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.buffers.HasUniform
import afield.document.context.fromContext
import org.ejml.data.Complex_F64
import org.ejml.simple.SimpleMatrix
import afield.math.linalg.Matrix4f
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.tan
Simple perspective-only projection
class Projection : HasUniform<Matrix4f>
class Projection : HasUniform<Matrix4f> {
val projectionMatrix: Matrix4f
The matrix itself, rebuilt in place by resize and returned by evaluateUniform. Editing fov, zNear or zFar does not touch it until the next resize.
@JvmField
val projectionMatrix: Matrix4f
var fov = Options.fov
Vertical field of view in radians, and it must be positive.
A negative fovy does not flip the camera, it mirrors the image: h = 1 / tan(fovy / 2)
changes sign, which negates m00 and m11 together and so rotates the picture 180° while
preserving triangle winding. See Options.fov for the degrees-in-a-radians-slot bug that
shipped exactly that for the whole standard-renderer path.
var zNear = Options.zNear
Distance to the near clip plane, in view-space units; depth maps to 0 here. Defaults to Options.zNear.
var zFar = Options.zFar
Distance to the far clip plane, where depth maps to 1 — Vulkan's [0,1] range rather than OpenGL's [-1,1]. Defaults to Options.zFar.
var aspect = 1.0f
Width over height of the viewport this was last sized for. resize computes it; assigning it alone changes nothing until the next resize.
var auto: () -> Unit = {}
Run by evaluateUniform immediately before the matrix is handed to a shader. The renderer constructor sets it to re-size to the window, so a projection built that way follows the window; one built from an explicit width and height leaves it empty and must be resized by hand.
constructor(width: Int, height: Int, fov: Double = Options.fov)
constructor(width: Int, height: Int, fov: Double = Options.fov) {
this.projectionMatrix = Matrix4f()
this.fov = fov
this.resize(width, height)
}
constructor(renderer: Renderer = fromContext())
constructor(renderer: Renderer = fromContext()) {
this.projectionMatrix = Matrix4f()
resize(renderer.window.width, renderer.window.height)
auto = {
resize(renderer.window.width, renderer.window.height)
}
}
fun resize(width: Int, height: Int): Projection
fun resize(width: Int, height: Int): Projection {
projectionMatrix.identity()
aspect = width.toFloat() / height.toFloat()
projectionMatrix.setPerspective(
fov.toFloat(),
aspect,
zNear.toFloat(),
zFar.toFloat(),
true // Vulkan-style depth [0,1]
)
return this
}
override fun evaluateUniform(): Matrix4f
override fun evaluateUniform(): Matrix4f {
auto()
return projectionMatrix
}
}
class SuperProjection : HasUniform<Matrix4f>
class SuperProjection : HasUniform<Matrix4f> {
val projectionMatrix: Matrix4f
The blended matrix, rebuilt in place by resize and setAspect and returned by evaluateUniform. Every dial below is read when it is rebuilt, not when it is assigned.
@JvmField
val projectionMatrix: Matrix4f
var fov = Options.fov
Vertical field of view in radians, and must be positive — see Projection.fov.
var zNear = Options.zNear
Distance to the near clip plane, in view-space units; the blended matrix is renormalized so depth is 0 here. Defaults to Options.zNear.
var zFar = Options.zFar
Distance to the far clip plane, where the renormalized depth reaches 1. Defaults to Options.zFar.
var aspect = 1.0f
Width over height of the viewport, set by resize from a window size or by setAspect directly.
var ox = 0.0
Sideways offset of the principal point, in half-widths of what is visible at matchDepth: 0 is centred, 1 moves the centre of the picture a half-width towards +X.
var oy = 0.0
The same offset vertically, in half-heights at matchDepth. Off-centre projections of this kind are what tiled renders and stereo pairs are built from.
var orthoness = 0.0
Where the projection sits between perspective at 0 and orthographic at 1. Values between the two are interpolated along a matrix-logarithm path rather than by blending the two matrices entry by entry, and anything outside [0,1] is clamped.
var matchDepth = 5.0
The view-space depth, measured along -Z, at which the perspective and orthographic ends of the blend frame exactly the same rectangle. The blend is also renormalized to hold the vertical scale there, so what the camera frames at this distance stays put as orthoness is turned and only nearer and farther geometry changes size. Zero or less falls back to the midpoint between zNear and zFar.
var autoMatchDistance: () -> Double
Where auto gets matchDepth from each frame — a focus distance, say, or the camera's distance to its subject. Both constructors default it to returning matchDepth unchanged, and only the renderer constructor installs an auto that calls it.
constructor(width: Int, height: Int, fov: Double = Options.fov)
constructor(width: Int, height: Int, fov: Double = Options.fov) {
this.projectionMatrix = Matrix4f()
this.fov = fov
this.resize(width, height)
this.autoMatchDistance = { matchDepth }
}
constructor(renderer: Renderer = fromContext(), autoMatchDistance: (() -> Double)?
constructor(renderer: Renderer = fromContext(), autoMatchDistance: (() -> Double)? = null) {
this.projectionMatrix = Matrix4f()
this.autoMatchDistance = autoMatchDistance ?: { matchDepth }
resize(renderer.window.width, renderer.window.height)
auto = {
matchDepth = this.autoMatchDistance()
resize(renderer.window.width, renderer.window.height)
}
}
var auto: () -> Unit = {}
Run by evaluateUniform immediately before the matrix is handed to a shader. The renderer constructor sets it to read autoMatchDistance and re-size to the window, which is what makes a projection built that way track both the window and the focus distance; one built from an explicit width and height leaves it empty.
fun resize(width: Int, height: Int): SuperProjection
fun resize(width: Int, height: Int): SuperProjection {
aspect = width.toFloat() / height.toFloat()
val m = buildLieInterpolatedProjection()
projectionMatrix.set(m)
return this
}
fun setAspect(aspect: Float): SuperProjection
fun setAspect(aspect: Float): SuperProjection {
this.aspect = aspect
val m = buildLieInterpolatedProjection()
projectionMatrix.set(m)
return this
}
override fun evaluateUniform(): Matrix4f
override fun evaluateUniform(): Matrix4f {
auto()
return projectionMatrix
}
Build a projection by Lie-interpolating between:
- an off-center perspective matrix (with FOV, aspect, zNear, zFar, ox, oy)
- an off-center orthographic matrix whose footprint (l/r/b/t) at matchDepth
matches that perspective footprint at the same depth.
Then apply:
- depth renormalization to [0,1]
- isotropic FOV renormalization so the vertical scale at matchDepth
matches the perspective's vertical scale there.
source — 72 lines, from line 188
private fun buildLieInterpolatedProjection(): Matrix4f {
val fovF = fov.toFloat()
val nearF = zNear.toFloat()
val farF = zFar.toFloat()
// Choose the effective match depth (fallback to midpoint if needed).
val matchDepthD = if (matchDepth > 0.0) {
matchDepth
} else {
0.5 * (zNear + zFar)
}
val matchDepthF = matchDepthD.toFloat()
// At depth d along -Z, the symmetric perspective half-height is:
// h(d) = d * tan(fov/2)
// and half-width = h(d) * aspect.
val halfHeightD = matchDepthD * tan(0.5 * fov)
val halfWidthD = halfHeightD * aspect
// Base symmetric extents at match depth (Double for accuracy)
val lBaseD = -halfWidthD
val rBaseD = +halfWidthD
val bBaseD = -halfHeightD
val tBaseD = +halfHeightD
// Apply principal-point offsets at matchDepth.
// ox/oy are in "half-width/half-height units" at matchDepth.
val dxD = ox * halfWidthD
val dyD = oy * halfHeightD
val lMatch = (lBaseD + dxD).toFloat()
val rMatch = (rBaseD + dxD).toFloat()
val bMatch = (bBaseD + dyD).toFloat()
val tMatch = (tBaseD + dyD).toFloat()
// Perspective: we need extents at the *near plane* for a frustum.
// Given extents at depth d, near-plane extents are scaled by (zNear / d).
val scaleToNear = nearF / matchDepthF
val lNear = lMatch * scaleToNear
val rNear = rMatch * scaleToNear
val bNear = bMatch * scaleToNear
val tNear = tMatch * scaleToNear
// Off-center perspective with Vulkan-style depth [0,1]
val persp = Matrix4f()
.identity()
.frustum(lNear, rNear, bNear, tNear, nearF, farF, true)
// Off-center ortho with the same footprint (l/r/b/t) at matchDepth.
// Since ortho is a prism, these extents hold for all depths; objects at
// matchDepth see identical x/y mapping in perspective vs. ortho.
val ortho = Matrix4f()
.identity()
.ortho(lMatch, rMatch, bMatch, tMatch, nearF, farF)
val tBlend = orthoness.toFloat().coerceIn(0f, 1f)
// Lie interpolation in GL(4): P(t) = P0 * exp(t * log(P0^{-1} * P1))
// Here P0 = perspective, P1 = ortho.
val raw = lieInterpolateProjectionEJML(persp, ortho, tBlend)
// Renormalize depth and FOV.
val perspRef = Matrix4f().set(persp)
val zRefF = matchDepthF
raw
.renormDepth01(nearF, farF)
.renormVerticalFovAtDepth(perspRef, zRefF, weight = 1f)
return raw
}
}
Field linalg <-> EJML conversion
fun matrix4fToSimple(m: Matrix4f): SimpleMatrix
Field Matrix4f -> EJML SimpleMatrix (4x4, row-major)
fun matrix4fToSimple(m: Matrix4f): SimpleMatrix {
return SimpleMatrix(
4, 4, true,
// Row 0 (x' row)
m.m00().toDouble(), m.m10().toDouble(), m.m20().toDouble(), m.m30().toDouble(),
// Row 1 (y' row)
m.m01().toDouble(), m.m11().toDouble(), m.m21().toDouble(), m.m31().toDouble(),
// Row 2 (z' row)
m.m02().toDouble(), m.m12().toDouble(), m.m22().toDouble(), m.m32().toDouble(),
// Row 3 (w' row)
m.m03().toDouble(), m.m13().toDouble(), m.m23().toDouble(), m.m33().toDouble()
)
}
fun simpleToMatrix4f(s: SimpleMatrix): Matrix4f
EJML SimpleMatrix (4x4) -> Field Matrix4f
fun simpleToMatrix4f(s: SimpleMatrix): Matrix4f {
require(s.numRows == 4 && s.numCols == 4) { "Expected 4x4 matrix" }
val m = Matrix4f()
// Row 0
m.m00(s.get(0, 0).toFloat())
m.m10(s.get(0, 1).toFloat())
m.m20(s.get(0, 2).toFloat())
m.m30(s.get(0, 3).toFloat())
// Row 1
m.m01(s.get(1, 0).toFloat())
m.m11(s.get(1, 1).toFloat())
m.m21(s.get(1, 2).toFloat())
m.m31(s.get(1, 3).toFloat())
// Row 2
m.m02(s.get(2, 0).toFloat())
m.m12(s.get(2, 1).toFloat())
m.m22(s.get(2, 2).toFloat())
m.m32(s.get(2, 3).toFloat())
// Row 3
m.m03(s.get(3, 0).toFloat())
m.m13(s.get(3, 1).toFloat())
m.m23(s.get(3, 2).toFloat())
m.m33(s.get(3, 3).toFloat())
return m
}
Matrix exp/log via eigen decomposition (EJML)
private const val IMAG_TOL = 1e-8
private const val POS_TOL = 1e-12
fun expmEigen(A: SimpleMatrix): SimpleMatrix
Matrix exponential via eigen decomposition: A = V * diag(λ) * V^{-1} exp(A) = V * diag(exp(λ)) * V^{-1}
Assumptions: - A has a full set of real eigenvectors (V invertible) - eigenvalues are numerically real
fun expmEigen(A: SimpleMatrix): SimpleMatrix {
require(A.numRows == A.numCols) { "expmEigen requires a square matrix" }
val n = A.numRows
val evd = A.eig()
val V = SimpleMatrix(n, n)
val Dexp = SimpleMatrix(n, n)
for (i in 0 until evd.numberOfEigenvalues) {
val lambda: Complex_F64 = evd.getEigenvalue(i)
if (abs(lambda.imaginary) > IMAG_TOL) {
throw IllegalArgumentException(
"expmEigen: complex eigenvalue λ=$lambda — not supported in this real implementation"
)
}
val lam = lambda.real
val v: SimpleMatrix? = evd.getEigenVector(i)
require(v != null) { "expmEigen: missing eigenvector for eigenvalue index $i" }
// Place v as column i in V
for (r in 0 until n) {
V.set(r, i, v.get(r, 0))
}
Dexp.set(i, i, exp(lam))
}
val Vinv = V.invert()
return V.mult(Dexp).mult(Vinv)
}
fun logmEigen(A: SimpleMatrix): SimpleMatrix
Matrix logarithm via eigen decomposition (real principal log): A = V * diag(λ) * V^{-1} log(A) = V * diag(log(λ)) * V^{-1}
Requires: - all eigenvalues have |Im(λ)| < IMAG_TOL - all eigenvalues have Re(λ) > POS_TOL (no nonpositive real eigenvalues) - A has a full set of real eigenvectors (V invertible)
fun logmEigen(A: SimpleMatrix): SimpleMatrix {
require(A.numRows == A.numCols) { "logmEigen requires a square matrix" }
val n = A.numRows
val evd = A.eig()
val V = SimpleMatrix(n, n)
val Dlog = SimpleMatrix(n, n)
for (i in 0 until evd.numberOfEigenvalues) {
val lambda: Complex_F64 = evd.getEigenvalue(i)
if (abs(lambda.imaginary) > IMAG_TOL) {
throw IllegalArgumentException(
"logmEigen: complex eigenvalue λ=$lambda — real principal log not implemented here"
)
}
val lam = lambda.real
if (lam <= POS_TOL) {
throw IllegalArgumentException(
"logmEigen: non-positive eigenvalue λ=$lam — real log undefined"
)
}
val v: SimpleMatrix? = evd.getEigenVector(i)
require(v != null) { "logmEigen: missing eigenvector for eigenvalue index $i" }
for (r in 0 until n) {
V.set(r, i, v.get(r, 0))
}
Dlog.set(i, i, ln(lam))
}
val Vinv = V.invert()
return V.mult(Dlog).mult(Vinv)
}
fun lieInterpolateProjectionEJML
Lie-group interpolation in GL(4, R) between P0 and P1:
M = P0^{-1} * P1 L = log(M) P(t) = P0 * exp(t * L)
t in [0,1], so P(0) = P0, P(1) = P1.
fun lieInterpolateProjectionEJML(
P0: Matrix4f,
P1: Matrix4f,
t: Float
): Matrix4f {
require(t in 0f..1f) { "t must be in [0,1], got $t" }
val A0 = matrix4fToSimple(P0)
val A1 = matrix4fToSimple(P1)
val M = A0.invert().mult(A1) // relative transform P0^{-1} P1
val L = logmEigen(M)
val Lt = L.scale(t.toDouble())
val E = expmEigen(Lt)
val At = A0.mult(E)
return simpleToMatrix4f(At)
}
Renormalization helpers (depth + FOV) on Matrix4f
Field transform (column-major): x' = m00x + m10y + m20z + m30w y' = m01x + m11y + m21z + m31w z' = m02x + m12y + m22z + m32w w' = m03x + m13y + m23z + m33w
private fun Matrix4f.ndcDepthAt(zView: Float): Float {
val x = 0f
val y = 0f
val z = zView
val w = 1f
val cz = m02() * x + m12() * y + m22() * z + m32() * w
val cw = m03() * x + m13() * y + m23() * z + m33() * w
return cz / cw
}
private fun Matrix4f.ndcY(x: Float, y: Float, z: Float): Float {
val w = 1f
val cy = m01() * x + m11() * y + m21() * z + m31() * w
val cw = m03() * x + m13() * y + m23() * z + m33() * w
return cy / cw
}
Approximate vertical scale in NDC at a given view-space depth: scale ≈ d(y_ndc) / d(y_view)
private fun Matrix4f.verticalScaleAtDepth(zView: Float, sampleY: Float = 1f): Float {
val x = 0f
val y0 = 0f
val y1 = sampleY
val yNdc0 = ndcY(x, y0, zView)
val yNdc1 = ndcY(x, y1, zView)
return (yNdc1 - yNdc0) / sampleY
}
fun Matrix4f.renormDepth01(near: Float, far: Float): Matrix4f
Remap depth so that: z = -near → z_ndc = 0 z = -far → z_ndc = 1
Assumes right-handed view space (camera at origin, looking down -Z). Only adjusts the Z row; W row is preserved.
fun Matrix4f.renormDepth01(near: Float, far: Float): Matrix4f {
val zn = ndcDepthAt(-near)
val zf = ndcDepthAt(-far)
val eps = 1e-6f
val denom = zf - zn
if (abs(denom) < eps) {
// Degenerate mapping; leave it alone.
return this
}
// z_ndc' = (z_ndc - zn) / (zf - zn) = a*z_ndc + b
val a = 1f / denom
val b = -zn / denom
// Old Z row and W row
val z0 = m02()
val w0 = m03()
val z1 = m12()
val w1 = m13()
val z2 = m22()
val w2 = m23()
val z3 = m32()
val w3 = m33()
// New Z row = a * oldZ + b * oldW
m02(a * z0 + b * w0)
m12(a * z1 + b * w1)
m22(a * z2 + b * w2)
m32(a * z3 + b * w3)
return this
}
fun Matrix4f.renormVerticalFovAtDepth
Isotropic FOV renormalization at a given depth zRef.
Adjusts both X and Y rows so that the vertical NDC scale at zRef matches
that of the reference perspective matrix. The weight parameter blends
between no change (0) and full correction (1).
body — 47 lines, from line 529
fun Matrix4f.renormVerticalFovAtDepth(
referencePersp: Matrix4f,
zRef: Float,
sampleY: Float = 1f,
weight: Float = 1f
): Matrix4f {
val w = weight.coerceIn(0f, 1f)
if (w <= 0f) {
// No FOV correction requested
return this
}
val zView = -zRef
val targetScale = referencePersp.verticalScaleAtDepth(zView, sampleY)
val currentScale = this.verticalScaleAtDepth(zView, sampleY)
val eps = 1e-6f
if (kotlin.math.abs(currentScale) < eps) {
// Avoid dividing by ~0; skip in pathological cases
return this
}
// Full correction factor (what we'd apply with weight = 1)
val full = targetScale / currentScale
// Blend between 1 (no change) and full correction
// weight = 0 -> s = 1
// weight = 1 -> s = full
val s = 1f + w * (full - 1f)
// Isotropic: scale BOTH X and Y rows by the same factor s.
// X row: (m00, m10, m20, m30)
m00(m00() * s)
m10(m10() * s)
m20(m20() * s)
m30(m30() * s)
// Y row: (m01, m11, m21, m31)
m01(m01() * s)
m11(m11() * s)
m21(m21() * s)
m31(m31() * s)
return this
}