Skip to main content


Each View is a flat, 2D surface bound to a 2D grid. This means Views can only have an x/y position, and an area within the plane. However, Views can also be transformed using Affine Transformations (and perspective) that alter the way they are displayed. These transforms allow you to change a View's position and shape in the full 3D volume.


Use the right slider to change the camera distance from the View, which changes the intensity of the perspective used to render it.

Affine transforms

Each View has a transform that can be set at any time to change how it is rendered. Changes to this property will update the View on screen if it is visible. Transforms are very lightweight and do not require re-rendering or trigger layouts when modified. This makes them a great option for animations and other UI treatments.

import io.nacular.doodle.core.View import import io.nacular.doodle.drawing.AffineTransform.Companion.Identity import io.nacular.measured.units.Angle.Companion.degrees import io.nacular.measured.units.times fun affineTransform(view: View) { //sampleStart view.transform = Identity view.transform = Identity.rotateX(around =, by = 45 * degrees) //sampleEnd }

Multiple AffineTransform instances can also be combined to produce more complex operations. The end result is a single transform that will behave as though each operation were done in succession.

import io.nacular.doodle.drawing.AffineTransform import io.nacular.doodle.geometry.Point import io.nacular.measured.units.Angle.Companion.degrees import io.nacular.measured.units.Angle.Companion.radians import io.nacular.measured.units.times import kotlin.math.PI fun affineTransformChaining(transform: AffineTransform) { //sampleStart transform. rotateY(by = 45 * degrees, around = Point( 2, 4)). rotateX(by = PI * radians, around = Point(10, 3)). //... flipHorizontally() //sampleEnd }

Identity transform

Views default to the Identity transform, which does not change the View at all. Set this to effectively clear a View's transform.

import io.nacular.doodle.core.View import io.nacular.doodle.drawing.AffineTransform.Companion.Identity fun identityTransform(view: View) { //sampleStart // Scale view by 2 in x and y view.transform = Identity.scale(2.0, 2.0) // ... // Remove all transform effects view.transform = Identity //sampleEnd }


AffineTransforms warp an object while preserving collinearity (points on a line remain on a line) and distance ratios. This means they are insufficient to produce perspective. Perspective requires a transformation that preserves collinearity, but not distance ratios. Doodle supports this via Cameras. Each Camera has a position (relative to a View's parent) and distance, which define where vanishing points will be and the intensity their parallel warping effect. Each View has a camera that will combine with its transform to produce a final perspective transformation.


Drag the camera to change its position. Resize it to change its distance from the plane.

Sharing cameras

Views can share a single Camera, which allows them to share the same perspective when they have the same parent (or are all top-level). The result, as you can see below is the effect of the Views sharing the same 3D space.


A View's Camera position is relative to its parent, so shared Cameras only produce the single 3D space for Views with the same parent.

2-sided views

Views render onto an infinitely flat plane that is similar to a sheet of glass. This means you see the contents of a View that is flipped or rotated as though you are looking through the back of that pane of glass. You can have Views with 2-sides of course, as you see with the playing cards. But that requires logic to decide when the View's back is visible. The playing card does this by recomputing its face direction whenever its transform or camera change, and repainting if needed.

import io.nacular.doodle.core.View fun View.twoSidedView() { //sampleStart // Example of how one might determine whether a View is facing the user. // This takes the cross product of 2 vectors on the View's surface and // applies its transformation and camera. val points = (camera.projection * transform).invoke(bounds.points.take(3)) val faceUp = (points[1] - points[0] cross points[2] - points[1]).z > 0.0 //sampleEnd }