Fit Huginn terrain to graph bounds
This commit is contained in:
parent
0d2c749fb3
commit
4e4485c7ba
2 changed files with 83 additions and 27 deletions
|
|
@ -57,6 +57,12 @@ type GraphViewportTransform = {
|
|||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
const gpuParticleComputeShader = /* wgsl */ `
|
||||
|
|
@ -81,9 +87,13 @@ struct SimUniforms {
|
|||
detail: f32,
|
||||
envelopeCount: f32,
|
||||
nodeGain: f32,
|
||||
terrainOffsetX: f32,
|
||||
terrainOffsetY: f32,
|
||||
terrainScale: f32,
|
||||
worldX: f32,
|
||||
worldY: f32,
|
||||
worldWidth: f32,
|
||||
worldHeight: f32,
|
||||
viewX: f32,
|
||||
viewY: f32,
|
||||
viewScale: f32,
|
||||
pad1: f32,
|
||||
};
|
||||
|
||||
|
|
@ -235,9 +245,13 @@ struct SimUniforms {
|
|||
detail: f32,
|
||||
envelopeCount: f32,
|
||||
nodeGain: f32,
|
||||
terrainOffsetX: f32,
|
||||
terrainOffsetY: f32,
|
||||
terrainScale: f32,
|
||||
worldX: f32,
|
||||
worldY: f32,
|
||||
worldWidth: f32,
|
||||
worldHeight: f32,
|
||||
viewX: f32,
|
||||
viewY: f32,
|
||||
viewScale: f32,
|
||||
pad1: f32,
|
||||
};
|
||||
|
||||
|
|
@ -266,11 +280,10 @@ fn vertexMain(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index)
|
|||
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 center = vec2f(renderUniforms.width, renderUniforms.height) * 0.5;
|
||||
let terrainScale = max(renderUniforms.terrainScale, 0.05);
|
||||
let terrainOffset = vec2f(renderUniforms.terrainOffsetX, renderUniforms.terrainOffsetY);
|
||||
let basePosition = (particle.position * vec2f(renderUniforms.width, renderUniforms.height) - center) * terrainScale + center + terrainOffset;
|
||||
let pixelPosition = basePosition + ellipse * sqrt(terrainScale);
|
||||
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);
|
||||
|
|
@ -569,7 +582,7 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam
|
|||
particleBuffer.unmap();
|
||||
|
||||
const uniformBuffer = device.createBuffer({
|
||||
size: 16 * Float32Array.BYTES_PER_ELEMENT,
|
||||
size: 20 * Float32Array.BYTES_PER_ELEMENT,
|
||||
usage: 64 | 8,
|
||||
});
|
||||
const nodeEnvelopeBuffer = device.createBuffer({
|
||||
|
|
@ -581,11 +594,11 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam
|
|||
count: 0,
|
||||
};
|
||||
const transformState: {
|
||||
base: GraphViewportTransform | null;
|
||||
received: boolean;
|
||||
current: GraphViewportTransform;
|
||||
} = {
|
||||
base: null,
|
||||
current: { x: 0, y: 0, scale: 1 },
|
||||
received: false,
|
||||
current: { x: 0, y: 0, scale: 1, bounds: { x: 0, y: 0, width: 1, height: 1 } },
|
||||
};
|
||||
|
||||
const computeShader = device.createShaderModule({ code: gpuParticleComputeShader });
|
||||
|
|
@ -647,8 +660,15 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam
|
|||
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 baseTransform = transformState.base ?? transformState.current;
|
||||
const terrainScale = Math.max(0.18, Math.min(16, transformState.current.scale / Math.max(0.001, baseTransform.scale)));
|
||||
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,
|
||||
|
|
@ -660,9 +680,13 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam
|
|||
detail,
|
||||
nodeEnvelopeState.count,
|
||||
1.12 + detail * 0.18,
|
||||
transformState.current.x - baseTransform.x,
|
||||
transformState.current.y - baseTransform.y,
|
||||
terrainScale,
|
||||
bounds.x,
|
||||
bounds.y,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
graphTransform.x,
|
||||
graphTransform.y,
|
||||
graphTransform.scale,
|
||||
0,
|
||||
]));
|
||||
|
||||
|
|
@ -806,7 +830,7 @@ function attachNodeEnvelopeListener(
|
|||
|
||||
function attachViewportTransformListener(
|
||||
canvas: HTMLCanvasElement,
|
||||
state: { base: GraphViewportTransform | null; current: GraphViewportTransform },
|
||||
state: { received: boolean; current: GraphViewportTransform },
|
||||
): () => void {
|
||||
const target = canvas.parentElement ?? canvas;
|
||||
const onTransform = (event: Event) => {
|
||||
|
|
@ -822,13 +846,39 @@ function attachViewportTransformListener(
|
|||
x: detail.x,
|
||||
y: detail.y,
|
||||
scale: detail.scale,
|
||||
bounds: isGraphBounds(detail.bounds) ? detail.bounds : state.current.bounds,
|
||||
};
|
||||
state.base ??= state.current;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState, type TerrainForceSample, type ViewerSelection } from "@epiphanygraph/epiphany-graph-viewer";
|
||||
import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState, type TerrainForceContext, type TerrainForceSample, type ViewerSelection } from "@epiphanygraph/epiphany-graph-viewer";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Component, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from "react";
|
||||
|
||||
|
|
@ -441,11 +441,17 @@ function useHuginnTerrainForces(fieldUrl: string) {
|
|||
damping: 0.9,
|
||||
envelopeStrength: 0.03,
|
||||
emitNodeEnvelopes: true,
|
||||
sample: (x: number, y: number): TerrainForceSample => {
|
||||
sample: (x: number, y: number, context: TerrainForceContext): TerrainForceSample => {
|
||||
const texture = textureRef.current;
|
||||
const bounds = context.bounds ?? { x: 0, y: 0, width: 1, height: 1 };
|
||||
const worldX = (x * context.viewportWidth - context.viewX) / Math.max(0.001, context.scale);
|
||||
const worldY = (y * context.viewportHeight - context.viewY) / Math.max(0.001, context.scale);
|
||||
const side = Math.max(1, Math.max(bounds.width, bounds.height));
|
||||
const uvX = (worldX - (bounds.x + bounds.width * 0.5 - side * 0.5)) / side;
|
||||
const uvY = (worldY - (bounds.y + bounds.height * 0.5 - side * 0.5)) / side;
|
||||
if (!texture) {
|
||||
const dx = x - 0.5;
|
||||
const dy = y - 0.5;
|
||||
const dx = uvX - 0.5;
|
||||
const dy = uvY - 0.5;
|
||||
const radius = Math.max(0.001, Math.hypot(dx, dy));
|
||||
return {
|
||||
flowX: -dy / radius * 0.35,
|
||||
|
|
@ -454,7 +460,7 @@ function useHuginnTerrainForces(fieldUrl: string) {
|
|||
curvature: 0.25,
|
||||
};
|
||||
}
|
||||
return sampleTerrainTexture(texture, x, y);
|
||||
return sampleTerrainTexture(texture, uvX, uvY);
|
||||
},
|
||||
}), []);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue