Rewrite canvas to use React state management properly

This commit is contained in:
Bradlee Speice 2024-11-29 23:08:47 -05:00
parent ce5a28b7bd
commit 112470ce5a
9 changed files with 190 additions and 118 deletions

View File

@ -1,6 +1,6 @@
import {useEffect, useState} from "react"; import {useEffect, useState, useContext} from "react";
import Canvas from "../src/Canvas"; import {PainterContext} from "../src/Canvas";
import { Params, chaosGameWeighted } from "./chaosGameWeighted"; import {chaosGameWeighted } from "./chaosGameWeighted";
import TeX from '@matejmazur/react-katex'; import TeX from '@matejmazur/react-katex';
import styles from "../src/css/styles.module.css" 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]; type Transform = (x: number, y: number) => [number, number];
export default function GasketWeighted() { export default function GasketWeighted() {
const image = new ImageData(600, 600);
const iterations = 100_000;
const step = 1000;
const [f0Weight, setF0Weight] = useState<number>(1); const [f0Weight, setF0Weight] = useState<number>(1);
const [f1Weight, setF1Weight] = useState<number>(1); const [f1Weight, setF1Weight] = useState<number>(1);
const [f2Weight, setF2Weight] = useState<number>(1); const [f2Weight, setF2Weight] = useState<number>(1);
@ -20,19 +16,14 @@ export default function GasketWeighted() {
const f1: Transform = (x, y) => [(x + 1) / 2, y / 2]; const f1: Transform = (x, y) => [(x + 1) / 2, y / 2];
const f2: Transform = (x, y) => [x / 2, (y + 1) / 2]; const f2: Transform = (x, y) => [x / 2, (y + 1) / 2];
const [game, setGame] = useState<Generator<ImageData>>(null); const {setPainter} = useContext(PainterContext);
useEffect(() => { useEffect(() => {
const params: Params = { setPainter(chaosGameWeighted([
transforms: [
[f0Weight, f0], [f0Weight, f0],
[f1Weight, f1], [f1Weight, f1],
[f2Weight, f2] [f2Weight, f2]
], ]));
image,
iterations,
step
}
setGame(chaosGameWeighted(params))
}, [f0Weight, f1Weight, f2Weight]); }, [f0Weight, f1Weight, f2Weight]);
const weightInput = (title, weight, setWeight) => ( const weightInput = (title, weight, setWeight) => (
@ -47,7 +38,6 @@ export default function GasketWeighted() {
return ( return (
<> <>
<Canvas width={image.width} height={image.height} painter={game}/>
<div style={{paddingTop: '1em', display: 'grid', gridTemplateColumns: 'auto auto auto'}}> <div style={{paddingTop: '1em', display: 'grid', gridTemplateColumns: 'auto auto auto'}}>
{weightInput("F_0", f0Weight, setF0Weight)} {weightInput("F_0", f0Weight, setF0Weight)}
{weightInput("F_1", f1Weight, setF1Weight)} {weightInput("F_1", f1Weight, setF1Weight)}

View File

@ -1,18 +1,16 @@
function Gasket() { // Hint: try increasing the iteration count
// Hint: try increasing the iteration count const iterations = 10000;
const iterations = 10000;
// Hint: negating `x` and `y` creates some interesting images // Hint: negating `x` and `y` creates some interesting images
const transforms = [ const transforms = [
(x, y) => [x / 2, y / 2], (x, y) => [x / 2, y / 2],
(x, y) => [(x + 1) / 2, y / 2], (x, y) => [(x + 1) / 2, y / 2],
(x, y) => [x / 2, (y + 1) / 2] (x, y) => [x / 2, (y + 1) / 2]
] ]
const image = new ImageData(600, 600); function* chaosGame() {
let image = new ImageData(500, 500);
function* chaosGame() { let [x, y] = [randomBiUnit(), randomBiUnit()];
var [x, y] = [randomBiUnit(), randomBiUnit()];
for (var count = 0; count < iterations; count++) { for (var count = 0; count < iterations; count++) {
const i = randomInteger(0, transforms.length); const i = randomInteger(0, transforms.length);
@ -24,13 +22,15 @@ function Gasket() {
if (count % 1000 === 0) if (count % 1000 === 0)
yield image; yield image;
} }
}
return ( yield image;
<Canvas }
width={image.width}
height={image.height} // Wiring so the code above displays properly
painter={chaosGame()}/> function Gasket() {
) const {setPainter} = useContext(PainterContext);
setPainter(chaosGame());
return (<></>)
} }
render(<Gasket/>) render(<Gasket/>)

View File

@ -2,15 +2,12 @@
import { randomBiUnit } from "../src/randomBiUnit"; import { randomBiUnit } from "../src/randomBiUnit";
import { randomChoice } from "../src/randomChoice"; import { randomChoice } from "../src/randomChoice";
import { plot } from "./plot" import { plot } from "./plot"
export type Transform = (x: number, y: number) => [number, number]; import {Transform} from "../src/transform";
export type Params = { const iterations = 50_000;
transforms: [number, Transform][], const step = 1000;
image: ImageData,
iterations: number,
step: number
}
// hidden-end // 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()]; var [x, y] = [randomBiUnit(), randomBiUnit()];
for (let i = 0; i < iterations; i++) { for (let i = 0; i < iterations; i++) {

View File

@ -177,7 +177,9 @@ import Scope from './scope'
import chaosGameSource from '!!raw-loader!./chaosGame' import chaosGameSource from '!!raw-loader!./chaosGame'
<!--
<Playground scope={Scope} noInline={true}>{chaosGameSource}</Playground> <Playground scope={Scope} noInline={true}>{chaosGameSource}</Playground>
-->
<hr/> <hr/>
@ -198,7 +200,9 @@ import chaosGameWeightedSource from "!!raw-loader!./chaosGameWeighted";
<CodeBlock language={'typescript'}>{chaosGameWeightedSource}</CodeBlock> <CodeBlock language={'typescript'}>{chaosGameWeightedSource}</CodeBlock>
import BrowserOnly from "@docusaurus/BrowserOnly";
import GasketWeighted from "./GasketWeighted" import GasketWeighted from "./GasketWeighted"
import Canvas from "../src/Canvas"
<BrowserOnly>{() => <GasketWeighted/>}</BrowserOnly> <Canvas width={500} height={500}>
<GasketWeighted/>
</Canvas>

View File

@ -1,12 +1,15 @@
import {useContext} from "react";
import { plot } from './plot'; import { plot } from './plot';
import { randomBiUnit } from '../src/randomBiUnit'; import { randomBiUnit } from '../src/randomBiUnit';
import { randomInteger } from '../src/randomInteger'; import { randomInteger } from '../src/randomInteger';
import Canvas from "../src/Canvas"; import Canvas, {PainterContext} from "../src/Canvas";
const Scope = { const Scope = {
Canvas,
PainterContext,
plot, plot,
randomBiUnit, randomBiUnit,
randomInteger, randomInteger,
Canvas useContext,
} }
export default Scope; export default Scope;

View File

@ -1,4 +1,4 @@
import {useState} from "react"; import {useContext, useEffect, useState} from "react";
import { blend } from "./blend"; import { blend } from "./blend";
import { applyCoefs, Coefs } from "../src/coefs" import { applyCoefs, Coefs } from "../src/coefs"
import {randomBiUnit} from "../src/randomBiUnit"; import {randomBiUnit} from "../src/randomBiUnit";
@ -19,7 +19,7 @@ import {
} from "../src/params"; } from "../src/params";
import {randomChoice} from "../src/randomChoice"; import {randomChoice} from "../src/randomChoice";
import {plotBinary} from "../src/plotBinary" import {plotBinary} from "../src/plotBinary"
import Canvas from "../src/Canvas" import {PainterContext} from "../src/Canvas"
import styles from "../src/css/styles.module.css" import styles from "../src/css/styles.module.css"
@ -31,10 +31,11 @@ type VariationBlend = {
} }
export default function FlameBlend() { export default function FlameBlend() {
const image = new ImageData(400, 400);
const quality = 2; const quality = 2;
const step = 5000; const step = 5000;
const {width, height, setPainter} = useContext(PainterContext);
const xform1Default: VariationBlend = { const xform1Default: VariationBlend = {
linear: 0, linear: 0,
julia: 1, julia: 1,
@ -73,6 +74,7 @@ export default function FlameBlend() {
} }
} }
const image = new ImageData(width, height);
function* chaosGame() { function* chaosGame() {
let [x, y] = [randomBiUnit(), randomBiUnit()]; let [x, y] = [randomBiUnit(), randomBiUnit()];
const transforms: [number, Transform][] = [ const transforms: [number, Transform][] = [
@ -97,6 +99,7 @@ export default function FlameBlend() {
yield image; yield image;
} }
useEffect(() => setPainter(chaosGame()), [xform1Variations, xform2Variations, xform3Variations]);
const variationEditor = (title, variations, setVariations) => { const variationEditor = (title, variations, setVariations) => {
return ( return (
@ -127,16 +130,10 @@ export default function FlameBlend() {
} }
return ( return (
<>
<Canvas
width={image.width}
height={image.height}
painter={chaosGame()}/>
<div style={{paddingTop: '1em', display: 'grid', gridTemplateColumns: 'auto auto auto auto'}}> <div style={{paddingTop: '1em', display: 'grid', gridTemplateColumns: 'auto auto auto auto'}}>
{variationEditor("Transform 1", xform1Variations, setXform1Variations)} {variationEditor("Transform 1", xform1Variations, setXform1Variations)}
{variationEditor("Transform 2", xform2Variations, setXform2Variations)} {variationEditor("Transform 2", xform2Variations, setXform2Variations)}
{variationEditor("Transform 3", xform3Variations, setXform3Variations)} {variationEditor("Transform 3", xform3Variations, setXform3Variations)}
</div> </div>
</>
) )
} }

View File

@ -119,7 +119,7 @@ import pdjSrc from '!!raw-loader!../src/pdj'
<CodeBlock language={'typescript'}>{pdjSrc}</CodeBlock> <CodeBlock language={'typescript'}>{pdjSrc}</CodeBlock>
### Blending ## Blending
Now, one variation is fun, but we can also combine variations in a single transform by "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. 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: The formula looks intimidating, but it's not hard to implement:
TODO: Blending implementation? import blendSource from "!!raw-loader!./blend";
<CodeBlock language={'typescript'}>{blendSource}</CodeBlock>
And with that in place, we have enough to render a first full fractal flame: And with that in place, we have enough to render a first full fractal flame:
import Canvas from "../src/Canvas";
import FlameBlend from "./FlameBlend"; import FlameBlend from "./FlameBlend";
<FlameBlend/> <Canvas width={500} height={500}>
<FlameBlend/>
</Canvas>

View File

@ -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"; import {useColorMode} from "@docusaurus/theme-common";
interface Props { export interface PainterProps {
readonly width: number;
readonly height: number;
readonly setPainter: (painter: Iterator<ImageData>) => void;
}
/**
* Context provider for child elements to submit image iterator functions
* (painters) for rendering
*/
export const PainterContext = createContext<PainterProps>(null);
interface CanvasProps {
width: number; width: number;
height: number; height: number;
painter: Iterator<ImageData>;
children?: React.ReactNode; 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<CanvasRenderingContext2D>(null); const [canvasCtx, setCanvasCtx] = useState<CanvasRenderingContext2D>(null);
const canvasRef = useCallback(node => { const canvasRef = useCallback(node => {
if (node !== null) { if (node !== null) {
@ -18,52 +55,93 @@ export default function Canvas({width, height, painter, children}: Props) {
} }
}, []); }, []);
const paintImage = new ImageData(width, height); const {colorMode} = useColorMode();
const paint = () => {
if (!canvasCtx || !image) { // 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<ImageHolder>({ image: null });
useEffect(() => {
const image = imageHolder.image;
if (!image) {
// No image is available, leave the canvas as-is
return; return;
} }
for (const [index, value] of image[0].data.entries()) { if (!canvasCtx) {
if (index % 4 === 3) { // Canvas is not ready for the image we have,
// Alpha values are copied as-is // re-submit the image and wait for the ref to populate
paintImage.data[index] = value; setImageHolder({ image });
} else { return;
// If dark mode is active, invert the color
paintImage.data[index] = colorMode === 'light' ? value : 255 - value;
}
} }
// If light mode is active, paint the image as-is
if (colorMode === 'light') {
canvasCtx.putImageData(image, 0, 0);
return;
}
// 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); canvasCtx.putImageData(paintImage, 0, 0);
} }, [colorMode, imageHolder]);
useEffect(paint, [colorMode, image]);
const animate = () => { // 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<ImageData> };
const [animHolder, setAnimHolder] = useState<PainterHolder>({ painter: null });
useEffect(() => {
const painter = animHolder.painter;
if (!painter) { if (!painter) {
return; return;
} }
console.log("Animating"); if (!canvasCtx) {
const nextImage = painter.next().value; setAnimHolder({ painter });
if (nextImage) { return;
setImage([nextImage])
requestAnimationFrame(animate);
} }
const image = painter.next().value;
if (image) {
setImageHolder({ image });
setAnimHolder({ painter });
} else {
setAnimHolder({ painter: null });
} }
useEffect(animate, [painter, canvasCtx]); }, [animHolder, canvasCtx]);
// Finally, child elements submit painters through a context provider
const [painter, setPainter] = useState<Iterator<ImageData>>(null);
useEffect(() => setAnimHolder({ painter }), [painter]);
return ( return (
<> <>
<center>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={width} width={width}
height={height} height={height}
style={{ style={{
aspectRatio: width / height, aspectRatio: width / height,
width: '100%' width: '80%'
}} }}
/> />
</center>
<PainterContext.Provider value={{width, height, setPainter}}>
{children} {children}
</PainterContext.Provider>
</> </>
) )
} }

View File

@ -4,9 +4,7 @@
* The way `flam3` actually calculates the "camera" for mapping a point * The way `flam3` actually calculates the "camera" for mapping a point
* to its pixel coordinate is fairly involved - it also needs to calculate * to its pixel coordinate is fairly involved - it also needs to calculate
* zoom and rotation (see the bucket accumulator code in rect.c). * zoom and rotation (see the bucket accumulator code in rect.c).
* We'll make some simplifying assumptions: * We simplify things here by assuming a square image
* - The final image is square
* - We want to plot the range [-2, 2]
* *
* The reference parameters were designed in Apophysis, which uses the * The reference parameters were designed in Apophysis, which uses the
* range [-2, 2] by default (the `scale` parameter in XML defines 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 x point in the range [-2, 2]
* @param y 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 * @returns pair of pixel coordinates
*/ */
export function camera(x: number, y: number, size: number): [number, number] { export function camera(x: number, y: number, size: number): [number, number] {