import { converter, toGamut } from "culori";
import {
Application,
BufferImageSource,
Container,
Graphics,
Sprite,
Text,
TextStyle,
Texture,
} from "pixi.js";
type RGB = [number, number, number];
const toOklch = converter("oklch");
const toRgb = converter("rgb");
const gamutMap = toGamut("rgb", "oklch");
const COLORS: Array<{ label: string; rgb: RGB }> = [
{ label: "black", rgb: [0, 0, 0] },
{ label: "gray", rgb: [80, 80, 80] },
{ label: "white", rgb: [210, 210, 210] },
{ label: "red", rgb: [150, 25, 25] },
{ label: "orange", rgb: [170, 75, 15] },
{ label: "yellow", rgb: [170, 150, 15] },
{ label: "green", rgb: [25, 110, 35] },
{ label: "cyan", rgb: [15, 130, 150] },
{ label: "blue", rgb: [20, 35, 140] },
{ label: "purple", rgb: [60, 20, 100] },
{ label: "magenta", rgb: [140, 15, 110] },
];
const NOISE_DENSITY = 0.1;
const TILE_SIZE = 50;
const PAD = 12;
const LABEL_H = 16;
const AXIS_LABEL_H = LABEL_H + 4;
const TAB_H = 32;
const TAB_PAD_X = 14;
const DEMO_FONT_SIZE = 14;
function monoStyle(fill: number): TextStyle {
return new TextStyle({
fill,
fontSize: DEMO_FONT_SIZE,
fontFamily: "monospace",
});
}
const AXIS_STYLE = monoStyle(0x666666);
const AXIS_LABEL_W = (() => {
const maxW = Math.max(
...COLORS.map((c) => new Text({ text: c.label, style: AXIS_STYLE }).width),
);
return Math.ceil(maxW) + PAD;
})();
function rgbToNorm(c: RGB): { r: number; g: number; b: number; mode: "rgb" } {
return { mode: "rgb", r: c[0] / 255, g: c[1] / 255, b: c[2] / 255 };
}
function normToRgb255(c: { r?: number; g?: number; b?: number }): RGB {
return [
Math.round((c.r ?? 0) * 255),
Math.round((c.g ?? 0) * 255),
Math.round((c.b ?? 0) * 255),
];
}
function oklchToRgb(oklch: NonNullable<ReturnType<typeof toOklch>>): RGB {
return normToRgb255(toRgb(gamutMap(oklch)!)!);
}
/** Return bg unchanged and fg adjusted to match bg's oklch luminance. */
function equalLuminance(bg: RGB, fg: RGB): { bg: RGB; fg: RGB } {
const bgOklch = toOklch(rgbToNorm(bg))!;
const fgOklch = toOklch(rgbToNorm(fg))!;
const fgMapped = gamutMap({ ...fgOklch, l: bgOklch.l ?? 0 })!;
return { bg, fg: normToRgb255(toRgb(fgMapped)!) };
}
function rgbaBufferToTexture(
data: Uint8Array,
width: number,
height: number,
): Texture {
return new Texture({
source: new BufferImageSource({
resource: data,
width,
height,
format: "rgba8unorm",
}),
});
}
function makeNoiseTileTexture(bg: RGB, fg: RGB, size: number): Texture {
const d = new Uint8Array(size * size * 4);
for (let i = 0; i < size * size; i++) {
const c = Math.random() < NOISE_DENSITY ? fg : bg;
d[i * 4] = c[0];
d[i * 4 + 1] = c[1];
d[i * 4 + 2] = c[2];
d[i * 4 + 3] = 255;
}
return rgbaBufferToTexture(d, size, size);
}
function makeNoiseTileTexture3(
c1: RGB,
c2: RGB,
c3: RGB,
size: number,
density = NOISE_DENSITY,
): Texture {
const d = new Uint8Array(size * size * 4);
for (let i = 0; i < size * size; i++) {
const r = Math.random();
const c = r < density / 2 ? c2 : r < density ? c3 : c1;
d[i * 4] = c[0];
d[i * 4 + 1] = c[1];
d[i * 4 + 2] = c[2];
d[i * 4 + 3] = 255;
}
return rgbaBufferToTexture(d, size, size);
}
function makeNoiseTileTextureDomino(
bg: RGB,
lighter: RGB,
darker: RGB,
size: number,
density = NOISE_DENSITY,
): Texture {
const d = new Uint8Array(size * size * 4);
const used = new Uint8Array(size * size);
const isFree = (x: number, y: number) =>
x < size && y < size && !used[y * size + x];
const claim = (x: number, y: number, c: RGB) => {
used[y * size + x] = 1;
const i = (y * size + x) * 4;
d[i] = c[0];
d[i + 1] = c[1];
d[i + 2] = c[2];
d[i + 3] = 255;
};
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (used[y * size + x]) continue;
const r = Math.random();
const c = r < density / 2 ? lighter : r < density ? darker : bg;
claim(x, y, c);
const canH = isFree(x + 1, y);
const canV = isFree(x, y + 1);
if (canH && canV ? Math.random() < 0.5 : canH) claim(x + 1, y, c);
else if (canV) claim(x, y + 1, c);
}
}
return rgbaBufferToTexture(d, size, size);
}
// All 7 tetrominoes × rotations, offsets from anchor, extending only right/down
const TETROMINO_SHAPES = [
// I
[
[0, 0],
[1, 0],
[2, 0],
[3, 0],
],
[
[0, 0],
[0, 1],
[0, 2],
[0, 3],
],
// O
[
[0, 0],
[1, 0],
[0, 1],
[1, 1],
],
// T
[
[0, 0],
[1, 0],
[2, 0],
[1, 1],
],
[
[0, 0],
[0, 1],
[1, 1],
[0, 2],
],
[
[1, 0],
[0, 1],
[1, 1],
[2, 1],
],
[
[1, 0],
[0, 1],
[1, 1],
[1, 2],
],
// S
[
[1, 0],
[2, 0],
[0, 1],
[1, 1],
],
[
[0, 0],
[0, 1],
[1, 1],
[1, 2],
],
// Z
[
[0, 0],
[1, 0],
[1, 1],
[2, 1],
],
[
[1, 0],
[0, 1],
[1, 1],
[0, 2],
],
// L
[
[0, 0],
[0, 1],
[0, 2],
[1, 2],
],
[
[0, 0],
[1, 0],
[2, 0],
[0, 1],
],
[
[0, 0],
[1, 0],
[1, 1],
[1, 2],
],
[
[2, 0],
[0, 1],
[1, 1],
[2, 1],
],
// J
[
[1, 0],
[1, 1],
[0, 2],
[1, 2],
],
[
[0, 0],
[0, 1],
[1, 1],
[2, 1],
],
[
[0, 0],
[1, 0],
[0, 1],
[0, 2],
],
[
[0, 0],
[1, 0],
[2, 0],
[2, 1],
],
] as const;
// L-triomino orientations as offsets from anchor, extending only right/down
const CORNER_SHAPES = [
[
[0, 0],
[1, 0],
[0, 1],
],
[
[0, 0],
[1, 0],
[1, 1],
],
[
[0, 0],
[0, 1],
[1, 1],
],
] as const;
type Shape = ReadonlyArray<readonly [number, number]>;
function makeNoiseTileTextureShapes(
bg: RGB,
lighter: RGB,
darker: RGB,
size: number,
shapes: ReadonlyArray<Shape>,
density = NOISE_DENSITY,
): Texture {
const d = new Uint8Array(size * size * 4);
const used = new Uint8Array(size * size);
const order = Array.from({ length: shapes.length }, (_, i) => i);
const isFree = (x: number, y: number) =>
x < size && y < size && !used[y * size + x];
const claim = (x: number, y: number, c: RGB) => {
used[y * size + x] = 1;
const i = (y * size + x) * 4;
d[i] = c[0];
d[i + 1] = c[1];
d[i + 2] = c[2];
d[i + 3] = 255;
};
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (used[y * size + x]) continue;
const r = Math.random();
const c = r < density / 2 ? lighter : r < density ? darker : bg;
order.sort(() => Math.random() - 0.5);
const shape = order
.map((i) => shapes[i])
.find((s) => s.every(([dx, dy]) => isFree(x + dx, y + dy)));
if (shape) {
for (const [dx, dy] of shape) claim(x + dx, y + dy, c);
} else {
claim(x, y, c);
const canH = isFree(x + 1, y);
const canV = isFree(x, y + 1);
if (canH && canV ? Math.random() < 0.5 : canH) claim(x + 1, y, c);
else if (canV) claim(x, y + 1, c);
}
}
}
return rgbaBufferToTexture(d, size, size);
}
function addRowLabel(
container: Container,
text: string,
row: number,
gridOriginY: number,
) {
const lbl = new Text({ text, style: AXIS_STYLE });
lbl.x = AXIS_LABEL_W - lbl.width;
lbl.y = gridOriginY + row * (TILE_SIZE + PAD) + (TILE_SIZE - LABEL_H) / 2;
container.addChild(lbl);
}
function buildGrid(
getPair: (bg: RGB, fg: RGB) => { bg: RGB; fg: RGB },
): Container {
const container = new Container();
const gridOriginX = AXIS_LABEL_W;
const gridOriginY = AXIS_LABEL_H;
COLORS.forEach(({ label }, col) => {
const lbl = new Text({ text: label, style: AXIS_STYLE });
lbl.x = gridOriginX + col * (TILE_SIZE + PAD);
lbl.y = 0;
container.addChild(lbl);
});
COLORS.forEach(({ label: bgLabel, rgb: bgRgb }, row) => {
addRowLabel(container, bgLabel, row, gridOriginY);
COLORS.forEach(({ rgb: fgRgb }, col) => {
const pair = getPair(bgRgb, fgRgb);
const sprite = new Sprite(
makeNoiseTileTexture(pair.bg, pair.fg, TILE_SIZE),
);
sprite.x = gridOriginX + col * (TILE_SIZE + PAD);
sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
container.addChild(sprite);
});
});
return container;
}
const CHROMA_OFFSETS = Array.from({ length: COLORS.length }, (_, i) =>
parseFloat((-0.2 + (i / (COLORS.length - 1)) * 0.4).toFixed(4)),
);
const THREE_TONE_OFFSETS = Array.from({ length: COLORS.length }, (_, i) =>
parseFloat(((i / (COLORS.length - 1)) * 0.2).toFixed(4)),
);
const LUMINANCE_LEVELS = Array.from({ length: COLORS.length }, (_, i) =>
parseFloat((i / (COLORS.length - 1)).toFixed(4)),
);
const THREE_TONE_LUM_OFFSET = 0.12;
const PAGE_BG_L = toOklch(rgbToNorm([17, 17, 17]))!.l ?? 0;
const NOISE_ALG_FILL = 0.07;
const FILL_FRACTIONS = Array.from({ length: COLORS.length }, (_, i) =>
parseFloat(((i / (COLORS.length - 1)) * 0.33).toFixed(4)),
);
function addChromaSubgrid(
container: Container,
originY: number,
offsets: number[],
makeTile: (
bgRgb: RGB,
bgOklch: ReturnType<typeof toOklch>,
offset: number,
) => Texture,
) {
const gridOriginX = AXIS_LABEL_W;
const gridOriginY = originY + AXIS_LABEL_H;
offsets.forEach((offset, col) => {
const pct = Math.round(offset * 100);
const lbl = new Text({
text: pct > 0 ? `+${pct}` : `${pct}`,
style: AXIS_STYLE,
});
lbl.x = gridOriginX + col * (TILE_SIZE + PAD);
lbl.y = originY;
container.addChild(lbl);
});
COLORS.forEach(({ label: bgLabel, rgb: bgRgb }, row) => {
addRowLabel(container, bgLabel, row, gridOriginY);
const bgOklch = toOklch(rgbToNorm(bgRgb))!;
offsets.forEach((offset, col) => {
const sprite = new Sprite(makeTile(bgRgb, bgOklch, offset));
sprite.x = gridOriginX + col * (TILE_SIZE + PAD);
sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
container.addChild(sprite);
});
});
}
function buildEqualChromaGrid(): Container {
const container = new Container();
addChromaSubgrid(container, 0, CHROMA_OFFSETS, (bgRgb, bgOklch, offset) => {
const fgL = Math.max(0, Math.min(1, (bgOklch!.l ?? 0) + offset));
const fg = oklchToRgb({ ...bgOklch!, l: fgL });
return makeNoiseTileTexture(bgRgb, fg, TILE_SIZE);
});
return container;
}
function buildThreeToneGrid(): Container {
const container = new Container();
addChromaSubgrid(
container,
0,
THREE_TONE_OFFSETS,
(bgRgb, bgOklch, offset) => {
const lighterL = Math.min(1, (bgOklch!.l ?? 0) + offset);
const darkerL = Math.max(0, (bgOklch!.l ?? 0) - offset);
const lighter = oklchToRgb({ ...bgOklch!, l: lighterL });
const darker = oklchToRgb({ ...bgOklch!, l: darkerL });
return makeNoiseTileTexture3(bgRgb, lighter, darker, TILE_SIZE);
},
);
return container;
}
function buildLuminanceSweepGrid(): Container {
const container = new Container();
const gridOriginX = AXIS_LABEL_W;
const gridOriginY = AXIS_LABEL_H;
LUMINANCE_LEVELS.forEach((l, col) => {
const lbl = new Text({
text: `${Math.round(l * 100)}`,
style: AXIS_STYLE,
});
lbl.x = gridOriginX + col * (TILE_SIZE + PAD);
lbl.y = 0;
container.addChild(lbl);
});
COLORS.forEach(({ label: colorLabel, rgb: colorRgb }, row) => {
addRowLabel(container, colorLabel, row, gridOriginY);
const baseOklch = toOklch(rgbToNorm(colorRgb))!;
LUMINANCE_LEVELS.forEach((l, col) => {
const bgRgb = oklchToRgb({ ...baseOklch, l });
const lighterL = Math.min(1, l + THREE_TONE_LUM_OFFSET);
const darkerL = Math.max(0, l - THREE_TONE_LUM_OFFSET);
const lighter = oklchToRgb({ ...baseOklch, l: lighterL });
const darker = oklchToRgb({ ...baseOklch, l: darkerL });
const sprite = new Sprite(
makeNoiseTileTexture3(bgRgb, lighter, darker, TILE_SIZE),
);
sprite.x = gridOriginX + col * (TILE_SIZE + PAD);
sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
container.addChild(sprite);
});
});
return container;
}
function buildFillFractionGrid(): Container {
const container = new Container();
const gridOriginX = AXIS_LABEL_W;
const gridOriginY = AXIS_LABEL_H;
FILL_FRACTIONS.forEach((frac, col) => {
const lbl = new Text({
text: `${Math.round(frac * 100)}%`,
style: AXIS_STYLE,
});
lbl.x = gridOriginX + col * (TILE_SIZE + PAD);
lbl.y = 0;
container.addChild(lbl);
});
COLORS.forEach(({ label: colorLabel, rgb: colorRgb }, row) => {
addRowLabel(container, colorLabel, row, gridOriginY);
const baseOklch = toOklch(rgbToNorm(colorRgb))!;
const bgRgb = oklchToRgb({ ...baseOklch, l: PAGE_BG_L });
const lighterL = Math.min(1, PAGE_BG_L + THREE_TONE_LUM_OFFSET);
const darkerL = Math.max(0, PAGE_BG_L - THREE_TONE_LUM_OFFSET);
const lighter = oklchToRgb({ ...baseOklch, l: lighterL });
const darker = oklchToRgb({ ...baseOklch, l: darkerL });
FILL_FRACTIONS.forEach((frac, col) => {
const sprite = new Sprite(
makeNoiseTileTexture3(bgRgb, lighter, darker, TILE_SIZE, frac * 2),
);
sprite.x = gridOriginX + col * (TILE_SIZE + PAD);
sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
container.addChild(sprite);
});
});
return container;
}
function noiseStats(lumOffset: number, fillFraction: number) {
const sigma = lumOffset * Math.sqrt(2 * fillFraction);
return { sigma, uniformRange: sigma * Math.sqrt(3) };
}
function sampleNormal(): number {
return (
Math.sqrt(-2 * Math.log(Math.random())) *
Math.cos(2 * Math.PI * Math.random())
);
}
type NoiseContext = {
bg: RGB;
lighter: RGB;
darker: RGB;
mapL: (l: number) => RGB;
sigma: number;
uniformRange: number;
centerL: number;
density: number;
};
type NoiseAlgorithm = {
label: string;
generate: (ctx: NoiseContext, size: number) => Texture;
};
function makeNoiseTileTextureContinuous(
mapL: (l: number) => RGB,
sampleL: () => number,
size: number,
): Texture {
const d = new Uint8Array(size * size * 4);
for (let i = 0; i < size * size; i++) {
const c = mapL(sampleL());
d[i * 4] = c[0];
d[i * 4 + 1] = c[1];
d[i * 4 + 2] = c[2];
d[i * 4 + 3] = 255;
}
return rgbaBufferToTexture(d, size, size);
}
const NOISE_DENSITY_ALG = NOISE_ALG_FILL * 2;
const NOISE_ALGORITHMS: NoiseAlgorithm[] = [
{
label: "none",
generate: ({ bg }, size) => makeNoiseTileTexture(bg, bg, size),
},
{
label: "random",
generate: ({ bg, lighter, darker, density }, size) =>
makeNoiseTileTexture3(bg, lighter, darker, size, density),
},
{
label: "domino",
generate: ({ bg, lighter, darker, density }, size) =>
makeNoiseTileTextureDomino(bg, lighter, darker, size, density),
},
{
label: "corner",
generate: ({ bg, lighter, darker, density }, size) =>
makeNoiseTileTextureShapes(
bg,
lighter,
darker,
size,
CORNER_SHAPES,
density,
),
},
{
label: "tetromino",
generate: ({ bg, lighter, darker, density }, size) =>
makeNoiseTileTextureShapes(
bg,
lighter,
darker,
size,
TETROMINO_SHAPES,
density,
),
},
{
label: "uniform",
generate: ({ mapL, uniformRange, centerL }, size) =>
makeNoiseTileTextureContinuous(
mapL,
() => centerL + (Math.random() * 2 - 1) * uniformRange,
size,
),
},
{
label: "normal",
generate: ({ mapL, sigma, centerL }, size) =>
makeNoiseTileTextureContinuous(
mapL,
() => centerL + sampleNormal() * sigma,
size,
),
},
];
function buildNoiseAlgorithmGrid(): Container {
const container = new Container();
const gridOriginX = AXIS_LABEL_W;
const gridOriginY = AXIS_LABEL_H;
const colLabels = NOISE_ALGORITHMS.map(
({ label }) => new Text({ text: label, style: AXIS_STYLE }),
);
const maxColLabelW = Math.max(...colLabels.map((l) => l.width));
const colStride = Math.max(TILE_SIZE + PAD, Math.ceil(maxColLabelW) + PAD);
colLabels.forEach((lbl, col) => {
lbl.x = gridOriginX + col * colStride;
lbl.y = 0;
container.addChild(lbl);
});
const { sigma, uniformRange } = noiseStats(
THREE_TONE_LUM_OFFSET,
NOISE_ALG_FILL,
);
COLORS.forEach(({ label: colorLabel, rgb: colorRgb }, row) => {
addRowLabel(container, colorLabel, row, gridOriginY);
const baseOklch = toOklch(rgbToNorm(colorRgb))!;
const mapL = (l: number): RGB =>
oklchToRgb({ ...baseOklch, l: Math.max(0, Math.min(1, l)) });
const ctx: NoiseContext = {
bg: mapL(PAGE_BG_L),
lighter: mapL(PAGE_BG_L + THREE_TONE_LUM_OFFSET),
darker: mapL(PAGE_BG_L - THREE_TONE_LUM_OFFSET),
mapL,
sigma,
uniformRange,
centerL: PAGE_BG_L,
density: NOISE_DENSITY_ALG,
};
NOISE_ALGORITHMS.forEach(({ generate }, col) => {
const sprite = new Sprite(generate(ctx, TILE_SIZE));
sprite.x = gridOriginX + col * colStride;
sprite.y = gridOriginY + row * (TILE_SIZE + PAD);
container.addChild(sprite);
});
});
return container;
}
// ─── Explorer Tab ────────────────────────────────────────────────────────────
const EXPLORER_GRID_SIZE = 20;
const CONTROL_ROW_H = 22;
const CONTROL_ROW_GAP = 8;
const CONTROL_LABEL_W = 90;
const EXPLORER_STORAGE_PREFIX = "scratchProceduralTexture.explorer.";
const EXPLORER_LUM_STEPS = [
PAGE_BG_L,
0.1,
0.15,
0.2,
0.3,
0.4,
0.5,
0.6,
0.7,
0.8,
0.9,
1.0,
];
const EXPLORER_RANGE_STEPS = [0.02, 0.05, 0.08, 0.12, 0.16, 0.2];
const EXPLORER_FILL_STEPS = [
0.007, 0.01, 0.014, 0.02, 0.05, 0.07, 0.1, 0.15, 0.2, 0.33,
];
const EXPLORER_TILE_COUNTS = [1, 2, 4, 8, 16];
function buildSelectorRow(
label: string,
options: string[],
initialIndex: number,
onChange: (i: number) => void,
makeTile?: (i: number) => Texture,
): {
container: Container;
setIndex: (i: number) => void;
refreshTiles: (newMakeTile: (i: number) => Texture) => void;
rowHeight: number;
} {
const hasTiles = makeTile !== undefined;
const rowHeight = hasTiles ? TILE_SIZE + LABEL_H + 4 : CONTROL_ROW_H;
const row = new Container();
const dimStyle = monoStyle(0x666666);
const activeStyle = monoStyle(0xdddddd);
let current = initialIndex;
const optionTexts: Text[] = [];
const tileSprites: Sprite[] = [];
const tileTextures: Texture[] = [];
const tileBorders: { gfx: Graphics; spriteX: number }[] = [];
function drawTileBorder(
entry: { gfx: Graphics; spriteX: number },
active: boolean,
) {
entry.gfx.clear();
if (active) {
entry.gfx
.rect(entry.spriteX - 1, -1, TILE_SIZE + 2, TILE_SIZE + 2)
.stroke({ color: 0xaaaaaa, width: 2 });
}
}
const lbl = new Text({ text: label + ":", style: dimStyle });
row.addChild(lbl);
let x = CONTROL_LABEL_W;
options.forEach((opt, i) => {
const txt = new Text({
text: opt,
style: i === current ? activeStyle : dimStyle,
});
const colW = Math.ceil(
hasTiles ? Math.max(TILE_SIZE, txt.width + 4) : txt.width + 8,
);
if (hasTiles) {
const spriteX = x + Math.round((colW - TILE_SIZE) / 2);
const tex = makeTile!(i);
tileTextures.push(tex);
const sprite = new Sprite(tex);
sprite.x = spriteX;
row.addChild(sprite);
tileSprites.push(sprite);
const borderGfx = new Graphics();
const borderEntry = { gfx: borderGfx, spriteX };
tileBorders.push(borderEntry);
drawTileBorder(borderEntry, i === current);
row.addChild(borderGfx);
txt.x = x + Math.round((colW - txt.width) / 2);
txt.y = TILE_SIZE + 4;
} else {
txt.x = x;
}
row.addChild(txt);
optionTexts.push(txt);
const hit = new Graphics()
.rect(hasTiles ? x : x - 4, -2, colW, rowHeight + 2)
.fill({ color: 0, alpha: 0 });
hit.eventMode = "static";
hit.cursor = "pointer";
hit.on("pointerdown", () => {
if (current === i) return;
optionTexts[current].style = dimStyle;
if (tileBorders[current]) drawTileBorder(tileBorders[current], false);
current = i;
optionTexts[current].style = activeStyle;
if (tileBorders[current]) drawTileBorder(tileBorders[current], true);
onChange(i);
});
row.addChild(hit);
x += hasTiles ? colW + PAD : colW + 2;
});
const setIndex = (i: number) => {
if (i === current) return;
optionTexts[current].style = dimStyle;
if (tileBorders[current]) drawTileBorder(tileBorders[current], false);
current = i;
optionTexts[current].style = activeStyle;
if (tileBorders[current]) drawTileBorder(tileBorders[current], true);
};
const refreshTiles = (newMakeTile: (i: number) => Texture) => {
options.forEach((_, i) => {
tileTextures[i]?.destroy(true);
const newTex = newMakeTile(i);
tileTextures[i] = newTex;
if (tileSprites[i]) tileSprites[i].texture = newTex;
});
};
return { container: row, setIndex, refreshTiles, rowHeight };
}
function buildExplorerTab(): Container {
const container = new Container();
const storageGet = (key: string, def: number, max: number): number => {
const v = localStorage.getItem(EXPLORER_STORAGE_PREFIX + key);
if (v === null) return def;
const n = parseInt(v, 10);
return isNaN(n) ? def : Math.max(0, Math.min(max, n));
};
const storageSave = (key: string, v: number) =>
localStorage.setItem(EXPLORER_STORAGE_PREFIX + key, String(v));
const state = {
colorIndex: storageGet(
"color",
Math.max(
0,
COLORS.findIndex((c) => c.label === "cyan"),
),
COLORS.length - 1,
),
lumIndex: storageGet("lum", 0, EXPLORER_LUM_STEPS.length - 1),
rangeIndex: storageGet(
"range",
Math.max(0, EXPLORER_RANGE_STEPS.indexOf(THREE_TONE_LUM_OFFSET)),
EXPLORER_RANGE_STEPS.length - 1,
),
fillIndex: storageGet(
"fill",
Math.max(0, EXPLORER_FILL_STEPS.indexOf(NOISE_ALG_FILL)),
EXPLORER_FILL_STEPS.length - 1,
),
algoIndex: storageGet(
"algo",
Math.max(
0,
NOISE_ALGORITHMS.findIndex((a) => a.label === "tetromino"),
),
NOISE_ALGORITHMS.length - 1,
),
tileCountIndex: storageGet("tileCount", 2, EXPLORER_TILE_COUNTS.length - 1),
};
function buildCtx(
cIdx: number,
lIdx: number,
rIdx: number,
fIdx: number,
): NoiseContext {
const { rgb: colorRgb } = COLORS[cIdx];
const lum = EXPLORER_LUM_STEPS[lIdx];
const range = EXPLORER_RANGE_STEPS[rIdx];
const fill = EXPLORER_FILL_STEPS[fIdx];
const baseOklch = toOklch(rgbToNorm(colorRgb))!;
const mapL = (l: number): RGB =>
oklchToRgb({ ...baseOklch, l: Math.max(0, Math.min(1, l)) });
const { sigma, uniformRange } = noiseStats(range, fill);
return {
bg: mapL(lum),
lighter: mapL(lum + range),
darker: mapL(lum - range),
mapL,
sigma,
uniformRange,
centerL: lum,
density: fill * 2,
};
}
const makeColorTile = (i: number) =>
NOISE_ALGORITHMS[state.algoIndex].generate(
buildCtx(i, state.lumIndex, state.rangeIndex, state.fillIndex),
TILE_SIZE,
);
const makeLumTile = (i: number) =>
NOISE_ALGORITHMS[state.algoIndex].generate(
buildCtx(state.colorIndex, i, state.rangeIndex, state.fillIndex),
TILE_SIZE,
);
const makeRangeTile = (i: number) =>
NOISE_ALGORITHMS[state.algoIndex].generate(
buildCtx(state.colorIndex, state.lumIndex, i, state.fillIndex),
TILE_SIZE,
);
const makeFillTile = (i: number) =>
NOISE_ALGORITHMS[state.algoIndex].generate(
buildCtx(state.colorIndex, state.lumIndex, state.rangeIndex, i),
TILE_SIZE,
);
const makeAlgoTile = (i: number) =>
NOISE_ALGORITHMS[i].generate(
buildCtx(
state.colorIndex,
state.lumIndex,
state.rangeIndex,
state.fillIndex,
),
TILE_SIZE,
);
type RefreshFn = (m: (i: number) => Texture) => void;
let refreshColorTiles: RefreshFn = () => {};
let refreshLumTiles: RefreshFn = () => {};
let refreshRangeTiles: RefreshFn = () => {};
let refreshFillTiles: RefreshFn = () => {};
let refreshAlgoTiles: RefreshFn = () => {};
const tilesetContainer = new Container();
const gridContainer = new Container();
let currentTiles: Texture[] = [];
function regenerate() {
for (const tex of currentTiles) tex.destroy(true);
currentTiles = [];
tilesetContainer.removeChildren();
gridContainer.removeChildren();
const tileCount = EXPLORER_TILE_COUNTS[state.tileCountIndex];
const algo = NOISE_ALGORITHMS[state.algoIndex];
const noiseCtx = buildCtx(
state.colorIndex,
state.lumIndex,
state.rangeIndex,
state.fillIndex,
);
currentTiles = Array.from({ length: tileCount }, () =>
algo.generate(noiseCtx, TILE_SIZE),
);
currentTiles.forEach((tex, i) => {
const sprite = new Sprite(tex);
sprite.x = i * (TILE_SIZE + PAD);
tilesetContainer.addChild(sprite);
});
for (let row = 0; row < EXPLORER_GRID_SIZE; row++) {
for (let col = 0; col < EXPLORER_GRID_SIZE; col++) {
const tex =
currentTiles[Math.floor(Math.random() * currentTiles.length)];
const sprite = new Sprite(tex);
sprite.x = col * TILE_SIZE;
sprite.y = row * TILE_SIZE;
gridContainer.addChild(sprite);
}
}
refreshColorTiles(makeColorTile);
refreshLumTiles(makeLumTile);
refreshRangeTiles(makeRangeTile);
refreshFillTiles(makeFillTile);
refreshAlgoTiles(makeAlgoTile);
}
const controlsContainer = new Container();
container.addChild(controlsContainer);
let rowY = 0;
function addRow(result: { container: Container; rowHeight: number }) {
result.container.y = rowY;
controlsContainer.addChild(result.container);
rowY += result.rowHeight + CONTROL_ROW_GAP;
}
const colorRow = buildSelectorRow(
"color",
COLORS.map((c) => c.label),
state.colorIndex,
(i) => {
state.colorIndex = i;
storageSave("color", i);
regenerate();
},
makeColorTile,
);
addRow(colorRow);
const setColor = colorRow.setIndex;
refreshColorTiles = colorRow.refreshTiles;
const lumRow = buildSelectorRow(
"lum",
EXPLORER_LUM_STEPS.map((v, i) => (i === 0 ? "bg" : v.toFixed(2))),
state.lumIndex,
(i) => {
state.lumIndex = i;
storageSave("lum", i);
regenerate();
},
makeLumTile,
);
addRow(lumRow);
const setLum = lumRow.setIndex;
refreshLumTiles = lumRow.refreshTiles;
const rangeRow = buildSelectorRow(
"range",
EXPLORER_RANGE_STEPS.map((v) => v.toFixed(2)),
state.rangeIndex,
(i) => {
state.rangeIndex = i;
storageSave("range", i);
regenerate();
},
makeRangeTile,
);
addRow(rangeRow);
const setRange = rangeRow.setIndex;
refreshRangeTiles = rangeRow.refreshTiles;
const fillRow = buildSelectorRow(
"fill",
EXPLORER_FILL_STEPS.map((v) => {
const pct = v * 100;
return pct < 2
? `${parseFloat(pct.toPrecision(2))}%`
: `${Math.round(pct)}%`;
}),
state.fillIndex,
(i) => {
state.fillIndex = i;
storageSave("fill", i);
regenerate();
},
makeFillTile,
);
addRow(fillRow);
const setFill = fillRow.setIndex;
refreshFillTiles = fillRow.refreshTiles;
const algoRow = buildSelectorRow(
"algo",
NOISE_ALGORITHMS.map((a) => a.label),
state.algoIndex,
(i) => {
state.algoIndex = i;
storageSave("algo", i);
regenerate();
},
makeAlgoTile,
);
addRow(algoRow);
const setAlgo = algoRow.setIndex;
refreshAlgoTiles = algoRow.refreshTiles;
const tilesRow = buildSelectorRow(
"tiles",
EXPLORER_TILE_COUNTS.map(String),
state.tileCountIndex,
(i) => {
state.tileCountIndex = i;
storageSave("tileCount", i);
regenerate();
},
);
addRow(tilesRow);
const setTileCount = tilesRow.setIndex;
const NOISE_ALG_PRESET = {
colorIndex: Math.max(
0,
COLORS.findIndex((c) => c.label === "cyan"),
),
lumIndex: 0,
rangeIndex: Math.max(
0,
EXPLORER_RANGE_STEPS.indexOf(THREE_TONE_LUM_OFFSET),
),
fillIndex: Math.max(0, EXPLORER_FILL_STEPS.indexOf(NOISE_ALG_FILL)),
algoIndex: Math.max(
0,
NOISE_ALGORITHMS.findIndex((a) => a.label === "tetromino"),
),
tileCountIndex: state.tileCountIndex,
};
function applyPreset() {
setColor(NOISE_ALG_PRESET.colorIndex);
setLum(NOISE_ALG_PRESET.lumIndex);
setRange(NOISE_ALG_PRESET.rangeIndex);
setFill(NOISE_ALG_PRESET.fillIndex);
setAlgo(NOISE_ALG_PRESET.algoIndex);
setTileCount(NOISE_ALG_PRESET.tileCountIndex);
Object.assign(state, NOISE_ALG_PRESET);
for (const [k, v] of Object.entries(NOISE_ALG_PRESET))
storageSave(k.replace("Index", ""), v);
regenerate();
}
const regenStyle = monoStyle(0x555555);
const regenHoverStyle = monoStyle(0xdddddd);
function makeBtn(text: string, onClick: () => void, x: number) {
const btn = new Text({ text, style: regenStyle });
btn.x = x;
btn.y = rowY;
btn.eventMode = "static";
btn.cursor = "pointer";
btn.on("pointerover", () => {
btn.style = regenHoverStyle;
});
btn.on("pointerout", () => {
btn.style = regenStyle;
});
btn.on("pointerdown", onClick);
controlsContainer.addChild(btn);
return btn;
}
const regenBtn = makeBtn("[ regenerate ]", regenerate, CONTROL_LABEL_W);
const presetBtn = makeBtn(
"[ tetromino cyan ]",
applyPreset,
regenBtn.x + Math.ceil(regenBtn.width) + 16,
);
const copyBtn = makeBtn(
"[ copy params ]",
() => {
const params = {
color: COLORS[state.colorIndex].label,
rgb: COLORS[state.colorIndex].rgb,
lum: EXPLORER_LUM_STEPS[state.lumIndex],
range: EXPLORER_RANGE_STEPS[state.rangeIndex],
fill: EXPLORER_FILL_STEPS[state.fillIndex],
algo: NOISE_ALGORITHMS[state.algoIndex].label,
tileCount: EXPLORER_TILE_COUNTS[state.tileCountIndex],
};
navigator.clipboard.writeText(JSON.stringify(params, null, 2));
copyBtn.text = "[ copied! ]";
setTimeout(() => {
copyBtn.text = "[ copy params ]";
}, 1500);
},
presetBtn.x + Math.ceil(presetBtn.width) + 16,
);
rowY += CONTROL_ROW_H + CONTROL_ROW_GAP;
const controlsH = rowY;
const tilemapContainer = new Container();
container.addChild(tilemapContainer);
const modeLabel = new Text({ text: "", style: monoStyle(0x555555) });
tilemapContainer.addChild(modeLabel);
tilesetContainer.y = CONTROL_ROW_H + 8;
tilemapContainer.addChild(tilesetContainer);
gridContainer.y = CONTROL_ROW_H + 8 + TILE_SIZE + PAD;
tilemapContainer.addChild(gridContainer);
let currentLayoutMode = "";
function applyLayout() {
const isLandscape = window.innerWidth > window.innerHeight;
const mode = isLandscape ? "landscape" : "portrait";
if (mode === currentLayoutMode) return;
currentLayoutMode = mode;
modeLabel.text = mode;
if (isLandscape) {
const cw = Math.ceil(controlsContainer.width);
tilemapContainer.x = Math.round(cw + PAD);
tilemapContainer.y = 0;
} else {
tilemapContainer.x = 0;
tilemapContainer.y = Math.round(controlsH + PAD);
}
}
applyLayout();
window.addEventListener("resize", applyLayout);
regenerate();
return container;
}
function buildTabs(
app: Application,
tabs: Array<{ label: string; build: () => Container }>,
defaultLabel = tabs[0].label,
) {
const activeStyle = monoStyle(0xdddddd);
const dimStyle = monoStyle(0x555555);
const STORAGE_KEY = "scratchProceduralTexture.activeTab";
const savedLabel = localStorage.getItem(STORAGE_KEY);
const savedIndex = tabs.findIndex((t) => t.label === savedLabel);
const tabBar = new Container();
app.stage.addChild(tabBar);
const contentY = TAB_H;
const defaultIndex = Math.max(
0,
tabs.findIndex((t) => t.label === defaultLabel),
);
let activeIndex = savedIndex >= 0 ? savedIndex : defaultIndex;
const contents: (Container | null)[] = tabs.map(() => null);
function getContent(i: number): Container {
if (!contents[i]) {
const c = tabs[i].build();
c.x = PAD;
c.y = contentY + PAD;
c.visible = false;
app.stage.addChild(c);
contents[i] = c;
}
return contents[i]!;
}
getContent(activeIndex).visible = true;
const bg = new Graphics();
tabBar.addChild(bg);
const tabLabels: Text[] = [];
let x = PAD;
tabs.forEach(({ label }, i) => {
const lbl = new Text({
text: label,
style: i === activeIndex ? activeStyle : dimStyle,
});
lbl.x = Math.round(x + TAB_PAD_X);
lbl.y = Math.round((TAB_H - lbl.height) / 2);
const tabW = lbl.width + TAB_PAD_X * 2;
const hit = new Graphics()
.rect(x, 0, tabW, TAB_H)
.fill({ color: 0, alpha: 0 });
hit.eventMode = "static";
hit.cursor = "pointer";
hit.on("pointerdown", () => {
getContent(activeIndex).visible = false;
tabLabels[activeIndex].style = dimStyle;
activeIndex = i;
getContent(activeIndex).visible = true;
tabLabels[activeIndex].style = activeStyle;
localStorage.setItem(STORAGE_KEY, tabs[activeIndex].label);
redrawBg();
});
tabBar.addChild(hit);
tabBar.addChild(lbl);
tabLabels.push(lbl);
x += tabW;
});
const tabPositions = tabLabels.map((lbl) => ({
x: lbl.x - TAB_PAD_X,
w: lbl.width + TAB_PAD_X * 2,
}));
function redrawBg() {
bg.clear();
tabPositions.forEach(({ x: tx, w }, i) => {
bg.rect(tx, 0, w, TAB_H).fill(i === activeIndex ? 0x222222 : 0x111111);
});
}
redrawBg();
}
async function setup(app: Application) {
await app.init({ background: "#111111", resizeTo: window, antialias: true });
document.getElementById("pixi-container")!.appendChild(app.canvas);
}
(async () => {
const app = new Application();
await setup(app);
buildTabs(
app,
[
{ label: "original", build: () => buildGrid((bg, fg) => ({ bg, fg })) },
{
label: "equal luminance",
build: () => buildGrid((bg, fg) => equalLuminance(bg, fg)),
},
{ label: "equal chroma", build: () => buildEqualChromaGrid() },
{ label: "three-tone", build: () => buildThreeToneGrid() },
{ label: "luminance", build: () => buildLuminanceSweepGrid() },
{ label: "fill fraction", build: () => buildFillFractionGrid() },
{ label: "noise", build: () => buildNoiseAlgorithmGrid() },
{ label: "explorer", build: () => buildExplorerTab() },
],
"explorer",
);
})();