Couple Huginn graph to terrain field
This commit is contained in:
parent
211e445374
commit
73ec476118
2 changed files with 182 additions and 7 deletions
|
|
@ -43,6 +43,15 @@ 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;
|
||||
};
|
||||
|
||||
const gpuParticleComputeShader = /* wgsl */ `
|
||||
struct Particle {
|
||||
|
|
@ -64,12 +73,17 @@ struct SimUniforms {
|
|||
flowGain: f32,
|
||||
alpha: f32,
|
||||
detail: f32,
|
||||
envelopeCount: f32,
|
||||
nodeGain: f32,
|
||||
pad0: 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);
|
||||
|
|
@ -132,7 +146,26 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) {
|
|||
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);
|
||||
let velocity = fieldVelocity + turbulence + ridgeSlip + channelPull + jitter * (5.0 + packed.a * 18.0) * detail;
|
||||
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;
|
||||
|
|
@ -157,7 +190,7 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) {
|
|||
|
||||
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, 0.0, 1.0);
|
||||
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);
|
||||
|
|
@ -165,9 +198,9 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) {
|
|||
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) * (0.72 + detail * 0.11)
|
||||
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) * (0.92 + detail * 0.12) * (1.0 - abs(particle.life - 0.5) * 0.42);
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
|
@ -192,6 +225,10 @@ struct SimUniforms {
|
|||
flowGain: f32,
|
||||
alpha: f32,
|
||||
detail: f32,
|
||||
envelopeCount: f32,
|
||||
nodeGain: f32,
|
||||
pad0: f32,
|
||||
pad1: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<storage, read> renderParticles: array<Particle>;
|
||||
|
|
@ -518,9 +555,17 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam
|
|||
particleBuffer.unmap();
|
||||
|
||||
const uniformBuffer = device.createBuffer({
|
||||
size: 8 * Float32Array.BYTES_PER_ELEMENT,
|
||||
size: 12 * 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 computeShader = device.createShaderModule({ code: gpuParticleComputeShader });
|
||||
const renderShader = device.createShaderModule({ code: gpuParticleRenderShader });
|
||||
|
|
@ -545,6 +590,7 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam
|
|||
{ binding: 1, resource: { buffer: uniformBuffer } },
|
||||
{ binding: 2, resource: sampler },
|
||||
{ binding: 3, resource: fieldTexture.createView() },
|
||||
{ binding: 4, resource: { buffer: nodeEnvelopeBuffer } },
|
||||
],
|
||||
});
|
||||
const renderBindGroup = device.createBindGroup({
|
||||
|
|
@ -567,6 +613,7 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam
|
|||
const detailController = attachDetailController(canvas, (nextDetail) => {
|
||||
targetDetail = nextDetail;
|
||||
});
|
||||
const envelopeListener = attachNodeEnvelopeListener(canvas, nodeEnvelopeState, device, nodeEnvelopeBuffer);
|
||||
|
||||
const frame = (time: number) => {
|
||||
if (stopped) {
|
||||
|
|
@ -587,6 +634,10 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam
|
|||
92 + detail * 62,
|
||||
0.76,
|
||||
detail,
|
||||
nodeEnvelopeState.count,
|
||||
1.12 + detail * 0.18,
|
||||
0,
|
||||
0,
|
||||
]));
|
||||
|
||||
const encoder = device.createCommandEncoder();
|
||||
|
|
@ -618,8 +669,10 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam
|
|||
cancelAnimationFrame(frameId);
|
||||
observer.disconnect();
|
||||
detailController();
|
||||
envelopeListener();
|
||||
particleBuffer.destroy();
|
||||
uniformBuffer.destroy();
|
||||
nodeEnvelopeBuffer.destroy();
|
||||
fieldTexture.destroy();
|
||||
};
|
||||
}
|
||||
|
|
@ -696,6 +749,34 @@ function attachDetailController(canvas: HTMLCanvasElement, setDetail: (detail: n
|
|||
};
|
||||
}
|
||||
|
||||
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 resizeGpuCanvas(canvas: HTMLCanvasElement): void {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState, type ViewerSelection } from "@epiphanygraph/epiphany-graph-viewer";
|
||||
import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState, type TerrainForceSample, type ViewerSelection } from "@epiphanygraph/epiphany-graph-viewer";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Component, useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||
import { Component, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from "react";
|
||||
|
||||
import { inspectCultCacheBytes, type CultCacheInspection, type InspectedCatalogEntry, type InspectedRecord } from "../../src/cult-cache-inspector";
|
||||
import { HuginnFieldCanvas } from "./HuginnFieldCanvas";
|
||||
|
|
@ -24,6 +24,12 @@ type GraphProjection = {
|
|||
truncatedValueNodes: number;
|
||||
};
|
||||
|
||||
type TerrainTexture = {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8ClampedArray;
|
||||
};
|
||||
|
||||
const MAX_EXPANDED_VALUE_NODES = 320;
|
||||
const assetPath = (name: string) => `${import.meta.env.BASE_URL}${name}`;
|
||||
const HUGINN_ART = {
|
||||
|
|
@ -272,6 +278,7 @@ function InspectionView({
|
|||
setSelection: (selection: Selection) => void;
|
||||
onInspectFile: (file: File) => Promise<void>;
|
||||
}) {
|
||||
const terrainForces = useHuginnTerrainForces(HUGINN_ART.field);
|
||||
const record = inspection.records[selection.record];
|
||||
const catalogEntry = inspection.catalog[selection.catalog];
|
||||
const expandedNode = graphSelection?.kind === "node"
|
||||
|
|
@ -338,6 +345,7 @@ function InspectionView({
|
|||
title="CultCache Structure"
|
||||
viewportBackdrop={<HuginnField />}
|
||||
viewportBackground="#03070a"
|
||||
terrainForces={terrainForces}
|
||||
overlayPanels
|
||||
showSidebar={false}
|
||||
focusSelection
|
||||
|
|
@ -409,6 +417,92 @@ function InspectionView({
|
|||
);
|
||||
}
|
||||
|
||||
function useHuginnTerrainForces(fieldUrl: string) {
|
||||
const textureRef = useRef<TerrainTexture | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadTerrainTexture(fieldUrl)
|
||||
.then((texture) => {
|
||||
if (!cancelled) {
|
||||
textureRef.current = texture;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
textureRef.current = null;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fieldUrl]);
|
||||
|
||||
return useMemo(() => ({
|
||||
strength: 1.35,
|
||||
damping: 0.9,
|
||||
envelopeStrength: 0.03,
|
||||
emitNodeEnvelopes: true,
|
||||
sample: (x: number, y: number): TerrainForceSample => {
|
||||
const texture = textureRef.current;
|
||||
if (!texture) {
|
||||
const dx = x - 0.5;
|
||||
const dy = y - 0.5;
|
||||
const radius = Math.max(0.001, Math.hypot(dx, dy));
|
||||
return {
|
||||
flowX: -dy / radius * 0.35,
|
||||
flowY: dx / radius * 0.35,
|
||||
strength: Math.max(0, 1 - radius * 2),
|
||||
curvature: 0.25,
|
||||
};
|
||||
}
|
||||
return sampleTerrainTexture(texture, x, y);
|
||||
},
|
||||
}), []);
|
||||
}
|
||||
|
||||
function loadTerrainTexture(url: string): Promise<TerrainTexture> {
|
||||
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(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) {
|
||||
throw new Error("Canvas 2D context unavailable.");
|
||||
}
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = "high";
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
rgba: context.getImageData(0, 0, canvas.width, canvas.height).data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sampleTerrainTexture(texture: TerrainTexture, x: number, y: number): TerrainForceSample {
|
||||
const sx = Math.max(0, Math.min(texture.width - 1, Math.floor(x * texture.width)));
|
||||
const sy = Math.max(0, Math.min(texture.height - 1, Math.floor(y * texture.height)));
|
||||
const index = (sy * texture.width + sx) * 4;
|
||||
return {
|
||||
flowX: texture.rgba[index] / 255 * 2 - 1,
|
||||
flowY: texture.rgba[index + 1] / 255 * 2 - 1,
|
||||
curvature: texture.rgba[index + 2] / 255,
|
||||
strength: texture.rgba[index + 3] / 255,
|
||||
};
|
||||
}
|
||||
|
||||
function PanelHeader({ title, count }: { title: string; count: string }) {
|
||||
return (
|
||||
<header>
|
||||
|
|
|
|||
Loading…
Reference in a new issue