distance field JFA/GPU implementation

This commit is contained in:
Orion Reed 2024-12-01 19:42:01 -05:00
parent fdd7fb9d84
commit 1b14970313
8 changed files with 540 additions and 604 deletions

View File

@ -46,7 +46,7 @@
<script type="module">
import { FolkGeometry } from '../src/canvas/fc-geometry.ts';
import { DistanceField } from '../src/distanceField/distance-field.ts';
import { DistanceField } from '../src/distance-field.ts';
FolkGeometry.define();
DistanceField.define();

520
src/distance-field.ts Normal file
View File

@ -0,0 +1,520 @@
import { frag, vert } from './utils/tags.ts';
/** Previously used a CPU-based implementation. https://github.com/folk-canvas/folk-canvas/commit/fdd7fb9d84d93ad665875cad25783c232fd17bcc */
export class DistanceField extends HTMLElement {
static tagName = 'distance-field';
static define() {
customElements.define(this.tagName, this);
}
private geometries: NodeListOf<Element>;
private textures: WebGLTexture[] = [];
private pingPongIndex: number = 0;
private offsets!: Float32Array;
private canvas!: HTMLCanvasElement;
private gl!: WebGL2RenderingContext;
private program!: WebGLProgram;
private displayProgram!: WebGLProgram;
private seedProgram!: WebGLProgram;
private framebuffer!: WebGLFramebuffer;
private fullscreenQuadVAO!: WebGLVertexArrayObject;
private shapeVAO!: WebGLVertexArrayObject;
constructor() {
super();
this.geometries = document.querySelectorAll('fc-geometry');
const { gl } = this.createWebGLCanvas(window.innerWidth, window.innerHeight);
if (!gl) {
return;
}
this.gl = gl;
// Initialize shaders
this.initShaders();
// Initialize textures and framebuffer for ping-pong rendering
this.initPingPongTextures();
// Initialize seed point rendering
this.initSeedPointRendering();
// Start the JFA process
this.runJFA();
}
// Lifecycle hooks
connectedCallback() {
// Update distance field when geometries move or resize
this.geometries.forEach((geometry) => {
geometry.addEventListener('move', this.handleGeometryUpdate);
geometry.addEventListener('resize', this.handleGeometryUpdate);
});
}
disconnectedCallback() {
// Remove event listeners
this.geometries.forEach((geometry) => {
geometry.removeEventListener('move', this.handleGeometryUpdate);
geometry.removeEventListener('resize', this.handleGeometryUpdate);
});
}
// Handle updates from geometries
private handleGeometryUpdate = () => {
console.log('handleGeometryUpdate');
// Re-render seed points and rerun JFA
this.initSeedPointRendering();
this.runJFA();
};
private createWebGLCanvas(width: number, height: number) {
this.canvas = document.createElement('canvas');
// Set canvas styles
this.canvas.style.position = 'absolute';
this.canvas.style.top = '0';
this.canvas.style.left = '0';
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
this.canvas.style.zIndex = '-1';
this.canvas.width = width;
this.canvas.height = height;
// Initialize WebGL2 context
const gl = this.canvas.getContext('webgl2');
if (!gl) {
console.error('WebGL2 is not available.');
return {};
}
this.appendChild(this.canvas);
return { gl };
}
private initShaders() {
const gl = this.gl;
// Shader sources
const vertexShaderSource = vert`#version 300 es
precision highp float;
in vec2 a_position;
out vec2 v_texCoord;
void main() {
v_texCoord = a_position * 0.5 + 0.5; // Transform to [0, 1] range
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const fragmentShaderSource = frag`#version 300 es
precision highp float;
precision mediump int;
in vec2 v_texCoord;
out vec4 outColor;
uniform sampler2D u_previousTexture;
uniform vec2 u_offsets[9];
void main() {
// Start with the current texel's nearest seed point and distance
vec4 nearest = texture(u_previousTexture, v_texCoord);
// Initialize minDist with the current distance
float minDist = nearest.a;
// Loop through neighbor offsets
for (int i = 0; i < 9; ++i) {
vec2 sampleCoord = v_texCoord + u_offsets[i];
// Clamp sampleCoord to [0, 1] to prevent sampling outside texture
sampleCoord = clamp(sampleCoord, vec2(0.0), vec2(1.0));
vec4 sampled = texture(u_previousTexture, sampleCoord);
// Compute distance to the seed point stored in this neighbor
float dist = distance(sampled.xy, v_texCoord);
if (dist < minDist) {
nearest = sampled;
nearest.a = dist;
minDist = dist;
}
}
// Output the nearest seed point and updated distance
outColor = nearest;
}`;
const displayVertexShaderSource = vert`#version 300 es
in vec2 a_position;
out vec2 v_texCoord;
void main() {
v_texCoord = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const displayFragmentShaderSource = frag`#version 300 es
precision highp float;
in vec2 v_texCoord;
out vec4 outColor;
uniform sampler2D u_texture;
void main() {
vec4 texel = texture(u_texture, v_texCoord);
// Extract shape ID and distance
float shapeID = texel.z;
float distance = texel.a;
// Hash-based color for shape
vec3 shapeColor = vec3(
fract(sin(shapeID * 12.9898) * 43758.5453),
fract(sin(shapeID * 78.233) * 43758.5453),
fract(sin(shapeID * 93.433) * 43758.5453)
);
// Visualize distance (e.g., as intensity)
float intensity = exp(-distance * 10.0);
outColor = vec4(shapeColor * intensity, 1.0);
}`;
// Compute offsets on the CPU
const offsets = [];
for (let y = -1; y <= 1; y++) {
for (let x = -1; x <= 1; x++) {
offsets.push(x, y);
}
}
this.offsets = new Float32Array(offsets);
// Compile JFA shaders
const vertexShader = this.createShader(gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = this.createShader(gl.FRAGMENT_SHADER, fragmentShaderSource);
this.program = this.createProgram(vertexShader, fragmentShader);
// Compile display shaders
const displayVertexShader = this.createShader(gl.VERTEX_SHADER, displayVertexShaderSource);
const displayFragmentShader = this.createShader(gl.FRAGMENT_SHADER, displayFragmentShaderSource);
this.displayProgram = this.createProgram(displayVertexShader, displayFragmentShader);
}
private createShader(type: GLenum, source: string): WebGLShader {
const gl = this.gl;
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (!success) {
console.error('Could not compile shader:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
throw new Error('Shader compilation failed');
}
return shader;
}
private createProgram(vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram {
const gl = this.gl;
const program = gl.createProgram()!;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!success) {
console.error('Program failed to link:', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
throw new Error('Program linking failed');
}
return program;
}
private initPingPongTextures() {
const gl = this.gl;
const width = this.canvas.width;
const height = this.canvas.height;
// Enable the EXT_color_buffer_float extension
const ext = gl.getExtension('EXT_color_buffer_float');
if (!ext) {
console.error('EXT_color_buffer_float extension is not supported.');
return;
}
for (let i = 0; i < 2; i++) {
const texture = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, texture);
// Set texture parameters
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// Use gl.RGBA32F and gl.FLOAT for higher precision
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA32F, // Internal format
width,
height,
0,
gl.RGBA, // Format
gl.FLOAT, // Type
null
);
this.textures.push(texture);
}
// Create framebuffer
this.framebuffer = gl.createFramebuffer()!;
}
private initSeedPointRendering() {
const gl = this.gl;
// Shader sources for seed point rendering
const seedVertexShaderSource = vert`#version 300 es
precision highp float;
in vec3 a_position; // x, y, shapeID
flat out float v_shapeID;
void main() {
gl_Position = vec4(a_position.xy, 0.0, 1.0);
v_shapeID = a_position.z; // Pass shape ID to fragment shader
}`;
const seedFragmentShaderSource = frag`#version 300 es
precision highp float;
flat in float v_shapeID;
uniform vec2 u_resolution;
out vec4 outColor;
void main() {
vec2 seedCoord = gl_FragCoord.xy / u_resolution;
outColor = vec4(seedCoord, v_shapeID, 0.0); // Seed coords, shape ID, initial distance 0
}`;
// Compile seed shaders
const seedVertexShader = this.createShader(gl.VERTEX_SHADER, seedVertexShaderSource);
const seedFragmentShader = this.createShader(gl.FRAGMENT_SHADER, seedFragmentShaderSource);
this.seedProgram = this.createProgram(seedVertexShader, seedFragmentShader);
// Set up VAO and buffer for shapes
this.shapeVAO = gl.createVertexArray()!;
gl.bindVertexArray(this.shapeVAO);
const positionBuffer = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
// Collect positions and shape IDs for all shapes
const positions: number[] = [];
this.geometries.forEach((geometry, index) => {
const rect = geometry.getBoundingClientRect();
// Convert to Normalized Device Coordinates (NDC)
const x1 = (rect.left / window.innerWidth) * 2 - 1;
const y1 = -((rect.top / window.innerHeight) * 2 - 1);
const x2 = (rect.right / window.innerWidth) * 2 - 1;
const y2 = -((rect.bottom / window.innerHeight) * 2 - 1);
const shapeID = index + 1; // Avoid zero to prevent hash function issues
// Two triangles per rectangle, include shapeID as z component
positions.push(
x1,
y1,
shapeID,
x2,
y1,
shapeID,
x1,
y2,
shapeID,
x1,
y2,
shapeID,
x2,
y1,
shapeID,
x2,
y2,
shapeID
);
});
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const positionLocation = gl.getAttribLocation(this.seedProgram, 'a_position');
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
// Render the seed points into the texture
this.renderSeedPoints();
}
private renderSeedPoints() {
const gl = this.gl;
// Bind framebuffer to render to the seed texture
const seedTexture = this.textures[this.pingPongIndex % 2];
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, seedTexture, 0);
// Clear the texture with a large initial distance
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
gl.clearColor(0.0, 0.0, 0.0, 99999.0); // Max initial distance
gl.clear(gl.COLOR_BUFFER_BIT);
// Use seed shader program
gl.useProgram(this.seedProgram);
// Set uniforms
gl.uniform2f(gl.getUniformLocation(this.seedProgram, 'u_resolution'), this.canvas.width, this.canvas.height);
// Bind VAO and draw shapes
gl.bindVertexArray(this.shapeVAO);
gl.drawArrays(gl.TRIANGLES, 0, this.geometries.length * 6);
// Unbind VAO and framebuffer
gl.bindVertexArray(null);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
private runJFA() {
const maxDimension = Math.max(this.canvas.width, this.canvas.height);
let stepSize = Math.pow(2, Math.floor(Math.log2(maxDimension)));
const minStepSize = 1;
while (stepSize >= minStepSize) {
this.renderPass(stepSize);
stepSize = Math.floor(stepSize / 2);
}
this.renderToScreen();
}
private renderPass(stepSize: number) {
const gl = this.gl;
// Swap textures for ping-pong rendering
const inputTexture = this.textures[this.pingPongIndex % 2];
const outputTexture = this.textures[(this.pingPongIndex + 1) % 2];
// Bind framebuffer to output texture
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, outputTexture, 0);
// Check framebuffer status
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
console.error('Framebuffer is incomplete:', status.toString(16));
return;
}
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
// Use shader program
gl.useProgram(this.program);
// Adjust offsets based on step size and resolution
const adjustedOffsets = [];
for (let i = 0; i < this.offsets.length; i += 2) {
const offsetX = (this.offsets[i] * stepSize) / this.canvas.width;
const offsetY = (this.offsets[i + 1] * stepSize) / this.canvas.height;
adjustedOffsets.push(offsetX, offsetY);
}
// Set the offsets uniform
const offsetsLocation = gl.getUniformLocation(this.program, 'u_offsets');
gl.uniform2fv(offsetsLocation, new Float32Array(adjustedOffsets));
// Bind input texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, inputTexture);
gl.uniform1i(gl.getUniformLocation(this.program, 'u_previousTexture'), 0);
// Draw a fullscreen quad
this.drawFullscreenQuad();
// Swap ping-pong index
this.pingPongIndex++;
}
private renderToScreen() {
const gl = this.gl;
// Unbind framebuffer to render to the canvas
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
// Use display shader program
gl.useProgram(this.displayProgram);
// Bind the final texture
const finalTexture = this.textures[this.pingPongIndex % 2];
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, finalTexture);
gl.uniform1i(gl.getUniformLocation(this.displayProgram, 'u_texture'), 0);
// Draw a fullscreen quad
this.drawFullscreenQuad();
}
private drawFullscreenQuad() {
const gl = this.gl;
// Initialize VAO if not already done
if (!this.fullscreenQuadVAO) {
this.initFullscreenQuad();
}
gl.bindVertexArray(this.fullscreenQuadVAO);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.bindVertexArray(null);
}
private initFullscreenQuad() {
const gl = this.gl;
const positions = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
this.fullscreenQuadVAO = gl.createVertexArray()!;
gl.bindVertexArray(this.fullscreenQuadVAO);
const positionBuffer = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const positionAttributeLocation = gl.getAttribLocation(this.program, 'a_position');
gl.enableVertexAttribArray(positionAttributeLocation);
gl.vertexAttribPointer(
positionAttributeLocation,
2, // size
gl.FLOAT, // type
false, // normalize
0, // stride
0 // offset
);
gl.bindVertexArray(null);
}
}

