Drive Huginn stardust from packed art channels
This commit is contained in:
parent
8b36467c09
commit
e3f0d8ffaf
5 changed files with 47 additions and 124 deletions
11
README.md
11
README.md
|
|
@ -72,10 +72,13 @@ intersects the current screen bounds with the artwork bounds, and assigns only
|
|||
visible quadtree cells to those slots. Each visible cell is seeded with the
|
||||
Aetheria Stardust `RandomFirst` hash and xorshift follow-up sequence, then each
|
||||
particle pair trades opacity with sine/cosine phase while tracing a short
|
||||
segment through the packed flow texture. Zooming raises the live quadtree level;
|
||||
coarse cells fade down while finer cells seeded from the same spatial lattice
|
||||
fade in around them, so detail density rises without changing the field's
|
||||
identity.
|
||||
segment through the packed flow texture. The packed map uses `RG` for flow, `B`
|
||||
for structure/curvature/detail pressure, and `A` for emission/field strength;
|
||||
particle color comes from the source Huginn albedo at the starting UV so the
|
||||
flow carries brushstrokes instead of repainting them at the final sample.
|
||||
Zooming raises the live quadtree level; coarse cells fade down while finer cells
|
||||
seeded from the same spatial lattice fade in around them, so detail density
|
||||
rises without changing the field's identity.
|
||||
|
||||
## Example
|
||||
|
||||
|
|
|
|||
|
|
@ -314,14 +314,18 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) {
|
|||
);
|
||||
let sourceAlbedo = sampleAlbedo(base);
|
||||
|
||||
var packed = samplePacked(base);
|
||||
let startPacked = samplePacked(base);
|
||||
var packed = startPacked;
|
||||
let levelDetail = f32(level) / f32(${STARDUST_MAX_LEVEL}u);
|
||||
let detail = clamp(uniforms.detail, 0.75, 2.0);
|
||||
let fieldVector = packed.rg * 2.0 - vec2f(1.0);
|
||||
let fieldVector = startPacked.rg * 2.0 - vec2f(1.0);
|
||||
let fieldDirection = normalize(fieldVector + vec2f(0.0001, 0.0));
|
||||
let curvatureGradient = channelGradient(base, 2u);
|
||||
let tangent = normalize(vec2f(-curvatureGradient.y, curvatureGradient.x) + fieldDirection * 0.28 + vec2f(0.0001, 0.0));
|
||||
let fieldBlend = normalize(fieldDirection * 0.62 + tangent * (0.32 + packed.b * 0.22) + triFlow(base * 18.0 - vec2f(0.0, uniforms.time * 0.035), triSeed) * 0.22);
|
||||
let sourceStructure = startPacked.b;
|
||||
let sourceEmission = startPacked.a;
|
||||
let domainNoise = triFlow(base * (14.0 + sourceStructure * 18.0) - vec2f(0.0, uniforms.time * (0.026 + sourceEmission * 0.018)), triSeed);
|
||||
let fieldBlend = normalize(fieldDirection * (0.56 + sourceEmission * 0.18) + tangent * (0.26 + sourceStructure * 0.36) + domainNoise * (0.13 + sourceStructure * 0.27));
|
||||
var nodePush = vec2f(0.0);
|
||||
var nodeHeat = 0.0;
|
||||
for (var envelopeIndex = 0u; envelopeIndex < ${MAX_NODE_ENVELOPES}u; envelopeIndex = envelopeIndex + 1u) {
|
||||
|
|
@ -342,30 +346,36 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) {
|
|||
}
|
||||
|
||||
let flowLine = normalize(fieldBlend + nodePush);
|
||||
var uv = clamp(base - flowLine * lifetime * (uniforms.flowGain / 24.0) * (0.009 + packed.a * 0.028 + packed.b * 0.012) * spacing, vec2f(0.001), vec2f(0.999));
|
||||
var uv = clamp(base - flowLine * lifetime * (uniforms.flowGain / 24.0) * (0.009 + sourceEmission * 0.030 + sourceStructure * 0.016) * spacing, vec2f(0.001), vec2f(0.999));
|
||||
packed = samplePacked(uv);
|
||||
|
||||
particle.position = uv;
|
||||
particle.velocity = flowLine * (4.0 + packed.a * 9.0 + packed.b * 4.0);
|
||||
let advectedFlow = packed.rg * 2.0 - vec2f(1.0);
|
||||
let advectedShear = length(advectedFlow - fieldVector);
|
||||
let emission = clamp(sourceEmission * 0.42 + packed.a * 0.78, 0.0, 1.0);
|
||||
let structure = clamp(sourceStructure * 0.52 + packed.b * 0.62, 0.0, 1.0);
|
||||
particle.velocity = flowLine * (3.2 + emission * 10.5 + structure * 5.4 + advectedShear * 2.2);
|
||||
particle.life = lifetime;
|
||||
|
||||
let flowAxis = abs(fieldVector.x - fieldVector.y);
|
||||
let lifeFade = parabola(lifetime, 2.0);
|
||||
let phase = lifetime * 6.28318530718 + f32(companion) * 1.57079632679;
|
||||
let pairFade = pow(clamp(sin(phase) * 0.5 + 0.5, 0.0, 1.0), 1.35);
|
||||
let heat = clamp(packed.a * 0.78 + packed.b * 0.62 + nodeHeat * 0.38 + lifeFade * 0.18, 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);
|
||||
let deep = vec3f(0.02, 0.10, 0.12);
|
||||
let albedoLift = mix(deep, sourceAlbedo.rgb, clamp(sourceAlbedo.a * (0.72 + heat * 0.24), 0.0, 1.0));
|
||||
let fieldTint = mix(cyan, gold, brass * 0.26);
|
||||
let rgb = mix(albedoLift, fieldTint, 0.18 + heat * 0.16);
|
||||
let heat = clamp(emission * 0.82 + structure * 0.48 + advectedShear * 0.22 + nodeHeat * 0.38 + lifeFade * 0.16, 0.0, 1.0);
|
||||
let brass = clamp((structure - emission * 0.22) * 1.22 + flowAxis * 0.22, 0.0, 1.0);
|
||||
let cyan = vec3f(0.07 + emission * 0.24, 0.40 + heat * 0.50, 0.52 + emission * 0.72);
|
||||
let gold = vec3f(0.96, 0.65 + structure * 0.14, 0.24);
|
||||
let violet = vec3f(0.40 + sourceAlbedo.r * 0.18, 0.14 + sourceAlbedo.g * 0.08, 0.54 + sourceAlbedo.b * 0.24);
|
||||
let deep = vec3f(0.012, 0.052, 0.065);
|
||||
let albedoLift = mix(deep, sourceAlbedo.rgb, clamp(sourceAlbedo.a * (0.64 + emission * 0.30 + structure * 0.16), 0.0, 1.0));
|
||||
let fieldTint = mix(mix(cyan, gold, brass * 0.34), violet, clamp(sourceStructure * (1.0 - sourceEmission) * 0.28, 0.0, 0.28));
|
||||
let emissiveInk = sourceAlbedo.rgb * (0.25 + emission * 0.72) + fieldTint * (0.20 + structure * 0.22);
|
||||
let rgb = mix(albedoLift, emissiveInk, clamp(0.16 + heat * 0.28 + sourceAlbedo.a * 0.18, 0.0, 0.74));
|
||||
particle.color = vec4f(
|
||||
rgb,
|
||||
uniforms.alpha * (0.003 + heat * 0.010 + packed.a * 0.005 + nodeHeat * 0.0025) * lifeFade * pairFade * band.weight * (0.72 + levelDetail * 0.28)
|
||||
uniforms.alpha * (0.0022 + emission * 0.013 + structure * 0.006 + advectedShear * 0.003 + nodeHeat * 0.0025) * lifeFade * pairFade * band.weight * (0.72 + levelDetail * 0.28)
|
||||
);
|
||||
particle.size = mix(0.24, 0.92, sizeRandom) * (0.7 + packed.a * 0.7 + packed.b * 0.28 + nodeHeat * 0.14) * (0.92 + detail * 0.035) * (0.82 + levelDetail * 0.22) * lifeFade * (0.35 + pairFade * 0.65) * (0.82 + band.weight * 0.18);
|
||||
particle.size = mix(0.20, 0.98, sizeRandom) * (0.62 + emission * 0.82 + structure * 0.36 + advectedShear * 0.18 + nodeHeat * 0.14) * (0.92 + detail * 0.035) * (0.82 + levelDetail * 0.22) * lifeFade * (0.35 + pairFade * 0.65) * (0.82 + band.weight * 0.18);
|
||||
particles[index] = particle;
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState, type TerrainForceContext, type TerrainForceSample, type ViewerSelection } from "@epiphanygraph/epiphany-graph-viewer";
|
||||
import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState, 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";
|
||||
import { Component, useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||
|
||||
import { inspectCultCacheBytes, type CultCacheInspection, type InspectedCatalogEntry, type InspectedRecord } from "../../src/cult-cache-inspector";
|
||||
import { HuginnFieldCanvas } from "./HuginnFieldCanvas";
|
||||
|
|
@ -22,12 +22,6 @@ type GraphProjection = {
|
|||
truncatedValueNodes: number;
|
||||
};
|
||||
|
||||
type TerrainTexture = {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8ClampedArray;
|
||||
};
|
||||
|
||||
const MAX_EXPANDED_VALUE_NODES = 960;
|
||||
const assetPath = (name: string) => `${import.meta.env.BASE_URL}${name}`;
|
||||
const HUGINN_ART = {
|
||||
|
|
@ -279,7 +273,6 @@ function InspectionView({
|
|||
setSelection: (selection: Selection) => void;
|
||||
onInspectFile: (file: File) => Promise<void>;
|
||||
}) {
|
||||
const terrainForces = useHuginnTerrainForces(HUGINN_ART.field);
|
||||
const expandedRawSelection = graphSelection?.kind === "node"
|
||||
? graphProjection.nodeSelections.get(graphSelection.nodeId)
|
||||
: graphSelection?.kind === "edge"
|
||||
|
|
@ -358,7 +351,16 @@ function InspectionView({
|
|||
title="CultCache Structure"
|
||||
viewportBackdrop={<HuginnField />}
|
||||
viewportBackground="#03070a"
|
||||
terrainForces={terrainForces}
|
||||
layoutMode={{ architecture: "layered", dataflow: "combined-force" }}
|
||||
motion={{
|
||||
strength: 1.24,
|
||||
damping: 0.88,
|
||||
flow: 1.2,
|
||||
orbit: 0.84,
|
||||
lift: 0.9,
|
||||
pulse: 1,
|
||||
emitNodeEnvelopes: true,
|
||||
}}
|
||||
overlayPanels
|
||||
showSidebar={false}
|
||||
focusSelection
|
||||
|
|
@ -401,98 +403,6 @@ 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, 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 = uvX - 0.5;
|
||||
const dy = uvY - 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, uvX, uvY);
|
||||
},
|
||||
}), []);
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "cultcache-ts",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cultcache-ts",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cultcache-ts",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
Loading…
Reference in a new issue