Finish first draft.

This commit is contained in:
Bradlee Speice 2025-03-09 16:56:49 -04:00
parent 59a3e455f0
commit ced7827d0c
4 changed files with 144 additions and 55 deletions

View File

@ -16,14 +16,14 @@ export default function FlameCamera({ children }: Props) {
const [zoom, setZoom] = React.useState(0); const [zoom, setZoom] = React.useState(0);
const [rotate, setRotate] = React.useState(0); const [rotate, setRotate] = React.useState(0);
const [offsetX, setOffsetX] = React.useState(0); const [positionX, setPositionX] = React.useState(0);
const [offsetY, setOffsetY] = React.useState(0); const [positionY, setPositionY] = React.useState(0);
const resetCamera = () => { const resetCamera = () => {
setZoom(0); setZoom(0);
setRotate(0); setRotate(0);
setOffsetX(0); setPositionX(0);
setOffsetY(0); setPositionY(0);
} }
const resetButton = <button className={styles.inputReset} onClick={resetCamera}>Reset</button>; const resetButton = <button className={styles.inputReset} onClick={resetCamera}>Reset</button>;
@ -42,12 +42,12 @@ export default function FlameCamera({ children }: Props) {
finalColor: { color: params.xformFinalColor, colorSpeed: 0.5 }, finalColor: { color: params.xformFinalColor, colorSpeed: 0.5 },
scale, scale,
zoom, zoom,
rotate: rotate / 180 * Math.PI, rotate: -rotate / 180 * Math.PI,
offsetX, positionX,
offsetY positionY
}; };
setPainter(chaosGameCamera(gameParams)); setPainter(chaosGameCamera(gameParams));
}, [scale, zoom, rotate, offsetX, offsetY]); }, [scale, zoom, rotate, positionX, positionY]);
return ( return (
<> <>
@ -64,14 +64,14 @@ export default function FlameCamera({ children }: Props) {
onInput={e => setRotate(Number(e.currentTarget.value))}/> onInput={e => setRotate(Number(e.currentTarget.value))}/>
</div> </div>
<div className={styles.inputElement}> <div className={styles.inputElement}>
<p>Offset X: {offsetX}</p> <p>Offset X: {positionX}</p>
<input type={"range"} min={-2} max={2} step={0.01} value={offsetX} <input type={"range"} min={-2} max={2} step={0.01} value={positionX}
onInput={e => setOffsetX(Number(e.currentTarget.value))}/> onInput={e => setPositionX(Number(e.currentTarget.value))}/>
</div> </div>
<div className={styles.inputElement}> <div className={styles.inputElement}>
<p>Offset Y: {offsetY}</p> <p>Offset Y: {positionY}</p>
<input type={"range"} min={-2} max={2} step={0.01} value={offsetY} <input type={"range"} min={-2} max={2} step={0.01} value={positionY}
onInput={e => setOffsetY(Number(e.currentTarget.value))}/> onInput={e => setPositionY(Number(e.currentTarget.value))}/>
</div> </div>
</div> </div>
{children} {children}

View File

@ -3,19 +3,17 @@ export function camera(
y: number, y: number,
width: number, width: number,
height: number, height: number,
scale: number, positionX: number,
zoom: number, positionY: number,
rotate: number, rotate: number,
offsetX: number, zoom: number,
offsetY: number, scale: number,
): [number, number] { ): [number, number] {
const zoomFactor = Math.pow(2, zoom); // Position, rotation, and zoom are
// Zoom, offset, and rotation are
// applied in IFS coordinates // applied in IFS coordinates
[x, y] = [ [x, y] = [
(x - offsetX) * zoomFactor, (x - positionX),
(y - offsetY) * zoomFactor, (y - positionY),
]; ];
[x, y] = [ [x, y] = [
@ -23,7 +21,12 @@ export function camera(
y * Math.sin(rotate), y * Math.sin(rotate),
x * Math.sin(rotate) + x * Math.sin(rotate) +
y * Math.cos(rotate), y * Math.cos(rotate),
] ];
[x, y] = [
x * Math.pow(2, zoom),
y * Math.pow(2, zoom)
];
// Scale transforms IFS coordinates // Scale transforms IFS coordinates
// to pixel coordinates. Shift by half // to pixel coordinates. Shift by half

View File

@ -1,4 +1,3 @@
// hidden-start
import { Props as ChaosGameColorProps } from "../3-log-density/chaosGameColor"; import { Props as ChaosGameColorProps } from "../3-log-density/chaosGameColor";
import { randomBiUnit } from "../src/randomBiUnit"; import { randomBiUnit } from "../src/randomBiUnit";
import { randomChoice } from "../src/randomChoice"; import { randomChoice } from "../src/randomChoice";
@ -10,14 +9,13 @@ import {paintColor} from "../3-log-density/paintColor";
const quality = 10; const quality = 10;
const step = 100_000; const step = 100_000;
// hidden-end
export type Props = ChaosGameColorProps & { export type Props = ChaosGameColorProps & {
scale: number; positionX: number;
zoom: number, positionY: number;
rotate: number; rotate: number;
offsetX: number; zoom: number,
offsetY: number; scale: number;
} }
export function* chaosGameCamera( export function* chaosGameCamera(
@ -29,16 +27,15 @@ export function* chaosGameCamera(
palette, palette,
colors, colors,
finalColor, finalColor,
scale, positionX,
zoom, positionY,
rotate, rotate,
offsetX, zoom,
offsetY, scale,
}: Props }: Props
) { ) {
const pixels = width * height; const pixels = width * height;
// highlight-start
const imgRed = Array<number>(pixels) const imgRed = Array<number>(pixels)
.fill(0); .fill(0);
const imgGreen = Array<number>(pixels) const imgGreen = Array<number>(pixels)
@ -54,7 +51,7 @@ export function* chaosGameCamera(
c: number c: number
) => { ) => {
const [pixelX, pixelY] = const [pixelX, pixelY] =
camera(x, y, width, height, scale, zoom, rotate, offsetX, offsetY); camera(x, y, width, height, positionX, positionY, rotate, zoom, scale);
if ( if (
pixelX < 0 || pixelX < 0 ||
@ -75,7 +72,6 @@ export function* chaosGameCamera(
imgBlue[hIndex] += b; imgBlue[hIndex] += b;
imgAlpha[hIndex] += 1; imgAlpha[hIndex] += 1;
} }
// highlight-end
let [x, y] = [ let [x, y] = [
randomBiUnit(), randomBiUnit(),

View File

@ -22,7 +22,7 @@ some missing features.
To review, the restrictions we've had so far: To review, the restrictions we've had so far:
> ...we need to convert from fractal flame coordinates to pixel coordinates. > ...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) > -- [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 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]$. use a display range of $[-2, 2]$.
Finally, the camera controls available in Apophysis/[`flam3`](https://github.com/scottdraves/flam3/wiki/XML-File-Format) ## Parameters
have a number of settings that were simply not implemented so far:
For comparison, here are the camera controls available in Apophysis and [`flam3`](https://github.com/scottdraves/flam3/wiki/XML-File-Format):
<center>![Screenshot of Apophysis camera controls](./camera-controls.png)</center> <center>![Screenshot of Apophysis camera controls](./camera-controls.png)</center>
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 Fractal flames normally use the origin as the center of an image. The position parameters (X and Y) move
into specific pixels, and scale is the parameter we use to do it. Specifically, scale is **the number of pixels the center point, which effectively pans the image. A positive X position shifts the image left,
in one unit of the fractal flame coordinate system**. 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 ```xml
<flame name="final xform" size="600 600" center="0 0" scale="150"> <flame name="final xform" size="600 600" center="0 0" scale="150">
@ -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 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). 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"`, 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. 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. 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 scale stayed the same, the output image would have If the output image changed to `size="1200 1200"` and we kept `scale="150"`, the output image would
a lot more white space. Instead, it's usually better to use a different parameter for controlling image size. 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 ## Camera
## Offset
With the individual steps defined, we can put together a more robust "camera" for viewing the fractal flame.
import CodeBlock from "@theme/CodeBlock"; import CodeBlock from "@theme/CodeBlock";
import cameraSource from "!!raw-loader!./camera" import cameraSource from "!!raw-loader!./camera"
<CodeBlock language="typescript">{cameraSource}</CodeBlock> <CodeBlock language="typescript">{cameraSource}</CodeBlock>
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 {SquareCanvas} from "../src/Canvas";
import FlameCamera from "./FlameCamera"; import FlameCamera from "./FlameCamera";
<SquareCanvas name={"flame_camera"} width={'95%'} aspectRatio={'4/3'}><FlameCamera /></SquareCanvas> <SquareCanvas name={"flame_camera"} width={'95%'} aspectRatio={'4/3'}><FlameCamera /></SquareCanvas>
## 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.