From 95581bc6f149a71acfd934bc0543d66cc911111f Mon Sep 17 00:00:00 2001 From: Edwin Jakobs Date: Wed, 6 Aug 2025 18:10:39 +0200 Subject: [PATCH] [orx-camera] Add OrbitalManual, add instant parameter to OrbitalCamera functions --- orx-camera/src/commonMain/kotlin/Camera2D.kt | 1 + orx-camera/src/commonMain/kotlin/Orbital.kt | 12 + .../src/commonMain/kotlin/OrbitalCamera.kt | 269 +++++++++++++++--- .../commonMain/kotlin/ParametricOrbital.kt | 2 +- .../jvmDemo/kotlin/DemoCamera2DManual01.kt | 1 + .../src/jvmDemo/kotlin/DemoOrbitalCamera01.kt | 8 +- .../src/jvmDemo/kotlin/DemoOrbitalManual01.kt | 49 ++++ 7 files changed, 298 insertions(+), 44 deletions(-) create mode 100644 orx-camera/src/jvmDemo/kotlin/DemoOrbitalManual01.kt diff --git a/orx-camera/src/commonMain/kotlin/Camera2D.kt b/orx-camera/src/commonMain/kotlin/Camera2D.kt index 0d572ec6..b5431577 100644 --- a/orx-camera/src/commonMain/kotlin/Camera2D.kt +++ b/orx-camera/src/commonMain/kotlin/Camera2D.kt @@ -35,6 +35,7 @@ class Camera2D : Extension, ChangeEvents { set(value) { if (value && !field) { changed.trigger(Unit) + program.window.requestDraw() } field = value } diff --git a/orx-camera/src/commonMain/kotlin/Orbital.kt b/orx-camera/src/commonMain/kotlin/Orbital.kt index 1c25b6c0..36b97f97 100644 --- a/orx-camera/src/commonMain/kotlin/Orbital.kt +++ b/orx-camera/src/commonMain/kotlin/Orbital.kt @@ -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) + } } \ No newline at end of file diff --git a/orx-camera/src/commonMain/kotlin/OrbitalCamera.kt b/orx-camera/src/commonMain/kotlin/OrbitalCamera.kt index c014b9f9..8ae4c4c2 100644 --- a/orx-camera/src/commonMain/kotlin/OrbitalCamera.kt +++ b/orx-camera/src/commonMain/kotlin/OrbitalCamera.kt @@ -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() 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) \ No newline at end of file +fun Program.OrbitalManual(): Orbital { + val orbital = Orbital() + orbital.setup(this) + return orbital +} \ No newline at end of file diff --git a/orx-camera/src/commonMain/kotlin/ParametricOrbital.kt b/orx-camera/src/commonMain/kotlin/ParametricOrbital.kt index 44d90063..92f78797 100644 --- a/orx-camera/src/commonMain/kotlin/ParametricOrbital.kt +++ b/orx-camera/src/commonMain/kotlin/ParametricOrbital.kt @@ -92,7 +92,7 @@ class ParametricOrbital : Extension, ChangeEvents { override fun setup(program: Program) { - + camera.setup(program) } override fun beforeDraw(drawer: Drawer, program: Program) { diff --git a/orx-camera/src/jvmDemo/kotlin/DemoCamera2DManual01.kt b/orx-camera/src/jvmDemo/kotlin/DemoCamera2DManual01.kt index 3da9bd80..a4a9211d 100644 --- a/orx-camera/src/jvmDemo/kotlin/DemoCamera2DManual01.kt +++ b/orx-camera/src/jvmDemo/kotlin/DemoCamera2DManual01.kt @@ -1,3 +1,4 @@ +import org.openrndr.PresentationMode import org.openrndr.application import org.openrndr.color.ColorRGBa import org.openrndr.draw.isolatedWithTarget diff --git a/orx-camera/src/jvmDemo/kotlin/DemoOrbitalCamera01.kt b/orx-camera/src/jvmDemo/kotlin/DemoOrbitalCamera01.kt index ba136d12..43f08ff1 100644 --- a/orx-camera/src/jvmDemo/kotlin/DemoOrbitalCamera01.kt +++ b/orx-camera/src/jvmDemo/kotlin/DemoOrbitalCamera01.kt @@ -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) diff --git a/orx-camera/src/jvmDemo/kotlin/DemoOrbitalManual01.kt b/orx-camera/src/jvmDemo/kotlin/DemoOrbitalManual01.kt new file mode 100644 index 00000000..a4444d86 --- /dev/null +++ b/orx-camera/src/jvmDemo/kotlin/DemoOrbitalManual01.kt @@ -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) + } + } + } +} \ No newline at end of file