View File

@ -1,108 +0,0 @@
import type { Vector2 } from '../utils/Vector2.ts';
/** Adapted from Felzenszwalb, P. F., & Huttenlocher, D. P. (2012). Distance Transforms of Sampled Functions. Theory of Computing, 8(1), 415428. */
export function computeCPT(
sedt: Float32Array[],
cpt: Vector2[][],
xcoords: Float32Array[],
ycoords: Float32Array[]
): Vector2[][] {
const length = sedt.length;
const tempArray = new Float32Array(length);
// Pre-allocate hull arrays
const hullVertices: Vector2[] = [];
const hullIntersections: Vector2[] = [];
for (let row = 0; row < length; row++) {
horizontalPass(sedt[row], xcoords[row], hullVertices, hullIntersections);
}
for (let i = 0; i < length; i++) {
for (let j = i + 1; j < length; j++) {
tempArray[0] = sedt[i][j];
sedt[i][j] = sedt[j][i];
sedt[j][i] = tempArray[0];
}
}
for (let row = 0; row < length; row++) {
horizontalPass(sedt[row], ycoords[row], hullVertices, hullIntersections);
}
const len = length * length;
for (let i = 0; i < len; i++) {
const row = i % length;
const col = (i / length) | 0;
const y = ycoords[col][row];
const x = xcoords[y][col];
cpt[row][col].x = x;
cpt[row][col].y = y;
}
return cpt;
}
function horizontalPass(
singleRow: Float32Array,
indices: Float32Array,
hullVertices: Vector2[],
hullIntersections: Vector2[]
) {
// Clear hull arrays before use
hullVertices.length = 0;
hullIntersections.length = 0;
findHullParabolas(singleRow, hullVertices, hullIntersections);
marchParabolas(singleRow, hullVertices, hullIntersections, indices);
}
function marchParabolas(row: Float32Array, verts: Vector2[], intersections: Vector2[], indices: Float32Array) {
let k = 0;
const n = row.length;
const numVerts = verts.length;
for (let i = 0; i < n; i++) {
while (k < numVerts - 1 && intersections[k + 1].x < i) {
k++;
}
const dx = i - verts[k].x;
row[i] = dx * dx + verts[k].y;
indices[i] = verts[k].x;
}
}
function findHullParabolas(row: Float32Array, verts: Vector2[], intersections: Vector2[]) {
let k = 0;
verts[k] = { x: 0, y: row[0] };
intersections[k] = { x: -Infinity, y: 0 };
intersections[k + 1] = { x: Infinity, y: 0 };
const n = row.length;
for (let i = 1; i < n; i++) {
const s: Vector2 = { x: 0, y: 0 };
const qx = i;
const qy = row[i];
let p = verts[k];
// Calculate intersection
s.x = (qy + qx * qx - (p.y + p.x * p.x)) / (2 * (qx - p.x));
while (k > 0 && s.x <= intersections[k].x) {
k--;
p = verts[k];
s.x = (qy + qx * qx - (p.y + p.x * p.x)) / (2 * (qx - p.x));
}
k++;
verts[k] = { x: qx, y: qy };
intersections[k] = { x: s.x, y: 0 };
intersections[k + 1] = { x: Infinity, y: 0 };
}
// Adjust the length of verts and intersections arrays
verts.length = k + 1;
intersections.length = k + 2;
}

