diff --git a/inspector/src/HuginnFieldCanvas.tsx b/inspector/src/HuginnFieldCanvas.tsx index 9ebf53f..d6834a6 100644 --- a/inspector/src/HuginnFieldCanvas.tsx +++ b/inspector/src/HuginnFieldCanvas.tsx @@ -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 particles: array; @group(0) @binding(1) var uniforms: SimUniforms; @group(0) @binding(2) var fieldSampler: sampler; @group(0) @binding(3) var fieldTexture: texture_2d; +@group(0) @binding(4) var nodeEnvelopes: array; 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 renderParticles: array; @@ -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).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; diff --git a/inspector/src/main.tsx b/inspector/src/main.tsx index 01de644..5a5dd1a 100644 --- a/inspector/src/main.tsx +++ b/inspector/src/main.tsx @@ -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; }) { + 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={} viewportBackground="#03070a" + terrainForces={terrainForces} overlayPanels showSidebar={false} focusSelection @@ -409,6 +417,92 @@ function InspectionView({ ); } +function useHuginnTerrainForces(fieldUrl: string) { + const textureRef = useRef(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 { + const image = new Image(); + image.decoding = "async"; + image.src = url; + return image.decode() + .catch(() => new Promise((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 (