From 671b97b7a08d0ce6fbde1e6faa570941ac79c544 Mon Sep 17 00:00:00 2001 From: Bradlee Speice Date: Sun, 2 Jul 2023 23:34:34 +0000 Subject: [PATCH] Refactor, fix julia bug, implement binary/linear/logarithmic membership --- posts/2023/06/flam3/0-utility.tsx | 34 ++++++++++++ posts/2023/06/flam3/1-gasket.ts | 20 +++---- posts/2023/06/flam3/2a-variations.ts | 65 +++++++++++----------- posts/2023/06/flam3/2c-final.ts | 35 +++++++----- posts/2023/06/flam3/3a-binary.ts | 78 +++++++++++++++++++++++++++ posts/2023/06/flam3/3b-linear.ts | 30 +++++++++++ posts/2023/06/flam3/3c-logarithmic.ts | 36 +++++++++++++ posts/2023/06/flam3/index.tsx | 10 +++- 8 files changed, 249 insertions(+), 59 deletions(-) create mode 100644 posts/2023/06/flam3/3a-binary.ts create mode 100644 posts/2023/06/flam3/3b-linear.ts create mode 100644 posts/2023/06/flam3/3c-logarithmic.ts diff --git a/posts/2023/06/flam3/0-utility.tsx b/posts/2023/06/flam3/0-utility.tsx index 113957b..c20464c 100644 --- a/posts/2023/06/flam3/0-utility.tsx +++ b/posts/2023/06/flam3/0-utility.tsx @@ -31,6 +31,40 @@ export const Canvas: React.FC<{ f: renderFn }> = ({ f }) => { return ; }; +export function randomBiUnit() { + // Math.random() produces a number in the range [0, 1), + // scale to (-1, 1) + return Math.random() * 2 - 1; +} + export function randomInteger(min: number, max: number) { return Math.floor(Math.random() * (max - min)) + min; } + +export function weightedChoice(choices: [number, T][]) { + const weightSum = choices.reduce( + (current, [weight, _t]) => current + weight, + 0 + ); + var choice = Math.random() * weightSum; + + for (var i = 0; i < choices.length; i++) { + const [weight, t] = choices[i]; + if (choice < weight) { + return t; + } + + choice -= weight; + } + + throw "unreachable"; +} + +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 + return y * (width * 4) + x * 4; +} + +export function histIndex(x: number, y: number, width: number) { + return y * width + x; +} diff --git a/posts/2023/06/flam3/1-gasket.ts b/posts/2023/06/flam3/1-gasket.ts index 3114df2..03eebf8 100644 --- a/posts/2023/06/flam3/1-gasket.ts +++ b/posts/2023/06/flam3/1-gasket.ts @@ -1,6 +1,4 @@ -import { randomInteger, renderFn } from "./0-utility"; - -const ITER = 100_000; +import { randomBiUnit, randomInteger, renderFn, imageIndex } from "./0-utility"; function plot(x: number, y: number, image: ImageData) { // A trivial `plot` implementation would take the range [-1, 1], @@ -19,10 +17,8 @@ function plot(x: number, y: number, image: ImageData) { var pixelX = Math.floor((-x + 1) * image.width); var pixelY = Math.floor((-y + 1) * image.height); - // Now translate the (x, y) pixel coordinates to a buffer index - // and paint it black: - const index = pixelY * (image.width * 4) + pixelX * 4; - + // Set the pixel black: + const index = imageIndex(pixelX, pixelY, image.width); image.data[index + 0] = 0; image.data[index + 1] = 0; image.data[index + 2] = 0; @@ -44,13 +40,13 @@ export const gasket: renderFn = (image) => { }, ]; - let x = Math.random() * 2 - 1; - let y = Math.random() * 2 - 1; + let x = randomBiUnit(); + let y = randomBiUnit(); - // Heuristic for iteration count - const iter = image.height * image.width; + // Plot with quality 1 + const iterations = image.height * image.width; - for (var i = 0; i < iter; i++) { + for (var i = 0; i < iterations; i++) { const Fi = randomInteger(0, F.length); [x, y] = F[Fi](x, y); diff --git a/posts/2023/06/flam3/2a-variations.ts b/posts/2023/06/flam3/2a-variations.ts index adf5ca2..e88235f 100644 --- a/posts/2023/06/flam3/2a-variations.ts +++ b/posts/2023/06/flam3/2a-variations.ts @@ -1,3 +1,5 @@ +import { randomBiUnit, weightedChoice } from "./0-utility"; + export type Variation = ( x: number, y: number, @@ -17,7 +19,7 @@ function r(x: number, y: number) { } function theta(x: number, y: number) { - return Math.atan2(y, x); + return Math.atan2(x, y); } function omega(): number { @@ -28,10 +30,8 @@ export const linear: Variation = (x, y) => [x, y]; export const julia: Variation = (x, y) => { const sqrtR = Math.sqrt(r(x, y)); - return [ - sqrtR * Math.cos(theta(x, y) / 2 + omega()), - sqrtR * Math.sin(theta(x, y) / 2 + omega()), - ]; + const thetaVal = theta(x, y) / 2 + omega(); + return [sqrtR * Math.cos(thetaVal), sqrtR * Math.sin(thetaVal)]; }; export const popcorn: Variation = (x, y, transformCoefs) => { @@ -74,28 +74,9 @@ export class Transform { } } -export function weightedChoice(choices: [number, T][]) { - const weightSum = choices.reduce( - (current, [weight, _t]) => current + weight, - 0 - ); - var choice = Math.random() * weightSum; - - for (var i = 0; i < choices.length; i++) { - const [weight, t] = choices[i]; - if (choice < weight) { - return t; - } - - choice -= weight; - } - - throw "unreachable"; -} - export class Flame { - x: number = Math.random() * 2 - 1; - y: number = Math.random() * 2 - 1; + protected x: number = randomBiUnit(); + protected y: number = randomBiUnit(); constructor(public readonly transforms: [number, Transform][]) {} @@ -103,17 +84,35 @@ export class Flame { const transform = weightedChoice(this.transforms); [this.x, this.y] = transform.apply(this.x, this.y); } + + current() { + return [this.x, this.y]; + } +} + +export function camera(x: number, y: number, size: number): [number, number] { + // Assuming both: + // - The origin is the intended center of the output image + // - The output image is square + // ...then map points in the range (-scale, scale) to pixel coordinates. + // + // The way `flam3` actually calculates the "camera" for taking a point + // and determining which pixel to update is fairly involved. The example + // fractal was designed in Apophysis (which shows points in the range + // [-2, 2] by default) so we use that assumption to simplify the math here. + return [Math.floor(((x + 2) * size) / 4), Math.floor(((y + 2) * size) / 4)]; } export function plot(x: number, y: number, image: ImageData) { - const pixelX = Math.floor(((x + 2) * image.width) / 4); - const pixelY = Math.floor(((y + 2) * image.height) / 4); + // "Zoom out" the camera by a factor of 2 to match the default Apophysis scaling + // (plot all points in the range [-2, 2]) + const [pixelX, pixelY] = camera(x, y, image.width); if ( pixelX < 0 || - pixelX > image.width || + pixelX >= image.width || pixelY < 0 || - pixelY > image.height + pixelY >= image.height ) { return; } @@ -132,7 +131,8 @@ export function render(flame: Flame, quality: number, image: ImageData) { for (var i = 0; i < iterations; i++) { flame.step(); if (i > 20) { - plot(flame.x, flame.y, image); + const [flameX, flameY] = flame.current(); + plot(flameX, flameY, image); } } } @@ -167,7 +167,6 @@ export const transform2 = new Transform( ); export const transform3Weight = 0.42233; -export const transform3Pdj = [1.09358, 2.13048, 2.54127, 2.37267] as const; export const transform3 = new Transform( { a: 1.51523, @@ -177,7 +176,7 @@ export const transform3 = new Transform( e: -1.455964, f: -0.362059, }, - [[1, pdj(...transform3Pdj)]] + [[1, pdj(1.09358, 2.13048, 2.54127, 2.37267)]] ); export function renderBaseline(image: ImageData) { diff --git a/posts/2023/06/flam3/2c-final.ts b/posts/2023/06/flam3/2c-final.ts index 5652eda..1f81c23 100644 --- a/posts/2023/06/flam3/2c-final.ts +++ b/posts/2023/06/flam3/2c-final.ts @@ -1,5 +1,4 @@ import { - Coefs, Flame, Transform, julia, @@ -13,6 +12,8 @@ import { import { transform2Post } from "./2b-post"; export class FlameFinal extends Flame { + didLog: boolean = false; + constructor( transforms: [number, Transform][], public readonly final: Transform @@ -20,10 +21,20 @@ export class FlameFinal extends Flame { super(transforms); } - step() { + override step(): void { super.step(); [this.x, this.y] = this.final.apply(this.x, this.y); } + + override current() { + if (!this.didLog) { + this.didLog = true; + console.trace(`Getting final xform to plot`); + } + // NOTE: The final transform does not modify the iterator point + // return this.final.apply(this.x, this.y); + return [this.x, this.y]; + } } export const transformFinal = new Transform( @@ -38,15 +49,15 @@ export const transformFinal = new Transform( [[1, julia]] ); -export function renderFinal(image: ImageData) { - const flame = new FlameFinal( - [ - [transform1Weight, transform1], - [transform2Weight, transform2Post], - [transform3Weight, transform3], - ], - transformFinal - ); +export const flameFinal = new FlameFinal( + [ + [transform1Weight, transform1], + [transform2Weight, transform2Post], + [transform3Weight, transform3], + ], + transformFinal +); - render(flame, 1, image); +export function renderFinal(image: ImageData) { + render(flameFinal, 1, image); } diff --git a/posts/2023/06/flam3/3a-binary.ts b/posts/2023/06/flam3/3a-binary.ts new file mode 100644 index 0000000..728f716 --- /dev/null +++ b/posts/2023/06/flam3/3a-binary.ts @@ -0,0 +1,78 @@ +import { histIndex, imageIndex } from "./0-utility"; +import { camera } from "./2a-variations"; +import { flameFinal, FlameFinal } from "./2c-final"; + +export abstract class Accumulator { + histogram: number[] = []; + + constructor( + protected readonly width: number, + protected readonly height: number + ) { + for (var i = 0; i < width * height; i++) { + this.histogram.push(0); + } + } + + accumulate(x: number, y: number) { + const [pixelX, pixelY] = camera(x, y, this.width); + + if ( + pixelX < 0 || + pixelX >= this.width || + pixelY < 0 || + pixelY >= this.height + ) { + return; + } + + const index = histIndex(pixelX, pixelY, this.width); + this.histogram[index] += 1; + } + + abstract render(image: ImageData): void; +} + +class AccumulateBinary extends Accumulator { + render(image: ImageData) { + for (var x = 0; x < image.width; x++) { + for (var y = 0; y < image.height; y++) { + const index = histIndex(x, y, image.width); + + // Color black if this pixel is part of the solution set, white otherwise + const value = this.histogram[index] > 0 ? 0 : 0xff; + + const iIdx = imageIndex(x, y, image.width); + image.data[iIdx + 0] = value; + image.data[iIdx + 1] = value; + image.data[iIdx + 2] = value; + image.data[iIdx + 3] = 0xff; + } + } + } +} + +export function render( + flame: FlameFinal, + quality: number, + accumulator: Accumulator, + image: ImageData +) { + const iterations = quality * image.width * image.height; + + for (var i = 0; i < iterations; i++) { + flame.step(); + + if (i > 20) { + const [flameX, flameY] = flame.current(); + accumulator.accumulate(flameX, flameY); + } + } + + accumulator.render(image); +} + +export function renderBinary(image: ImageData) { + const accumulator = new AccumulateBinary(image.width, image.height); + render(flameFinal, 10, accumulator, image); +} diff --git a/posts/2023/06/flam3/3b-linear.ts b/posts/2023/06/flam3/3b-linear.ts new file mode 100644 index 0000000..4581ffd --- /dev/null +++ b/posts/2023/06/flam3/3b-linear.ts @@ -0,0 +1,30 @@ +import { histIndex, imageIndex } from "./0-utility"; +import { flameFinal } from "./2c-final"; +import { Accumulator, render } from "./3a-binary"; + +export class AccumulateLinear extends Accumulator { + render(image: ImageData): void { + const maxValue = Math.max(...this.histogram); + + for (var x = 0; x < image.width; x++) { + for (var y = 0; y < image.height; y++) { + const index = histIndex(x, y, image.width); + + // Color full black if this pixel is maxValue, white if not part + // of the solution set + const value = (1 - this.histogram[index] / maxValue) * 0xff; + + const iIdx = imageIndex(x, y, image.width); + image.data[iIdx + 0] = value; + image.data[iIdx + 1] = value; + image.data[iIdx + 2] = value; + image.data[iIdx + 3] = 0xff; + } + } + } +} + +export function renderLinear(image: ImageData) { + const accumulator = new AccumulateLinear(image.width, image.height); + render(flameFinal, 10, accumulator, image); +} diff --git a/posts/2023/06/flam3/3c-logarithmic.ts b/posts/2023/06/flam3/3c-logarithmic.ts new file mode 100644 index 0000000..b674321 --- /dev/null +++ b/posts/2023/06/flam3/3c-logarithmic.ts @@ -0,0 +1,36 @@ +import { histIndex, imageIndex } from "./0-utility"; +import { flameFinal } from "./2c-final"; +import { Accumulator, render } from "./3a-binary"; + +export class AccumulateLogarithmic extends Accumulator { + render(image: ImageData): void { + // Re-scale vibrancy to be log scale... + for (var i = 0; i < this.histogram.length; i++) { + this.histogram[i] = Math.log(this.histogram[i]); + } + + // ...but otherwise render the same way as linear + const maxValue = Math.max(...this.histogram); + + for (var x = 0; x < image.width; x++) { + for (var y = 0; y < image.height; y++) { + const index = histIndex(x, y, image.width); + + // Color full black if this pixel is maxValue, white if not part + // of the solution set + const value = (1 - this.histogram[index] / maxValue) * 0xff; + + const iIdx = imageIndex(x, y, image.width); + image.data[iIdx + 0] = value; + image.data[iIdx + 1] = value; + image.data[iIdx + 2] = value; + image.data[iIdx + 3] = 0xff; + } + } + } +} + +export function renderLogarithmic(image: ImageData) { + const accumulator = new AccumulateLogarithmic(image.width, image.height); + render(flameFinal, 10, accumulator, image); +} diff --git a/posts/2023/06/flam3/index.tsx b/posts/2023/06/flam3/index.tsx index 0b4a26c..dcd570b 100644 --- a/posts/2023/06/flam3/index.tsx +++ b/posts/2023/06/flam3/index.tsx @@ -5,6 +5,9 @@ import { gasket } from "./1-gasket"; import { renderBaseline } from "./2a-variations"; import { renderPost } from "./2b-post"; import { renderFinal } from "./2c-final"; +import { renderBinary } from "./3a-binary"; +import { renderLinear } from "./3b-linear"; +import { renderLogarithmic } from "./3c-logarithmic"; export default function () { const Layout = Blog({ @@ -14,14 +17,17 @@ export default function () { }); return ( -
+ {/*
-
+
*/} + + +
); }