Drive Huginn stardust from packed art channels

This commit is contained in:
Meta Vens 2026-05-19 14:08:05 +01:00
commit e3f0d8ffaf
5 changed files with 47 additions and 124 deletions

View file

@ -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

View file

@ -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;
}
`;

View file

@ -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
View file

@ -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",

View file

@ -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",