Start on animations so I don't kill the browser

This commit is contained in:
Bradlee Speice 2023-07-17 02:27:45 +00:00
parent 458920b1ae
commit 01af290363
4 changed files with 184 additions and 52 deletions

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { renderFn } from "./0-utility"; import { Renderer, renderFn } from "./0-utility";
export const Canvas: React.FC<{ f: renderFn }> = ({ f }) => { export const Canvas: React.FC<{ f: renderFn }> = ({ f }) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
@ -29,3 +29,45 @@ export const Canvas: React.FC<{ f: renderFn }> = ({ f }) => {
return <canvas ref={canvasRef} width={400} height={400} />; return <canvas ref={canvasRef} width={400} height={400} />;
}; };
export type CanvasParams = {
defaultUrl: string;
size: number;
qualityMax: number;
qualityStep: number;
renderer: Renderer;
};
export const CanvasRenderer: React.FC<{ params: CanvasParams }> = ({
params,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
var qualityCurrent: number = 0;
const animate = () => {
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) {
return;
}
console.log("Animating");
const image = ctx.createImageData(params.size, params.size);
params.renderer.run(params.qualityStep);
params.renderer.render(image);
ctx.putImageData(image, 0, 0);
qualityCurrent += params.qualityStep;
if (qualityCurrent < params.qualityMax) {
requestAnimationFrame(animate);
}
};
useEffect(() => {
if (canvasRef.current) {
requestAnimationFrame(animate);
}
}, []);
return <canvas ref={canvasRef} width={params.size} height={params.size} />;
};

View File

