npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

material-designer-runtime

v0.1.7

Published

Lightweight runtime for loading and rendering Material Designer node graph documents.

Readme

material-designer-runtime

Lightweight runtime for loading Material Designer node graph documents and applying them to Three.js meshes.

Install

npm install material-designer-runtime [email protected]

three is a peer dependency — this package uses its WebGPU renderer and TSL, so install the exact supported version alongside it.

Usage

There are two ways to put a document's material on a mesh: let the runtime hand you a ready-made material (all config loaded for you), or bake the channel textures and assign them to a material you build yourself. Everything bakes on a WebGPURenderer, which must be initialized (await renderer.init()) first.

Get a ready-made material

getNodeMaterial() and getMeshMaterial() both load the document's family and every setting (metalness, roughness, the physical lobes, phong shininess, toon gradient, …) — you never copy config by hand. Pick based on what you need:

  • getNodeMaterial() — the TSL node material (MeshStandardNodeMaterial, MeshPhysicalNodeMaterial, …) with full procedural fidelity: triplanar projection, parallax-occlusion, per-vertex AO, and procedurally-driven lobes. WebGPU only.
  • getMeshMaterial() — a plain Three.js material (MeshStandardMaterial, MeshPhysicalMaterial, MeshLambertMaterial, MeshToonMaterial, MeshPhongMaterial, MeshMatcapMaterial) with the baked maps in the standard slots. Drops the node-only features above.
import { MaterialGraphRuntime } from "material-designer-runtime";
import { WebGPURenderer } from "three/webgpu";
import { Mesh, SphereGeometry } from "three";

const renderer = new WebGPURenderer();
await renderer.init();

const runtime = new MaterialGraphRuntime()
  .setRenderer(renderer)
  .fromDocument(document);
await runtime.refresh(); // bake the graph to channel textures

const geometry = new SphereGeometry(1, 128, 64);

// (a) TSL node material — full fidelity (triplanar, parallax, procedural lobes), WebGPU only:
const mesh = new Mesh(geometry, runtime.getNodeMaterial());
// The node material object can change on a re-bake (e.g. a family switch) — keep the mesh current:
runtime.surface.onRebuilt(() => { mesh.material = runtime.getNodeMaterial(); });

// (b) …or a plain Three.js material with the baked maps + settings loaded in (pick one path):
//   geometry.setAttribute("uv1", geometry.getAttribute("uv")); // .aoMap samples the 2nd UV set
//   const mesh = new Mesh(geometry, runtime.getMeshMaterial());

// Live edits re-bake implicitly — await whenIdle() before reading back:
runtime.setNodeParam("noise", "scale", 18);
runtime.setOutputResolution(1024);
await runtime.whenIdle();

runtime.dispose(); // release GPU resources when done

Bring your own material

Bake just the PBR channel maps with the shared bakeService (no material surface is created) and assign them to your own material. The baked textures already carry the correct .colorSpace (base color / emission are sRGB, data channels linear), so you assign them as-is:

import { bakeService, MaterialGraphSession, defaultRegistry } from "material-designer-runtime";
import { WebGPURenderer } from "three/webgpu";
import { Mesh, BoxGeometry, MeshStandardMaterial } from "three";

const renderer = new WebGPURenderer();
await renderer.init();
bakeService.attachRenderer(renderer);

const session = new MaterialGraphSession(document, defaultRegistry);
const set = await bakeService.bake(session, { size: 1024 }); // set.present = the channels actually baked

const material = new MeshStandardMaterial();
material.map = set.texture("baseColor"); // THREE.Texture | null, keyed by channel
material.roughnessMap = set.texture("roughness");
material.metalnessMap = set.texture("metallic");
material.normalMap = set.texture("normal");
material.aoMap = set.texture("ambientOcclusion");
material.emissiveMap = set.texture("emission");
material.emissive.set(0xffffff);
material.roughness = 1;
material.metalness = 1; // the maps carry the values — keep the multipliers at 1

const geometry = new BoxGeometry(1, 1, 1);
geometry.setAttribute("uv1", geometry.getAttribute("uv")); // aoMap samples the 2nd UV set
const mesh = new Mesh(geometry, material);
// Keep `set` alive while the material is in use — set.dispose() frees the render targets (and the textures).

