--- slug: 2025/03/playing-with-fire-camera title: "Playing with fire: The camera" date: 2025-03-10 12:00:00 authors: [bspeice] tags: [] --- Something that bugged me while writing the first three articles on fractal flames were the constraints on output images. At the time, I had worked out how to render fractal flames by studying [Apophysis](https://sourceforge.net/projects/apophysis/) and [flam3](https://github.com/scottdraves/flam3); just enough to display images in a browser. Having spent more time with fractal flames and computer graphics, it's time to implement some missing features. ## Restrictions To review, the restrictions we've had so far: > ...we need to convert from fractal flame coordinates to pixel coordinates. > To simplify things, we'll assume that we're plotting a square image with range $[0,1]$ for both x and y > > -- [The fractal flame algorithm](/2024/11/playing-with-fire) First, we've assumed that fractals get displayed in a square image. Ignoring aspect ratios simplifies the render process, but we don't usually want square images. It's possible to render a large square image and crop it to fit, but we'd rather render directly to the desired size. Second, we've assumed that fractals have a pre-determined display range. For Sierpinski's Gasket, that was $[0, 1]$ (the reference parameters used a range of $[-2, 2]$). However, if we could control the display range, it would let us zoom in and out of the image. ## Parameters For comparison, the camera controls available in Apophysis offer a lot of flexibility:
![Screenshot of Apophysis camera controls](./camera-controls.png)
The remaining parameters to implement are: position (X and Y), rotation, zoom, and scale. ### Position Fractal flames normally use $(0, 0)$ as the image center. The position parameters (X and Y) move the center point, which effectively pans the image. A positive X position shifts left, and a negative X position shifts right. Similarly, a positive Y position shifts up, and a negative Y position shifts the image down. To apply the position parameters, simply subtract them from each point in the chaos game prior to plotting it: ```typescript [x, y] = [ x - positionX, y - positionY ]; ``` ### Rotation After the position parameters, we can rotate the image around the (new) center point. To do so, we'll go back to the [affine transformations](https://en.wikipedia.org/wiki/Affine_transformation) we've been using so far. Specifically, the rotation angle $\theta$ gives us a transform matrix we can apply prior to plotting: $$ \begin{bmatrix} \text{cos}(\theta) & -\text{sin}(\theta) \\ \text{sin}(\theta) & \text{cos}(\theta) \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} $$ ```typescript [x, y] = [ x * Math.cos(-rotate) - y * Math.sin(-rotate), x * Math.sin(-rotate) + y * Math.cos(-rotate), ]; ``` :::note To match the behavior of Apophysis/`flam3`, we need to negate the rotation angle. ::: ### Zoom This parameter does what the name implies; zoom in and out of the image. To do this, we multiply the X and Y coordinates of each point by a zoom factor. For a zoom parameter $z$, the zoom factor will be $\text{pow}(2, z)$. For example, if the current point is $(1, 1)$, a zoom parameter of 1 means we actually plot $(1, 1) \cdot \text{pow}(2, 1) = (2, 2)$. ``` [x, y] = [ x * Math.pow(2, zoom), y * Math.pow(2, zoom) ]; ``` :::info In addition to scaling the image, renderers also [scale the image quality](https://github.com/scottdraves/flam3/blob/f8b6c782012e4d922ef2cc2f0c2686b612c32504/rect.c#L796-L797) to compensate for the reduced display range. ::: ### Scale Finally, we need to convert from fractal flame coordinates to individual pixels. The scale parameter defines how many pixels are in one unit of the fractal flame coordinate system, which gives us a mapping from one system to the other. If you open the [reference parameters](../params.flame) in a text editor, you'll see the following: ```xml ``` Here's what each element means: - `size="600 600"`: The image should be 600 pixels wide and 600 pixels tall - `center="0 0"`: The image is centered at the point $(0, 0)$ - `scale="150"`: The image has 150 pixels per unit Let's break it down. Dividing the image width (600) by the image scale (150) gives us a value of 4. This means the image should be 4 units wide (same for the height). Because the center is at $(0, 0)$, the final image is effectively using the range $[-2, 2]$ in fractal coordinates. Now, to go from fractal coordinates to pixel coordinates we multiply by the scale, then subtract half the image width and height: ```typescript [pixelX, pixelY] = [ x * scale - imageWidth / 2, y * scale - imageHeight / 2 ] ``` Scale and zoom have similar effects on images. If the reference parameters used `scale="300"`, the same 600 pixels would instead be looking at the range $[-1, 1]$ in the fractal coordinate system. Using `zoom="1"` would accomplish the same result. However, this also demonstrates the biggest problem with using scale: it only controls the output image. For example, if the output image changed to `size="1200 1200"` and we kept `scale="150"`, it would have a display range of $[-4, 4]$. There would be a lot of extra white space. Because the zoom parameter has the same effect regardless of output image size, it is the preferred way to zoom in and out. :::info One final note about the camera controls: every step in this process (position, rotation, zoom, scale) is an affine transformation. And because affine transformations can be chained together, it's possible to express all the camera controls as a single transformation matrix. This is important for software optimization; rather than applying parameters step-by-step, we can apply all of them at once. They could also be implemented as part of the final transform, but in practice, it's helpful to control them separately. ::: ## Camera With the individual steps defined, we can put together a more robust "camera" for viewing the fractal flame. import CodeBlock from "@theme/CodeBlock"; import cameraSource from "!!raw-loader!./camera" {cameraSource} To demonstrate, this display has a 4:3 aspect ratio, removing the restriction of a square image. In addition, the scale is automatically chosen so the image width covers the range $[-2, 2]$. Because of the 4:3 aspect ratio, the image height now covers the range $[-1.5, 1.5]$. import {SquareCanvas} from "../src/Canvas"; import FlameCamera from "./FlameCamera"; ## Summary The previous fractal images relied on assumptions about the output format to make sure they looked correct. Now, we have much more control. We can implement a 2D "camera" as a series of affine transformations, going from fractal flame coordinates to pixel coordinates. More recent fractal flame renderers like [Fractorium](http://fractorium.com/) can also operate in 3D, and have to implement a more complex camera system to handle the extra dimension. But for this blog series, it's nice to achieve feature parity with the reference implementation.