@ -1,15 +1,55 @@
/**
* Image render manager
*
* This class tracks the chaos game state so we can periodically
* get an image.
*/
export abstract class Renderer {
/**
* Build a render manager. For simplicity, this class assumes
* we're working with a square image.
*
* @param size Image width and height
*/
constructor(public readonly size: number) {}
/**
* Run the chaos game
*
* @param quality iteration count
*/
abstract run(quality: number): void;
/**
* Output the current chaos game state to image
*
* @param image output pixel buffer
*/
abstract render(image: ImageData): void;
}
export type renderFn = (image: ImageData) => void; export type renderFn = (image: ImageData) => void;
/**
* @returns random number in the bi-unit square (-1, 1)
*/
export function randomBiUnit() { export function randomBiUnit() {
// Math.random() produces a number in the range [0, 1), // Math.random() produces a number in the range [0, 1),
// scale to (-1, 1) // scale to (-1, 1)
return Math.random() * 2 - 1; return Math.random() * 2 - 1;
} }
/**
* @returns random integer (with equal weight) in the range [min, max)
*/
export function randomInteger(min: number, max: number) { export function randomInteger(min: number, max: number) {
return Math.floor(Math.random() * (max - min)) + min; return Math.floor(Math.random() * (max - min)) + min;
} }
/**
* @param choices array of [weight, value] pairs
* @returns pair of [index, value]
*/
export function weightedChoice<T>(choices: [number, T][]): [number, T] { export function weightedChoice<T>(choices: [number, T][]): [number, T] {
const weightSum = choices.reduce( const weightSum = choices.reduce(
(current, [weight, _t]) => current + weight, (current, [weight, _t]) => current + weight,
@ -29,11 +69,23 @@ export function weightedChoice<T>(choices: [number, T][]): [number, T] {
throw "unreachable"; throw "unreachable";
} }
/**
* @param x pixel coordinate
* @param y pixel coordinate
* @param width image width
* @returns index into ImageData buffer for a specific pixel
*/
export function imageIndex(x: number, y: number, width: number) { export function imageIndex(x: number, y: number, width: number) {
// Taken from: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas // Taken from: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas
return y * (width * 4) + x * 4; return y * (width * 4) + x * 4;
} }
/**
* @param x pixel coordinate
* @param y pixel coordinate
* @param width image width
* @returns index into a histogram for a specific pixel
*/
export function histIndex(x: number, y: number, width: number) { export function histIndex(x: number, y: number, width: number) {
return y * width + x; return y * width + x;
} }

View File

@ -1,57 +1,86 @@
import { randomBiUnit, randomInteger, renderFn, imageIndex } from "./0-utility"; import {
randomBiUnit,
randomInteger,
imageIndex,
Renderer,
histIndex,
} from "./0-utility";
function plot(x: number, y: number, image: ImageData) { type Transform = (x: number, y: number) => [number, number];
// A trivial `plot` implementation would take the range [-1, 1],
// shift it to [0, 2], then scale by the width or height
// as appropriate:
// pixelX = Math.floor((x + 1) * image.width / 2)
// pixelY = Math.floor((y + 1) * image.height / 2)
//
// However, that produces a mirror image (across both X and Y)
// from the paper. We'll negate X and Y to compensate.
// Second, because the gasket solution only contains points in
// the range [0, 1), the naive plot above would waste 75% of
// the pixels available. We'll keep the shift by 1 (to compensate
// for mirroring X and Y), but scale by the full image width or
// height so we'll plot the specific quadrant we care about.
var pixelX = Math.floor((-x + 1) * image.width);
var pixelY = Math.floor((-y + 1) * image.height);
// Set the pixel black: export class RendererGasket extends Renderer {
const index = imageIndex(pixelX, pixelY, image.width); private values = new Uint8Array(this.size * this.size);
image.data[index + 0] = 0;
image.data[index + 1] = 0;
image.data[index + 2] = 0;
image.data[index + 3] = 0xff;
}
type Xform = (x: number, y: number) => [number, number]; /**
* Translate values in the flame coordinate system to pixel coordinates
*
* A trivial implementation would take the range [-1, 1], shift it to [0, 2],
* then scale by the image size:
* pixelX = Math.floor((x + 1) * this.size / 2)
* pixelY = Math.floor((y + 1) * this.size / 2)
*
* However, because the gasket solution set only has values in the range [0, 1],
* that would lead to wasting 3/4 of the pixels. We'll instead plot just the range
* we care about:
* pixelX = Math.floor(x * this.size)
* pixelY = Math.floor(x * this.size)
*
* @param x point in the range [-1, 1]
* @param y point in the range [-1, 1]
*/
plot(x: number, y: number): void {
var pixelX = Math.floor(x * this.size);
var pixelY = Math.floor(y * this.size);
export const gasket: renderFn = (image) => { if (
const F: Xform[] = [ pixelX < 0 ||
(x, y) => { pixelX >= this.size ||
return [x / 2, y / 2]; pixelY < 0 ||
}, pixelY >= this.size
(x, y) => { ) {
return [(x + 1) / 2, y / 2]; return;
}, }
(x, y) => {
return [x / 2, (y + 1) / 2];
},
];
let x = randomBiUnit(); const index = histIndex(pixelX, pixelY, this.size);
let y = randomBiUnit(); this.values[index] = 1;
}
// Plot with quality 1 run(quality: number): void {
const iterations = image.height * image.width; const transforms: Transform[] = [
(x, y) => [x / 2, y / 2],
(x, y) => [(x + 1) / 2, y / 2],
(x, y) => [x / 2, (y + 1) / 2],
];
for (var i = 0; i < iterations; i++) { let x = randomBiUnit();
const Fi = randomInteger(0, F.length); let y = randomBiUnit();
[x, y] = F[Fi](x, y);
if (i >= 20) { const iterations = quality * this.size * this.size;
plot(x, y, image); for (var i = 0; i < iterations; i++) {
const transformIndex = randomInteger(0, transforms.length);
[x, y] = transforms[transformIndex](x, y);
if (i >= 20) {
this.plot(x, y);
}
} }
} }
};
render(image: ImageData): void {
for (var pixelX = 0; pixelX < image.width; pixelX++) {
for (var pixelY = 0; pixelY < image.height; pixelY++) {
const hIndex = histIndex(pixelX, pixelY, this.size);
if (!this.values[hIndex]) {
continue;
}
// Set the pixel black
const iIndex = imageIndex(pixelX, pixelY, this.size);
image.data[iIndex + 0] = 0;
image.data[iIndex + 1] = 0;
image.data[iIndex + 2] = 0;
image.data[iIndex + 3] = 0xff;
}
}
}
}

View File

@ -1,7 +1,7 @@
import Blog from "../../../LayoutBlog"; import Blog from "../../../LayoutBlog";
import { Canvas } from "./0-canvas"; import { Canvas, CanvasRenderer } from "./0-canvas";
import { gasket } from "./1-gasket"; import { RendererGasket } from "./1-gasket";
import { renderBaseline } from "./2a-variations"; import { renderBaseline } from "./2a-variations";
import { renderPost } from "./2b-post"; import { renderPost } from "./2b-post";
import { renderFinal } from "./2c-final"; import { renderFinal } from "./2c-final";
@ -23,7 +23,16 @@ export default function () {
}); });
return ( return (
<Layout> <Layout>
<div> <CanvasRenderer
params={{
defaultUrl: "",
size: 400,
renderer: new RendererGasket(400),
qualityMax: 10,
qualityStep: 0.25,
}}
/>
{/* <div>
<Canvas f={gasket} /> <Canvas f={gasket} />
<Canvas f={renderBaseline} /> <Canvas f={renderBaseline} />
</div> </div>
@ -34,7 +43,7 @@ export default function () {
<div> <div>
<Canvas f={renderLogarithmic} /> <Canvas f={renderLogarithmic} />
<Canvas f={renderColor} /> <Canvas f={renderColor} />
</div> </div> */}
</Layout> </Layout>
); );
} }