height is baked separately (set.heightTarget?.texture). To read a channel back as CPU pixels for PNG export, use bakeService.readImage(session, channel, 1024)ImageData (size must be a multiple of 64). If you keep a live MaterialGraphRuntime, the same baked textures are also reachable via runtime.surface.getChannelTexture(channel) and runtime.surface.getHeightTexture().

Opt-in profiling

Profiling is a separate package entry and is not reachable from the production runtime bundle. Import it only in a benchmark or development surface:

import { MaterialBakeService } from "material-designer-runtime";
import { profileMaterialNodes } from "material-designer-runtime/profiling";

const service = new MaterialBakeService();
service.attachRenderer(renderer);
const report = await profileMaterialNodes(service, session, {
  size: 512,
  runs: 5,
  logCompiledShaders: true,
});

The /profiling entry owns timestamp queries, shader inspection, workload accounting, matched-baseline compiles, and cold shader identities. None of those modules are imported by material-designer-runtime. logCompiledShaders adds collapsed console groups for each output's actual compiled Three material, timing values, and complete vertex/fragment WGSL; it is disabled unless requested.

Persistent texture cache (opt-in)

Baking is dominated by shader compilation, not rendering — on a heavy graph the pipeline compile runs into seconds while the render is tens of milliseconds. Without a cache that cost is paid again on every page load, even when the document hasn't changed by a single texel.

The cache stores the baked channel texels and, on a hit, restores them with a GPU-to-GPU copy that short-circuits before the compile. It is off by default — enabling it is the only way anything is persisted:

const runtime = new MaterialGraphRuntime({ document, cache: {} }); // {} = defaults, enabled
// …or turn it on later:
runtime.setCacheEnabled(true);

Everything you need:

runtime.cacheEnabled;                      // is it on?
runtime.setCacheEnabled(true | false);     // toggle (installs the default cache on first enable)
await runtime.clearCache();                // drop every cached bake
await runtime.rebuildCache();              // re-bake for real and replace this document's entry,
                                           //   resolving only once the new entry is durably written
await runtime.getCachedTextures();         // this document's entry metadata, or null
await runtime.listCachedTextures();        // every entry's metadata, newest use first
await runtime.loadCachedChannelTextures(); // …or the actual textures (see below)
await runtime.cacheMetrics();              // size taken + hit/miss/evict counters
await runtime.flushCache();                // force the deferred write now (e.g. on unload)

cacheMetrics() answers "how much space is this taking":

{ enabled, available, store: "worker-indexeddb", encoding: "png",
  entries, bytes,            // bytes AS STORED (post-compression) — the real footprint
  budgetBytes,               // LRU-evicted down to this
  hits, misses, writes, evictions, collisions,
  quota: { usage, quota },   // navigator.storage.estimate(), where the browser reports it
  lastError }

Configuration

new MaterialGraphRuntime({ document, cache: {
  enabled: true,
  store: myStore,           // bring your own (see below); default is worker+IndexedDB
  encoding: "deflate",      // or "rgba8" (cheaper saves) / "png" (smallest, slower restores)
  budgetBytes: 256 * 2 ** 20,   // on-disk ceiling, in STORED (compressed) bytes
  maxEntryBytes: 256 * 2 ** 20, // per-capture ceiling, in RAW texel bytes — see note below
  minBakeMs: 250,           // don't spend disk to save a bake cheaper than this
  writeDelayMs: 750,        // quiet period before the capture runs
  worker: true,             // false = main thread; { url } = self-hosted worker (strict CSP)
  namespace: "my-app",      // your own invalidation lever
  onEvent: (e) => {},       // hit | miss | write | evict | collision | disabled | error
}});

The codec and all storage IO run in a worker, so neither encoding nor IO touches the main thread. Only the GPU readback is main-thread (it has to be), and it is deferred past writeDelayMs, skipped when an entry already exists, and collapsed to one capture per burst of re-bakes.

On encoding. All three options are lossless; they trade save cost against footprint.

  • "deflate" (default) — byte compression, no image codec. Restores as fast as raw texels, and stores about 6× smaller. Saving costs real CPU, but it runs in the worker and does not hold the bake queue, so nothing waits on it.
  • "rgba8" — raw texels. Cheapest to save, largest on disk.
  • "png" — smallest footprint, but measurably slower to restore: unpacking an image is real work, and a cache exists to make the second load fast.

Footprint matters more than it first looks. Raw texels run ~80 MB per material at 2048², so the default 256 MB budget would hold roughly three materials before evicting; compressed it holds around eighteen. That is the difference between a cache that accumulates and one that thrashes.