View File

@ -1,202 +0,0 @@
import type { FolkGeometry } from '../canvas/fc-geometry.ts';
import type { Vector2 } from '../utils/Vector2.ts';
export class DistanceField extends HTMLElement {
static tagName = 'distance-field';
static define() {
customElements.define(this.tagName, this);
}
private canvas!: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private offscreenCanvas: HTMLCanvasElement;
private offscreenCtx: CanvasRenderingContext2D;
private resolution: number;
private imageSmoothing: boolean;
private worker!: Worker;
private geometryShapeIds: Map<HTMLElement, string> = new Map();
// Get all geometry elements
private geometries = document.querySelectorAll('fc-geometry');
constructor() {
super();
this.resolution = 800; // default resolution
this.imageSmoothing = true;
const { ctx, offscreenCtx, offscreenCanvas } = this.createCanvas(
window.innerWidth,
window.innerHeight,
this.resolution,
this.resolution
);
this.ctx = ctx;
this.offscreenCtx = offscreenCtx;
this.offscreenCanvas = offscreenCanvas;
// Initialize the Web Worker
try {
this.worker = new Worker(new URL('./distance-field.worker.ts', import.meta.url).href, { type: 'module' });
this.worker.onmessage = this.handleWorkerMessage;
this.worker.postMessage({ type: 'initialize', data: { resolution: this.resolution } });
} catch (error) {
console.error('Error initializing worker', error);
}
this.renderDistanceField();
}
connectedCallback() {
// Update distance field when geometries move or resize
this.geometries.forEach((geometry) => {
geometry.addEventListener('move', this.handleGeometryUpdate);
geometry.addEventListener('resize', this.handleGeometryUpdate);
});
}
disconnectedCallback() {
// Remove event listeners and terminate the worker
this.geometries.forEach((geometry) => {
geometry.removeEventListener('move', this.handleGeometryUpdate);
geometry.removeEventListener('resize', this.handleGeometryUpdate);
});
this.worker.terminate();
}
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
if (name === 'resolution') {
this.resolution = parseInt(newValue, 10);
// Re-initialize the worker with the new resolution
this.worker.postMessage({ type: 'initialize', data: { resolution: this.resolution } });
} else if (name === 'image-smoothing') {
this.imageSmoothing = newValue === 'true';
if (this.ctx) {
this.ctx.imageSmoothingEnabled = this.imageSmoothing;
}
}
}
private renderDistanceField() {
// Request the worker to generate ImageData
this.worker.postMessage({ type: 'generateImageData' });
}
// Handle messages from the worker
private handleWorkerMessage = (event: MessageEvent) => {
const { type, imageData } = event.data;
if (type === 'imageData') {
// Reconstruct ImageData from the transferred buffer
const imgData = new ImageData(new Uint8ClampedArray(imageData.data), imageData.width, imageData.height);
// Update the canvas with the new image data
this.offscreenCtx.putImageData(imgData, 0, 0);
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(
this.offscreenCanvas,
0,
0,
this.resolution,
this.resolution,
0,
0,
this.canvas.width,
this.canvas.height
);
}
};
// Public method to reset fields
reset() {
// Reset the fields in the worker
this.worker.postMessage({ type: 'initialize', data: { resolution: this.resolution } });
}
private transformToFieldCoordinates(point: Vector2): Vector2 {
// Transform from screen coordinates to field coordinates (0 to resolution)
return {
x: (point.x / this.canvas.width) * this.resolution,
y: (point.y / this.canvas.height) * this.resolution,
};
}
addShape(points: Vector2[]) {
// Transform and send points to the worker
const transformedPoints = points.map((point) => this.transformToFieldCoordinates(point));
this.worker.postMessage({ type: 'addShape', data: { points: transformedPoints } });
this.renderDistanceField();
}
removeShape(index: number) {
// Inform the worker to remove a shape
this.worker.postMessage({ type: 'removeShape', data: { index } });
this.renderDistanceField();
}
private createCanvas(width: number, height: number, offScreenWidth: number, offScreenHeight: number) {
this.canvas = document.createElement('canvas');
const offscreenCanvas = document.createElement('canvas');
// Set canvas styles
this.canvas.style.position = 'absolute';
this.canvas.style.top = '0';
this.canvas.style.left = '0';
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
this.canvas.style.zIndex = '-1';
offscreenCanvas.width = offScreenWidth;
offscreenCanvas.height = offScreenHeight;
this.canvas.width = width;
this.canvas.height = height;
const offscreenCtx = offscreenCanvas.getContext('2d', {
willReadFrequently: true,
});
const ctx = this.canvas.getContext('2d');
if (!ctx || !offscreenCtx) throw new Error('Could not get context');
ctx.imageSmoothingEnabled = this.imageSmoothing;
this.appendChild(this.canvas);
return { ctx, offscreenCtx, offscreenCanvas };
}
handleGeometryUpdate = (event: Event) => {
const geometry = event.target as HTMLElement;
const shapeId = this.geometryShapeIds.get(geometry);
const rect = geometry.getBoundingClientRect();
const points = [
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.width, y: rect.y },
{ x: rect.x + rect.width, y: rect.y + rect.height },
{ x: rect.x, y: rect.y + rect.height },
];
const transformedPoints = this.transformPoints(points);
if (shapeId) {
this.worker.postMessage({
type: 'updateShape',
data: { id: shapeId, points: transformedPoints },
});
} else {
const newId = crypto.randomUUID();
this.geometryShapeIds.set(geometry, newId);
this.worker.postMessage({
type: 'addShape',
data: { id: newId, points: transformedPoints },
});
}
this.renderDistanceField();
};
private transformPoints(points: Vector2[]): Vector2[] {
return points.map((point) => this.transformToFieldCoordinates(point));
}
}

View File

@ -1,40 +0,0 @@
/// <reference lib="webworker" />
import { Fields } from './fields.ts';
declare const self: DedicatedWorkerGlobalScope;
// Initialize the Fields instance
let fields: Fields;
// Listen for messages from the main thread
self.onmessage = (event) => {
const { type, data } = event.data;
switch (type) {
case 'initialize':
fields = new Fields(data.resolution);
break;
case 'addShape':
fields.addShape(data.id, data.points, data.color);
break;
case 'removeShape':
fields.removeShape(data.id);
break;
case 'updateShape':
fields.updateShape(data.id, data.points);
break;
case 'generateImageData': {
const imageData = fields.generateImageData();
// Post the ImageData back to the main thread
postMessage({ type: 'imageData', imageData }, [imageData.data.buffer]);
break;
}
default:
console.warn(`Unknown message type: ${type}`);
}
};

View File

@ -1,36 +0,0 @@
import { findHullParabolas, transpose } from './utils.ts';
import type { Vector2 } from '../utils/Vector2.ts';
// TODO: test performance of non-square sedt
export function computeEDT(sedt: Float32Array[]): Float32Array[] {
for (let row = 0; row < sedt.length; row++) {
horizontalPass(sedt[row]);
}
transpose(sedt);
for (let row = 0; row < sedt.length; row++) {
horizontalPass(sedt[row]);
}
transpose(sedt);
return sedt.map((row) => row.map(Math.sqrt));
}
function horizontalPass(singleRow: Float32Array) {
const hullVertices: Vector2[] = [];
const hullIntersections: Vector2[] = [];
findHullParabolas(singleRow, hullVertices, hullIntersections);
marchParabolas(singleRow, hullVertices, hullIntersections);
}
function marchParabolas(row: Float32Array, verts: Vector2[], intersections: Vector2[]) {
let k = 0;
for (let i = 0; i < row.length; i++) {
while (intersections[k + 1].x < i) {
k++;
}
const dx = i - verts[k].x;
row[i] = dx * dx + verts[k].y;
}
}

