distance field JFA/GPU implementation
This commit is contained in:
parent
fdd7fb9d84
commit
1b14970313
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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), 415–428. */
|
||||
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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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];
|
||||
}
|
||||
Loading…
Reference in New Issue