Skip to content

feat(core): placeholderImage — shared pinned image placeholder while a texture loads - #97

Open
chiefcll wants to merge 6 commits into
mainfrom
feat/placeholder-image
Open

feat(core): placeholderImage — shared pinned image placeholder while a texture loads#97
chiefcll wants to merge 6 commits into
mainfrom
feat/placeholder-image

Conversation

@chiefcll

@chiefcll chiefcll commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

What

New per-node placeholderImage prop, extending placeholderColor (#96) with image placeholders. While the node's texture is not loaded — initial load, freed-texture reload (#87 lifecycle), or permanent failure — the quad renders the placeholder image through the node's own shader (rounded corners/borders apply), stretched to the node's dimensions. The per-frame fallback chain is:

main texture → placeholder image → placeholderColor rect → nothing

renderer.createNode({
  src: posterUrl,
  placeholderImage: '/poster-placeholder-2x3.png', // shared across all posters of this size
  placeholderColor: 0x333333ff,                    // shown for the instant the placeholder itself loads
  shader: renderer.createShader('Rounded', { radius: [20] }),
  ...
});

Design: a stage-owned manager, pinned once per URL

Apps use a small number of distinct placeholder images (e.g. one per poster size class) across many nodes. The lifecycle therefore lives in a new stage-owned PlaceholderManager (src/core/PlaceholderManager.ts), keyed by URL — not on CoreNode:

  • One shared, pinned texture per URL. The manager resolves through the texture keyCache with src-only props — 500 posters across 3 size classes = 3 texture instances. preventCleanup = true, so the memory manager never frees it and the fix(textures): evict orphaned cached textures during cleanup #95 orphan eviction never touches it. Eagerly priority-loaded once, at entry creation.
  • One listener trio per URL, not per node. The manager subscribes to loaded/failed/freed once when it creates the entry, then walks its own subscriber array. 500 poster nodes put 3 listeners on the shared texture, not 1500.
  • O(1) subscribe/release. Nodes hold their slot index and the manager swap-pops on release — the same trick Texture uses for renderable owners. EventEmitter.off is indexOf + splice, so a per-node-listener design made tearing down a shared row quadratic, at a page transition.
  • Zero per-node allocation. CoreNode gained no bound handlers: it keeps the two fields the per-quad path already read (placeholderTexture, placeholderTextureLoaded) as manager-written caches, two plain bookkeeping slots, and one ordinary method the manager calls. The class is back to exactly 4 per-instance closures — the same count as before this feature.
  • The freed handler is the self-heal: if the pinned texture is freed out-of-band — in practice a node whose src matches the URL, since loadTextureTask writes its own textureOptions.preventCleanup onto the shared instance — the manager re-pins and reloads. One handler decides, so there is no race between nodes. (GL context loss is terminal in this engine — the Stage emits contextLost and the app reloads — so it is not a case this covers.)

Trade-off documented on the prop: placeholder images are resident for the app lifetime — use a few app-level images, not per-item artwork. Cost is bounded by distinct URLs, not node count. Note that a pinned texture is unreachable by both orphan eviction and renderer.cleanup(), so clearing placeholderImage on the last node using a URL does not reclaim it.

Reviewer notes

  • Texture-coords guard (both renderers): node.textureCoords belongs to the main texture (resizeMode, flips, sub-rects). The 1×1 white texture in feat(core): placeholderColor — solid color placeholder while a texture loads #96 masked stale coords; a real placeholder image would be mis-cropped by them, so placeholders now always sample full-quad.
  • Once the image is showing it renders untinted (vertex colors go to white); placeholderColor only colors the rect fallback. Keeps "gray rect, then branded image, then poster" from becoming "gray-tinted branded image".
  • TV cost model: unchanged from feat(core): placeholderColor — solid color placeholder while a texture loads #96 — no extra nodes/quads, nothing on the per-translation scroll path (all state hangs off texture lifecycle events), shader uniform caching untouched, and loading walls batch per size class since they share one texture.
  • The renderer texture pick is a three-way branch on two cached booleans per quad (no string compares, no getter calls). Both renderers are untouched by the manager refactor.

Testing

  • Unit: 13 tests in CoreNode.test.ts (374 total pass), driving a real PlaceholderManager rather than a mock, since the sharing behavior under test lives there: pin + eager load on first use; already-loaded shared texture used immediately and untinted; color-rect fallback until the image loads; image-only placeholder renders nothing until loaded; loaded main wins; freed main re-shows the placeholder; failed placeholder falls back to the rect; freed self-heal re-pins and reloads exactly once; destroy/swap/clear unsubscribe hygiene; 50 nodes on one URL keep a single listener set; out-of-order release keeps every survivor subscribed (swap-pop index integrity).
  • Visual regression: new examples/tests/texture-placeholder-image.ts with five deterministic states (failed main + placeholder rounded, same under the border shader, placeholder-404 → color-rect fallback, loaded image wins, no-fallback control). Certified on both backends — chromium-ci and chromium-ci-canvas — captured in Docker, twice, byte-identical.
  • Full suite green on both backends after the refactor: 181 WebGL + 152 Canvas2D = 333 snapshots, 0 failures, identical to the pre-refactor run.
  • Live-verified on both backends (WebGL + Canvas2D), including pixel-exact bounds via DOM overlays against the inspector geometry.

🤖 Generated with Claude Code

chiefcll and others added 6 commits June 10, 2026 15:48
…a texture loads

Extends placeholderColor (#96) with a per-node placeholder image. While
the node's texture is not loaded (initial load, freed-texture reload,
permanent failure) the quad renders the placeholder image through the
node's own shader, stretched to the node's dimensions. Fallback chain
per frame: main texture -> placeholder image -> placeholderColor rect
-> nothing.

Lifecycle is pin-once instead of per-node ownership: the setter resolves
the URL through the texture keyCache (src-only props, so every node
using the same URL shares one instance — e.g. one image per poster
size), sets preventCleanup, and eagerly priority-loads from idle states
only (a state guard keeps N nodes from starting N duplicate fetches).
Per-node loaded/failed/freed listeners drive the fallback state machine
and are detached on swap, clear, and destroy so nodes never leak through
the long-lived texture. The freed handler self-heals out-of-band frees
(context loss, another node unpinning the shared URL) by re-pinning and
reloading.

Renderers additionally stop applying node.textureCoords while a
placeholder shows — those coords belong to the main texture (resizeMode,
flips) and would mis-crop a real placeholder image (the 1x1 white
texture masked this). Once the image is showing it renders untinted;
placeholderColor only colors the rect fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ently

Covers the shared-placeholder contract explicitly: one cached instance
and a single fetch across N nodes, every node notified on load, each
switching to its own main texture independently, and one node's destroy
leaving the others' subscriptions intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves conflicts in CoreNode.ts (textureOwnership cache field landed
next to the placeholderActive doc block) and CoreNode.test.ts (the
placeholderImage suite and main's renderOnlyInViewport / texture
ownership cache suites were added at the same spot).
The webgl snapshot was captured before the Canvas2D visual-regression
suite landed on main (#125/#126). Without a chromium-ci-canvas baseline
the canvas CI job fails the test outright ("snapshot does not exist").

Captured in the Docker runner, twice, byte-identical.
Splitting the texture pick into an if/else left `let tx;` / `let texture;`
uninitialized, so TypeScript's evolving-let inference widened them to
`any` — `tx.type` and `tx.ctxTexture` in the per-quad hot path became
unchecked member accesses (eslint no-unsafe-member-access). Annotate the
declarations; type-only change, emitted JS is unchanged.
…Manager

placeholderImage attached three per-node listeners to a texture the design
deliberately shares across many nodes, so a 500-poster list put 1500
listeners on one emitter. EventEmitter.off is indexOf + splice, making
teardown of such a list quadratic — at a page transition, the worst moment
for it on a TV SoC. The three handlers were also class-field arrows, so
every CoreNode in the graph allocated them whether or not it used a
placeholder.

Stage now owns a PlaceholderManager keyed by URL. It creates, pins and
eagerly loads each shared texture once, subscribes one listener trio per
URL, and walks its own subscriber list on a state change. Nodes subscribe
and release; release is O(1) via a swap-pop against a stored index, the
same trick Texture uses for renderable owners.

CoreNode keeps only the two fields the per-quad path already read
(placeholderTexture, placeholderTextureLoaded) as manager-written caches,
plus two plain bookkeeping slots — and is back to exactly 4 per-instance
closures, the pre-feature count. Both renderers are untouched, so the hot
path is byte-identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant