@pixi-ui-editor/runtime-pixi
v0.36.0
Published
PixiJS runtime that renders Pixi UI Editor scene documents in a game.
Readme
@pixi-ui-editor/runtime-pixi
PixiJS runtime that loads and renders scene documents produced by Pixi UI Editor inside a game.
Consumes documents validated against @pixi-ui-editor/schema.
Install
pnpm add @pixi-ui-editor/runtime-pixiReparent live views
Use reparentPreservingWorld for a live Pixi Container created by game code or taken from a
mounted scene. It recalculates the ancestor chain instead of reading Pixi's render-pass cache, so the
view does not jump even before the first frame:
import { reparentPreservingWorld } from "@pixi-ui-editor/runtime-pixi";
if (!reparentPreservingWorld(symbolView, bonusLayer, 0)) {
// World transform could not be preserved: the destination parent, or the view's own current
// world matrix, is singular (for example scale.x === 0). The tree move still happened.
}containerWorldMatrix, applyWorldTransform, and matchWorldTransform expose the same live-view
layer for lower-level use. applyWorldTransform returns false and leaves the local transform
untouched when either the target world matrix or the current parent is singular; on its successful
branch it also clears any stale view.skew so it cannot shear the result.
Use NodeTransformSpace instead when moving serialized document nodes: it additionally resolves
layout profiles, anchors, Canvas scaling, and layout-managed rectangles.
Spawn a preset
import { loadPrefabView } from "@pixi-ui-editor/runtime-pixi";
import { materializedPrefabNodeId } from "@pixi-ui-editor/schema";
const configured = scene.nodes.find(
(node) => node.type === "prefab-instance" && node.bindingKey === "rewardCardTemplate",
);
if (configured?.type !== "prefab-instance") throw new Error("Configured preset instance is missing.");
const labelDefinitionNodeId = document.prefabs
.find((prefab) => prefab.id === configured.prefabId)!
.nodes.find((node) => node.bindingKey === "label")!.id;
const mounted = await loadPrefabView(
document,
configured.prefabId,
"desktop",
resolveFileUrl,
{ interaction: "runtime" },
{
propertyOverrides: configured.propertyOverrides,
locallyAddedNodes: configured.locallyAddedNodes,
},
);
gameLayer.addChild(mounted.root);
const labelView = mounted.nodeViews.get(
materializedPrefabNodeId(mounted.instanceId, labelDefinitionNodeId),
);Omit the last argument to instantiate the prefab definition defaults. nodeViews contains the outer
instance plus deterministic instance-scoped IDs for inherited children and stable IDs for local children.
The lookup above is two-step on purpose, and it is the contract: a bindingKey is unique within the scope
it resolves against, not across the whole document. A key on a scene node — including a preset instance
itself — is unique within that scene. A key inside a preset definition is unique within that definition and
resolves against one instance, which is what makes it usable for presets you spawn many times: a hundred
symbol views can each carry "spine" without colliding. So you find the instance by its scene-level key,
then find the node inside it by its definition-level key.
Reuse parsed assets across loads
loadPrefabView and loadSceneView fetch and parse every asset a scene references. Spawning presets one
by one — a symbol view per reel stop, a card per deal — repeats that work for each call: the skeleton JSON
is re-parsed, the atlas is rebuilt, and every texture becomes a separate GPU upload of the same file.
Pass an assetCache you own to share the parsed results. Its lifetime is yours: keep one per scene session
and drop it with the scene.
import { createSceneAssetCache, loadPrefabView } from "@pixi-ui-editor/runtime-pixi";
const assetCache = createSceneAssetCache();
const mounted = await loadPrefabView(document, prefabId, "desktop", resolveFileUrl, {
interaction: "runtime",
assetCache,
});Each map is keyed by the asset file's URI, so the same file assigned to different assets loads once. Without
assetCache every call starts from empty maps, which is fine for a single scene load and wasteful for
anything spawned repeatedly. The lower-level loadSceneTextures / loadSceneSpines / loadSceneFonts /
loadSceneSounds take the individual maps directly if you drive loading yourself.
Texture quality
By default the runtime shows the original textures, or the best available quality copy when the originals
are not downloaded yet (resolveFileUrl returns undefined for a missing file). To pick a quality
explicitly, pass textureQuality to loadSceneView / loadPrefabView (the loaders take it as a trailing
quality argument). undefined is the default above, null means originals, a string is a project quality
preset name. When the files of an explicit target are not available, the runtime picks the available option
nearest to it among the original and the asset's own preset copies, by distance in the ordered list
[original, ...project presets]; a tie goes to the lower quality. Choosing Low therefore gives Medium rather
than originals while Low is missing. undefined keeps "best available".
textureQualityand the loaders'qualityargument apply only to assets that are loaded now: a cache hit is returned as is. When you change the quality on a sharedassetCache, callapplyTextureQuality.
Once applyTextureQuality has chosen a quality (null or a preset name) for a cache, that quality becomes the
default for later loads: getTextureQuality(assetCache) returns it, and loadSceneView / loadPrefabView use it
when textureQuality is omitted. The low-level loaders that only receive individual maps (loadSceneTextures,
loadSceneSpines, loadSpineAsset, loadAtlasSpritesheet, loadImageAsset) use it too, because
applyTextureQuality remembers the choice on cache.textures, cache.spritesheets and cache.spines themselves;
an explicit quality argument always wins. loadImageAsset(document, asset, resolveFileUrl, cache, quality?) loads one
standalone image with the same cache key and quality handling as a scene load, so it takes part in quality switching.
Pass resolveProjectQualityPresets(document) as presets to the direct loaders when you can: without it the
fallback to quality copies walks the asset's own qualityVariants keys in insertion order (it no longer throws when
the originals are not delivered yet, but the order is not best-to-worst).
For progressive delivery, call upgradeTextures(document, resolveFileUrl, assetCache) after each batch of
downloaded files. It covers every quality-capable asset in the cache (Spine of symbols, prefabs, standalone
textures), not one scene as upgradeSceneTextures does. With a quality chosen by applyTextureQuality it converges
to that quality: originals loaded as a fallback while the Low files were missing are swapped to Low when those
files arrive, even though that is a downgrade by rank. While the target's files are still missing, a swap happens
only when the new selection is strictly nearer to the target than the applied one. Without a chosen quality it
only swaps to strictly better. applyTextureQuality and upgradeTextures handle each cache key once per call:
assets that share one file (same source.uri#version image, same atlas JSON, same skeleton) but declare different
qualityVariants follow the first of them in document order, so the shared object is not switched back and forth.
To decide what to download, use collectQualityFiles(document, quality) from the schema package (the texture files a
quality needs) and collectPacksForQuality(document, quality) (the pack ids that contain those texture files, from packFiles of
an export package, or derived for a working document). Both cover texture files only: quality-independent files,
such as an atlas JSON or a Spine skeleton, count only when they share a pack with a texture file, so add the
packs of the other files you need yourself.
Spine atlas pages with pma: true are loaded with premultiplied-alpha, both on load and when pages are swapped.
applyTextureQuality moves already loaded textures to another quality in place, in both directions, for
every image, atlas and Spine asset in the cache. Built views keep their animations and control state.
A newer call supersedes an unfinished one, so rapid switching ends in the last requested quality.
upgradeSceneTextures then aims at that explicitly chosen quality and never moves assets away from it; it
only fills in better available files for the same target. Spine views created by the runtime pick up the
swapped pages even with autoUpdate = false (createSpineView and the scene views register their Spine). The
refresh sets spine-pixi-v8's internal "state changed" flag and requests a view update, so on the next render the
slot textures are re-read and vertices recomputed from the current skeleton pose; animations, physics and
listeners do not run and manually posed bones are kept. If a future spine-pixi-v8 lacks those internals, the
runtime falls back to spine.update(0), which applies the animation state and physics with a zero step and may
override a manually posed bone or fire state listeners. A Spine you build yourself with new Spine(...) is not
tracked and needs a spine.update(0) after the swap.
Image textures and atlas frames in the cache keep their Texture object; only its source changes, and the
previous source is destroyed. They are created with dynamic: true, because Pixi v8 notifies a Sprite,
NineSliceSprite, TilingSprite or Mesh about a source change only for dynamic textures; without the flag the
view keeps drawing the destroyed source and every frame fails (a black canvas). Game code may put cached textures
(from loadSceneView, loadImageAsset, loadAtlasSpritesheet) into its own sprites, AnimatedSprites and
@pixi/ui components: they follow the swap. Two things do not: a Texture you build yourself on a cached source
(new Texture({ source: cached.source, frame })) keeps the old, destroyed source, so build it from the cached
Texture again after applyTextureQuality; and a texture you mark dynamic = false stops notifying its views.
A load that overlaps applyTextureQuality is reconciled: if the cache quality changed before the loaded image,
atlas or Spine reached the cache (and the loader took the quality from the cache, not from an explicit argument),
it is discarded and loaded again with the new choice. Parallel loads of one image, atlas or Spine share a single
load, so no orphaned object escapes applyTextureQuality or disposal.
import { applyTextureQuality, createSceneAssetCache, loadSceneView } from "@pixi-ui-editor/runtime-pixi";
const assetCache = createSceneAssetCache();
await loadSceneView(document, sceneId, "desktop", resolveFileUrl, { interaction: "runtime", assetCache, textureQuality: "Low" });
await applyTextureQuality(document, resolveFileUrl, assetCache, null); // back to originalsMemory / disposal
Free everything through the runtime, not through Pixi directly: the runtime knows about the shared asset cache, its technical wrapper containers and the listeners Pixi does not remove on its own.
Scenes and preset instances. destroySceneView(view) frees a result of buildSceneView, loadSceneView,
buildPrefabView or loadPrefabView: it detaches the root (also from a page group it was added to), destroys the
whole tree, clears nodeViews and disposes localization (later setLocale / setParams do nothing). It is
safe to call twice. It never touches the asset cache, fonts, RuntimeAudio or your Application.
const popup = await loadPrefabView(document, prefabId, "desktop", resolveFileUrl, { interaction: "runtime", assetCache });
app.stage.addChild(popup.root);
// ...
destroySceneView(popup);Single nodes. destroyNodeView(view, sceneView?) removes one node with its subtree; with sceneView it also
drops the ids from nodeViews. detachNodeView(view) takes a node out of the scene and keeps it alive. Both
remove the runtime's technical wrapper of a node inside a layout group, scroll view or page group, so no empty
slot or blank page is left behind; reparentPreservingWorld does the same when it moves such a node.
removePageGroupViewPage(view, page, { destroy }) is the counterpart of addPageGroupViewPage.
Your own display objects. Destroy anything you built on cached textures (new Sprite(texture),
AnimatedSprite frames, @pixi/ui views) with destroyView(container). Cached textures are dynamic so views
follow quality swaps, and Pixi's Sprite / NineSliceSprite / TilingSprite / Mesh destroy() does not
unsubscribe from a dynamic texture: a plain destroy() leaves the destroyed object referenced by the shared
texture. destroyView releases those listeners first and never destroys textures. Never pass texture: true
or textureSource: true to destroy() of anything showing cached textures: that destroys the shared texture
for every scene. Runtime views already release their listeners in destroy({ children: true }).
Assets. When a cache is no longer needed, destroy the views built from it first, then call
disposeSceneAssetCache(assetCache): it destroys textures and their sources, spritesheets, Spine atlas pages and
the sounds the cache owns, clears the maps, resets the chosen quality and cancels unfinished
applyTextureQuality / upgradeTextures calls and image, atlas, Spine and sound loads. releaseCachedAsset(assetCache, asset)
unloads a single image, atlas, Spine or sound asset the same way (cache keys are file URIs, so assets sharing a
file share the object). Both expect that no live view uses the released asset. Resources loaded by a cancelled
load are destroyed instead of being put back into the cache, and callers waiting on an image, atlas or Spine load
get an error.
Fonts are not released by either call: sharedFontCache and sharedBitmapFontCache are global, shared by all
caches and registered in document.fonts / Pixi's Cache. To drop a font that no scene needs any more, destroy
its views and call releaseFontAsset(asset) (removes the FontFace from document.fonts) or
releaseBitmapFontAsset(asset). A font that failed to load is not cached, so the next load tries again.
Audio. RuntimeAudio.dispose() stops the music and every sound the director started, removes its listeners
and forgets its sounds; later load / emit / setScene calls do nothing. The Sound objects themselves belong
to the asset cache and are destroyed by disposeSceneAssetCache.
Order for a full teardown: destroySceneView for every scene and preset instance, audio.dispose(),
disposeSceneAssetCache(assetCache), then optionally releaseFontAsset / releaseBitmapFontAsset.
Scene viewport
buildSceneView and loadSceneView return a runtime-owned SceneViewport as root. With
interaction: "runtime", that object clips content to the authored reference viewport and owns the
only contain-fit calculation used by Preview and consuming applications:
const { root } = await loadSceneView(document, sceneId, profile, resolveFileUrl, {
interaction: "runtime",
});
app.stage.addChild(root);
root.resize({ width: app.screen.width, height: app.screen.height });Call root.resize(...) after the Pixi renderer changes size. It applies one uniform scale and centers
the unchanged authored viewport; letterbox and pillarbox space never reveal off-viewport content.
Hosts must not reproduce the fit formula or add their own viewport mask. Spawned prefab views stay
unclipped because they are scene content rather than player-screen boundaries.
Use resolveViewport for physical host measurements. It returns null while the host is collapsed or
non-finite, preventing a transient 0×0 measurement from selecting desktop and applying a singular
transform. resolveProfileForViewport and fitViewportToHost remain available as low-level primitives;
passing the live profile to the former enables a five-percent breakpoint dead zone.
const resolved = resolveViewport(
document.settings,
scene.layout.referenceViewports,
host,
mounted.profile,
);
if (resolved === null) return; // keep the last usable profile and transform
if (resolved.profile !== mounted.profile) applyProfile(mounted, document, sceneId, resolved.profile);
mounted.root.resize(host);Crossing the breakpoint does not require rebuilding or reloading assets. SceneViewResult.profile is
the source of truth for the live tree, and applyProfile updates its reference viewport, Canvas scale,
node transforms/styles, layout-managed children, page groups, item tables, scroll content, and fixed-grid
line breaks in place. Runtime state such as the current button/page, capacity override, and scroll offset
is retained; applying the already-live profile is a no-op.
Localization
buildSceneView, loadSceneView, buildPrefabView and loadPrefabView return a SceneLocalization
next to root and nodeViews. A Text node that carries a textKey already renders its base-locale
translation without any extra call; a document without keys behaves exactly as before.
const { root, localization } = await loadSceneView(document, sceneId, "desktop", resolveFileUrl, {
interaction: "runtime",
});
localization.setLocale("pt-BR");
// Values ship as a flat Record<string, string> in locales/<locale>.json, so any dictionary works.
localization.setTranslate((key, { locale }) => dictionaries[locale]?.[key]);
// `{amount}` inside a translation is a runtime parameter, not a second key.
localization.setParams({ amount: 48 });
// An immutable editor/game store hands over a new document copy without rebuilding the scene.
localization.syncDocument(nextDocument);Resolution order is translate → requested locale → base locale → Text.text → the key itself; a
translate returning undefined keeps the chain going. Every call mutates the existing TextNodeView
objects: the scene root, NodeView identity and live control state are preserved, and re-rendering the
same string does no work at all.
