diff --git a/posts/2023/06/flam3/0-canvas.tsx b/posts/2023/06/flam3/0-canvas.tsx index cf182fe..63c354c 100644 --- a/posts/2023/06/flam3/0-canvas.tsx +++ b/posts/2023/06/flam3/0-canvas.tsx @@ -1,34 +1,5 @@ import React, { useEffect, useRef } from "react"; -import { Renderer, renderFn } from "./0-utility"; - -export const Canvas: React.FC<{ f: renderFn }> = ({ f }) => { - const canvasRef = useRef(null); - let image: ImageData | null = null; - useEffect(() => { - if (canvasRef.current) { - const canvas = canvasRef.current; - const ctx = canvas.getContext("2d"); - - if (!ctx) { - return; - } - - if ( - !image || - image.width !== canvas.width || - image.height !== canvas.height - ) { - image = ctx.createImageData(canvas.width, canvas.height); - } - - f(image); - - ctx.putImageData(image, 0, 0); - } - }); - - return ; -}; +import { Renderer } from "./0-utility"; export type CanvasParams = { defaultUrl: string; diff --git a/posts/2023/06/flam3/0-palette.ts b/posts/2023/06/flam3/0-palette.ts deleted file mode 100644 index 051f0f3..0000000 --- a/posts/2023/06/flam3/0-palette.ts +++ /dev/null @@ -1,33 +0,0 @@ -// https://stackoverflow.com/a/34356351 -function hexToBytes(hex: string) { - var bytes = []; - for (var i = 0; i < hex.length; i += 2) { - bytes.push(parseInt(hex.substring(i, i + 2), 16)); - } - - return bytes; -} - -export const paletteHex = - "7E3037762C45722B496E2A4E6A29506728536527546326565C265C5724595322574D2155482153462050451F4E441E4D431E4C3F1E473F1E453F1E433F1E3F3F1E3B3E1E393E1E37421D36431C38451C3A471B3B491B3C4A1A3C4B1A3D4D1A3E4F19405318435517445817465A16475D15495E154960154A65134E6812506B12526E1153711055720F55740F55770E577A0E59810C58840B58880A588B09588F08589107569307559A05539D0451A1034FA5024BA90147AA0046AC0045B00242B4043DBB0634BE082EC20A29C30B27C50C26C90F1DCC1116D32110D6280EDA300CDC380ADF4109E04508E24A08E45106E75704EA6402EC6B01EE7300EE7600EF7A00F07E00F18300F29000F29300F39600F39900F39C00F3A000F3A100F3A201F2A502F1A805F0A906EFAA08EEA909EEA80AEDA60CEBA50FE5A313E1A113DD9F13DB9E13D99D14D49C15D09815CC9518C79318BE8B1ABB891BB9871DB4811FB07D1FAB7621A671239C6227975C289256299053298E502A89482C853F2D803A2E7E3037762C45742B47722B496E2A4E6A29516728536326565C265C5724595322575022564E2255482153452050451F4E431E4C3F1E473E1D463D1D453F1E43411E413F1E3B3E1E37421D36421D38431D3B451C3A471B3A491B3C4B1A3D4D1A3E4F19405318435418445518455817465A16475D154960154A65134E66124F6812506B12526E1153711055740F55770E577A0E597E0D57810C58840B58880A588B09588F08589307559A05539C04529E0452A1034FA5024BA90147AC0045B00242B4043DB7053ABB0634BE0831C20A29C50C26C90F1DCC1116D01711D32110D72A0EDA300CDD390ADF4109E24A08E45106E75704E95F03EA6402EC6C01EE7300EF7A00F07E00F18300F28900F29000F39300F39600F39C00F3A000F3A100F3A201F2A502F2A503F1A805F0A807EFAA08EEA80AEDA60CEBA50FE9A411E5A313E1A113DD9F13D99D14D49C15D09815CC9518C79318C38F1ABE8B1AB9871DB4811FB07D1FAB7621A67123A16A249C6227975E289256298E502A89482C853F2D803A2E"; -export const paletteBytes = hexToBytes(paletteHex); -console.log(paletteBytes); - -// Re-scale colors to 0-1 (see flam3_get_palette) -export const paletteNumber = paletteBytes.map((b) => b / 0xff); -console.log(paletteNumber); - -export function colorIndex(c: number): number { - return Math.floor(c * (paletteNumber.length / 3)); -} - -export function colorFromIndex(c: number): [number, number, number] { - // A smarter coloring implementation would interpolate between points in the palette, - // but we'll use a step function here to keep things simple - const colorIndex = Math.floor(c * (paletteNumber.length / 3)) * 3; - return [ - paletteNumber[colorIndex + 0], - paletteNumber[colorIndex + 1], - paletteNumber[colorIndex + 2], - ]; -} diff --git a/posts/2023/06/flam3/1-gasket.ts b/posts/2023/06/flam3/1-gasket.ts index 590866b..6bc2e67 100644 --- a/posts/2023/06/flam3/1-gasket.ts +++ b/posts/2023/06/flam3/1-gasket.ts @@ -11,6 +11,8 @@ type Transform = (x: number, y: number) => [number, number]; export class RendererGasket extends Renderer { private values = new Uint8Array(this.size * this.size); + private x = randomBiUnit(); + private y = randomBiUnit(); /** * Translate values in the flame coordinate system to pixel coordinates @@ -53,16 +55,17 @@ export class RendererGasket extends Renderer { (x, y) => [x / 2, (y + 1) / 2], ]; - let x = randomBiUnit(); - let y = randomBiUnit(); + // NOTE: `x` and `y` are set as fields on this class + // (rather than here in the main chaos game method) because + // we render in chunks (to avoid bogging down the browser) const iterations = quality * this.size * this.size; for (var i = 0; i < iterations; i++) { const transformIndex = randomInteger(0, transforms.length); - [x, y] = transforms[transformIndex](x, y); + [this.x, this.y] = transforms[transformIndex](this.x, this.y); if (i >= 20) { - this.plot(x, y); + this.plot(this.x, this.y); } } } @@ -85,3 +88,7 @@ export class RendererGasket extends Renderer { } } } + +export function buildGasket(size: number) { + return new RendererGasket(size); +} diff --git a/posts/2023/06/flam3/2a-variations.ts b/posts/2023/06/flam3/2a-variations.ts index ecc974b..51a0f5f 100644 --- a/posts/2023/06/flam3/2a-variations.ts +++ b/posts/2023/06/flam3/2a-variations.ts @@ -90,19 +90,40 @@ export class Transform { } } +/** + * Translate values in the flame coordinate system to pixel coordinates + * + * 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. We'll make some simplifying assumptions: + * - The final image is square + * - We want to plot the range [-2, 2] + * + * The reference parameters were designed in Apophysis, which uses the + * range [-2, 2] by default (the `scale` parameter in XML defines the + * "pixels per unit", and with the default zoom, is chosen to give a + * range of [-2, 2]). + * + * @param x point in the range [-2, 2] + * @param y point in the range [-2, 2] + * @param size image size + * @returns pair of pixel coordinates + */ +export function camera(x: number, y: number, size: number): [number, number] { + return [Math.floor(((x + 2) * size) / 4), Math.floor(((y + 2) * size) / 4)]; +} + export class RendererFlame extends Renderer { private values = new Uint8Array(this.size * this.size); + protected x = randomBiUnit(); + protected y = randomBiUnit(); constructor(size: number, public readonly transforms: [number, Transform][]) { super(size); } plot(x: number, y: number) { - // By default, Apophysis uses a camera that covers the range [-2, 2] - // (specifically, the `scale` parameter becomes `pixels_per_unit` in flam3) - // Shift the coordinate system to [0, 4], then scale to pixel coordinates - const pixelX = Math.floor(((x + 2) * this.size) / 4); - const pixelY = Math.floor(((y + 2) * this.size) / 4); + const [pixelX, pixelY] = camera(x, y, this.size); if ( pixelX < 0 || @@ -118,16 +139,13 @@ export class RendererFlame extends Renderer { } run(quality: number): void { - var x = randomBiUnit(); - var y = randomBiUnit(); - const iterations = quality * this.size * this.size; for (var i = 0; i < iterations; i++) { const [_, transform] = weightedChoice(this.transforms); - [x, y] = transform.apply(x, y); + [this.x, this.y] = transform.apply(this.x, this.y); if (i > 20) { - this.plot(x, y); + this.plot(this.x, this.y); } } } @@ -150,67 +168,6 @@ export class RendererFlame extends Renderer { } } -export class Flame { - protected x: number = randomBiUnit(); - protected y: number = randomBiUnit(); - - constructor(public readonly transforms: [number, Transform][]) {} - - step(): void { - const [_index, transform] = weightedChoice(this.transforms); - [this.x, this.y] = transform.apply(this.x, this.y); - } - - current(): [number, number] { - 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, pixelY] = camera(x, y, image.width); - - if ( - pixelX < 0 || - pixelX >= image.width || - pixelY < 0 || - pixelY >= image.height - ) { - return; - } - - const index = pixelY * (image.width * 4) + pixelX * 4; - - image.data[index + 0] = 0; - image.data[index + 1] = 0; - image.data[index + 2] = 0; - image.data[index + 3] = 0xff; -} - -export function render(flame: Flame, quality: number, 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(); - plot(flameX, flameY, image); - } - } -} - export const transform1Weight = 0.56453495; export const transform1 = new Transform( { @@ -253,10 +210,12 @@ export const transform3 = new Transform( [[1, pdj(1.09358, 2.13048, 2.54127, 2.37267)]] ); -export function rendererBaseline(size: number) { - return new RendererFlame(size, [ - [transform1Weight, transform1], - [transform2Weight, transform2], - [transform3Weight, transform3], - ]); +export const transformAll: [number, Transform][] = [ + [transform1Weight, transform1], + [transform2Weight, transform2], + [transform3Weight, transform3], +]; + +export function buildBaseline(size: number) { + return new RendererFlame(size, transformAll); } diff --git a/posts/2023/06/flam3/2b-post.ts b/posts/2023/06/flam3/2b-post.ts index 132b795..e841b52 100644 --- a/posts/2023/06/flam3/2b-post.ts +++ b/posts/2023/06/flam3/2b-post.ts @@ -1,13 +1,8 @@ +import { randomBiUnit, weightedChoice } from "./0-utility"; import { Coefs, Variation, - Flame, Transform, - linear, - julia, - popcorn, - pdj, - render, transform1Weight, transform1, transform2Weight, @@ -15,6 +10,7 @@ import { transform3Weight, transform3, identityCoefs, + RendererFlame, } from "./2a-variations"; export class TransformPost extends Transform { @@ -35,16 +31,6 @@ export class TransformPost extends Transform { } } -export function variationPost(coefs: Coefs, variation: Variation): Variation { - return (x, y, transformCoefs) => { - const [varX, varY] = variation(x, y, transformCoefs); - return [ - varX * coefs.a + varY * coefs.b + coefs.c, - varX * coefs.d + varY * coefs.e + coefs.f, - ]; - }; -} - export const transform1Post = new TransformPost( transform1.coefs, transform1.variations, @@ -70,12 +56,12 @@ export const transform3Post = new TransformPost( identityCoefs ); -export function renderPost(image: ImageData) { - const flame = new Flame([ - [transform1Weight, transform1], - [transform2Weight, transform2Post], - [transform3Weight, transform3], - ]); +export const transformAllPost: [number, TransformPost][] = [ + [transform1Weight, transform1Post], + [transform2Weight, transform2Post], + [transform3Weight, transform3Post], +]; - render(flame, 1, image); +export function buildPost(size: number) { + return new RendererFlame(size, transformAllPost); } diff --git a/posts/2023/06/flam3/2c-final.ts b/posts/2023/06/flam3/2c-final.ts index 8d12b41..829b532 100644 --- a/posts/2023/06/flam3/2c-final.ts +++ b/posts/2023/06/flam3/2c-final.ts @@ -1,34 +1,5 @@ -import { - Flame, - Transform, - julia, - transform1Weight, - transform2Weight, - transform3Weight, - render, - identityCoefs, -} from "./2a-variations"; -import { - TransformPost, - transform1Post, - transform2Post, - transform3Post, -} from "./2b-post"; - -export class FlameFinal extends Flame { - didLog: boolean = false; - - constructor( - transforms: [number, Transform][], - public readonly final: Transform - ) { - super(transforms); - } - - override current(): [number, number] { - return this.final.apply(this.x, this.y); - } -} +import { julia, identityCoefs, RendererFlame } from "./2a-variations"; +import { TransformPost, transformAllPost } from "./2b-post"; export const transformFinal = new TransformPost( { @@ -43,15 +14,20 @@ export const transformFinal = new TransformPost( identityCoefs ); -export const flameFinal = new FlameFinal( - [ - [transform1Weight, transform1Post], - [transform2Weight, transform2Post], - [transform3Weight, transform3Post], - ], - transformFinal -); +export class RendererFinal extends RendererFlame { + constructor( + size: number, + transforms: [number, TransformPost][], + public readonly final: TransformPost + ) { + super(size, transforms); + } -export function renderFinal(image: ImageData) { - render(flameFinal, 1, image); + plot(x: number, y: number): void { + super.plot(...this.final.apply(x, y)); + } +} + +export function buildFinal(size: number) { + return new RendererFinal(size, transformAllPost, transformFinal); } diff --git a/posts/2023/06/flam3/3a-binary.ts b/posts/2023/06/flam3/3a-binary.ts index 0786baf..e79d4c3 100644 --- a/posts/2023/06/flam3/3a-binary.ts +++ b/posts/2023/06/flam3/3a-binary.ts @@ -1,78 +1,72 @@ import { histIndex, imageIndex } from "./0-utility"; -import { camera } from "./2a-variations"; -import { flameFinal, FlameFinal } from "./2c-final"; +import { + camera, + transform1Weight, + transform2Weight, + transform3Weight, +} from "./2a-variations"; +import { + TransformPost, + transform1Post, + transform2Post, + transform3Post, +} from "./2b-post"; +import { RendererFinal, transformFinal } from "./2c-final"; -export abstract class Accumulator { - histogram: number[] = []; +export class RendererHistogram extends RendererFinal { + protected histogram: number[] = []; constructor( - protected readonly width: number, - protected readonly height: number + size: number, + transforms: [number, TransformPost][], + final: TransformPost ) { - for (var i = 0; i < width * height; i++) { + super(size, transforms, final); + + for (var i = 0; i < this.size * this.size; i++) { this.histogram.push(0); } } - accumulate(x: number, y: number) { - const [pixelX, pixelY] = camera(x, y, this.width); + plot(x: number, y: number): void { + [x, y] = this.final.apply(x, y); + const [pixelX, pixelY] = camera(x, y, this.size); if ( pixelX < 0 || - pixelX >= this.width || + pixelY >= this.size || pixelY < 0 || - pixelY >= this.height + pixelY >= this.size ) { return; } - const index = histIndex(pixelX, pixelY, this.width); - this.histogram[index] += 1; + const hIndex = histIndex(pixelX, pixelY, this.size); + this.histogram[hIndex] += 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] = value ? 0xff : 0; + render(image: ImageData): void { + for (var x = 0; x < this.size; x++) { + for (var y = 0; y < this.size; y++) { + const hIndex = histIndex(x, y, this.size); + const iIndex = imageIndex(x, y, this.size); + image.data[iIndex + 0] = 0; + image.data[iIndex + 1] = 0; + image.data[iIndex + 2] = 0; + image.data[iIndex + 3] = (this.histogram[hIndex] > 0 ? 1 : 0) * 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); +export function buildBinary(size: number) { + return new RendererHistogram( + size, + [ + [transform1Weight, transform1Post], + [transform2Weight, transform2Post], + [transform3Weight, transform3Post], + ], + transformFinal + ); } diff --git a/posts/2023/06/flam3/3b-linear.ts b/posts/2023/06/flam3/3b-linear.ts index 42bf5d8..189d4f8 100644 --- a/posts/2023/06/flam3/3b-linear.ts +++ b/posts/2023/06/flam3/3b-linear.ts @@ -1,30 +1,25 @@ import { histIndex, imageIndex } from "./0-utility"; -import { flameFinal } from "./2c-final"; -import { Accumulator, render } from "./3a-binary"; +import { transformAllPost } from "./2b-post"; +import { transformFinal } from "./2c-final"; +import { RendererHistogram } from "./3a-binary"; -export class AccumulateLinear extends Accumulator { +class RendererLinear extends RendererHistogram { render(image: ImageData): void { - const maxValue = Math.max(...this.histogram); + const maxHistogram = 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 - value; + for (var x = 0; x < this.size; x++) { + for (var y = 0; y < this.size; y++) { + const hIndex = histIndex(x, y, this.size); + const iIndex = imageIndex(x, y, this.size); + image.data[iIndex + 0] = 0; + image.data[iIndex + 1] = 0; + image.data[iIndex + 2] = 0; + image.data[iIndex + 3] = (this.histogram[hIndex] / maxHistogram) * 0xff; } } } } -export function renderLinear(image: ImageData) { - const accumulator = new AccumulateLinear(image.width, image.height); - render(flameFinal, 10, accumulator, image); +export function buildLinear(size: number) { + return new RendererLinear(size, transformAllPost, transformFinal); } diff --git a/posts/2023/06/flam3/3c-logarithmic.ts b/posts/2023/06/flam3/3c-logarithmic.ts index 217290c..e4c6656 100644 --- a/posts/2023/06/flam3/3c-logarithmic.ts +++ b/posts/2023/06/flam3/3c-logarithmic.ts @@ -1,36 +1,28 @@ import { histIndex, imageIndex } from "./0-utility"; -import { flameFinal } from "./2c-final"; -import { Accumulator, render } from "./3a-binary"; +import { transformAllPost } from "./2b-post"; +import { transformFinal } from "./2c-final"; +import { RendererHistogram } from "./3a-binary"; -export class AccumulateLogarithmic extends Accumulator { +export class RendererLogarithmic extends RendererHistogram { 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]); - } + // Because log(0) is -Infinity, all the math actually works out. + const histogramLog = this.histogram.map(Math.log); + const histogramLogMax = Math.max(...histogramLog); - // ...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 - value; + for (var x = 0; x < this.size; x++) { + for (var y = 0; y < this.size; y++) { + const hIndex = histIndex(x, y, this.size); + const iIndex = imageIndex(x, y, this.size); + image.data[iIndex + 0] = 0; + image.data[iIndex + 1] = 0; + image.data[iIndex + 2] = 0; + image.data[iIndex + 3] = + (histogramLog[hIndex] / histogramLogMax) * 0xff; } } } } -export function renderLogarithmic(image: ImageData) { - const accumulator = new AccumulateLogarithmic(image.width, image.height); - render(flameFinal, 20, accumulator, image); +export function buildLogarithmic(size: number) { + return new RendererLogarithmic(size, transformAllPost, transformFinal); } diff --git a/posts/2023/06/flam3/4-color.ts b/posts/2023/06/flam3/4-color.ts new file mode 100644 index 0000000..c4b0e36 --- /dev/null +++ b/posts/2023/06/flam3/4-color.ts @@ -0,0 +1,185 @@ +import { histIndex, imageIndex, weightedChoice } from "./0-utility"; +import { + Coefs, + Variation, + camera, + transform1Weight, + transform2Weight, + transform3Weight, +} from "./2a-variations"; +import { + TransformPost, + transform1Post, + transform2Post, + transform3Post, +} from "./2b-post"; +import { RendererFinal, transformFinal } from "./2c-final"; + +export class TransformColor extends TransformPost { + constructor( + coefs: Coefs, + variations: [number, Variation][], + post: Coefs, + public readonly color: number + ) { + super(coefs, variations, post); + } +} + +export class RendererColor extends RendererFinal { + protected color: number = Math.random(); + protected red: number[] = []; + protected green: number[] = []; + protected blue: number[] = []; + protected alpha: number[] = []; + + constructor( + size: number, + transforms: [number, TransformColor][], + final: TransformColor, + private readonly palette: number[] + ) { + super(size, transforms, final); + for (var i = 0; i < this.size * this.size; i++) { + this.red.push(0); + this.green.push(0); + this.blue.push(0); + this.alpha.push(0); + } + } + + colorFromIndex(c: number): [number, number, number] { + // A smarter coloring implementation would interpolate between points in the palette, + // but we'll use a step function here to keep things simple + const colorIndex = Math.floor(c * (this.palette.length / 3)) * 3; + return [ + paletteNumber[colorIndex + 0], + paletteNumber[colorIndex + 1], + paletteNumber[colorIndex + 2], + ]; + } + + plotColor(x: number, y: number, color: number): void { + const [finalX, finalY] = this.final.apply(x, y); + const [pixelX, pixelY] = camera(finalX, finalY, this.size); + if (pixelX < 0 || pixelX > this.size || pixelY < 0 || pixelY > this.size) { + return; + } + + // NOTE: The reference parameters use a final `symmetry` of 1, + // which effectively disables the final transform's contribution + // to color mixing (see the `color_speed` in flam3). + // While we'd normally want to apply the same color transformation + // like we do in the `run` method, it is skipped here so the output + // image matches the reference image + // + // const finalColor = (color + (final as TransformColor).color) / 2 + const finalColor = color; + const [r, g, b] = this.colorFromIndex(finalColor); + + const hIndex = histIndex(pixelX, pixelY, this.size); + this.red[hIndex] += r; + this.green[hIndex] += g; + this.blue[hIndex] += b; + this.alpha[hIndex] += 1; + } + + run(quality: number): void { + const iterations = quality * this.size * this.size; + for (var i = 0; i < iterations; i++) { + const [_, transform] = weightedChoice(this.transforms); + [this.x, this.y] = transform.apply(this.x, this.y); + this.color = (this.color + (transform as TransformColor).color) / 2; + + if (i > 20) { + this.plotColor(this.x, this.y, this.color); + } + } + } + + render(image: ImageData): void { + for (var x = 0; x < image.width; x++) { + for (var y = 0; y < image.height; y++) { + const hIndex = histIndex(x, y, image.width); + + // NOTE: Calculating the scaling factor for accumulated color value to final + // pixel coloring is very involved (gamma, vibrancy, etc.). This scaling implementation + // is only intended to approximate the reference parameters. + const aScale = + Math.log10(this.alpha[hIndex]) / (this.alpha[hIndex] * 1.5); + + const iIdx = imageIndex(x, y, this.size); + image.data[iIdx + 0] = this.red[hIndex] * aScale * 0xff; + image.data[iIdx + 1] = this.green[hIndex] * aScale * 0xff; + image.data[iIdx + 2] = this.blue[hIndex] * aScale * 0xff; + image.data[iIdx + 3] = this.alpha[hIndex] * aScale * 0xff; + } + } + } +} + +export const transform1ColorValue = 0; +export const transform1Color = new TransformColor( + transform1Post.coefs, + transform1Post.variations, + transform1Post.post, + transform1ColorValue +); +export const transform2ColorValue = 0.844; +export const transform2Color = new TransformColor( + transform2Post.coefs, + transform2Post.variations, + transform2Post.post, + transform2ColorValue +); + +export const transform3ColorValue = 0.349; +export const transform3Color = new TransformColor( + transform3Post.coefs, + transform3Post.variations, + transform3Post.post, + transform3ColorValue +); + +export const transformFinalColorValue = 0; +export const transformFinalColor = new TransformColor( + transformFinal.coefs, + transformFinal.variations, + transformFinal.post, + transformFinalColorValue +); + +// Copied from the reference parameters +export const paletteHex = + "7E3037762C45722B496E2A4E6A29506728536527546326565C265C5724595322574D2155482153462050451F4E441E4D431E4C3F1E473F1E453F1E433F1E3F3F1E3B3E1E393E1E37421D36431C38451C3A471B3B491B3C4A1A3C4B1A3D4D1A3E4F19405318435517445817465A16475D15495E154960154A65134E6812506B12526E1153711055720F55740F55770E577A0E59810C58840B58880A588B09588F08589107569307559A05539D0451A1034FA5024BA90147AA0046AC0045B00242B4043DBB0634BE082EC20A29C30B27C50C26C90F1DCC1116D32110D6280EDA300CDC380ADF4109E04508E24A08E45106E75704EA6402EC6B01EE7300EE7600EF7A00F07E00F18300F29000F29300F39600F39900F39C00F3A000F3A100F3A201F2A502F1A805F0A906EFAA08EEA909EEA80AEDA60CEBA50FE5A313E1A113DD9F13DB9E13D99D14D49C15D09815CC9518C79318BE8B1ABB891BB9871DB4811FB07D1FAB7621A671239C6227975C289256299053298E502A89482C853F2D803A2E7E3037762C45742B47722B496E2A4E6A29516728536326565C265C5724595322575022564E2255482153452050451F4E431E4C3F1E473E1D463D1D453F1E43411E413F1E3B3E1E37421D36421D38431D3B451C3A471B3A491B3C4B1A3D4D1A3E4F19405318435418445518455817465A16475D154960154A65134E66124F6812506B12526E1153711055740F55770E577A0E597E0D57810C58840B58880A588B09588F08589307559A05539C04529E0452A1034FA5024BA90147AC0045B00242B4043DB7053ABB0634BE0831C20A29C50C26C90F1DCC1116D01711D32110D72A0EDA300CDD390ADF4109E24A08E45106E75704E95F03EA6402EC6C01EE7300EF7A00F07E00F18300F28900F29000F39300F39600F39C00F3A000F3A100F3A201F2A502F2A503F1A805F0A807EFAA08EEA80AEDA60CEBA50FE9A411E5A313E1A113DD9F13D99D14D49C15D09815CC9518C79318C38F1ABE8B1AB9871DB4811FB07D1FAB7621A67123A16A249C6227975E289256298E502A89482C853F2D803A2E"; + +// https://stackoverflow.com/a/34356351 +export function hexToBytes(hex: string) { + var bytes = []; + for (var i = 0; i < hex.length; i += 2) { + bytes.push(parseInt(hex.substring(i, i + 2), 16)); + } + + return bytes; +} + +export const paletteBytes = hexToBytes(paletteHex); + +/** + * Re-scale pixel color values to the range [0, 1], done to match + * 'flam3_get_palette' + */ +export const paletteNumber = paletteBytes.map((b) => b / 0xff); + +export function buildColor(size: number) { + return new RendererColor( + size, + [ + [transform1Weight, transform1Color], + [transform2Weight, transform2Color], + [transform3Weight, transform3Color], + ], + transformFinalColor, + paletteNumber + ); +} diff --git a/posts/2023/06/flam3/4a-solo.ts b/posts/2023/06/flam3/4a-solo.ts deleted file mode 100644 index eb98cb4..0000000 --- a/posts/2023/06/flam3/4a-solo.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { weightedChoice } from "./0-utility"; -import { - transform1, - transform1Weight, - transform2Weight, - transform3, - transform3Weight, -} from "./2a-variations"; -import { transform2Post } from "./2b-post"; -import { FlameFinal, transformFinal } from "./2c-final"; -import { AccumulateLogarithmic } from "./3c-logarithmic"; - -export class AccumulateSolo extends AccumulateLogarithmic { - constructor( - width: number, - height: number, - public readonly soloTransform: number - ) { - super(width, height); - } - - accumulateWithIndex(x: number, y: number, index: number) { - if (index === this.soloTransform) { - super.accumulate(x, y); - } - } -} - -export class FlameIndex extends FlameFinal { - protected index: number = -1; - - step() { - const [index, transform] = weightedChoice(this.transforms); - this.index = index; - [this.x, this.y] = transform.apply(this.x, this.y); - } - - currentWithIndex(): [number, number, number] { - const [finalX, finalY] = this.final.apply(this.x, this.y); - return [finalX, finalY, this.index]; - } -} - -export function render( - flame: FlameIndex, - quality: number, - accumulator: AccumulateSolo, - image: ImageData -) { - const iterations = quality * image.width * image.height; - - for (var i = 0; i < iterations; i++) { - flame.step(); - - if (i > 20) { - const [flameX, flameY, index] = flame.currentWithIndex(); - accumulator.accumulateWithIndex(flameX, flameY, index); - } - } - - accumulator.render(image); -} - -export const flameIndex = new FlameIndex( - [ - [transform1Weight, transform1], - [transform2Weight, transform2Post], - [transform3Weight, transform3], - ], - transformFinal -); - -export function renderTransform1(image: ImageData) { - const accumulateTransform1 = new AccumulateSolo(image.width, image.height, 0); - render(flameIndex, 10, accumulateTransform1, image); -} - -export function renderTransform2(image: ImageData) { - const accumulateTransform2 = new AccumulateSolo(image.width, image.height, 1); - render(flameIndex, 10, accumulateTransform2, image); -} - -export function renderTransform3(image: ImageData) { - const accumulateTransform3 = new AccumulateSolo(image.width, image.height, 2); - render(flameIndex, 10, accumulateTransform3, image); -} diff --git a/posts/2023/06/flam3/4b-color.ts b/posts/2023/06/flam3/4b-color.ts deleted file mode 100644 index 551359f..0000000 --- a/posts/2023/06/flam3/4b-color.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { colorFromIndex, colorIndex, paletteNumber } from "./0-palette"; -import { - histIndex, - imageIndex, - randomBiUnit, - weightedChoice, -} from "./0-utility"; -import { - Coefs, - Variation, - camera, - transform1Weight, - transform2Weight, - transform3Weight, -} from "./2a-variations"; -import { - TransformPost, - transform1Post, - transform2Post, - transform3Post, -} from "./2b-post"; -import { FlameFinal, transformFinal } from "./2c-final"; - -export class AccumulatorColor { - red: number[] = []; - green: number[] = []; - blue: number[] = []; - alpha: number[] = []; - - constructor(public readonly width: number, public readonly height: number) { - for (var i = 0; i < width * height; i++) { - this.red.push(0); - this.green.push(0); - this.blue.push(0); - this.alpha.push(0); - } - } - - accumulate(x: number, y: number, c: number) { - const [pixelX, pixelY] = camera(x, y, this.width); - - if ( - pixelX < 0 || - pixelX >= this.width || - pixelY < 0 || - pixelY >= this.height - ) { - return; - } - - const hIndex = histIndex(pixelX, pixelY, this.width); - const [r, g, b] = colorFromIndex(c); - - this.red[hIndex] += r; - this.green[hIndex] += g; - this.blue[hIndex] += b; - this.alpha[hIndex] += 1; - } - - render(image: ImageData) { - for (var x = 0; x < image.width; x++) { - for (var y = 0; y < image.height; y++) { - const hIndex = histIndex(x, y, image.width); - - const aNorm = this.alpha[hIndex] ? this.alpha[hIndex] : 1; - const aScale = Math.log10(aNorm) / (aNorm * 1.5); - - const iIdx = imageIndex(x, y, this.width); - image.data[iIdx + 0] = this.red[hIndex] * aScale * 0xff; - image.data[iIdx + 1] = this.green[hIndex] * aScale * 0xff; - image.data[iIdx + 2] = this.blue[hIndex] * aScale * 0xff; - image.data[iIdx + 3] = this.alpha[hIndex] * aScale * 0xff; - } - } - } -} - -export class TransformColor extends TransformPost { - constructor( - coefs: Coefs, - variations: [number, Variation][], - post: Coefs, - public readonly color: number - ) { - super(coefs, variations, post); - } -} - -export class FlameColor extends FlameFinal { - protected color: number = Math.random(); - - constructor(transforms: [number, TransformColor][], final: TransformColor) { - super(transforms, final); - } - - step() { - const [_index, transform] = weightedChoice(this.transforms); - [this.x, this.y] = transform.apply(this.x, this.y); - const transformColor = (transform as TransformColor).color; - this.color = (this.color + transformColor) / 2; - } - - currentWithColor(): [number, number, number] { - const [finalX, finalY] = this.final.apply(this.x, this.y); - // TODO(bspeice): Why does everyone ignore final coloring? - // In `flam3`, the `color_speed` is set to 0 for the final xform, - // so it doesn't actually get used. - return [finalX, finalY, this.color]; - } -} - -function render( - flame: FlameColor, - quality: number, - accumulator: AccumulatorColor, - image: ImageData -) { - const iterations = quality * image.width * image.height; - - for (var i = 0; i < iterations; i++) { - flame.step(); - - if (i > 20) { - const [flameX, flameY, color] = flame.currentWithColor(); - accumulator.accumulate(flameX, flameY, color); - } - } - - accumulator.render(image); -} - -export const transform1ColorValue = 0; -export const transform1Color = new TransformColor( - transform1Post.coefs, - transform1Post.variations, - transform1Post.post, - transform1ColorValue -); -export const transform2ColorValue = 0.844; -export const transform2Color = new TransformColor( - transform2Post.coefs, - transform2Post.variations, - transform2Post.post, - transform2ColorValue -); - -export const transform3ColorValue = 0.349; -export const transform3Color = new TransformColor( - transform3Post.coefs, - transform3Post.variations, - transform3Post.post, - transform3ColorValue -); - -export const transformFinalColorValue = 0; -export const transformFinalColor = new TransformColor( - transformFinal.coefs, - transformFinal.variations, - transformFinal.post, - transformFinalColorValue -); - -export const flameColor = new FlameColor( - [ - [transform1Weight, transform1Color], - [transform2Weight, transform2Color], - [transform3Weight, transform3Color], - ], - transformFinalColor -); - -export function renderColor(image: ImageData) { - const accumulator = new AccumulatorColor(image.width, image.height); - render(flameColor, 40, accumulator, image); -} diff --git a/posts/2023/06/flam3/5a-gasket.ts b/posts/2023/06/flam3/5a-gasket.ts new file mode 100644 index 0000000..80a83f6 --- /dev/null +++ b/posts/2023/06/flam3/5a-gasket.ts @@ -0,0 +1,48 @@ +import { RendererFlame, Transform, linear } from "./2a-variations"; +import { RendererLogarithmic } from "./3c-logarithmic"; + +export const transformGasket1 = new Transform( + { + a: 0.5, + b: 0, + c: 0, + d: 0, + e: 0.5, + f: 0, + }, + [[1, linear]] +); + +export const transformGasket2 = new Transform( + { + a: 0.5, + b: 0, + c: 0.5, + d: 0, + e: 0.5, + f: 0, + }, + [[1, linear]] +); + +export const transformGasket3 = new Transform( + { + a: 0.5, + b: 0, + c: 0, + d: 0, + e: 0.5, + f: 0.5, + }, + [[1, linear]] +); + +export const transformGasket: [number, Transform][] = [ + [1 / 3, transformGasket1], + [1 / 3, transformGasket2], + [1 / 3, transformGasket3], +]; + +export function buildGasketFlame(size: number) { + return new RendererFlame(size, transformGasket); +} diff --git a/posts/2023/06/flam3/5b-solo.ts b/posts/2023/06/flam3/5b-solo.ts new file mode 100644 index 0000000..382f48f --- /dev/null +++ b/posts/2023/06/flam3/5b-solo.ts @@ -0,0 +1,40 @@ +import { weightedChoice } from "./0-utility"; +import { TransformPost, transformAllPost } from "./2b-post"; +import { transformFinal } from "./2c-final"; +import { RendererLogarithmic } from "./3c-logarithmic"; + +export class RendererSolo extends RendererLogarithmic { + constructor( + size: number, + transforms: [number, TransformPost][], + final: TransformPost, + private readonly transformSolo: number + ) { + super(size, transforms, final); + } + + run(quality: number): void { + const iterations = quality * this.size * this.size; + for (var i = 0; i < iterations; i++) { + const [transformIndex, transform] = weightedChoice(this.transforms); + [this.x, this.y] = transform.apply(this.x, this.y); + + // NOTE: Only plot if the current point is from the solo transform + if (i > 20 && transformIndex == this.transformSolo) { + this.plot(this.x, this.y); + } + } + } +} + +export function buildSolo1(size: number) { + return new RendererSolo(size, transformAllPost, transformFinal, 0); +} + +export function buildSolo2(size: number) { + return new RendererSolo(size, transformAllPost, transformFinal, 1); +} + +export function buildSolo3(size: number) { + return new RendererSolo(size, transformAllPost, transformFinal, 2); +} diff --git a/posts/2023/06/flam3/index.tsx b/posts/2023/06/flam3/index.tsx index 8624e59..f7e3d7e 100644 --- a/posts/2023/06/flam3/index.tsx +++ b/posts/2023/06/flam3/index.tsx @@ -1,19 +1,16 @@ +import { build } from "vite"; import Blog from "../../../LayoutBlog"; -import { Canvas, CanvasRenderer } from "./0-canvas"; -import { RendererGasket } from "./1-gasket"; -import { rendererBaseline } 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"; -import { - renderTransform1, - renderTransform2, - renderTransform3, -} from "./4a-solo"; -import { renderColor } from "./4b-color"; +import { CanvasRenderer } from "./0-canvas"; +import { buildBaseline } from "./2a-variations"; +import { buildPost } from "./2b-post"; +import { buildFinal } from "./2c-final"; +import { buildBinary } from "./3a-binary"; +import { buildColor } from "./4-color"; +import { buildLinear } from "./3b-linear"; +import { buildLogarithmic } from "./3c-logarithmic"; +import { buildSolo1, buildSolo2, buildSolo3 } from "./5b-solo"; +import { buildGasketFlame } from "./5a-gasket"; export default function () { const Layout = Blog({ @@ -32,27 +29,96 @@ export default function () { qualityStep: 0.1, }} /> */} + {/* buildBaseline(size), + qualityMax: 1, + qualityStep: 0.1, + }} + /> */} + {/* buildPost(size), + qualityMax: 1, + qualityStep: 0.1 + }} /> */} + {/* buildFinal(size), + qualityMax: 1, + qualityStep: 0.1, + }} /> */} + {/* buildBinary(size), + qualityMax: 1, + qualityStep: 0.1, + }} /> */} + {/* buildLinear(size), + qualityMax: 5, + qualityStep: 0.1, + }} /> */} + {/* buildLogarithmic(size), + qualityMax: 5, + qualityStep: 0.1, + }} /> */} + {/* buildColor(size), + qualityMax: 50, + qualityStep: 0.1, + }} /> */} + {/* buildSolo1(size), + qualityMax: 5, + qualityStep: 0.1, + }} /> */} + {/* buildSolo2(size), + qualityMax: 5, + qualityStep: 0.1, + }} /> */} + {/* buildSolo3(size), + qualityMax: 5, + qualityStep: 0.1, + }} /> */} rendererBaseline(size), - qualityMax: 1, + renderer: (size) => buildGasketFlame(size), + qualityMax: 0.3, qualityStep: 0.1, }} /> - {/*
- - -
-
- - -
-
- - -
*/} ); }