View File

@ -1,217 +0,0 @@
import type { Vector2 } from '../utils/Vector2.ts';
import { computeCPT } from './cpt.ts';
interface Shape {
id: string;
points: Vector2[];
color: number;
}
export class Fields {
private edt: Float32Array[] = [];
private cpt: Vector2[][] = [];
private colorField: Float32Array[] = [];
private xcoords: Float32Array[] = [];
private ycoords: Float32Array[] = [];
private resolution: number;
private shapes: Map<string, Shape> = new Map();
constructor(resolution: number) {
this.resolution = resolution + 1;
this.initializeArrays();
}
private initializeArrays() {
this.edt = new Array(this.resolution).fill(Infinity).map(() => new Float32Array(this.resolution).fill(Infinity));
this.colorField = new Array(this.resolution).fill(0).map(() => new Float32Array(this.resolution).fill(0));
this.xcoords = Array.from({ length: this.resolution }, () => new Float32Array(this.resolution).fill(0));
this.ycoords = Array.from({ length: this.resolution }, () => new Float32Array(this.resolution).fill(0));
this.cpt = Array.from({ length: this.resolution }, () =>
Array.from({ length: this.resolution }, () => ({ x: 0, y: 0 }))
);
}
// Public getters for field data
getDistance(row: number, col: number): number {
return this.edt[row][col];
}
getColor(row: number, col: number): number {
const { x, y } = this.cpt[row][col];
return this.colorField[x][y];
}
addShape(id: string, points: Vector2[], color?: number) {
const shapeColor = color ?? Math.floor(Math.random() * 255);
this.shapes.set(id, { id, points, color: shapeColor });
this.updateFields();
}
removeShape(id: string) {
if (this.shapes.delete(id)) {
this.updateFields();
}
}
updateShape(id: string, points: Vector2[]) {
const shape = this.shapes.get(id);
if (shape) {
shape.points = points;
this.updateFields();
}
}
updateFields() {
this.boolifyFields(this.edt, this.colorField);
this.cpt = computeCPT(this.edt, this.cpt, this.xcoords, this.ycoords);
this.deriveEDTfromCPT();
}
deriveEDTfromCPT() {
for (let x = 0; x < this.resolution; x++) {
for (let y = 0; y < this.resolution; y++) {
const { x: closestX, y: closestY } = this.cpt[y][x];
const distance = Math.sqrt((x - closestX) ** 2 + (y - closestY) ** 2);
this.edt[y][x] = distance;
}
}
}
private boolifyFields(distanceField: Float32Array[], colorField: Float32Array[]): void {
const LARGE_NUMBER = 1000000000000;
const size = distanceField.length;
const cellSize = 1;
for (let x = 0; x < size; x++) {
for (let y = 0; y < size; y++) {
distanceField[x][y] = LARGE_NUMBER;
colorField[y][x] = 0;
}
}
const drawLine = (start: Vector2, end: Vector2, color: number) => {
const startCell = {
x: Math.floor(start.x / cellSize),
y: Math.floor(start.y / cellSize),
};
const endCell = {
x: Math.floor(end.x / cellSize),
y: Math.floor(end.y / cellSize),
};
if (startCell.x < 0 || startCell.x >= size || startCell.y < 0 || startCell.y >= size) {
return;
}
if (endCell.x < 0 || endCell.x >= size || endCell.y < 0 || endCell.y >= size) {
return;
}
if (startCell.x === endCell.x && startCell.y === endCell.y) {
distanceField[startCell.x][startCell.y] = 0;
colorField[startCell.y][startCell.x] = color;
return;
}
let x0 = startCell.x;
let y0 = startCell.y;
const x1 = endCell.x;
const y1 = endCell.y;
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
while (true) {
distanceField[x0][y0] = 0;
colorField[y0][x0] = color;
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
};
for (const shape of this.shapes.values()) {
const { points, color } = shape;
for (let i = 0; i < points.length; i++) {
const start = points[i];
const end = points[(i + 1) % points.length];
drawLine(start, end, color);
}
}
}
public generateImageData(): ImageData {
const imageData = new ImageData(this.resolution, this.resolution);
const data = imageData.data;
const resolution = this.resolution;
const edt = this.edt;
const cpt = this.cpt;
const colorField = this.colorField;
// Pre-calculate color lookup table
const colorLookup = new Float32Array(256 * 3);
for (let i = 0; i < 256; i++) {
colorLookup[i * 3] = (i * 7) % 256; // r
colorLookup[i * 3 + 1] = (i * 13) % 256; // g
colorLookup[i * 3 + 2] = (i * 19) % 256; // b
}
// Unrolled inner loop with direct calculations
const len = resolution * resolution;
for (let i = 0; i < len; i++) {
const row = i % resolution;
const col = (i / resolution) | 0;
const index = i * 4;
const distance = edt[row][col];
const { x: closestX, y: closestY } = cpt[row][col];
const shapeColor = colorField[closestX][closestY];
// Direct color calculation without function call
const normalizedDistance = Math.sqrt(distance) / 10;
const colorIndex = shapeColor * 3;
const factor = 1 - normalizedDistance;
data[index] = colorLookup[colorIndex] * factor; // r
data[index + 1] = colorLookup[colorIndex + 1] * factor; // g
data[index + 2] = colorLookup[colorIndex + 2] * factor; // b
data[index + 3] = 255;
}
return imageData;
}
}
const ColorMap = {
simple: (d: number) => {
return { r: 250 - d * 2, g: 250 - d * 5, b: 250 - d * 3 };
},
modulo: (d: number) => {
const period = 18;
const modulo = d % period;
return { r: modulo * period, g: (modulo * period) / 3, b: (modulo * period) / 2 };
},
grayscale: (d: number) => {
const value = 255 - Math.abs(d) * 10;
return { r: value, g: value, b: value };
},
heatmap: (d: number) => {
const value = Math.min(255, Math.max(0, 255 - Math.abs(d) * 10));
return { r: value, g: 0, b: 255 - value };
},
inverted: (d: number) => {
const value = Math.abs(d) % 255;
return { r: 255 - value, g: 255 - value, b: 255 - value };
},
rainbow: (d: number) => {
const value = Math.abs(d) % 255;
return { r: (value * 5) % 255, g: (value * 3) % 255, b: (value * 7) % 255 };
},
};

19
src/utils/tags.ts Normal file
View File

@ -0,0 +1,19 @@
export function glsl(strings: TemplateStringsArray) {
return strings[0];
}
export function vert(strings: TemplateStringsArray) {
return strings[0];
}
export function frag(strings: TemplateStringsArray) {
return strings[0];
}
export function css(strings: TemplateStringsArray) {
return strings[0];
}
export function html(strings: TemplateStringsArray) {
return strings[0];
}