{children}
diff --git a/blog/2024-11-15-playing-with-fire/4-camera/camera.ts b/blog/2024-11-15-playing-with-fire/4-camera/camera.ts
index 2a2435d..b6b8ef9 100644
--- a/blog/2024-11-15-playing-with-fire/4-camera/camera.ts
+++ b/blog/2024-11-15-playing-with-fire/4-camera/camera.ts
@@ -3,19 +3,17 @@ export function camera(
y: number,
width: number,
height: number,
- scale: number,
- zoom: number,
+ positionX: number,
+ positionY: number,
rotate: number,
- offsetX: number,
- offsetY: number,
+ zoom: number,
+ scale: number,
): [number, number] {
- const zoomFactor = Math.pow(2, zoom);
-
- // Zoom, offset, and rotation are
+ // Position, rotation, and zoom are
// applied in IFS coordinates
[x, y] = [
- (x - offsetX) * zoomFactor,
- (y - offsetY) * zoomFactor,
+ (x - positionX),
+ (y - positionY),
];
[x, y] = [
@@ -23,7 +21,12 @@ export function camera(
y * Math.sin(rotate),
x * Math.sin(rotate) +
y * Math.cos(rotate),
- ]
+ ];
+
+ [x, y] = [
+ x * Math.pow(2, zoom),
+ y * Math.pow(2, zoom)
+ ];
// Scale transforms IFS coordinates
// to pixel coordinates. Shift by half
diff --git a/blog/2024-11-15-playing-with-fire/4-camera/chaosGameCamera.ts b/blog/2024-11-15-playing-with-fire/4-camera/chaosGameCamera.ts
index a94d311..37874a3 100644
--- a/blog/2024-11-15-playing-with-fire/4-camera/chaosGameCamera.ts
+++ b/blog/2024-11-15-playing-with-fire/4-camera/chaosGameCamera.ts
@@ -1,4 +1,3 @@
-// hidden-start
import { Props as ChaosGameColorProps } from "../3-log-density/chaosGameColor";
import { randomBiUnit } from "../src/randomBiUnit";
import { randomChoice } from "../src/randomChoice";
@@ -10,14 +9,13 @@ import {paintColor} from "../3-log-density/paintColor";
const quality = 10;
const step = 100_000;
-// hidden-end
export type Props = ChaosGameColorProps & {
- scale: number;
- zoom: number,
+ positionX: number;
+ positionY: number;
rotate: number;
- offsetX: number;
- offsetY: number;
+ zoom: number,
+ scale: number;
}
export function* chaosGameCamera(
@@ -29,16 +27,15 @@ export function* chaosGameCamera(
palette,
colors,
finalColor,
- scale,
- zoom,
+ positionX,
+ positionY,
rotate,
- offsetX,
- offsetY,
+ zoom,
+ scale,
}: Props
) {
const pixels = width * height;
- // highlight-start
const imgRed = Array(pixels)
.fill(0);
const imgGreen = Array(pixels)
@@ -54,7 +51,7 @@ export function* chaosGameCamera(
c: number
) => {
const [pixelX, pixelY] =
- camera(x, y, width, height, scale, zoom, rotate, offsetX, offsetY);
+ camera(x, y, width, height, positionX, positionY, rotate, zoom, scale);
if (
pixelX < 0 ||
@@ -75,7 +72,6 @@ export function* chaosGameCamera(
imgBlue[hIndex] += b;
imgAlpha[hIndex] += 1;
}
- // highlight-end
let [x, y] = [
randomBiUnit(),
diff --git a/blog/2024-11-15-playing-with-fire/4-camera/index.mdx b/blog/2024-11-15-playing-with-fire/4-camera/index.mdx
index 78a6f5e..48f3e96 100644
--- a/blog/2024-11-15-playing-with-fire/4-camera/index.mdx
+++ b/blog/2024-11-15-playing-with-fire/4-camera/index.mdx
@@ -22,7 +22,7 @@ some missing features.
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
+> 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)
@@ -37,20 +37,82 @@ Second, the assumption that fractals use the range $[0, 1]$. My statement above
for Sierpinski's Gasket, the solution set is indeed defined on $[0, 1]$, but all other images in the series
use a display range of $[-2, 2]$.
-Finally, the camera controls available in Apophysis/[`flam3`](https://github.com/scottdraves/flam3/wiki/XML-File-Format)
-have a number of settings that were simply not implemented so far:
+## Parameters
+
+For comparison, here are the camera controls available in Apophysis and [`flam3`](https://github.com/scottdraves/flam3/wiki/XML-File-Format):

-The parameters remaining to implement are scale, zoom, rotation, and position.
+There are four parameters yet to implement: position, rotation, zoom, and scale.
-## Scale
+### Position
-Fractal flames are defined on a continuous range. At some point, we must convert from fractal flame coordinates
-into specific pixels, and scale is the parameter we use to do it. Specifically, scale is **the number of pixels
-in one unit of the fractal flame coordinate system**.
+Fractal flames normally use the origin as the center of an image. The position parameters (X and Y) move
+the center point, which effectively pans the image. A positive X position shifts the image left,
+and a negative X position shifts the image right. Similarly, a positive Y position shifts the image up,
+and a negative Y position shifts the image down.
-For example, if you open the [reference parameters](../params.flame) in a text editor, you'll see the following:
+To apply the position, simply subtract the X and Y position from each point in the chaos game prior to plotting it:
+
+```typescript
+[x, y] = [
+ x - positionX,
+ y - positionY
+];
+```
+
+### Rotation
+
+After the position parameters are applied, we can rotate the image around the (potentially shifted) center point.
+To do so, we'll go back to the [affine transformations](https://en.wikipedia.org/wiki/Affine_transformation)
+we've been using. Specifically, the rotation angle $\theta$ gives us a transform matrix we can apply to our point:
+
+$$
+\begin{bmatrix}
+\text{cos}(\theta) & -\text{sin}(\theta) \\
+\text{sin}(\theta) & \text{cos}(\theta)
+\end{bmatrix}
+
+\begin{bmatrix}
+x \\
+y
+\end{bmatrix}
+$$
+
+As a minor tweak, we also negate the rotation angle to match the behavior of Apophysis/`flam3`.
+
+```typescript
+[x, y] = [
+ x * Math.cos(-rotate) -
+ y * Math.sin(-rotate),
+ x * Math.sin(-rotate) +
+ y * Math.cos(-rotate),
+];
+```
+
+### Zoom
+
+This parameter does what the name implies; zoom in and out of the image. Specifically, for a zoom parameter $z$,
+every point in the chaos game is scaled by $\text{pow}(2, z)$ prior to plotting. For example, if the point is $(1, 1)$,
+a zoom 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 zoom parameter.
+:::
+
+### 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. For example, if you open the
+[reference parameters](../params.flame) in a text editor, you'll see the following:
```xml
@@ -61,29 +123,57 @@ with 150 pixels per unit. Dividing 600 by 150 gives us an image that is 4 units
And because the center is at $(0, 0)$, the final image is effectively looking at the range $[-2, 2]$ in the
fractal coordinate system (as mentioned above).
+To go from the fractal coordinate system to a pixel coordinate system, 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 can be used to implement a kind of "zoom" in images. If the reference parameters instead used `scale="300"`,
the same 600 pixels would instead be looking at the range $[-1, 1]$ in the fractal coordinate system.
-This also demonstrates the biggest problem with using scale: it's a parameter that only controls the output image.
-If the output image changed to `size="1200 1200"`, and scale stayed the same, the output image would have
-a lot more white space. Instead, it's usually better to use a different parameter for controlling image size.
+However, this also demonstrates the biggest problem with using scale: it's a parameter that only controls the output image.
+If the output image changed to `size="1200 1200"` and we kept `scale="150"`, the output image would
+be looking at the range $[-4, 4]$ - nothing but white space. Because, using the zoom parameter
+is the preferred way to zoom in and out of an image.
-## Zoom
+:::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 of our camera controls as a single transformation matrix. This is important for software optimization;
+rather than applying individual camera controls step-by-step, apply all of them at once.
+Additionally, because the camera controls are an affine transformation, they could be implemented
+as a transform after the final transform. In practice though, it's helpful to control them separately.
+:::
-## Rotation
-
-## Offset
-
+## 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}
+For demonstration, the output image has a 4:3 aspect ratio, removing the previous restriction of a square image.
+In addition, the scale is automatically chosen to make sure the width of the image covers the range $[-2, 2]$.
+As a result of the aspect ratio, the image height effectively covers the range $[-1.5, 1.5]$.
+
import {SquareCanvas} from "../src/Canvas";
import FlameCamera from "./FlameCamera";
+
+## Summary
+
+The fractal images so far relied on critical assumptions about the output format to make sure everything
+looked correct. However, we can implement a 2D "camera" with a series of affine transformations - going from
+the fractal flame coordinate system to pixel coordinates. Later implementations of fractal flame renderers like
+[Fractorium](http://fractorium.com/) operate in 3D, and have to implement the camera slightly differently.
+
+But for this blog series, it's nice to achieve feature parity with the existing code.
\ No newline at end of file