From 112470ce5a564cf71828d52331a1d4a1ab4d33ec Mon Sep 17 00:00:00 2001 From: Bradlee Speice Date: Fri, 29 Nov 2024 23:08:47 -0500 Subject: [PATCH] Rewrite canvas to use React state management properly --- .../1-introduction/GasketWeighted.tsx | 30 ++-- .../1-introduction/chaosGame.js | 54 +++--- .../1-introduction/chaosGameWeighted.ts | 13 +- .../1-introduction/index.mdx | 8 +- .../1-introduction/scope.tsx | 7 +- .../2-transforms/FlameBlend.tsx | 25 ++- .../2-transforms/index.mdx | 11 +- .../src/Canvas.tsx | 154 +++++++++++++----- .../src/camera.ts | 6 +- 9 files changed, 190 insertions(+), 118 deletions(-) diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/GasketWeighted.tsx b/blog/2024-11-15-playing-with-fire/1-introduction/GasketWeighted.tsx index fc3f6bb..d2edb62 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/GasketWeighted.tsx +++ b/blog/2024-11-15-playing-with-fire/1-introduction/GasketWeighted.tsx @@ -1,6 +1,6 @@ -import {useEffect, useState} from "react"; -import Canvas from "../src/Canvas"; -import { Params, chaosGameWeighted } from "./chaosGameWeighted"; +import {useEffect, useState, useContext} from "react"; +import {PainterContext} from "../src/Canvas"; +import {chaosGameWeighted } from "./chaosGameWeighted"; import TeX from '@matejmazur/react-katex'; import styles from "../src/css/styles.module.css" @@ -8,10 +8,6 @@ import styles from "../src/css/styles.module.css" type Transform = (x: number, y: number) => [number, number]; export default function GasketWeighted() { - const image = new ImageData(600, 600); - const iterations = 100_000; - const step = 1000; - const [f0Weight, setF0Weight] = useState(1); const [f1Weight, setF1Weight] = useState(1); const [f2Weight, setF2Weight] = useState(1); @@ -20,19 +16,14 @@ export default function GasketWeighted() { const f1: Transform = (x, y) => [(x + 1) / 2, y / 2]; const f2: Transform = (x, y) => [x / 2, (y + 1) / 2]; - const [game, setGame] = useState>(null); + const {setPainter} = useContext(PainterContext); + useEffect(() => { - const params: Params = { - transforms: [ - [f0Weight, f0], - [f1Weight, f1], - [f2Weight, f2] - ], - image, - iterations, - step - } - setGame(chaosGameWeighted(params)) + setPainter(chaosGameWeighted([ + [f0Weight, f0], + [f1Weight, f1], + [f2Weight, f2] + ])); }, [f0Weight, f1Weight, f2Weight]); const weightInput = (title, weight, setWeight) => ( @@ -47,7 +38,6 @@ export default function GasketWeighted() { return ( <> -
{weightInput("F_0", f0Weight, setF0Weight)} {weightInput("F_1", f1Weight, setF1Weight)} diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/chaosGame.js b/blog/2024-11-15-playing-with-fire/1-introduction/chaosGame.js index e51648c..a3e6ce0 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/chaosGame.js +++ b/blog/2024-11-15-playing-with-fire/1-introduction/chaosGame.js @@ -1,36 +1,36 @@ -function Gasket() { - // Hint: try increasing the iteration count - const iterations = 10000; +// Hint: try increasing the iteration count +const iterations = 10000; - // Hint: negating `x` and `y` creates some interesting images - const transforms = [ - (x, y) => [x / 2, y / 2], - (x, y) => [(x + 1) / 2, y / 2], - (x, y) => [x / 2, (y + 1) / 2] - ] +// Hint: negating `x` and `y` creates some interesting images +const transforms = [ + (x, y) => [x / 2, y / 2], + (x, y) => [(x + 1) / 2, y / 2], + (x, y) => [x / 2, (y + 1) / 2] +] - const image = new ImageData(600, 600); +function* chaosGame() { + let image = new ImageData(500, 500); + let [x, y] = [randomBiUnit(), randomBiUnit()]; - function* chaosGame() { - var [x, y] = [randomBiUnit(), randomBiUnit()]; + for (var count = 0; count < iterations; count++) { + const i = randomInteger(0, transforms.length); + [x, y] = transforms[i](x, y); - for (var count = 0; count < iterations; count++) { - const i = randomInteger(0, transforms.length); - [x, y] = transforms[i](x, y); + if (count > 20) + plot(x, y, image); - if (count > 20) - plot(x, y, image); - - if (count % 1000 === 0) - yield image; - } + if (count % 1000 === 0) + yield image; } - return ( - - ) + yield image; +} + +// Wiring so the code above displays properly +function Gasket() { + const {setPainter} = useContext(PainterContext); + setPainter(chaosGame()); + + return (<>) } render() diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/chaosGameWeighted.ts b/blog/2024-11-15-playing-with-fire/1-introduction/chaosGameWeighted.ts index 5001ff3..fac18d1 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/chaosGameWeighted.ts +++ b/blog/2024-11-15-playing-with-fire/1-introduction/chaosGameWeighted.ts @@ -2,15 +2,12 @@ import { randomBiUnit } from "../src/randomBiUnit"; import { randomChoice } from "../src/randomChoice"; import { plot } from "./plot" -export type Transform = (x: number, y: number) => [number, number]; -export type Params = { - transforms: [number, Transform][], - image: ImageData, - iterations: number, - step: number -} +import {Transform} from "../src/transform"; +const iterations = 50_000; +const step = 1000; // hidden-end -export function* chaosGameWeighted({transforms, image, iterations, step}: Params) { +export function* chaosGameWeighted(transforms: [number, Transform][]) { + let image = new ImageData(500, 500); var [x, y] = [randomBiUnit(), randomBiUnit()]; for (let i = 0; i < iterations; i++) { diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/index.mdx b/blog/2024-11-15-playing-with-fire/1-introduction/index.mdx index 22b022b..c4f1890 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/index.mdx +++ b/blog/2024-11-15-playing-with-fire/1-introduction/index.mdx @@ -177,7 +177,9 @@ import Scope from './scope' import chaosGameSource from '!!raw-loader!./chaosGame' +
@@ -198,7 +200,9 @@ import chaosGameWeightedSource from "!!raw-loader!./chaosGameWeighted"; {chaosGameWeightedSource} -import BrowserOnly from "@docusaurus/BrowserOnly"; import GasketWeighted from "./GasketWeighted" +import Canvas from "../src/Canvas" -{() => } \ No newline at end of file + + + \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/scope.tsx b/blog/2024-11-15-playing-with-fire/1-introduction/scope.tsx index 5b2dd7b..4ffe6d4 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/scope.tsx +++ b/blog/2024-11-15-playing-with-fire/1-introduction/scope.tsx @@ -1,12 +1,15 @@ +import {useContext} from "react"; import { plot } from './plot'; import { randomBiUnit } from '../src/randomBiUnit'; import { randomInteger } from '../src/randomInteger'; -import Canvas from "../src/Canvas"; +import Canvas, {PainterContext} from "../src/Canvas"; const Scope = { + Canvas, + PainterContext, plot, randomBiUnit, randomInteger, - Canvas + useContext, } export default Scope; \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/2-transforms/FlameBlend.tsx b/blog/2024-11-15-playing-with-fire/2-transforms/FlameBlend.tsx index cc49d53..5ef84c7 100644 --- a/blog/2024-11-15-playing-with-fire/2-transforms/FlameBlend.tsx +++ b/blog/2024-11-15-playing-with-fire/2-transforms/FlameBlend.tsx @@ -1,4 +1,4 @@ -import {useState} from "react"; +import {useContext, useEffect, useState} from "react"; import { blend } from "./blend"; import { applyCoefs, Coefs } from "../src/coefs" import {randomBiUnit} from "../src/randomBiUnit"; @@ -19,7 +19,7 @@ import { } from "../src/params"; import {randomChoice} from "../src/randomChoice"; import {plotBinary} from "../src/plotBinary" -import Canvas from "../src/Canvas" +import {PainterContext} from "../src/Canvas" import styles from "../src/css/styles.module.css" @@ -31,10 +31,11 @@ type VariationBlend = { } export default function FlameBlend() { - const image = new ImageData(400, 400); const quality = 2; const step = 5000; + const {width, height, setPainter} = useContext(PainterContext); + const xform1Default: VariationBlend = { linear: 0, julia: 1, @@ -73,6 +74,7 @@ export default function FlameBlend() { } } + const image = new ImageData(width, height); function* chaosGame() { let [x, y] = [randomBiUnit(), randomBiUnit()]; const transforms: [number, Transform][] = [ @@ -97,6 +99,7 @@ export default function FlameBlend() { yield image; } + useEffect(() => setPainter(chaosGame()), [xform1Variations, xform2Variations, xform3Variations]); const variationEditor = (title, variations, setVariations) => { return ( @@ -127,16 +130,10 @@ export default function FlameBlend() { } return ( - <> - -
- {variationEditor("Transform 1", xform1Variations, setXform1Variations)} - {variationEditor("Transform 2", xform2Variations, setXform2Variations)} - {variationEditor("Transform 3", xform3Variations, setXform3Variations)} -
- +
+ {variationEditor("Transform 1", xform1Variations, setXform1Variations)} + {variationEditor("Transform 2", xform2Variations, setXform2Variations)} + {variationEditor("Transform 3", xform3Variations, setXform3Variations)} +
) } \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/2-transforms/index.mdx b/blog/2024-11-15-playing-with-fire/2-transforms/index.mdx index 5af277d..fe8f569 100644 --- a/blog/2024-11-15-playing-with-fire/2-transforms/index.mdx +++ b/blog/2024-11-15-playing-with-fire/2-transforms/index.mdx @@ -119,7 +119,7 @@ import pdjSrc from '!!raw-loader!../src/pdj' {pdjSrc} -### Blending +## Blending Now, one variation is fun, but we can also combine variations in a single transform by "blending." Each variation receives the same $x$ and $y$ inputs, and we add together each variation's $x$ and $y$ outputs. @@ -132,10 +132,15 @@ $$ The formula looks intimidating, but it's not hard to implement: -TODO: Blending implementation? +import blendSource from "!!raw-loader!./blend"; + +{blendSource} And with that in place, we have enough to render a first full fractal flame: +import Canvas from "../src/Canvas"; import FlameBlend from "./FlameBlend"; - \ No newline at end of file + + + \ No newline at end of file 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 4b9b4d4..8d46cfd 100644 --- a/blog/2024-11-15-playing-with-fire/src/Canvas.tsx +++ b/blog/2024-11-15-playing-with-fire/src/Canvas.tsx @@ -1,16 +1,53 @@ -import React, {useCallback, useEffect, useState} from "react"; +import React, {useCallback, useEffect, useState, createContext} from "react"; import {useColorMode} from "@docusaurus/theme-common"; -interface Props { +export interface PainterProps { + readonly width: number; + readonly height: number; + readonly setPainter: (painter: Iterator) => void; +} + +/** + * Context provider for child elements to submit image iterator functions + * (painters) for rendering + */ +export const PainterContext = createContext(null); + +interface CanvasProps { width: number; height: number; - painter: Iterator; children?: React.ReactNode; } -export default function Canvas({width, height, painter, children}: Props) { - const {colorMode} = useColorMode(); - const [image, setImage] = useState<[ImageData]>(null); +/** + * Draw fractal flames to a canvas. + * + * This component is a bit involved because it attempts to solve + * a couple problems at the same time: + * - Incrementally drawing an image to the canvas + * - Interrupting drawing with new parameters on demand + * - Dark mode + * + * Image iterators provide a means to draw incremental images; + * iterators can easily checkpoint state, and this component will + * request the next image on the next animation frame. As a result, + * the browser should be responsive even though we run CPU-heavy + * code on the main thread. + * + * Swapping a new iterator allows interrupting a render in progress, + * as the canvas completely repaints on each provided image. + * + * Finally, check whether dark mode is active, and invert the most + * recent image prior to painting if so. + * + * PainterContext is used to allow child elements to swap in + * new iterators. + * + * @param width Canvas draw width + * @param height Canvas draw height + * @param children Child elements + */ +export default function Canvas({width, height, children}: CanvasProps) { const [canvasCtx, setCanvasCtx] = useState(null); const canvasRef = useCallback(node => { if (node !== null) { @@ -18,52 +55,93 @@ export default function Canvas({width, height, painter, children}: Props) { } }, []); - const paintImage = new ImageData(width, height); - const paint = () => { - if (!canvasCtx || !image) { + const {colorMode} = useColorMode(); + + // Holder objects are used to force re-painting even if the iterator + // returns a modified image with the same reference + type ImageHolder = { image?: ImageData }; + const [imageHolder, setImageHolder] = useState({ image: null }); + useEffect(() => { + const image = imageHolder.image; + if (!image) { + // No image is available, leave the canvas as-is return; } - for (const [index, value] of image[0].data.entries()) { - if (index % 4 === 3) { - // Alpha values are copied as-is - paintImage.data[index] = value; - } else { - // If dark mode is active, invert the color - paintImage.data[index] = colorMode === 'light' ? value : 255 - value; - } + if (!canvasCtx) { + // Canvas is not ready for the image we have, + // re-submit the image and wait for the ref to populate + setImageHolder({ image }); + return; } - canvasCtx.putImageData(paintImage, 0, 0); - } - useEffect(paint, [colorMode, image]); + // If light mode is active, paint the image as-is + if (colorMode === 'light') { + canvasCtx.putImageData(image, 0, 0); + return; + } - const animate = () => { + // If dark mode is active, copy the image into a new buffer + // and invert colors prior to painting. + // Copy alpha values as-is. + const paintImage = new ImageData(image.width, image.height); + image.data.forEach((value, index) => { + const isAlpha = index % 4 === 3; + paintImage.data[index] = isAlpha ? value : 255 - value; + }) + canvasCtx.putImageData(paintImage, 0, 0); + }, [colorMode, imageHolder]); + + // Image iterators (painters) are also in a holder; this allows + // re-submitting the existing iterator to draw the next frame, + // and also allows child components to over-write the iterator + // if a new set of parameters becomes available + // TODO(bspeice): Potential race condition? + // Not sure if it's possible for painters submitted by children + // to be over-ridden as a result re-submitting the + // existing iterator + type PainterHolder = { painter?: Iterator }; + const [animHolder, setAnimHolder] = useState({ painter: null }); + useEffect(() => { + const painter = animHolder.painter; if (!painter) { return; } - console.log("Animating"); - const nextImage = painter.next().value; - if (nextImage) { - setImage([nextImage]) - requestAnimationFrame(animate); + if (!canvasCtx) { + setAnimHolder({ painter }); + return; } - } - useEffect(animate, [painter, canvasCtx]); + + const image = painter.next().value; + if (image) { + setImageHolder({ image }); + setAnimHolder({ painter }); + } else { + setAnimHolder({ painter: null }); + } + }, [animHolder, canvasCtx]); + + // Finally, child elements submit painters through a context provider + const [painter, setPainter] = useState>(null); + useEffect(() => setAnimHolder({ painter }), [painter]); return ( <> - - {children} +
+ +
+ + {children} + ) } \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/src/camera.ts b/blog/2024-11-15-playing-with-fire/src/camera.ts index 28dd927..afef3af 100644 --- a/blog/2024-11-15-playing-with-fire/src/camera.ts +++ b/blog/2024-11-15-playing-with-fire/src/camera.ts @@ -4,9 +4,7 @@ * The way `flam3` actually calculates the "camera" for mapping a point * to its pixel coordinate is fairly involved - it also needs to calculate * zoom and rotation (see the bucket accumulator code in rect.c). - * We'll make some simplifying assumptions: - * - The final image is square - * - We want to plot the range [-2, 2] + * We simplify things here by assuming a square image * * The reference parameters were designed in Apophysis, which uses the * range [-2, 2] by default (the `scale` parameter in XML defines the @@ -15,7 +13,7 @@ * * @param x point in the range [-2, 2] * @param y point in the range [-2, 2] - * @param size image size + * @param size image width/height in pixels * @returns pair of pixel coordinates */ export function camera(x: number, y: number, size: number): [number, number] {