Lossy GPU formats (KTX2/Basis) are deliberately not offered. This cache must reproduce a bake exactly — otherwise you would author a material against one image and get a different one back on reload — and block compression visibly damages the data channels, normals worst of all. It would also buy nothing here: a restore writes into the same render targets the baker draws into, and those are uncompressed by construction.

The two size knobs are in different currencies. budgetBytes is compressed bytes on disk. maxEntryBytes is raw texel bytes, because it gates the capture before the readback — at which point the stored size can't be known. Size it against raw totals:

| bake size | 1 channel | 7 channels (raw) | | --- | --- | --- | | 512² | 1 MB | 7 MB | | 1024² | 4 MB | 28 MB | | 2048² | 16 MB | 112 MB | | 4096² | 64 MB | 448 MB |

PNG typically shrinks that by 10× or more, so if you set maxEntryBytes to the on-disk figure you have in mind, larger materials will silently stop being cached.

What a capture costs

The readback is the one part that runs on the main thread. Measured on an M-series Mac in Chrome, per channel, median of 12 runs:

| bake size | GPU→CPU read | row flip (JS) | total | ×7 channels | | --- | --- | --- | --- | --- | | 512² | 1.0 ms | 0.2 ms | 1.2 ms | ~8 ms | | 1024² | 2.4 ms | 0.6 ms | 3.0 ms | ~21 ms | | 2048² | 6.3 ms | 2.4 ms | 8.7 ms | ~61 ms | | 4096² | 22.3 ms | 9.9 ms | 32.2 ms | ~225 ms |

Roughly linear in bytes (~0.35 ms/MB transfer, ~0.15 ms/MB flip). This is why the capture is deferred rather than run inline at the end of a bake: it is work that only pays off on the next load, so making the current one wait for it would be paying now for a benefit later. De-padding is free at bake sizes that are multiples of 64 (the rows arrive tightly packed), which every surface bake is.

Bring your own storage

Implement BakeCacheStore and the runtime uses it instead of IndexedDB — for a server, a CDN, your own database, or node's filesystem:

import { BakeTextureCache, type BakeCacheStore } from "material-designer-runtime";

const store: BakeCacheStore = {
  name: "my-backend",
  async get(id) { … }, async put(entry) { … },
  async touch(id, lastUsedAt) { … },   // must NOT rewrite the texels — runs on every hit
  async delete(id) { … },
  async list() { … },                  // metadata ONLY, never texels
  async clear() { … },
};
runtime.service.setCache(new BakeTextureCache({ enabled: true, store }));

Texels cross this interface as tightly-packed, top-down RGBA8; compress internally if you like, but decode before returning, and overwrite meta.bytes with the size you actually stored so the metrics stay truthful. createBakeCacheKey(...) (also runtime.cacheKey()) is exported, so you can key your own cache identically.

Using cached textures directly

loadCachedChannelTextures() needs no renderer — a page shipping a fixed material can dress a plain THREE material from cache without ever compiling a shader:

const cached = await runtime.loadCachedChannelTextures();
if (cached) {
  const material = buildMeshMaterial(runtime.getDocument(), cached);
  // …you own these textures: cached.dispose() when the material is done with them.
}

Notes

  • Invalidation is automatic. The key covers the graph topology, every effective param value (registry defaults merged in, so a sparse document keys the same as an explicit one), bake size, channel set, document schema version, three's revision, and this package's version — the last because node maths changes texel output without a schema bump. An upgrade therefore starts cold rather than restoring stale texels. Cosmetics (node labels, positions, pan/zoom, metadata) never invalidate.
  • A cache failure can never break a bake. Every storage call is contained; a miss, a corrupt record, a dead worker, or an exhausted quota all fall through to baking normally.
  • The first edit after a hit costs one full rebuild. A restore deliberately skips compilation, so there are no compiled materials to re-render with until the next rebuild repopulates them.
  • It is a binary store, so localStorage.clear() will not touch it. If your app has a "reset everything" path, call clearCache() there too; BAKE_CACHE_DB_NAME is exported if you'd rather delete the database.

The editor owns UI state, selection, presets, and undo history. This package owns graph document loading, compilation, baking, direct parameter updates, the material surface, and — opt-in — a cache of its own bake artifacts. That last one is the single exception to "the editor owns storage": it caches only what this package produces, it is off unless you enable it, and cache.store hands storage ownership back to you whenever you want it.