diff --git a/README.md b/README.md index 898b97d..8d9fd35 100644 --- a/README.md +++ b/README.md @@ -38,47 +38,34 @@ CultCache files may use the `.cc` extension. The bytes are still the canonical `cultcache.store.v1` MessagePack snapshot; the extension is the human handle, not a second format. -Run the local inspector during development: +Huginn is the runtime steward for `.cc` and Persona-state inspection. VoidBot may +carry compatibility MCP calls while the swarm is still wired that way, but +inspection ownership lives here: schema catalogs, persisted envelopes, decoded +payload previews, read-only state traversal, and the Eve DSL that lets any +runtime render those facts without becoming the state owner. -```sh -npm run dev:inspector -``` +Huginn does not ship an Electron app, React renderer, Vite dashboard, or local +presentation runtime. That was the wrong owner. Huginn speaks CultMesh and Eve: +it inspects bytes, emits Eve DSL, and lets the consuming runtime lower that DSL +into browser, native, overlay, or other local UI. -Build the Vite inspector bundle: +`inspectCultCacheBytes(...)` reads `.cc`, `.msgpack`, or `.mpack` bytes and +returns the snapshot header, schema catalog, records, and decoded MessagePack +payload previews without registering application schemas. -```sh -npm run build:inspector -``` +`buildHuginnEveDsl(...)` lowers that inspection into Eve DSL for provider id +`cultcache.huginn.inspector`. The `.cc` bytes remain authority; the Eve surface +compiled from the DSL is a read-only operator witness: -Build the desktop package: +- source file path, format, and byte count; +- schema catalog entries, versions, ids, and member counts; +- persisted records, schema names, timestamps, payload byte counts, and decode + status. -```sh -npm run dist:inspector -``` - -Release builds must carry a new semantic version before any deployable artifact -is produced. While the package is pre-1.0, breaking public behavior increments -the minor version, compatible fixes increment the patch version, and the -generated Huginn executable must use that package version in its filename. - -Huginn is read-only. Drop a `.cc`, `.msgpack`, or `.mpack` file onto the window to -inspect the snapshot header, schema catalog, records, decoded MessagePack -payload previews, and an Norn graph cloud of the file's structured payload data -without registering application schemas. - -Huginn's background field uses the graph node AABB as its single world-space -authority. The WebGPU particle pass keeps a million particle slots available, -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. 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. +The next runtime cut is a Huginn CultMesh provider that publishes the same Eve +DSL directly so Eve clients can browse `.cc` files as dashboards from inside +their own runtime instead of asking VoidBot to impersonate a state inspector over +MCP. ## Example diff --git a/dist/huginn-eve-dsl.d.ts b/dist/huginn-eve-dsl.d.ts new file mode 100644 index 0000000..68542d7 --- /dev/null +++ b/dist/huginn-eve-dsl.d.ts @@ -0,0 +1,3 @@ +import type { CultCacheInspection } from "./cult-cache-inspector"; +export declare function buildHuginnEveDsl(inspection: CultCacheInspection): string; +//# sourceMappingURL=huginn-eve-dsl.d.ts.map \ No newline at end of file diff --git a/dist/huginn-eve-dsl.d.ts.map b/dist/huginn-eve-dsl.d.ts.map new file mode 100644 index 0000000..0e3337c --- /dev/null +++ b/dist/huginn-eve-dsl.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"huginn-eve-dsl.d.ts","sourceRoot":"","sources":["../src/huginn-eve-dsl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAA0C,MAAM,wBAAwB,CAAC;AAE1G,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,mBAAmB,GAAG,MAAM,CA8BzE"} \ No newline at end of file diff --git a/dist/huginn-eve-dsl.js b/dist/huginn-eve-dsl.js new file mode 100644 index 0000000..2bbb10a --- /dev/null +++ b/dist/huginn-eve-dsl.js @@ -0,0 +1,55 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildHuginnEveDsl = buildHuginnEveDsl; +function buildHuginnEveDsl(inspection) { + return [ + "# Huginn emits Eve DSL. Renderers compile and lower it.", + `surface cultcache.huginn.inspector ${quoteDsl("Huginn .cc Inspector")}`, + "version 1", + "", + "collection huginn.catalog.entries", + ...inspection.catalog.map((entry) => `item huginn.catalog.entries ${quoteDsl(renderCatalogEntry(entry))}`), + "", + "collection huginn.records.entries", + ...inspection.records.map((record) => `item huginn.records.entries ${quoteDsl(renderRecord(record))}`), + "", + "card source \"Source Witness\"", + `text ${quoteDsl(`file: ${inspection.filePath}`)}`, + `text ${quoteDsl(`format: ${inspection.format}`)}`, + `text ${quoteDsl(`bytes: ${inspection.fileSizeBytes}`)}`, + "text \".cc bytes are authority; Huginn only emits Eve DSL.\"", + "end", + "", + "card catalog \"Schema Catalog\"", + `text ${quoteDsl(`schemas: ${inspection.catalog.length}`)}`, + "list \"Catalog Entries\" bind huginn.catalog.entries", + "end", + "", + "card records \"Persisted Records\"", + `text ${quoteDsl(`records: ${inspection.records.length}`)}`, + "list \"Record Entries\" bind huginn.records.entries", + "end", + "", + ].join("\n"); +} +function renderCatalogEntry(entry) { + return [ + entry.schemaName, + entry.schemaVersion, + entry.schemaId, + `${entry.members.length} members`, + ].join(" | "); +} +function renderRecord(record) { + const status = record.payloadDecodeError ? `decode error: ${record.payloadDecodeError}` : "decoded"; + return [ + record.key, + record.schemaName, + record.storedAt, + `${record.payloadBytes} bytes`, + status, + ].join(" | "); +} +function quoteDsl(value) { + return JSON.stringify(value); +} diff --git a/dist/index.d.ts b/dist/index.d.ts index 0eb9738..6c83082 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,4 +1,5 @@ export { CultCache, CultCacheBuilder } from "./cult-cache"; +export { buildHuginnEveDsl } from "./huginn-eve-dsl"; export { inspectCultCacheBytes } from "./cult-cache-inspector"; export { defineDocumentRegistry, defineDocumentType } from "./document"; export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store"; diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index e22edeb..4f9d49f 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,iCAAiC,EAAE,MAAM,yCAAyC,CAAC;AAC5F,YAAY,EACV,8BAA8B,EAC9B,iBAAiB,EACjB,yBAAyB,EACzB,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,EAC1B,gCAAgC,EAChC,yBAAyB,EACzB,2BAA2B,EAC3B,4BAA4B,EAC5B,eAAe,EACf,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,yBAAyB,EACzB,0BAA0B,EAC1B,cAAc,GACf,MAAM,SAAS,CAAC;AACjB,YAAY,EACV,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,eAAe,GAChB,MAAM,wBAAwB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,iCAAiC,EAAE,MAAM,yCAAyC,CAAC;AAC5F,YAAY,EACV,8BAA8B,EAC9B,iBAAiB,EACjB,yBAAyB,EACzB,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,EAC1B,gCAAgC,EAChC,yBAAyB,EACzB,2BAA2B,EAC3B,4BAA4B,EAC5B,eAAe,EACf,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,yBAAyB,EACzB,0BAA0B,EAC1B,cAAc,GACf,MAAM,SAAS,CAAC;AACjB,YAAY,EACV,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,eAAe,GAChB,MAAM,wBAAwB,CAAC"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 3a8cee7..ff219c1 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,9 +1,11 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.SingleFileMessagePackBackingStore = exports.defineDocumentType = exports.defineDocumentRegistry = exports.inspectCultCacheBytes = exports.CultCacheBuilder = exports.CultCache = void 0; +exports.SingleFileMessagePackBackingStore = exports.defineDocumentType = exports.defineDocumentRegistry = exports.inspectCultCacheBytes = exports.buildHuginnEveDsl = exports.CultCacheBuilder = exports.CultCache = void 0; var cult_cache_1 = require("./cult-cache"); Object.defineProperty(exports, "CultCache", { enumerable: true, get: function () { return cult_cache_1.CultCache; } }); Object.defineProperty(exports, "CultCacheBuilder", { enumerable: true, get: function () { return cult_cache_1.CultCacheBuilder; } }); +var huginn_eve_dsl_1 = require("./huginn-eve-dsl"); +Object.defineProperty(exports, "buildHuginnEveDsl", { enumerable: true, get: function () { return huginn_eve_dsl_1.buildHuginnEveDsl; } }); var cult_cache_inspector_1 = require("./cult-cache-inspector"); Object.defineProperty(exports, "inspectCultCacheBytes", { enumerable: true, get: function () { return cult_cache_inspector_1.inspectCultCacheBytes; } }); var document_1 = require("./document"); diff --git a/electron-builder.json b/electron-builder.json deleted file mode 100644 index 28df4bf..0000000 --- a/electron-builder.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "appId": "com.gamecult.cultcache.inspector", - "productName": "Huginn", - "executableName": "Huginn", - "asar": false, - "icon": "inspector/public/hugin.ico", - "extraMetadata": { - "main": "electron/main.cjs" - }, - "files": [ - "electron/main.cjs", - "dist-inspector/**/*" - ], - "directories": { - "output": "release-inspector" - }, - "win": { - "signAndEditExecutable": false, - "target": ["portable"], - "artifactName": "Huginn-${version}.${ext}" - }, - "mac": { - "target": ["dir"] - }, - "linux": { - "target": ["dir"] - } -} diff --git a/electron/main.cjs b/electron/main.cjs deleted file mode 100644 index 2cf76b6..0000000 --- a/electron/main.cjs +++ /dev/null @@ -1,72 +0,0 @@ -const { app, BrowserWindow } = require("electron"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); - -const devServer = process.env.CULTCACHE_INSPECTOR_DEV_SERVER; -app.setName("Huginn"); -const logPath = path.join(app.getPath("userData"), "huginn-renderer.log"); - -function writeLog(message) { - const line = `[${new Date().toISOString()}] ${message}${os.EOL}`; - try { - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.appendFileSync(logPath, line); - } catch { - // Logging must never be the reason the inspector refuses to open. - } - console.log(line.trimEnd()); -} - -function createWindow() { - const window = new BrowserWindow({ - width: 1180, - height: 780, - minWidth: 820, - minHeight: 560, - title: "Huginn", - icon: path.join(__dirname, "..", "dist-inspector", "hugin-64.png"), - backgroundColor: "#05090d", - autoHideMenuBar: true, - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - window.webContents.on("console-message", (_event, level, message, line, sourceId) => { - writeLog(`renderer console[${level}] ${sourceId}:${line} ${message}`); - }); - window.webContents.on("did-fail-load", (_event, code, description, validatedUrl) => { - writeLog(`renderer failed load ${code} ${description} ${validatedUrl}`); - }); - window.webContents.on("render-process-gone", (_event, details) => { - writeLog(`renderer process gone ${details.reason} exitCode=${details.exitCode}`); - }); - window.webContents.on("did-finish-load", () => { - writeLog(`renderer loaded ${window.webContents.getURL()}`); - }); - - if (devServer) { - void window.loadURL(devServer); - } else { - void window.loadFile(path.join(__dirname, "..", "dist-inspector", "index.html")); - } -} - -app.whenReady().then(() => { - createWindow(); - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); -}); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); diff --git a/inspector/index.html b/inspector/index.html deleted file mode 100644 index 73efecc..0000000 --- a/inspector/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Huginn - - -
- - - diff --git a/inspector/public/hugin-256.png b/inspector/public/hugin-256.png deleted file mode 100644 index 4ff5743..0000000 Binary files a/inspector/public/hugin-256.png and /dev/null differ diff --git a/inspector/public/hugin-64.png b/inspector/public/hugin-64.png deleted file mode 100644 index 9437fb0..0000000 Binary files a/inspector/public/hugin-64.png and /dev/null differ diff --git a/inspector/public/hugin.ico b/inspector/public/hugin.ico deleted file mode 100644 index 95bd886..0000000 Binary files a/inspector/public/hugin.ico and /dev/null differ diff --git a/inspector/public/hugin.png b/inspector/public/hugin.png deleted file mode 100644 index bb817ad..0000000 Binary files a/inspector/public/hugin.png and /dev/null differ diff --git a/inspector/public/huginn-fieldpack.png b/inspector/public/huginn-fieldpack.png deleted file mode 100644 index 6a78b8b..0000000 Binary files a/inspector/public/huginn-fieldpack.png and /dev/null differ diff --git a/inspector/public/huginn-surface.png b/inspector/public/huginn-surface.png deleted file mode 100644 index d7b62bd..0000000 Binary files a/inspector/public/huginn-surface.png and /dev/null differ diff --git a/inspector/src/HuginnFieldCanvas.tsx b/inspector/src/HuginnFieldCanvas.tsx deleted file mode 100644 index 2fd1247..0000000 --- a/inspector/src/HuginnFieldCanvas.tsx +++ /dev/null @@ -1,1380 +0,0 @@ -import { useEffect, useRef } from "react"; - -type Brush = { - x: number; - y: number; - size: number; - r: number; - g: number; - b: number; - alpha: number; - energy: number; - curve: number; - gx: number; - gy: number; -}; - -type FieldSample = { - width: number; - height: number; - rgba: Uint8ClampedArray; - fieldRgba: Uint8ClampedArray; - luma: Float32Array; - alpha: Float32Array; - analysis: Float32Array; - flowX: Float32Array; - flowY: Float32Array; - fieldStrength: Float32Array; -}; - -type PyramidLevel = { - width: number; - height: number; - values: Float32Array; - energy: Float32Array; - gx: Float32Array; - gy: Float32Array; - curve: Float32Array; -}; - -const FIELD_SIZE = 256; -const MAX_DEPTH = 6; -const MIN_BRUSH_SIZE = 4; -const ENERGY_SPLIT_THRESHOLD = 0.038; -const PARTICLE_COUNT = new URLSearchParams(globalThis.location?.search ?? "").has("smoke") ? 8_192 : 1_048_576; -const PARTICLE_STRIDE_FLOATS = 12; -const MAX_NODE_ENVELOPES = 96; -const STARDUST_MAX_LEVEL = 13; -const STARDUST_COARSE_PARTICLES_PER_CELL = 2; -const STARDUST_FINE_PARTICLES_PER_CELL = 6; - -type NodeEnvelope = { - id: string; - x: number; - y: number; - radius: number; - strength: number; -}; - -type GraphViewportTransform = { - x: number; - y: number; - scale: number; - bounds: { - x: number; - y: number; - width: number; - height: number; - }; -}; - -const gpuParticleComputeShader = /* wgsl */ ` -struct Particle { - position: vec2f, - velocity: vec2f, - color: vec4f, - seed: f32, - size: f32, - life: f32, - pad: f32, -}; - -struct SimUniforms { - time: f32, - dt: f32, - width: f32, - height: f32, - particleCount: f32, - flowGain: f32, - alpha: f32, - detail: f32, - envelopeCount: f32, - nodeGain: f32, - worldX: f32, - worldY: f32, - worldWidth: f32, - worldHeight: f32, - viewX: f32, - viewY: f32, - viewScale: f32, - pad1: f32, -}; - -struct VisibleBand { - level: u32, - minX: u32, - minY: u32, - cellsX: u32, - cellsY: u32, - pairCount: u32, - weight: f32, - particlesPerCell: u32, -}; - -@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; -@group(0) @binding(5) var albedoTexture: texture_2d; - -fn hash32(value: u32) -> u32 { - var x = value; - x = (x ^ 61u) ^ (x >> 16u); - x = x * 9u; - x = x ^ (x >> 4u); - x = x * 0x27d4eb2du; - x = x ^ (x >> 15u); - return x; -} - -fn hashPair(a: u32, b: u32) -> u32 { - var x = hash32(a); - var y = b; - y = y - (y << 6u); - y = y ^ (x >> 17u); - y = y - (y << 9u); - y = y ^ (x << 4u); - y = y - (y << 3u); - y = y ^ (y << 10u); - y = y ^ (y >> 15u); - return x ^ y; -} - -fn randomFirst(a: u32, b: u32) -> f32 { - return f32(hashPair(a, b) & 0x00ffffffu) / 16777216.0; -} - -fn xorshift(value: u32) -> u32 { - var state = value; - state = state ^ (state << 13u); - state = state ^ (state >> 17u); - state = state ^ (state << 5u); - return state; -} - -fn randomValue(state: u32) -> f32 { - return f32(state & 0x00ffffffu) / 16777216.0; -} - -fn fadeCurve(t: f32) -> f32 { - return t * t * t * (t * (6.0 * t - 15.0) + 10.0); -} - -fn grad(seed: u32, p: f32) -> f32 { - if ((hash32(seed) & 1u) == 0u) { - return p; - } - return -p; -} - -fn perlinNoise1D(p: f32, seed: u32) -> f32 { - let pi = floor(p); - let pf = p - pi; - let cell = u32(max(pi, 0.0)); - let w = fadeCurve(pf); - return mix(grad(cell ^ seed, pf), grad((cell + 1u) ^ seed, pf - 1.0), w) * 2.0; -} - -fn tri(value: f32) -> f32 { - return abs(fract(value) - 0.5); -} - -fn tri3(point: vec3f) -> vec3f { - return vec3f( - tri(point.z + tri(point.y)), - tri(point.z + tri(point.x)), - tri(point.y + tri(point.x)) - ); -} - -fn triFlow(point: vec2f, seed: f32) -> vec2f { - let p1 = normalize(tri3(vec3f(point.x, seed, point.y)) * 2.0 - vec3f(1.0)); - let p2 = normalize(tri3(vec3f(point.x, seed, point.y) * 1.6180339) * 2.0 - vec3f(1.0)); - let c = cross(p1, p2); - return normalize(c.xz + vec2f(0.0001, 0.0)); -} - -fn parabola(x: f32, k: f32) -> f32 { - return pow(max(0.0, 4.0 * x * (1.0 - x)), k); -} - -fn samplePacked(uv: vec2f) -> vec4f { - return textureSampleLevel(fieldTexture, fieldSampler, clamp(uv, vec2f(0.001), vec2f(0.999)), 0.0); -} - -fn sampleAlbedo(uv: vec2f) -> vec4f { - return textureSampleLevel(albedoTexture, fieldSampler, clamp(uv, vec2f(0.001), vec2f(0.999)), 0.0); -} - -fn channelGradient(uv: vec2f, channel: u32) -> vec2f { - let step = vec2f(1.0 / 256.0, 1.0 / 256.0) / max(uniforms.detail, 1.0); - let left = samplePacked(uv - vec2f(step.x, 0.0)); - let right = samplePacked(uv + vec2f(step.x, 0.0)); - let up = samplePacked(uv - vec2f(0.0, step.y)); - let down = samplePacked(uv + vec2f(0.0, step.y)); - if (channel == 2u) { - return vec2f(right.b - left.b, down.b - up.b); - } - return vec2f(right.a - left.a, down.a - up.a); -} - -fn visibleUvBounds() -> vec4f { - let viewScale = max(uniforms.viewScale, 0.0001); - let worldMin = (vec2f(0.0, 0.0) - vec2f(uniforms.viewX, uniforms.viewY)) / viewScale; - let worldMax = (vec2f(uniforms.width, uniforms.height) - vec2f(uniforms.viewX, uniforms.viewY)) / viewScale; - let uvMin = clamp((worldMin - vec2f(uniforms.worldX, uniforms.worldY)) / vec2f(uniforms.worldWidth, uniforms.worldHeight), vec2f(0.0), vec2f(1.0)); - let uvMax = clamp((worldMax - vec2f(uniforms.worldX, uniforms.worldY)) / vec2f(uniforms.worldWidth, uniforms.worldHeight), vec2f(0.0), vec2f(1.0)); - return vec4f(min(uvMin, uvMax), max(uvMin, uvMax)); -} - -fn stardustLevelValue() -> f32 { - let screenSide = max(uniforms.worldWidth, uniforms.worldHeight) * max(uniforms.viewScale, 0.0001); - return clamp(log2(max(screenSide / 7.0, 1.0)) + uniforms.detail * 0.72, 4.0, f32(${STARDUST_MAX_LEVEL})); -} - -fn visibleBand(level: u32, particlesPerCell: u32, weight: f32) -> VisibleBand { - let span = 1u << level; - let uv = visibleUvBounds(); - let margin = 1.0 / f32(span); - let minUv = clamp(uv.xy - vec2f(margin), vec2f(0.0), vec2f(1.0)); - let maxUv = clamp(uv.zw + vec2f(margin), vec2f(0.0), vec2f(1.0)); - let minX = min(u32(floor(minUv.x * f32(span))), span - 1u); - let minY = min(u32(floor(minUv.y * f32(span))), span - 1u); - let maxX = min(u32(ceil(maxUv.x * f32(span))), span); - let maxY = min(u32(ceil(maxUv.y * f32(span))), span); - let cellsX = max(1u, maxX - minX); - let cellsY = max(1u, maxY - minY); - let visibleCells = cellsX * cellsY; - return VisibleBand(level, minX, minY, cellsX, cellsY, visibleCells * particlesPerCell, weight, particlesPerCell); -} - -@compute @workgroup_size(128) -fn updateParticles(@builtin(global_invocation_id) id: vec3u) { - let index = id.x; - if (index >= u32(uniforms.particleCount)) { - return; - } - - var particle = particles[index]; - let pairIndex = index / 2u; - let companion = index - pairIndex * 2u; - let pairCount = max(1u, u32(uniforms.particleCount * 0.5)); - let levelValue = stardustLevelValue(); - let coarseLevel = u32(floor(levelValue)); - let fineLevel = min(coarseLevel + 1u, ${STARDUST_MAX_LEVEL}u); - let fineBlend = smoothstep(0.18, 0.86, fract(levelValue)); - let coarseBand = visibleBand(coarseLevel, ${STARDUST_COARSE_PARTICLES_PER_CELL}u, 1.0 - fineBlend * 0.72); - let fineBand = visibleBand(fineLevel, ${STARDUST_FINE_PARTICLES_PER_CELL}u, fineBlend); - let coarsePairs = min(coarseBand.pairCount, max(1u, pairCount / 3u)); - let finePairs = min(fineBand.pairCount, pairCount - coarsePairs); - let activePairs = coarsePairs + finePairs; - if (pairIndex >= activePairs) { - particle.color = vec4f(0.0); - particle.size = 0.0; - particles[index] = particle; - return; - } - - var band = coarseBand; - var localPair = pairIndex; - if (pairIndex >= coarsePairs) { - band = fineBand; - localPair = pairIndex - coarsePairs; - } - - let level = band.level; - let span = 1u << level; - let particleInCell = localPair % band.particlesPerCell; - let cellOrdinal = localPair / band.particlesPerCell; - let cellX = band.minX + (cellOrdinal % band.cellsX); - let cellY = band.minY + (cellOrdinal / band.cellsX); - let cell = vec2f(f32(cellX), f32(cellY)); - let offset = 65535u; - let seedU = hashPair(level * 73856093u + cellX * 19349663u + particleInCell * 119954089u, cellY * 83492791u + companion * 2654435761u); - var rngState = hashPair(cellX + offset + particleInCell * 251u, cellY + offset + level * 509u); - let firstRandom = randomFirst(cellX + offset + particleInCell * 251u, cellY + offset + level * 509u); - rngState = xorshift(rngState); - let period = 5.2 + randomValue(rngState) * 1.8; - rngState = xorshift(rngState); - let jitterX = perlinNoise1D(randomValue(rngState) * 1024.0, seedU ^ 17u); - rngState = xorshift(rngState); - let jitterY = perlinNoise1D(randomValue(rngState) * 1024.0, seedU ^ 29u); - rngState = xorshift(rngState); - let triSeed = randomValue(rngState); - rngState = xorshift(rngState); - let sizeRandom = randomValue(rngState); - let lifetime = fract(uniforms.time / period + firstRandom); - let spacing = 1.0 / max(f32(span), 1.0); - let base = clamp( - (cell + vec2f(0.5)) * spacing + - vec2f(jitterX, jitterY) * spacing * 0.58, - vec2f(0.001), - vec2f(0.999) - ); - let sourceAlbedo = sampleAlbedo(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 = 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 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) { - if (f32(envelopeIndex) >= uniforms.envelopeCount) { - break; - } - let envelope = nodeEnvelopes[envelopeIndex]; - let delta = base - 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.92) * envelope.w * uniforms.nodeGain; - let away = normalize(delta + vec2f(0.0001, 0.0)); - let swirl = vec2f(-away.y, away.x); - nodePush += (swirl * 0.72 + away * 0.18) * influence; - nodeHeat += influence; - } - - let flowLine = normalize(fieldBlend + nodePush); - 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; - 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(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.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.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; -} -`; - -const gpuParticleRenderShader = /* wgsl */ ` -struct Particle { - position: vec2f, - velocity: vec2f, - color: vec4f, - seed: f32, - size: f32, - life: f32, - pad: f32, -}; - -struct SimUniforms { - time: f32, - dt: f32, - width: f32, - height: f32, - particleCount: f32, - flowGain: f32, - alpha: f32, - detail: f32, - envelopeCount: f32, - nodeGain: f32, - worldX: f32, - worldY: f32, - worldWidth: f32, - worldHeight: f32, - viewX: f32, - viewY: f32, - viewScale: f32, - pad1: f32, -}; - -@group(0) @binding(0) var renderParticles: array; -@group(0) @binding(1) var renderUniforms: SimUniforms; - -struct VertexOut { - @builtin(position) position: vec4f, - @location(0) color: vec4f, - @location(1) local: vec2f, -}; - -@vertex -fn vertexMain(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instanceIndex: u32) -> VertexOut { - let corners = array( - vec2f(-1.0, -1.0), - vec2f(1.0, -1.0), - vec2f(-1.0, 1.0), - vec2f(-1.0, 1.0), - vec2f(1.0, -1.0), - vec2f(1.0, 1.0) - ); - let particle = renderParticles[instanceIndex]; - let local = corners[vertexIndex]; - let direction = normalize(particle.velocity + vec2f(0.001, 0.0)); - let tangent = vec2f(-direction.y, direction.x); - let stretch = clamp(length(particle.velocity) / 16.0, 0.0, 1.4); - let ellipse = direction * local.x * particle.size * (1.0 + stretch * 0.62) + tangent * local.y * particle.size * (0.64 + renderUniforms.detail * 0.025); - 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; - out.local = local; - return out; -} - -@fragment -fn fragmentMain(input: VertexOut) -> @location(0) vec4f { - let radius2 = dot(input.local, input.local); - let falloff = 5.1 + renderUniforms.detail * 0.52; - let edge = exp(-falloff); - let gaussian = exp(-falloff * radius2); - let compact = pow(clamp((gaussian - edge) / max(1.0 - edge, 0.000001), 0.0, 1.0), 0.72); - let core = exp(-radius2 * 18.0) * 0.42; - return vec4f(input.color.rgb * (compact * 1.52 + core), input.color.a * compact); -} -`; - -type HuginnFieldCanvasProps = { - imageUrl: string; - fieldUrl: string; -}; - -export function HuginnFieldCanvas({ imageUrl, fieldUrl }: HuginnFieldCanvasProps) { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) { - return; - } - - let cancelled = false; - const render = async () => { - try { - const [image, fieldImage] = await Promise.all([ - loadImage(imageUrl), - loadImage(fieldUrl), - ]); - if (cancelled) { - return; - } - - const sample = sampleImage(image, fieldImage); - const gpuCleanup = await startGpuParticleField(canvas, sample); - if (gpuCleanup) { - console.info("Huginn WebGPU particle field active."); - return gpuCleanup; - } - - console.info("Huginn WebGPU unavailable; using canvas field fallback."); - const pyramid = buildCurvaturePyramid(sample); - const brushes = buildBrushes(sample, pyramid); - const observer = new ResizeObserver(() => drawFieldFallback(canvas, brushes, sample)); - observer.observe(canvas); - drawFieldFallback(canvas, brushes, sample); - - return () => observer.disconnect(); - } catch { - return; - } - }; - - let cleanup: (() => void) | undefined; - void render().then((nextCleanup) => { - cleanup = nextCleanup; - }); - - return () => { - cancelled = true; - cleanup?.(); - }; - }, [fieldUrl, imageUrl]); - - return