Skip to content

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

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

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

Conversation

@chiefcll

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: pin-once, not per-node ownership

Apps use a small number of distinct placeholder images (e.g. one per poster size class) across many nodes, so the texture lifecycle is deliberately simple:

  • One shared texture per URL. The setter resolves through the texture keyCache with src-only props — 500 posters across 3 size classes = 3 texture instances.
  • Pinned: 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 at assignment, from idle states only — a state guard keeps N nodes from starting N duplicate fetches of the same source.
  • Per-node loaded/failed/freed listeners drive the fallback state machine (same pattern as the main-texture handlers). They are detached on swap, on clearing the prop, and in destroy(), so destroyed nodes never leak through the long-lived shared texture (unit-tested via hasListeners()).
  • The freed handler is the self-heal: if the pinned texture is freed out-of-band (context loss, or another node's textureOptions unpinning the shared URL), the first notified node re-pins and reloads; the rest see 'loading'. Everyone falls back to the color rect meanwhile.

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.

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 now a three-way branch on two cached booleans per quad (no string compares, no getter calls).

Testing

  • Unit: 11 new tests in CoreNode.test.ts (438 total pass): pin + eager load on assignment; 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 listener hygiene on the shared texture.
  • 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 chromium-ci snapshot captured in Docker; the full 177-snapshot suite passes in the CI container.
  • 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 2 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>
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