From 2d810fe20fa46d6600d9385857dd34b8381394ec Mon Sep 17 00:00:00 2001 From: Bradlee Speice Date: Sat, 8 Mar 2025 12:26:20 -0500 Subject: [PATCH] Code for 2D camera system --- .../3-log-density/index.mdx | 4 +- .../4-camera/FlameCamera.tsx | 80 ++++++++++ .../4-camera/camera.ts | 38 +++++ .../4-camera/chaosGameCamera.ts | 139 ++++++++++++++++++ .../4-camera/index.mdx | 20 +++ .../src/Canvas.tsx | 6 +- package-lock.json | 9 +- 7 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 blog/2024-11-15-playing-with-fire/4-camera/FlameCamera.tsx create mode 100644 blog/2024-11-15-playing-with-fire/4-camera/camera.ts create mode 100644 blog/2024-11-15-playing-with-fire/4-camera/chaosGameCamera.ts create mode 100644 blog/2024-11-15-playing-with-fire/4-camera/index.mdx diff --git a/blog/2024-11-15-playing-with-fire/3-log-density/index.mdx b/blog/2024-11-15-playing-with-fire/3-log-density/index.mdx index 5abb1d0..9b703e0 100644 --- a/blog/2024-11-15-playing-with-fire/3-log-density/index.mdx +++ b/blog/2024-11-15-playing-with-fire/3-log-density/index.mdx @@ -27,6 +27,8 @@ If the chaos game encounters the same pixel twice, nothing changes. To demonstrate how much work is wasted, we'll count each time the chaos game visits a pixel while iterating. This gives us a kind of image "histogram": +import CodeBlock from "@theme/CodeBlock"; + import chaosGameHistogramSource from "!!raw-loader!./chaosGameHistogram" {chaosGameHistogramSource} @@ -35,8 +37,6 @@ When the chaos game finishes, we find the pixel encountered most often. Finally, we "paint" the image by setting each pixel's alpha (transparency) value to the ratio of times visited divided by the maximum: -import CodeBlock from "@theme/CodeBlock"; - import paintLinearSource from "!!raw-loader!./paintLinear" {paintLinearSource} 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 new file mode 100644 index 0000000..8c40fe7 --- /dev/null +++ b/blog/2024-11-15-playing-with-fire/4-camera/FlameCamera.tsx @@ -0,0 +1,80 @@ +import React, { useContext, useEffect } from "react"; +import * as params from "../src/params"; +import { PainterContext } from "../src/Canvas"; +import {chaosGameCamera, Props as ChaosGameColorProps} from "./chaosGameCamera"; +import styles from "../src/css/styles.module.css"; + +type Props = { + children?: React.ReactElement; +} +export default function FlameCamera({ children }: Props) { + const { width, height, setPainter } = useContext(PainterContext); + + // Scale chosen so the largest axis is `[-2, 2]` in IFS coordinates, + // the smaller axis will be a shorter range to maintain the aspect ratio. + const scale = Math.max(width, height) / 4; + + 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 resetCamera = () => { + setZoom(0); + setRotate(0); + setOffsetX(0); + setOffsetY(0); + } + const resetButton = ; + + useEffect(() => { + const gameParams: ChaosGameColorProps = { + width, + height, + transforms: params.xforms, + final: params.xformFinal, + palette: params.palette, + colors: [ + {color: params.xform1Color, colorSpeed: 0.5}, + {color: params.xform2Color, colorSpeed: 0.5}, + {color: params.xform3Color, colorSpeed: 0.5} + ], + finalColor: { color: params.xformFinalColor, colorSpeed: 0.5 }, + scale, + zoom, + rotate: rotate / 180 * Math.PI, + offsetX, + offsetY + }; + setPainter(chaosGameCamera(gameParams)); + }, [scale, zoom, rotate, offsetX, offsetY]); + + return ( + <> +
+

Camera {resetButton}

+
+

Zoom: {zoom}

+ setZoom(Number(e.currentTarget.value))}/> +
+
+

Rotate (deg): {rotate}

+ setRotate(Number(e.currentTarget.value))}/> +
+
+

Offset X: {offsetX}

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

Offset Y: {offsetY}

