From ced7827d0c9d8374bf1a3745e37682e7d2c71a95 Mon Sep 17 00:00:00 2001 From: Bradlee Speice Date: Sun, 9 Mar 2025 16:56:49 -0400 Subject: [PATCH] Finish first draft. --- .../4-camera/FlameCamera.tsx | 28 ++-- .../4-camera/camera.ts | 23 ++-- .../4-camera/chaosGameCamera.ts | 22 ++- .../4-camera/index.mdx | 126 +++++++++++++++--- 4 files changed, 144 insertions(+), 55 deletions(-) diff --git a/blog/2024-11-15-playing-with-fire/4-camera/FlameCamera.tsx b/blog/2024-11-15-playing-with-fire/4-camera/FlameCamera.tsx index 8c40fe7..b14d29e 100644 --- a/blog/2024-11-15-playing-with-fire/4-camera/FlameCamera.tsx +++ b/blog/2024-11-15-playing-with-fire/4-camera/FlameCamera.tsx @@ -16,14 +16,14 @@ export default function FlameCamera({ children }: Props) { const [zoom, setZoom] = React.useState(0); const [rotate, setRotate] = React.useState(0); - const [offsetX, setOffsetX] = React.useState(0); - const [offsetY, setOffsetY] = React.useState(0); + const [positionX, setPositionX] = React.useState(0); + const [positionY, setPositionY] = React.useState(0); const resetCamera = () => { setZoom(0); setRotate(0); - setOffsetX(0); - setOffsetY(0); + setPositionX(0); + setPositionY(0); } const resetButton = ; @@ -42,12 +42,12 @@ export default function FlameCamera({ children }: Props) { finalColor: { color: params.xformFinalColor, colorSpeed: 0.5 }, scale, zoom, - rotate: rotate / 180 * Math.PI, - offsetX, - offsetY + rotate: -rotate / 180 * Math.PI, + positionX, + positionY }; setPainter(chaosGameCamera(gameParams)); - }, [scale, zoom, rotate, offsetX, offsetY]); + }, [scale, zoom, rotate, positionX, positionY]); return ( <> @@ -64,14 +64,14 @@ export default function FlameCamera({ children }: Props) { onInput={e => setRotate(Number(e.currentTarget.value))}/>
-

Offset X: {offsetX}

- setOffsetX(Number(e.currentTarget.value))}/> +

Offset X: {positionX}

+ setPositionX(Number(e.currentTarget.value))}/>
-

Offset Y: {offsetY}

- setOffsetY(Number(e.currentTarget.value))}/> +

Offset Y: {positionY}

+ setPositionY(Number(e.currentTarget.value))}/>
{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):
![Screenshot of Apophysis camera controls](./camera-controls.png)
-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