[orx-camera] Add OrbitalManual, add instant parameter to OrbitalCamera functions

This commit is contained in:
Edwin Jakobs
2025-08-06 18:10:39 +02:00
parent 68f2505351
commit 95581bc6f1
7 changed files with 298 additions and 44 deletions

View File

@@ -35,6 +35,7 @@ class Camera2D : Extension, ChangeEvents {
set(value) {
if (value && !field) {
changed.trigger(Unit)
program.window.requestDraw()
}
field = value
}

View File

@@ -5,6 +5,9 @@ import org.openrndr.Program
import org.openrndr.draw.Drawer
import org.openrndr.events.Event
import org.openrndr.math.Vector3
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
/**
* Extension that provides orbital camera view and controls.
@@ -42,6 +45,7 @@ class Orbital : Extension, ChangeEvents {
val controls by lazy { OrbitalControls(camera, userInteraction, keySpeed) }
override fun setup(program: Program) {
camera.setup(program)
controls.setup(program)
}
@@ -52,4 +56,12 @@ class Orbital : Extension, ChangeEvents {
override fun afterDraw(drawer: Drawer, program: Program) {
camera.afterDraw(drawer, program)
}
@OptIn(ExperimentalContracts::class)
fun isolated(drawFunction: Drawer.() -> Unit) {
contract {
callsInPlace(drawFunction, InvocationKind.EXACTLY_ONCE)
}
camera.isolated(camera.program.drawer, drawFunction)
}
}

View File

@@ -9,6 +9,7 @@ import org.openrndr.math.Matrix44
import org.openrndr.math.Spherical
import org.openrndr.math.Vector3
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.pow
import org.openrndr.math.transforms.lookAt as lookAt_
@@ -27,6 +28,7 @@ class OrbitalCamera(
var projectionType: ProjectionType = ProjectionType.PERSPECTIVE
) : Extension, ChangeEvents {
internal lateinit var program: Program
override val changed = Event<Unit>()
override val hasChanged: Boolean
@@ -63,6 +65,14 @@ class OrbitalCamera(
var orthoNear = -1000.0
var orthoFar = 1000.0
/**
* Sets the view for the orbital camera by updating the look-at position, spherical coordinates,
* and field of view (FOV). This method initializes both the target and current states of these properties.
*
* @param lookAt the target position the camera should look at, represented as a 3D vector
* @param spherical the spherical coordinates defining the camera's orientation
* @param fov the field of view (in degrees) for the camera
*/
fun setView(lookAt: Vector3, spherical: Spherical, fov: Double) {
this.lookAt = lookAt
this.lookAtEnd = lookAt
@@ -72,82 +82,232 @@ class OrbitalCamera(
this.fovEnd = fov
}
fun rotate(rotX: Double, rotY: Double) {
sphericalEnd += Spherical(rotX, rotY, 0.0)
/**
* Rotates the orbital camera by the specified angles in the horizontal and vertical directions.
* The rotation can be applied instantly or smoothly interpolated over time.
*
* @param degreesX the rotation angle in degrees around the horizontal axis (theta)
* @param degreesY the rotation angle in degrees around the vertical axis (phi)
* @param instant whether the rotation is applied immediately; if false, it interpolates over time (default is false)
*/
fun rotate(degreesX: Double, degreesY: Double, instant: Boolean = false) {
sphericalEnd += Spherical(degreesX, degreesY, 0.0)
sphericalEnd = sphericalEnd.makeSafe()
if (instant) {
spherical = sphericalEnd
}
dirty = true
}
fun rotateTo(rotX: Double, rotY: Double) {
sphericalEnd = sphericalEnd.copy(theta = rotX, phi = rotY)
/**
* Rotates the camera to the specified spherical angles. The rotation can occur instantly or
* smoothly over time based on the `instant` parameter.
*
* @param degreesX the target horizontal rotation angle (theta) in degrees
* @param degreesY the target vertical rotation angle (phi) in degrees
* @param instant whether the rotation should be applied immediately (default is `false`)
*/
fun rotateTo(degreesX: Double, degreesY: Double, instant: Boolean = false) {
sphericalEnd = sphericalEnd.copy(theta = degreesX, phi = degreesY)
sphericalEnd = sphericalEnd.makeSafe()
if (instant) {
spherical = sphericalEnd
}
dirty = true
}
fun rotateTo(eye: Vector3) {
/**
* Rotates the orbital camera to the specified position defined by the `eye` vector.
* The rotation can either occur instantly or smoothly interpolated over time,
* depending on the `instant` parameter.
*
* @param eye the target position to rotate the camera to, represented as a 3D vector
* @param instant whether the rotation should be applied immediately (default is `false`)
*/
fun rotateTo(eye: Vector3, instant: Boolean = false) {
sphericalEnd = Spherical.fromVector(eye)
sphericalEnd = sphericalEnd.makeSafe()
if (instant) {
spherical = sphericalEnd
}
dirty = true
}
fun dollyIn() {
/**
* Zooms the camera in by decreasing the distance to the target. The zoom is based on
* an exponential scale factor determined by the `zoomSpeed` field. If the `instant`
* parameter is set to `true`, the zoom effect is applied immediately; otherwise, it
* will interpolate the change over time.
*
* @param instant whether the zoom-in effect should occur instantly (default is `false`)
*/
fun dollyIn(instant: Boolean = false) {
val zoomScale = pow(0.95, zoomSpeed)
dolly(sphericalEnd.radius * zoomScale - sphericalEnd.radius)
dolly(sphericalEnd.radius * zoomScale - sphericalEnd.radius, instant)
}
fun dollyOut() {
/**
* Zooms the camera out by increasing the distance to the target. The zoom operation
* is based on an exponential scale factor determined by the `zoomSpeed` field.
*
* @param instant whether the zoom-out effect should occur instantly (default is `false`)
*/
fun dollyOut(instant: Boolean = false) {
val zoomScale = pow(0.95, zoomSpeed)
dolly(sphericalEnd.radius / zoomScale - sphericalEnd.radius)
dolly(sphericalEnd.radius / zoomScale - sphericalEnd.radius, instant)
}
fun dolly(distance: Double) {
/**
* Adjusts the camera's distance from the target by the specified amount.
* The change in distance is applied immediately if `instant` is set to `true`,
* otherwise it will be interpolated over time with smoothing.
*
* @param distance the amount to adjust the camera's distance by
* @param instant whether the adjustment should be applied immediately (default is `false`)
*/
fun dolly(distance: Double, instant: Boolean = false) {
sphericalEnd += Spherical(0.0, 0.0, distance)
if (instant) {
spherical = sphericalEnd
}
dirty = true
}
fun pan(x: Double, y: Double, z: Double) {
fun pan(x: Double, y: Double, z: Double, instant: Boolean = false) {
val view = viewMatrix()
val xColumn = Vector3(view.c0r0, view.c1r0, view.c2r0) * x
val yColumn = Vector3(view.c0r1, view.c1r1, view.c2r1) * y
val zColumn = Vector3(view.c0r2, view.c1r2, view.c2r2) * z
lookAtEnd += xColumn + yColumn + zColumn
if (instant) {
lookAt = lookAtEnd
}
dirty = true
}
fun panTo(target: Vector3) {
/**
* Smoothly pans the camera to a specified target position. If the `instant` parameter is set
* to `true`, the panning occurs immediately; otherwise, it will be interpolated over time.
*
* @param target the target position to pan the camera to, represented as a 3D vector
* @param instant whether the panning should occur instantly (default is `false`)
*/
fun panTo(target: Vector3, instant: Boolean = false) {
lookAtEnd = target
if (instant) {
lookAt = lookAtEnd
}
dirty = true
}
fun dollyTo(distance: Double) {
/**
* Adjusts the camera's distance (radius) to the specified value. If the `instant` parameter
* is set to true, the distance change is applied immediately; otherwise, it will be interpolated
* over time during updates.
*
* @param distance the target distance (radius) that the camera should move to
* @param instant whether the distance adjustment should occur instantly (default is `false`)
*/
fun dollyTo(distance: Double, instant: Boolean = false) {
sphericalEnd = sphericalEnd.copy(radius = distance)
if (instant) {
spherical = sphericalEnd
}
dirty = true
}
fun scale(s: Double) {
magnitudeEnd += s
/**
* Adjusts the magnitude of the orbital camera by the specified scale factor.
* If the `instant` parameter is set to true, the adjustment is applied immediately;
* otherwise, it will be interpolated over time during updates.
*
* @param scale the amount by which to adjust the camera's magnitude
* @param instant whether the scale adjustment should be applied instantly (default is `false`)
*/
fun scale(scale: Double, instant: Boolean = false) {
magnitudeEnd += scale
if (instant) {
magnitude = magnitudeEnd
}
dirty = true
}
fun scaleTo(s: Double) {
magnitudeEnd = s
/**
* Adjusts the camera's scaling factor to the specified value. The scaling can either
* be applied instantly or interpolated over time during updates.
*
* @param scale the target scaling factor for the camera
* @param instant whether the scaling should be applied instantly (default is `false`)
*/
fun scaleTo(scale: Double, instant: Boolean = false) {
magnitudeEnd = scale
if (instant) {
magnitude = magnitudeEnd
}
dirty = true
}
fun zoom(degrees: Double) {
/**
* Adjusts the camera's field of view (FOV) by the specified number of degrees. The transition can either
* happen instantly or be interpolated over time during updates.
*
* @param degrees the number of degrees to adjust the field of view by
* @param instant whether the adjustment should occur instantly (default is `false`)
*/
fun zoom(degrees: Double, instant: Boolean = false) {
fovEnd += degrees
if (instant) {
fov = fovEnd
}
dirty = true
}
fun zoomTo(degrees: Double) {
/**
* Adjusts the camera's field of view (FOV) to the specified number of degrees. If the `instant`
* parameter is set to `true`, the FOV immediately transitions to the specified value; otherwise,
* it will be interpolated over time during updates.
*
* @param degrees the target field of view (in degrees) for the camera
* @param instant whether the transition to the target FOV should occur instantly (default is `false`)
*/
fun zoomTo(degrees: Double, instant: Boolean = false) {
fovEnd = degrees
if (instant) {
fov = fovEnd
}
dirty = true
}
/**
* Updates the orbital camera state by iteratively applying updates to the camera's parameters
* based on a fixed time step. The method ensures smooth interpolation of the camera properties
* (e.g., position, orientation) over a specified time delta.
*
* @param timeDelta the time elapsed for which the camera state should be updated, in seconds
*/
fun update(timeDelta: Double) {
if (!dirty) return
dirty = false
val stepSize = 1.0/60.0
val steps = max(timeDelta/stepSize, 1.0).toInt()
for (step in 0 until steps) {
updateStep(stepSize)
}
}
/**
* Updates the camera position, orientation, and view properties such as spherical coordinates,
* look-at point, field of view, and magnitude based on damping factors and time delta.
*
* @param timeDelta the time step used to update the interpolation of camera parameters
*/
fun updateStep(timeDelta: Double) {
val dampingFactor = if (dampingFactor > 0.0) {
dampingFactor * timeDelta / 0.0060
} else 1.0
@@ -179,6 +339,12 @@ class OrbitalCamera(
spherical = spherical.makeSafe()
}
/**
* Computes and returns the view matrix for the orbital camera. The view matrix is
* calculated using the current spherical coordinates, look-at position, and the up vector (Vector3.UNIT_Y).
*
* @return a 4x4 matrix representing the current view transformation of the camera
*/
fun viewMatrix(): Matrix44 {
return lookAt_(Vector3.fromSpherical(spherical) + lookAt, lookAt, Vector3.UNIT_Y)
}
@@ -190,9 +356,24 @@ class OrbitalCamera(
// EXTENSION
override var enabled: Boolean = true
override fun beforeDraw(drawer: Drawer, program: Program) {
override fun setup(program: Program) {
this.program = program
}
override fun beforeDraw(drawer: Drawer, program: Program) {
drawer.pushTransforms()
applyTo(drawer)
}
override fun afterDraw(drawer: Drawer, program: Program) {
drawer.popTransforms()
}
/**
* Enables the perspective camera. Use this faster method instead of .isolated()
* if you don't need to revert back to the orthographic projection.
*/
fun OrbitalCamera.applyTo(drawer: Drawer) {
if (lastSeconds == -1.0) lastSeconds = program.seconds
@@ -200,12 +381,21 @@ class OrbitalCamera(
lastSeconds = program.seconds
update(delta)
applyTo(drawer)
if (projectionType == ProjectionType.PERSPECTIVE) {
drawer.perspective(fov, drawer.width.toDouble() / drawer.height, near, far)
} else {
val ar = drawer.width * 1.0 / drawer.height
drawer.ortho(-ar * magnitude, ar * magnitude, -1.0 * magnitude, 1.0 * magnitude, orthoNear, orthoFar)
}
drawer.view = viewMatrix()
if (depthTest) {
drawer.drawStyle.depthWrite = true
drawer.drawStyle.depthTestPass = DepthTestPass.LESS_OR_EQUAL
}
}
override fun afterDraw(drawer: Drawer, program: Program) {
drawer.popTransforms()
}
}
/**
@@ -226,23 +416,18 @@ fun OrbitalCamera.isolated(drawer: Drawer, function: Drawer.() -> Unit) {
drawer.popTransforms()
}
private fun pow(a: Double, x: Double): Double = a.pow(x)
/**
* Enables the perspective camera. Use this faster method instead of .isolated()
* if you don't need to revert back to the orthographic projection.
* Creates an instance of the Orbital extension, sets it up with the calling Program,
* and returns the configured instance.
*
* @return a configured Orbital instance ready for use with the calling Program.
*/
fun OrbitalCamera.applyTo(drawer: Drawer) {
if (projectionType == ProjectionType.PERSPECTIVE) {
drawer.perspective(fov, drawer.width.toDouble() / drawer.height, near, far)
} else {
val ar = drawer.width * 1.0 / drawer.height
drawer.ortho(-ar * magnitude, ar * magnitude, -1.0 * magnitude, 1.0 * magnitude, orthoNear, orthoFar)
}
drawer.view = viewMatrix()
if (depthTest) {
drawer.drawStyle.depthWrite = true
drawer.drawStyle.depthTestPass = DepthTestPass.LESS_OR_EQUAL
}
}
private fun pow(a: Double, x: Double): Double = a.pow(x)
fun Program.OrbitalManual(): Orbital {
val orbital = Orbital()
orbital.setup(this)
return orbital
}

View File

@@ -92,7 +92,7 @@ class ParametricOrbital : Extension, ChangeEvents {
override fun setup(program: Program) {
camera.setup(program)
}
override fun beforeDraw(drawer: Drawer, program: Program) {

View File

@@ -1,3 +1,4 @@
import org.openrndr.PresentationMode
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.isolatedWithTarget

View File

@@ -1,3 +1,4 @@
import org.openrndr.WindowMultisample
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.DrawPrimitive
@@ -10,6 +11,11 @@ import org.openrndr.extra.meshgenerators.sphereMesh
import org.openrndr.math.Vector3
fun main() = application {
configure {
width = 720
height = 720
multisample = WindowMultisample.SampleCount(8)
}
program {
val camera = OrbitalCamera(
Vector3.UNIT_Z * 90.0, Vector3.ZERO, 90.0, 0.1, 5000.0
@@ -33,7 +39,7 @@ fun main() = application {
extend {
// mouse and keyboard input can be toggled on and off
controls.userInteraction = seconds.toInt() % 4 < 2
controls.userInteraction = true
drawer.vertexBuffer(sphere, DrawPrimitive.LINE_LOOP)
drawer.vertexBuffer(cube, DrawPrimitive.LINE_LOOP)

View File

@@ -0,0 +1,49 @@
import org.openrndr.WindowMultisample
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.DrawPrimitive
import org.openrndr.extra.camera.OrbitalManual
import org.openrndr.extra.meshgenerators.boxMesh
import org.openrndr.extra.meshgenerators.sphereMesh
import org.openrndr.math.Vector3
/**
* Demonstrate the use of an orbital camera to render a sphere and a cube in 3D space as wireframe meshes, positioned
* and rendered independently using the camera's isolated drawing state. A stationary pink circle is also drawn in the
* center of the scene.
*
* Functionality:
* - Initializes a sphere mesh and a cube mesh with predefined dimensions.
* - Spawns an orbital camera, initially positioned away from the origin, to allow for focused rendering.
* - Renders 3D wireframe shapes (sphere and cube) using the camera's isolated perspective.
* - Draws a static 2D pink circle overlay at the window center.
*/
fun main() = application {
configure {
width = 720
height = 720
multisample = WindowMultisample.SampleCount(8)
}
program {
val sphere = sphereMesh(radius = 25.0)
val cube = boxMesh(20.0, 20.0, 5.0, 5, 5, 2)
val camera = OrbitalManual()
camera.camera.rotateTo(Vector3(0.0, 0.0, 30.0), instant = true)
extend {
camera.isolated {
drawer.fill = ColorRGBa.WHITE
drawer.vertexBuffer(sphere, DrawPrimitive.LINE_LOOP)
}
drawer.fill = ColorRGBa.PINK
drawer.circle(drawer.bounds.center, 250.0)
camera.isolated {
drawer.fill = ColorRGBa.WHITE
drawer.vertexBuffer(cube, DrawPrimitive.LINE_LOOP)
}
}
}
}