+ setOffsetY(Number(e.currentTarget.value))}/> +
+
+ {children} + + ); +} \ No newline at end of file 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 new file mode 100644 index 0000000..556f5ff --- /dev/null +++ b/blog/2024-11-15-playing-with-fire/4-camera/camera.ts @@ -0,0 +1,38 @@ +export function camera( + x: number, + y: number, + width: number, + height: number, + scale: number, + zoom: number, + rotate: number, + offsetX: number, + offsetY: number, +): [number, number] { + const zoomFactor = Math.pow(2, zoom); + + // Zoom, offset, and rotation are + // applied in IFS coordinates + [x, y] = [ + (x - offsetX) * zoomFactor, + (y - offsetY) * zoomFactor, + ]; + + [x, y] = [ + x * Math.cos(rotate) - + y * Math.sin(rotate), + x * Math.sin(rotate) + + y * Math.cos(rotate), + ] + + // Scale is applied to pixel + // coordinates. Shift by half + // the image width and height + // to compensate for the + // IFS coordinates being symmetric + // around the origin + return [ + Math.floor(x * scale + width / 2), + Math.floor(y * scale + height / 2) + ]; +} 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 new file mode 100644 index 0000000..a94d311 --- /dev/null +++ b/blog/2024-11-15-playing-with-fire/4-camera/chaosGameCamera.ts @@ -0,0 +1,139 @@ +// hidden-start +import { Props as ChaosGameColorProps } from "../3-log-density/chaosGameColor"; +import { randomBiUnit } from "../src/randomBiUnit"; +import { randomChoice } from "../src/randomChoice"; +import { camera } from "./camera"; +import { histIndex } from "../src/camera"; +import {colorFromPalette} from "../3-log-density/colorFromPalette"; +import {mixColor} from "../3-log-density/mixColor"; +import {paintColor} from "../3-log-density/paintColor"; + +const quality = 10; +const step = 100_000; +// hidden-end + +export type Props = ChaosGameColorProps & { + scale: number; + zoom: number, + rotate: number; + offsetX: number; + offsetY: number; +} + +export function* chaosGameCamera( + { + width, + height, + transforms, + final, + palette, + colors, + finalColor, + scale, + zoom, + rotate, + offsetX, + offsetY, + }: Props +) { + const pixels = width * height; + + // highlight-start + const imgRed = Array(pixels) + .fill(0); + const imgGreen = Array(pixels) + .fill(0); + const imgBlue = Array(pixels) + .fill(0); + const imgAlpha = Array(pixels) + .fill(0); + + const plotColor = ( + x: number, + y: number, + c: number + ) => { + const [pixelX, pixelY] = + camera(x, y, width, height, scale, zoom, rotate, offsetX, offsetY); + + if ( + pixelX < 0 || + pixelX >= width || + pixelY < 0 || + pixelY >= width + ) + return; + + const hIndex = + histIndex(pixelX, pixelY, width, 1); + + const [r, g, b] = + colorFromPalette(palette, c); + + imgRed[hIndex] += r; + imgGreen[hIndex] += g; + imgBlue[hIndex] += b; + imgAlpha[hIndex] += 1; + } + // highlight-end + + let [x, y] = [ + randomBiUnit(), + randomBiUnit() + ]; + let c = Math.random(); + + const iterations = quality * pixels; + for (let i = 0; i < iterations; i++) { + const [transformIndex, transform] = + randomChoice(transforms); + [x, y] = transform(x, y); + + // highlight-start + const transformColor = + colors[transformIndex]; + + c = mixColor( + c, + transformColor.color, + transformColor.colorSpeed + ); + // highlight-end + + const [finalX, finalY] = final(x, y); + + // highlight-start + const finalC = mixColor( + c, + finalColor.color, + finalColor.colorSpeed + ); + // highlight-end + + if (i > 20) + plotColor( + finalX, + finalY, + finalC + ) + + if (i % step === 0) + yield paintColor( + width, + height, + imgRed, + imgGreen, + imgBlue, + imgAlpha + ); + } + + yield paintColor( + width, + height, + imgRed, + imgGreen, + imgBlue, + imgAlpha + ); +} \ No newline at end of file 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 new file mode 100644 index 0000000..9bb2058 --- /dev/null +++ b/blog/2024-11-15-playing-with-fire/4-camera/index.mdx @@ -0,0 +1,20 @@ +--- +slug: 2025/03/playing-with-fire-camera +title: "Playing with fire: The camera" +date: 2025-03-07 12:00:00 +authors: [bspeice] +tags: [] +--- + + + +import CodeBlock from "@theme/CodeBlock"; + +import cameraSource from "!!raw-loader!./camera" + +{cameraSource} + +import {SquareCanvas} from "../src/Canvas"; +import FlameCamera from "./FlameCamera"; + + diff --git a/blog/2024-11-15-playing-with-fire/src/Canvas.tsx b/blog/2024-11-15-playing-with-fire/src/Canvas.tsx index 155b982..365fd48 100644 --- a/blog/2024-11-15-playing-with-fire/src/Canvas.tsx +++ b/blog/2024-11-15-playing-with-fire/src/Canvas.tsx @@ -18,6 +18,8 @@ const downloadImage = (name: string) => type CanvasProps = { name: string; + width?: string; + aspectRatio?: string; style?: any; children?: React.ReactElement } @@ -105,6 +107,6 @@ export const Canvas: React.FC = ({name, style, children}) => { ) } -export const SquareCanvas: React.FC = ({name, style, children}) => { - return
+export const SquareCanvas: React.FC = ({name, width, aspectRatio, style, children}) => { + return
} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7ac63a3..fdea064 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3035,6 +3035,7 @@ "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.7.0.tgz", "integrity": "sha512-b0fUmaL+JbzDIQaamzpAFpTviiaU4cX3Qz8cuo14+HGBCwa0evEK0UYCBFY3n4cLzL8Op1BueeroUD2LYAIHbQ==", + "license": "MIT", "dependencies": { "@docusaurus/babel": "3.7.0", "@docusaurus/bundler": "3.7.0", @@ -3109,6 +3110,7 @@ "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/faster/-/faster-3.7.0.tgz", "integrity": "sha512-d+7uyOEs3SBk38i2TL79N6mFaP7J4knc5lPX/W9od+jplXZhnDdl5ZMh2u2Lg7JxGV/l33Bd7h/xwv4mr21zag==", + "license": "MIT", "dependencies": { "@docusaurus/types": "3.7.0", "@rspack/core": "1.2.0-alpha.0", @@ -3362,6 +3364,7 @@ "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.7.0.tgz", "integrity": "sha512-g7WdPqDNaqA60CmBrr0cORTrsOit77hbsTj7xE2l71YhBn79sxdm7WMK7wfhcaafkbpIh7jv5ef5TOpf1Xv9Lg==", + "license": "MIT", "dependencies": { "@docusaurus/types": "3.7.0", "@types/history": "^4.7.11", @@ -3586,6 +3589,7 @@ "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.7.0.tgz", "integrity": "sha512-nPHj8AxDLAaQXs+O6+BwILFuhiWbjfQWrdw2tifOClQoNfuXDjfjogee6zfx6NGHWqshR23LrcN115DmkHC91Q==", + "license": "MIT", "dependencies": { "@docusaurus/core": "3.7.0", "@docusaurus/plugin-content-blog": "3.7.0", @@ -3681,6 +3685,7 @@ "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/theme-live-codeblock/-/theme-live-codeblock-3.7.0.tgz", "integrity": "sha512-peLs77sk+TuHjAnhyhT8IH3Qsr/zewpwHg5A4EOe/8K4Lj2T8fhro1/Dj66FS8784wwAoxhy5A9Ux9Rsp8h87w==", + "license": "MIT", "dependencies": { "@docusaurus/core": "3.7.0", "@docusaurus/theme-common": "3.7.0", @@ -3746,12 +3751,14 @@ "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/tsconfig/-/tsconfig-3.7.0.tgz", "integrity": "sha512-vRsyj3yUZCjscgfgcFYjIsTcAru/4h4YH2/XAE8Rs7wWdnng98PgWKvP5ovVc4rmRpRg2WChVW0uOy2xHDvDBQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@docusaurus/types": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.7.0.tgz", "integrity": "sha512-kOmZg5RRqJfH31m+6ZpnwVbkqMJrPOG5t0IOl4i/+3ruXyNfWzZ0lVtVrD0u4ONc/0NOsS9sWYaxxWNkH1LdLQ==", + "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", "@types/history": "^4.7.11",