Huginn/inspector/src/HuginnFieldCanvas.tsx
2026-05-18 22:14:36 +01:00

1107 lines
36 KiB
TypeScript

import { useEffect, useRef } from "react";
type Brush = {
x: number;
y: number;
size: number;
r: number;
g: number;
b: number;
alpha: number;
energy: number;
curve: number;
gx: number;
gy: number;
};
type FieldSample = {
width: number;
height: number;
rgba: Uint8ClampedArray;
fieldRgba: Uint8ClampedArray;
luma: Float32Array;
alpha: Float32Array;
analysis: Float32Array;
flowX: Float32Array;
flowY: Float32Array;
fieldStrength: Float32Array;
};
type PyramidLevel = {
width: number;
height: number;
values: Float32Array;
energy: Float32Array;
gx: Float32Array;
gy: Float32Array;
curve: Float32Array;
};
const FIELD_SIZE = 256;
const MAX_DEPTH = 6;
const MIN_BRUSH_SIZE = 4;
const ENERGY_SPLIT_THRESHOLD = 0.038;
const PARTICLE_COUNT = new URLSearchParams(globalThis.location?.search ?? "").has("smoke") ? 24_576 : 196_608;
const PARTICLE_STRIDE_FLOATS = 12;
const MAX_NODE_ENVELOPES = 96;
type NodeEnvelope = {
id: string;
x: number;
y: number;
radius: number;
strength: number;
};
type GraphViewportTransform = {
x: number;
y: number;
scale: number;
bounds: {
x: number;
y: number;
width: number;
height: number;
};
};
const gpuParticleComputeShader = /* wgsl */ `
struct Particle {
position: vec2f,
velocity: vec2f,
color: vec4f,
seed: f32,
size: f32,
life: f32,
pad: f32,
};
struct SimUniforms {
time: f32,
dt: f32,
width: f32,
height: f32,
particleCount: f32,
flowGain: f32,
alpha: f32,
detail: f32,
envelopeCount: f32,
nodeGain: f32,
worldX: f32,
worldY: f32,
worldWidth: f32,
worldHeight: f32,
viewX: f32,
viewY: f32,
viewScale: f32,
pad1: f32,
};
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@group(0) @binding(1) var<uniform> uniforms: SimUniforms;
@group(0) @binding(2) var fieldSampler: sampler;
@group(0) @binding(3) var fieldTexture: texture_2d<f32>;
@group(0) @binding(4) var<storage, read> nodeEnvelopes: array<vec4f>;
fn hash(value: f32) -> f32 {
return fract(sin(value * 12.9898) * 43758.5453);
}
fn hash2(cell: vec2f) -> f32 {
return hash(dot(cell, vec2f(127.1, 311.7)));
}
fn wrap2(value: vec2f, size: vec2f) -> vec2f {
return fract(value / size) * size;
}
fn proceduralCurl(point: vec2f, seed: f32) -> vec2f {
let angle =
sin(point.x * 0.011 + uniforms.time * 0.23 + seed * 6.28318) +
cos(point.y * 0.017 - uniforms.time * 0.19 + seed * 4.71) +
sin((point.x + point.y) * 0.006 + uniforms.time * 0.11) +
sin(point.x * 0.041 + point.y * -0.032 + uniforms.time * 0.37 + seed);
return vec2f(cos(angle * 3.14159), sin(angle * 3.14159));
}
fn samplePacked(uv: vec2f) -> vec4f {
return textureSampleLevel(fieldTexture, fieldSampler, clamp(uv, vec2f(0.001), vec2f(0.999)), 0.0);
}
fn channelGradient(uv: vec2f, channel: u32) -> vec2f {
let step = vec2f(1.0 / 256.0, 1.0 / 256.0) / max(uniforms.detail, 1.0);
let left = samplePacked(uv - vec2f(step.x, 0.0));
let right = samplePacked(uv + vec2f(step.x, 0.0));
let up = samplePacked(uv - vec2f(0.0, step.y));
let down = samplePacked(uv + vec2f(0.0, step.y));
if (channel == 2u) {
return vec2f(right.b - left.b, down.b - up.b);
}
return vec2f(right.a - left.a, down.a - up.a);
}
@compute @workgroup_size(128)
fn updateParticles(@builtin(global_invocation_id) id: vec3u) {
let index = id.x;
if (index >= u32(uniforms.particleCount)) {
return;
}
var particle = particles[index];
let canvasSize = vec2f(max(uniforms.width, 1.0), max(uniforms.height, 1.0));
var position = particle.position * canvasSize;
var uv = clamp(particle.position, vec2f(0.001), vec2f(0.999));
var packed = samplePacked(uv);
let detail = clamp(uniforms.detail, 0.65, 4.0);
let strengthGradient = channelGradient(uv, 3u);
let curvatureGradient = channelGradient(uv, 2u);
let tangent = normalize(vec2f(-curvatureGradient.y, curvatureGradient.x) + vec2f(0.001, 0.0));
let inward = normalize(strengthGradient + vec2f(0.001, 0.0));
let fieldVector = packed.rg * 2.0 - vec2f(1.0);
let fieldVelocity = fieldVector * uniforms.flowGain * (0.34 + packed.a * 2.25) * detail;
let turbulence = proceduralCurl(position * (0.82 + packed.b * 0.9), particle.seed) * (9.0 + packed.b * 36.0 + packed.a * 18.0) * detail;
let ridgeSlip = tangent * (packed.b * 30.0 + packed.a * 18.0) * detail;
let channelPull = inward * (packed.a * 24.0 + packed.b * 8.0);
let cell = floor(position / max(12.0, 38.0 / detail));
let jitter = vec2f(hash2(cell + particle.seed), hash2(cell + vec2f(7.0, 13.0) + particle.seed)) - vec2f(0.5);
var nodePush = vec2f(0.0);
var nodeHeat = 0.0;
for (var envelopeIndex = 0u; envelopeIndex < ${MAX_NODE_ENVELOPES}u; envelopeIndex = envelopeIndex + 1u) {
if (f32(envelopeIndex) >= uniforms.envelopeCount) {
break;
}
let envelope = nodeEnvelopes[envelopeIndex];
let delta = uv - envelope.xy;
let radius = max(envelope.z, 0.008);
let normalized = dot(delta, delta) / (radius * radius);
let edge = exp(-4.0);
let gaussian = exp(-4.0 * normalized);
let influence = pow(clamp((gaussian - edge) / max(1.0 - edge, 0.000001), 0.0, 1.0), 0.78) * envelope.w * uniforms.nodeGain;
let away = normalize(delta + vec2f(0.0001, 0.0));
let swirl = vec2f(-away.y, away.x);
nodePush += (swirl * 34.0 + away * 18.0) * influence;
nodeHeat += influence;
}
let velocity = fieldVelocity + turbulence + ridgeSlip + channelPull + nodePush + jitter * (5.0 + packed.a * 18.0) * detail;
position = wrap2(position + velocity * uniforms.dt, canvasSize);
uv = position / canvasSize;
packed = samplePacked(uv);
let density = clamp(packed.a * 0.76 + packed.b * 0.54, 0.0, 1.0);
let die = hash(particle.seed * 91.7 + floor(uniforms.time * (7.0 + detail * 5.0)));
if (density < 0.10 && die > density + 0.34) {
let respawnSeed = particle.seed + uniforms.time * 0.071 + f32(index) * 0.000013;
let angle = respawnSeed * 6.28318;
let radius = sqrt(hash(respawnSeed * 31.0)) * (0.46 - min(packed.a, 0.32) * 0.28);
let center = vec2f(0.5) + vec2f(cos(angle), sin(angle)) * radius;
let micro = vec2f(hash(respawnSeed * 47.0), hash(respawnSeed * 59.0)) - vec2f(0.5);
uv = clamp(center + micro * (0.18 / detail), vec2f(0.001), vec2f(0.999));
position = uv * canvasSize;
packed = samplePacked(uv);
}
particle.position = uv;
particle.velocity = velocity;
particle.life = fract(particle.life + uniforms.dt * (0.08 + packed.a * 0.18 + packed.b * 0.11 + hash(particle.seed * 23.0) * 0.07) * detail);
let speed = clamp(length(velocity) / (96.0 + detail * 26.0), 0.0, 1.0);
let flowAxis = abs(fieldVector.x - fieldVector.y);
let heat = clamp(packed.a * 0.92 + packed.b * 0.86 + speed * 0.48 + nodeHeat * 0.62, 0.0, 1.0);
let brass = clamp((packed.b - packed.a * 0.32) * 1.35 + flowAxis * 0.18, 0.0, 1.0);
let cyan = vec3f(0.08 + heat * 0.28, 0.44 + heat * 0.46, 0.52 + heat * 0.62);
let gold = vec3f(0.86, 0.58, 0.22);
let deep = vec3f(0.02, 0.10, 0.12);
let rgb = mix(mix(deep, cyan, 0.42 + heat * 0.58), gold, brass * 0.32);
particle.color = vec4f(
rgb,
uniforms.alpha * (0.004 + heat * 0.018 + density * 0.012 + nodeHeat * 0.014) * (0.72 + detail * 0.11)
);
particle.size = (0.52 + hash(particle.seed * 19.0) * 1.85) * (0.58 + packed.a * 1.25 + packed.b * 0.62 + nodeHeat * 0.44) * (0.92 + detail * 0.12) * (1.0 - abs(particle.life - 0.5) * 0.42);
particles[index] = particle;
}
`;
const gpuParticleRenderShader = /* wgsl */ `
struct Particle {
position: vec2f,
velocity: vec2f,
color: vec4f,
seed: f32,
size: f32,
life: f32,
pad: f32,
};
struct SimUniforms {
time: f32,
dt: f32,
width: f32,
height: f32,
particleCount: f32,
flowGain: f32,
alpha: f32,
detail: f32,
envelopeCount: f32,
nodeGain: f32,
worldX: f32,
worldY: f32,
worldWidth: f32,
worldHeight: f32,
viewX: f32,
viewY: f32,
viewScale: f32,
pad1: f32,
};
@group(0) @binding(0) var<storage, read> renderParticles: array<Particle>;
@group(0) @binding(1) var<uniform> renderUniforms: SimUniforms;
struct VertexOut {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
@location(1) local: vec2f,
};
@vertex
fn vertexMain(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instanceIndex: u32) -> VertexOut {
let corners = array<vec2f, 6>(
vec2f(-1.0, -1.0),
vec2f(1.0, -1.0),
vec2f(-1.0, 1.0),
vec2f(-1.0, 1.0),
vec2f(1.0, -1.0),
vec2f(1.0, 1.0)
);
let particle = renderParticles[instanceIndex];
let local = corners[vertexIndex];
let direction = normalize(particle.velocity + vec2f(0.001, 0.0));
let tangent = vec2f(-direction.y, direction.x);
let stretch = clamp(length(particle.velocity) / 92.0, 0.0, 2.1);
let ellipse = direction * local.x * particle.size * (1.0 + stretch * 0.48) + tangent * local.y * particle.size * (0.74 + renderUniforms.detail * 0.035);
let world = vec2f(renderUniforms.worldX, renderUniforms.worldY) + particle.position * vec2f(renderUniforms.worldWidth, renderUniforms.worldHeight);
let viewScale = max(renderUniforms.viewScale, 0.05);
let basePosition = vec2f(renderUniforms.viewX, renderUniforms.viewY) + world * viewScale;
let pixelPosition = basePosition + ellipse * sqrt(viewScale);
var out: VertexOut;
out.position = vec4f((pixelPosition.x / renderUniforms.width) * 2.0 - 1.0, 1.0 - (pixelPosition.y / renderUniforms.height) * 2.0, 0.0, 1.0);
out.color = particle.color * (1.0 - abs(particle.life - 0.5) * 0.44);
out.local = local;
return out;
}
@fragment
fn fragmentMain(input: VertexOut) -> @location(0) vec4f {
let radius2 = dot(input.local, input.local);
let falloff = 5.1 + renderUniforms.detail * 0.52;
let edge = exp(-falloff);
let gaussian = exp(-falloff * radius2);
let compact = pow(clamp((gaussian - edge) / max(1.0 - edge, 0.000001), 0.0, 1.0), 0.72);
let core = exp(-radius2 * 18.0) * 0.42;
return vec4f(input.color.rgb * (compact * 1.52 + core), input.color.a * compact);
}
`;
type HuginnFieldCanvasProps = {
imageUrl: string;
fieldUrl: string;
};
export function HuginnFieldCanvas({ imageUrl, fieldUrl }: HuginnFieldCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
let cancelled = false;
const render = async () => {
try {
const [image, fieldImage] = await Promise.all([
loadImage(imageUrl),
loadImage(fieldUrl),
]);
if (cancelled) {
return;
}
const sample = sampleImage(image, fieldImage);
const gpuCleanup = await startGpuParticleField(canvas, sample);
if (gpuCleanup) {
console.info("Huginn WebGPU particle field active.");
return gpuCleanup;
}
console.info("Huginn WebGPU unavailable; using canvas field fallback.");
const pyramid = buildCurvaturePyramid(sample);
const brushes = buildBrushes(sample, pyramid);
const observer = new ResizeObserver(() => drawFieldFallback(canvas, brushes, sample));
observer.observe(canvas);
drawFieldFallback(canvas, brushes, sample);
return () => observer.disconnect();
} catch {
return;
}
};
let cleanup: (() => void) | undefined;
void render().then((nextCleanup) => {
cleanup = nextCleanup;
});
return () => {
cancelled = true;
cleanup?.();
};
}, [fieldUrl, imageUrl]);
return <canvas ref={canvasRef} className="huginn-field-canvas" aria-hidden="true" />;
}
function loadImage(url: string): Promise<HTMLImageElement> {
const image = new Image();
image.decoding = "async";
image.src = url;
return image.decode()
.catch(() => new Promise<void>((resolve, reject) => {
if (image.complete && image.naturalWidth > 0) {
resolve();
return;
}
image.onload = () => resolve();
image.onerror = () => reject(new Error(`Could not load ${url}`));
}))
.then(() => image);
}
function sampleImage(
image: HTMLImageElement,
fieldImage: HTMLImageElement,
): FieldSample {
const base = sampleBitmap(image);
const field = sampleBitmap(fieldImage);
const analysis = new Float32Array(FIELD_SIZE * FIELD_SIZE);
const flowX = new Float32Array(FIELD_SIZE * FIELD_SIZE);
const flowY = new Float32Array(FIELD_SIZE * FIELD_SIZE);
const fieldStrength = new Float32Array(FIELD_SIZE * FIELD_SIZE);
for (let index = 0; index < analysis.length; index += 1) {
const source = index * 4;
flowX[index] = field.rgba[source] / 255 * 2 - 1;
flowY[index] = field.rgba[source + 1] / 255 * 2 - 1;
analysis[index] = field.rgba[source + 2] / 255 * base.alpha[index];
fieldStrength[index] = field.rgba[source + 3] / 255;
}
return {
width: FIELD_SIZE,
height: FIELD_SIZE,
rgba: base.rgba,
fieldRgba: field.rgba,
luma: base.luma,
alpha: base.alpha,
analysis,
flowX,
flowY,
fieldStrength,
};
}
function sampleBitmap(image: HTMLImageElement) {
const canvas = document.createElement("canvas");
canvas.width = FIELD_SIZE;
canvas.height = FIELD_SIZE;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("Canvas 2D context unavailable.");
}
context.clearRect(0, 0, FIELD_SIZE, FIELD_SIZE);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(image, 0, 0, FIELD_SIZE, FIELD_SIZE);
const rgba = context.getImageData(0, 0, FIELD_SIZE, FIELD_SIZE).data;
const luma = new Float32Array(FIELD_SIZE * FIELD_SIZE);
const alpha = new Float32Array(FIELD_SIZE * FIELD_SIZE);
for (let index = 0; index < luma.length; index += 1) {
const source = index * 4;
const a = rgba[source + 3] / 255;
alpha[index] = a;
luma[index] = (
rgba[source] * 0.2126 +
rgba[source + 1] * 0.7152 +
rgba[source + 2] * 0.0722
) / 255 * a;
}
return { rgba, luma, alpha };
}
function buildCurvaturePyramid(sample: FieldSample): PyramidLevel[] {
const levels: PyramidLevel[] = [];
let width = sample.width;
let height = sample.height;
let values = sample.analysis;
for (let level = 0; level < 5; level += 1) {
const gx = new Float32Array(width * height);
const gy = new Float32Array(width * height);
const curve = new Float32Array(width * height);
const energy = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const left = scalarAt(values, width, height, x - 1, y);
const right = scalarAt(values, width, height, x + 1, y);
const up = scalarAt(values, width, height, x, y - 1);
const down = scalarAt(values, width, height, x, y + 1);
const center = scalarAt(values, width, height, x, y);
const index = y * width + x;
const dx = (right - left) * 0.5;
const dy = (down - up) * 0.5;
const laplace = Math.abs(left + right + up + down - center * 4);
gx[index] = dx;
gy[index] = dy;
curve[index] = laplace;
energy[index] = Math.sqrt(dx * dx + dy * dy) * 0.72 + laplace * 0.92;
}
}
normalize(energy);
normalize(curve);
levels.push({ width, height, values, energy, gx, gy, curve });
if (Math.min(width, height) <= 8) {
break;
}
values = downsampleHalf(values, width, height);
width = Math.ceil(width / 2);
height = Math.ceil(height / 2);
}
return levels;
}
function buildBrushes(sample: FieldSample, pyramid: PyramidLevel[]): Brush[] {
const brushes: Brush[] = [];
splitCell(sample, pyramid, brushes, 0, 0, sample.width, 0);
return brushes.sort((left, right) => left.size - right.size);
}
function splitCell(
sample: FieldSample,
pyramid: PyramidLevel[],
brushes: Brush[],
x: number,
y: number,
size: number,
depth: number,
): void {
const stats = cellStats(sample, pyramid[0], x, y, size);
const shouldSplit =
depth < MAX_DEPTH &&
size > MIN_BRUSH_SIZE &&
(stats.energy > ENERGY_SPLIT_THRESHOLD || stats.alphaVariance > 0.028 || stats.lumaVariance > 0.022);
if (shouldSplit) {
const half = Math.max(1, Math.floor(size / 2));
splitCell(sample, pyramid, brushes, x, y, half, depth + 1);
splitCell(sample, pyramid, brushes, x + half, y, size - half, depth + 1);
splitCell(sample, pyramid, brushes, x, y + half, size - half, depth + 1);
splitCell(sample, pyramid, brushes, x + half, y + half, size - half, depth + 1);
return;
}
if (stats.alpha < 0.035 && stats.energy < 0.02) {
return;
}
const field = sampleVector(sample, pyramid, x + size * 0.5, y + size * 0.5, depth);
brushes.push({
x: (x + size * 0.5) / sample.width,
y: (y + size * 0.5) / sample.height,
size: size / sample.width,
r: stats.r,
g: stats.g,
b: stats.b,
alpha: Math.min(0.82, 0.12 + stats.alpha * 0.7 + stats.energy * 0.32),
energy: stats.energy,
curve: field.curve,
gx: field.gx,
gy: field.gy,
});
}
async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSample): Promise<(() => void) | undefined> {
const gpu = (navigator as Navigator & { gpu?: any }).gpu;
if (!gpu) {
return undefined;
}
const context = canvas.getContext("webgpu") as any;
if (!context) {
return undefined;
}
const adapter = await gpu.requestAdapter();
const device = await adapter?.requestDevice();
if (!device) {
return undefined;
}
const format = gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: "premultiplied" });
const sampler = device.createSampler({
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
magFilter: "linear",
minFilter: "linear",
});
const fieldTexture = device.createTexture({
size: [sample.width, sample.height, 1],
format: "rgba8unorm",
usage: 1 | 2 | 4,
});
device.queue.writeTexture(
{ texture: fieldTexture },
sample.fieldRgba,
{ bytesPerRow: sample.width * 4, rowsPerImage: sample.height },
{ width: sample.width, height: sample.height, depthOrArrayLayers: 1 },
);
const particleBuffer = device.createBuffer({
size: PARTICLE_COUNT * PARTICLE_STRIDE_FLOATS * Float32Array.BYTES_PER_ELEMENT,
usage: 128 | 32 | 8,
mappedAtCreation: true,
});
seedParticles(new Float32Array(particleBuffer.getMappedRange()), PARTICLE_COUNT);
particleBuffer.unmap();
const uniformBuffer = device.createBuffer({
size: 20 * Float32Array.BYTES_PER_ELEMENT,
usage: 64 | 8,
});
const nodeEnvelopeBuffer = device.createBuffer({
size: MAX_NODE_ENVELOPES * 4 * Float32Array.BYTES_PER_ELEMENT,
usage: 128 | 8,
});
const nodeEnvelopeState = {
data: new Float32Array(MAX_NODE_ENVELOPES * 4),
count: 0,
};
const transformState: {
received: boolean;
current: GraphViewportTransform;
} = {
received: false,
current: { x: 0, y: 0, scale: 1, bounds: { x: 0, y: 0, width: 1, height: 1 } },
};
const computeShader = device.createShaderModule({ code: gpuParticleComputeShader });
const renderShader = device.createShaderModule({ code: gpuParticleRenderShader });
const computePipeline = device.createComputePipeline({
layout: "auto",
compute: { module: computeShader, entryPoint: "updateParticles" },
});
const renderPipeline = device.createRenderPipeline({
layout: "auto",
vertex: { module: renderShader, entryPoint: "vertexMain" },
fragment: {
module: renderShader,
entryPoint: "fragmentMain",
targets: [{ format, blend: { color: { srcFactor: "one", dstFactor: "one" }, alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" } } }],
},
primitive: { topology: "triangle-list" },
});
const bindGroup = device.createBindGroup({
layout: computePipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: particleBuffer } },
{ binding: 1, resource: { buffer: uniformBuffer } },
{ binding: 2, resource: sampler },
{ binding: 3, resource: fieldTexture.createView() },
{ binding: 4, resource: { buffer: nodeEnvelopeBuffer } },
],
});
const renderBindGroup = device.createBindGroup({
layout: renderPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: particleBuffer } },
{ binding: 1, resource: { buffer: uniformBuffer } },
],
});
let stopped = false;
let frameId = 0;
let lastTime = performance.now();
let detail = 1;
let targetDetail = 1;
const resize = () => resizeGpuCanvas(canvas);
const observer = new ResizeObserver(resize);
observer.observe(canvas);
resize();
const detailController = attachDetailController(canvas, (nextDetail) => {
targetDetail = nextDetail;
});
const envelopeListener = attachNodeEnvelopeListener(canvas, nodeEnvelopeState, device, nodeEnvelopeBuffer);
const transformListener = attachViewportTransformListener(canvas, transformState);
const frame = (time: number) => {
if (stopped) {
return;
}
resize();
const dt = Math.min(0.033, Math.max(0.001, (time - lastTime) / 1000));
lastTime = time;
detail += (targetDetail - detail) * Math.min(1, dt * 4.8);
const activeParticleCount = Math.floor(PARTICLE_COUNT * Math.min(1, 0.34 + detail * 0.22));
const graphTransform = transformState.received
? transformState.current
: {
x: 0,
y: 0,
scale: 1,
bounds: { x: 0, y: 0, width: canvas.width, height: canvas.height },
};
const bounds = fitArtworkBounds(graphTransform.bounds);
device.queue.writeBuffer(uniformBuffer, 0, new Float32Array([
time / 1000,
dt,
canvas.width,
canvas.height,
activeParticleCount,
92 + detail * 62,
0.76,
detail,
nodeEnvelopeState.count,
1.12 + detail * 0.18,
bounds.x,
bounds.y,
bounds.width,
bounds.height,
graphTransform.x,
graphTransform.y,
graphTransform.scale,
0,
]));
const encoder = device.createCommandEncoder();
const computePass = encoder.beginComputePass();
computePass.setPipeline(computePipeline);
computePass.setBindGroup(0, bindGroup);
computePass.dispatchWorkgroups(Math.ceil(activeParticleCount / 128));
computePass.end();
const renderPass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0.011, g: 0.027, b: 0.039, a: 1 },
loadOp: "clear",
storeOp: "store",
}],
});
renderPass.setPipeline(renderPipeline);
renderPass.setBindGroup(0, renderBindGroup);
renderPass.draw(6, activeParticleCount);
renderPass.end();
device.queue.submit([encoder.finish()]);
frameId = requestAnimationFrame(frame);
};
frameId = requestAnimationFrame(frame);
return () => {
stopped = true;
cancelAnimationFrame(frameId);
observer.disconnect();
detailController();
envelopeListener();
transformListener();
particleBuffer.destroy();
uniformBuffer.destroy();
nodeEnvelopeBuffer.destroy();
fieldTexture.destroy();
};
}
function seedParticles(particles: Float32Array, count: number): void {
const span = Math.ceil(Math.sqrt(count));
for (let index = 0; index < count; index += 1) {
const cellX = index % span;
const cellY = Math.floor(index / span);
const seed = hash(index * 19.19 + 3.7);
const angle = seed * Math.PI * 2;
const ring = Math.sqrt(hash(seed * 71.0)) * 0.48;
const latticeX = (cellX + hash(seed * 17.0)) / span;
const latticeY = (cellY + hash(seed * 29.0)) / span;
const radialX = 0.5 + Math.cos(angle) * ring + (hash(seed * 97.0) - 0.5) * 0.08;
const radialY = 0.5 + Math.sin(angle) * ring + (hash(seed * 113.0) - 0.5) * 0.08;
const radialBias = hash(seed * 41.0);
const offset = index * PARTICLE_STRIDE_FLOATS;
particles[offset] = radialBias < 0.64 ? clamp01(radialX) : latticeX;
particles[offset + 1] = radialBias < 0.64 ? clamp01(radialY) : latticeY;
particles[offset + 2] = 0;
particles[offset + 3] = 0;
particles[offset + 4] = 0.18;
particles[offset + 5] = 0.72;
particles[offset + 6] = 0.82;
particles[offset + 7] = 0.018;
particles[offset + 8] = seed;
particles[offset + 9] = 1.4 + hash(seed * 43.0) * 2.2;
particles[offset + 10] = 0;
particles[offset + 11] = 0;
}
}
function hash(value: number): number {
return fract(Math.sin(value * 12.9898) * 43758.5453);
}
function fract(value: number): number {
return value - Math.floor(value);
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
function attachDetailController(canvas: HTMLCanvasElement, setDetail: (detail: number) => void): () => void {
let target = 1;
let lastInput = performance.now();
const apply = (detail: number) => {
target = Math.max(0.7, Math.min(4.0, detail));
lastInput = performance.now();
setDetail(target);
};
const onWheel = (event: WheelEvent) => {
apply(target * (event.deltaY < 0 ? 1.22 : 0.86));
};
const onPointerMove = () => {
apply(Math.max(target, 1.55));
};
const decay = window.setInterval(() => {
const idleSeconds = (performance.now() - lastInput) / 1000;
if (idleSeconds > 1.6) {
target += (1 - target) * 0.08;
setDetail(target);
}
}, 180);
const targetElement = canvas.parentElement ?? canvas;
targetElement.addEventListener("wheel", onWheel, { passive: true });
targetElement.addEventListener("pointermove", onPointerMove, { passive: true });
return () => {
window.clearInterval(decay);
targetElement.removeEventListener("wheel", onWheel);
targetElement.removeEventListener("pointermove", onPointerMove);
};
}
function attachNodeEnvelopeListener(
canvas: HTMLCanvasElement,
state: { data: Float32Array; count: number },
device: any,
buffer: any,
): () => void {
const target = canvas.parentElement ?? canvas;
const onEnvelopes = (event: Event) => {
const envelopes = (event as CustomEvent<NodeEnvelope[]>).detail;
if (!Array.isArray(envelopes)) {
return;
}
state.data.fill(0);
state.count = Math.min(MAX_NODE_ENVELOPES, envelopes.length);
for (let index = 0; index < state.count; index += 1) {
const envelope = envelopes[index];
const offset = index * 4;
state.data[offset] = clamp01(envelope.x);
state.data[offset + 1] = clamp01(envelope.y);
state.data[offset + 2] = Math.max(0.006, Math.min(0.32, envelope.radius));
state.data[offset + 3] = Math.max(0, Math.min(1.6, envelope.strength));
}
device.queue.writeBuffer(buffer, 0, state.data);
};
target.addEventListener("epiphanygraph-node-envelopes", onEnvelopes as EventListener);
return () => target.removeEventListener("epiphanygraph-node-envelopes", onEnvelopes as EventListener);
}
function attachViewportTransformListener(
canvas: HTMLCanvasElement,
state: { received: boolean; current: GraphViewportTransform },
): () => void {
const target = canvas.parentElement ?? canvas;
const onTransform = (event: Event) => {
const detail = (event as CustomEvent<Partial<GraphViewportTransform>>).detail;
if (
typeof detail?.x !== "number" ||
typeof detail.y !== "number" ||
typeof detail.scale !== "number"
) {
return;
}
state.current = {
x: detail.x,
y: detail.y,
scale: detail.scale,
bounds: isGraphBounds(detail.bounds) ? detail.bounds : state.current.bounds,
};
state.received = true;
};
target.addEventListener("epiphanygraph-viewport-transform", onTransform as EventListener);
return () => target.removeEventListener("epiphanygraph-viewport-transform", onTransform as EventListener);
}
function isGraphBounds(value: unknown): value is GraphViewportTransform["bounds"] {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<GraphViewportTransform["bounds"]>;
return (
typeof candidate.x === "number" &&
typeof candidate.y === "number" &&
typeof candidate.width === "number" &&
typeof candidate.height === "number"
);
}
function fitArtworkBounds(bounds: GraphViewportTransform["bounds"]) {
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const side = Math.max(width, height);
return {
x: bounds.x + width * 0.5 - side * 0.5,
y: bounds.y + height * 0.5 - side * 0.5,
width: side,
height: side,
};
}
function resizeGpuCanvas(canvas: HTMLCanvasElement): void {
const rect = canvas.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(rect.width * scale));
const height = Math.max(1, Math.floor(rect.height * scale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function drawFieldFallback(canvas: HTMLCanvasElement, brushes: Brush[], sample: FieldSample): void {
const rect = canvas.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(rect.width * scale));
const height = Math.max(1, Math.floor(rect.height * scale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const context = canvas.getContext("2d");
if (!context) {
return;
}
context.setTransform(scale, 0, 0, scale, 0, 0);
context.clearRect(0, 0, rect.width, rect.height);
context.globalCompositeOperation = "source-over";
context.fillStyle = "#03070a";
context.fillRect(0, 0, rect.width, rect.height);
const logoBox = fitContain(sample.width, sample.height, rect.width, rect.height);
drawLogoWash(context, logoBox);
context.globalCompositeOperation = "lighter";
for (const brush of brushes) {
const distortion = Math.min(24, 5 + brush.curve * 20);
const x = logoBox.x + brush.x * logoBox.width + brush.gx * distortion;
const y = logoBox.y + brush.y * logoBox.height + brush.gy * distortion;
const radius = Math.max(1.4, brush.size * logoBox.width * (0.56 + brush.energy * 0.55));
drawSdfBrush(context, x, y, radius, brush);
}
context.globalCompositeOperation = "screen";
context.strokeStyle = "rgba(51, 230, 243, 0.09)";
context.lineWidth = 1;
for (let x = logoBox.x; x <= logoBox.x + logoBox.width; x += logoBox.width / 24) {
context.beginPath();
context.moveTo(x, logoBox.y);
context.lineTo(x, logoBox.y + logoBox.height);
context.stroke();
}
for (let y = logoBox.y; y <= logoBox.y + logoBox.height; y += logoBox.height / 24) {
context.beginPath();
context.moveTo(logoBox.x, y);
context.lineTo(logoBox.x + logoBox.width, y);
context.stroke();
}
}
function drawSdfBrush(context: CanvasRenderingContext2D, x: number, y: number, radius: number, brush: Brush): void {
const color = `rgb(${Math.round(brush.r)}, ${Math.round(brush.g)}, ${Math.round(brush.b)})`;
const glow = context.createRadialGradient(x, y, 0, x, y, radius * 2.5);
glow.addColorStop(0, `rgba(${Math.round(brush.r)}, ${Math.round(brush.g)}, ${Math.round(brush.b)}, ${brush.alpha})`);
glow.addColorStop(0.38, `rgba(51, 230, 243, ${brush.alpha * 0.24})`);
glow.addColorStop(1, "rgba(51, 230, 243, 0)");
context.fillStyle = glow;
context.beginPath();
context.arc(x, y, radius * 2.5, 0, Math.PI * 2);
context.fill();
const corner = radius * (0.22 + brush.curve * 0.48);
context.save();
context.translate(x, y);
context.rotate(Math.atan2(brush.gy, brush.gx) * 0.38);
context.fillStyle = color;
context.globalAlpha = brush.alpha * 0.42;
roundedRect(context, -radius, -radius * 0.74, radius * 2, radius * 1.48, corner);
context.fill();
context.restore();
}
function drawLogoWash(context: CanvasRenderingContext2D, box: { x: number; y: number; width: number; height: number }) {
const gradient = context.createRadialGradient(
box.x + box.width * 0.5,
box.y + box.height * 0.58,
box.width * 0.08,
box.x + box.width * 0.5,
box.y + box.height * 0.58,
box.width * 0.62,
);
gradient.addColorStop(0, "rgba(51, 230, 243, 0.18)");
gradient.addColorStop(0.48, "rgba(8, 56, 73, 0.22)");
gradient.addColorStop(1, "rgba(2, 5, 8, 0)");
context.fillStyle = gradient;
context.fillRect(box.x, box.y, box.width, box.height);
}
function cellStats(sample: FieldSample, level: PyramidLevel, x: number, y: number, size: number) {
let r = 0;
let g = 0;
let b = 0;
let alpha = 0;
let luma = 0;
let energy = 0;
let count = 0;
const maxY = Math.min(sample.height, y + size);
const maxX = Math.min(sample.width, x + size);
for (let yy = y; yy < maxY; yy += 1) {
for (let xx = x; xx < maxX; xx += 1) {
const index = yy * sample.width + xx;
const source = index * 4;
const a = sample.alpha[index];
r += sample.rgba[source] * a;
g += sample.rgba[source + 1] * a;
b += sample.rgba[source + 2] * a;
alpha += a;
luma += sample.luma[index];
energy += level.energy[index];
count += 1;
}
}
const safeCount = Math.max(1, count);
const meanAlpha = alpha / safeCount;
const meanLuma = luma / safeCount;
let alphaVariance = 0;
let lumaVariance = 0;
for (let yy = y; yy < maxY; yy += 1) {
for (let xx = x; xx < maxX; xx += 1) {
const index = yy * sample.width + xx;
alphaVariance += Math.abs(sample.alpha[index] - meanAlpha);
lumaVariance += Math.abs(sample.luma[index] - meanLuma);
}
}
const colorWeight = Math.max(0.001, alpha);
return {
r: Math.max(18, r / colorWeight),
g: Math.max(32, g / colorWeight),
b: Math.max(40, b / colorWeight),
alpha: meanAlpha,
energy: energy / safeCount,
alphaVariance: alphaVariance / safeCount,
lumaVariance: lumaVariance / safeCount,
};
}
function sampleVector(sample: FieldSample, pyramid: PyramidLevel[], x: number, y: number, depth: number) {
const level = pyramid[Math.min(pyramid.length - 1, Math.max(0, Math.floor(depth / 2)))] ?? pyramid[0];
const sx = Math.max(0, Math.min(level.width - 1, Math.floor(x / FIELD_SIZE * level.width)));
const sy = Math.max(0, Math.min(level.height - 1, Math.floor(y / FIELD_SIZE * level.height)));
const index = sy * level.width + sx;
const sourceX = Math.max(0, Math.min(sample.width - 1, Math.floor(x)));
const sourceY = Math.max(0, Math.min(sample.height - 1, Math.floor(y)));
const sourceIndex = sourceY * sample.width + sourceX;
const flowX = sample.flowX[sourceIndex] ?? 0;
const flowY = sample.flowY[sourceIndex] ?? 0;
const fieldStrength = sample.fieldStrength[sourceIndex] ?? 0;
return {
gx: level.gx[index] * 46 + flowX * 0.72,
gy: level.gy[index] * 46 + flowY * 0.72,
curve: Math.max(level.curve[index], fieldStrength),
};
}
function downsampleHalf(values: Float32Array, width: number, height: number): Float32Array {
const nextWidth = Math.ceil(width / 2);
const nextHeight = Math.ceil(height / 2);
const next = new Float32Array(nextWidth * nextHeight);
for (let y = 0; y < nextHeight; y += 1) {
for (let x = 0; x < nextWidth; x += 1) {
next[y * nextWidth + x] = (
scalarAt(values, width, height, x * 2, y * 2) +
scalarAt(values, width, height, x * 2 + 1, y * 2) +
scalarAt(values, width, height, x * 2, y * 2 + 1) +
scalarAt(values, width, height, x * 2 + 1, y * 2 + 1)
) * 0.25;
}
}
return next;
}
function scalarAt(values: Float32Array, width: number, height: number, x: number, y: number): number {
const sx = Math.max(0, Math.min(width - 1, x));
const sy = Math.max(0, Math.min(height - 1, y));
return values[sy * width + sx] ?? 0;
}
function normalize(values: Float32Array): void {
let max = 0;
for (const value of values) {
max = Math.max(max, value);
}
if (max <= 0.000001) {
return;
}
for (let index = 0; index < values.length; index += 1) {
values[index] /= max;
}
}
function fitContain(sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number) {
const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight) * 1.04;
const width = sourceWidth * scale;
const height = sourceHeight * scale;
return {
x: (targetWidth - width) * 0.5,
y: (targetHeight - height) * 0.5,
width,
height,
};
}
function roundedRect(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
const r = Math.min(radius, width / 2, height / 2);
context.beginPath();
context.moveTo(x + r, y);
context.lineTo(x + width - r, y);
context.quadraticCurveTo(x + width, y, x + width, y + r);
context.lineTo(x + width, y + height - r);
context.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
context.lineTo(x + r, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - r);
context.lineTo(x, y + r);
context.quadraticCurveTo(x, y, x + r, y);
}