export interface TextureOptions {
seed?: number;
}
export interface Textures {
grain: string;
glitter: string;
cosmosBottom: string;
cosmosMiddle: string;
cosmosTop: string;
}
export const DEFAULT_TEXTURE_SEED = 0x9e3779b9;
/** Deterministic PRNG (mulberry32) returning values in [0, 1) for a given seed. */
export const mulberry32 = (seed: number): (() => number) => {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
const svgToDataUri = (svg: string): string => {
const cleaned = svg
.replace(/>\s+<")
.replace(/\s{2,}/g, " ")
.trim();
const encoded = cleaned
.replace(/%/g, "%25")
.replace(//g, "%3E")
.replace(/#/g, "%23")
.replace(/&/g, "%26")
.replace(/"/g, "'")
.replace(/\n/g, "%0A");
return `data:image/svg+xml,${encoded}`;
};
const svgDocument = (width: number, height: number, defs: string, body: string): string =>
svgToDataUri(
``,
);
const pick = (rng: () => number, items: readonly T[]): T => {
const value = items[Math.floor(rng() * items.length)];
return value ?? (items[0] as T);
};
const rand = (rng: () => number, min: number, max: number): number => min + rng() * (max - min);
const circle = (
x: number,
y: number,
r: number,
fill: string,
opacity: number,
opts: { rDigits?: number; filter?: string } = {},
): string => {
const filter = opts.filter ? ` filter='url(${opts.filter})'` : "";
return ``;
};
const gaussianBlurFilter = (id: string, std: number, margin: string, size: string): string =>
``;
const discreteTable = (keep: number, steps = 32): string => {
const ones = Math.min(steps, Math.max(1, Math.round(steps * keep)));
const cells: string[] = [];
for (let i = 0; i < steps - ones; i += 1) {
cells.push("0");
}
for (let i = 0; i < ones; i += 1) {
cells.push("1");
}
return cells.join(" ");
};
interface SpeckleLayer {
freq: number;
keep: number;
color: string;
opacity: number;
}
const speckleField = (
idBase: string,
seed: number,
layers: readonly SpeckleLayer[],
stitch = true,
): { defs: string; body: string } => {
let defs = "";
let body = "";
const stitchTiles = stitch ? "stitch" : "noStitch";
layers.forEach((layer, index) => {
const id = `${idBase}${index}`;
defs +=
`` +
`` +
`` +
`` +
`` +
``;
body += ``;
});
return { defs, body };
};
const clusterBlobs = (
rng: () => number,
width: number,
height: number,
count: number,
palette: readonly string[],
blurId: string,
radius: [number, number],
opacity: [number, number],
): string => {
let blobs = "";
for (let i = 0; i < count; i += 1) {
blobs += circle(
rng() * width,
rng() * height,
rand(rng, radius[0], radius[1]),
pick(rng, palette),
rand(rng, opacity[0], opacity[1]),
{ rDigits: 1 },
);
}
return `${blobs}`;
};
const brightStars = (rng: () => number, width: number, height: number, count: number, glowId: string): string => {
let stars = "";
for (let i = 0; i < count; i += 1) {
const x = rng() * width;
const y = rng() * height;
const r = rand(rng, 1.1, 2.6);
stars +=
circle(x, y, r * 2.6, "#ffffff", 0.14, { rDigits: 1, filter: glowId }) +
circle(x, y, r, "#ffffff", rand(rng, 0.75, 1));
}
return stars;
};
const ringClusters = (rng: () => number, width: number, height: number, count: number, color: string): string => {
let out = "";
for (let i = 0; i < count; i += 1) {
const cx = rng() * width;
const cy = rng() * height;
const radius = rand(rng, 14, 30);
const dots = 26 + Math.floor(rng() * 22);
for (let k = 0; k < dots; k += 1) {
const angle = rng() * Math.PI * 2;
const rr = radius * rand(rng, 0.78, 1.28);
out += circle(
cx + Math.cos(angle) * rr,
cy + Math.sin(angle) * rr * 1.05,
rand(rng, 0.5, 1.5),
color,
rand(rng, 0.4, 0.9),
);
}
}
return out;
};
const COSMOS_W = 512;
const COSMOS_H = 716;
const cosmosDefs = (extraDefs: string): string =>
gaussianBlurFilter("blur", 2.6, "-30%", "160%") + gaussianBlurFilter("glow", 2.4, "-200%", "500%") + extraDefs;
export const grainTexture = (seed: number): string =>
svgDocument(
200,
200,
`` +
`` +
`` +
`` +
`` +
`` +
``,
`` +
``,
);
export const glitterTexture = (seed: number): string => {
const { defs, body } = speckleField("gl", seed, [
{ freq: 0.82, keep: 0.42, color: "#8f8f8f", opacity: 0.85 },
{ freq: 0.7, keep: 0.22, color: "#dcdcdc", opacity: 1 },
{ freq: 0.6, keep: 0.08, color: "#ffffff", opacity: 1 },
]);
const rng = mulberry32(seed + 99);
const flares = brightStars(rng, 240, 240, 16, "#glow");
return svgDocument(
240,
240,
`${gaussianBlurFilter("glow", 1.5, "-200%", "500%")}${defs}`,
`${body}${flares}`,
);
};
const cosmosLayer = (
idBase: string,
seed: number,
layers: readonly SpeckleLayer[],
paint: (rng: () => number, body: string) => string,
): string => {
const rng = mulberry32(seed);
const { defs, body } = speckleField(idBase, seed, layers, false);
return svgDocument(COSMOS_W, COSMOS_H, cosmosDefs(defs), paint(rng, body));
};
const cosmosBottom = (seed: number): string =>
cosmosLayer(
"csb",
seed,
[
{ freq: 0.85, keep: 0.32, color: "#97a3c8", opacity: 0.8 },
{ freq: 0.72, keep: 0.13, color: "#ffffff", opacity: 0.95 },
{ freq: 0.76, keep: 0.05, color: "#9fb6ff", opacity: 0.85 },
{ freq: 0.78, keep: 0.04, color: "#ffc2d8", opacity: 0.8 },
],
(rng, body) => {
const clusters = clusterBlobs(
rng,
COSMOS_W,
COSMOS_H,
26,
["#465777", "#6d6088", "#9a7790", "#aab3cc", "#566f9e"],
"#blur",
[5, 14],
[0.18, 0.4],
);
const stars = brightStars(rng, COSMOS_W, COSMOS_H, 24, "#glow");
return `${clusters}${body}${stars}`;
},
);
const cosmosMiddle = (seed: number): string =>
cosmosLayer(
"csm",
seed,
[
{ freq: 0.66, keep: 0.2, color: "#241a4e", opacity: 0.95 },
{ freq: 0.58, keep: 0.11, color: "#4a2168", opacity: 0.9 },
{ freq: 0.52, keep: 0.055, color: "#7a1f6b", opacity: 0.85 },
{ freq: 0.5, keep: 0.03, color: "#c87a3a", opacity: 0.85 },
],
(rng, body) => {
const clusters = clusterBlobs(
rng,
COSMOS_W,
COSMOS_H,
16,
["#241a4e", "#3a2168", "#1a1430"],
"#blur",
[7, 18],
[0.45, 0.8],
);
return `${clusters}${body}`;
},
);
const cosmosTop = (seed: number): string =>
cosmosLayer(
"cst",
seed,
[
{ freq: 0.62, keep: 0.06, color: "#6a6a76", opacity: 0.85 },
{ freq: 0.52, keep: 0.035, color: "#42424c", opacity: 0.9 },
],
(rng, body) => {
const rings = ringClusters(rng, COSMOS_W, COSMOS_H, 7, "#54545f");
return `${body}${rings}`;
},
);
export const generateTextures = (options: TextureOptions = {}): Textures => {
const seed = options.seed ?? DEFAULT_TEXTURE_SEED;
return {
grain: grainTexture(seed),
glitter: glitterTexture(seed + 1),
cosmosBottom: cosmosBottom(seed + 2),
cosmosMiddle: cosmosMiddle(seed + 3),
cosmosTop: cosmosTop(seed + 4),
};
};
export const TEXTURE_VARIABLES = {
grain: "--hc-grain",
glitter: "--hc-glitter",
cosmosBottom: "--hc-cosmos-bottom",
cosmosMiddle: "--hc-cosmos-middle",
cosmosTop: "--hc-cosmos-top",
} as const satisfies Record;
export const texturesToCssVariables = (textures: Textures): Record => {
const vars: Record = {};
for (const key of Object.keys(TEXTURE_VARIABLES) as (keyof Textures)[]) {
vars[TEXTURE_VARIABLES[key]] = `url("${textures[key]}")`;
}
return vars;
};