Skip to content

SceneGraph shape components: RoundedRectangle, Circle, Triangle, Polygon (#61) - #164

Open
markwpearce wants to merge 15 commits into
mainfrom
feature/61-scenegraph-shape-components
Open

SceneGraph shape components: RoundedRectangle, Circle, Triangle, Polygon (#61)#164
markwpearce wants to merge 15 commits into
mainfrom
feature/61-scenegraph-shape-components

Conversation

@markwpearce

Copy link
Copy Markdown
Owner

Summary

Ships four SceneGraph components — RoundedRectangle, Circle, Triangle, Polygon — that behave like native nodes (e.g. <Rectangle>) but render shapes SceneGraph has no built-in node for, using BGE.Renderer internally. ropm install the engine, drop one tag in a scene, get a rendered shape with no offscreen-bitmap plumbing of your own.

Closes #61.

What's shipped

  • src/components/Shapes/{RoundedRectangle,Circle,Triangle,Polygon}.{xml,bs} — each a Group wrapping a child Poster and one persistent, shared ShapeRenderTask instance (src/components/Shapes/ShapeRenderTask.{xml,bs}).
  • Fill + outline + shape-specific fields (cornerRadius, outlineSegments, vertices). Triangle/Polygon's vertices is a vector2darray, settable directly as an XML attribute.
  • Renders are cached by content-hash filename in cachefs:/ — identical field values reuse the same render permanently across app relaunches, not just within a session.
  • examples/scenegraph — the engine's first pure-SceneGraph example (no roScreen/BGE.Game anywhere), demonstrating all four shapes plus a stress-test scene (--param scene=stress) exercising 20-30 concurrent instances.
  • docs/scenegraph-shapes.md — usage guide, "How it works," and every hardware finding below.
  • specs/2026-08-24-scenegraph-shape-components-design.md — full design history.

Hardware findings (all confirmed on a real device, not the simulator)

  • Finish() alone (no roScreen anywhere in the process) is sufficient to realize a Triangle/Polygon's one-time getRightTriangleResource() build — the load-bearing risk this issue called out up front.
  • roFileSystem fails on the render thread; MatchFiles() doesn't have that restriction.
  • A Poster-extends design (the natural first approach) let Poster's native auto-scale-to-fit stretch a stale bitmap into a newly-set size while a redraw was still in flight, visibly distorting shapes (e.g. elliptical rounded-rect corners) during any width/height change. Fixed by wrapping in Group with an internal Poster whose width/height are never set.
  • Every shape costs ~150-200ms per redraw (PNG encode/decode dominates, not draw complexity) — none are animate-safe at a real per-frame rate. Moving the redraw to a shared Task removes render-thread blocking in the common case, but not entirely: with several shapes redrawing concurrently, residual heartbeat gaps up to ~287ms (4 shapes) / ~848ms (30 shapes) remain, traced to the internal Poster's required synchronous image decode (loadSync="true" — needed to avoid a worse bug: async loads went blank under rapid uri churn).
  • SceneGraph fires a field's change notification once per attribute, not once per XML tag — several attributes set on one tag used to trigger several real redraws. Fixed with a duration="0" debounce Timer; confirmed on-device with direct call-count instrumentation that N attribute changes now coalesce into exactly 1 real redraw (including the zero-attribute default-render case).
  • Default useBitmapPooling: true preallocated ~44MB per shape instance, exhausting memory past ~20-25 concurrent shapes. Fixed with useBitmapPooling: false (each shape's Renderer is one-shot/disposable, so pooling had no benefit to lose).

Known, undstill-open limitation

Mixing all four shape types at 30 concurrent instances non-deterministically drops 4-6 shapes with no visible error. Root cause not identified; documented in docs/scenegraph-shapes.md's "Mass construction" section rather than left unmentioned. rects:30 (single shape type) and mixed:20 are both clean.

Testing

  • npm run lint / npm run validate / npm run test:ci (744 passed) / npm run test:ropm-consumer all pass, matching this repo's documented baseline (one pre-existing unrelated lint warning, 3 known upstream emitDefinitions typedef errors).
  • Every claim above was independently reproduced on a real Roku device with fresh instrumentation across multiple verification passes, not taken on a single agent's report.

🤖 Generated with Claude Code

markwpearce and others added 12 commits August 24, 2026 19:02
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… Triangle, Polygon)

Adds four Poster-extending SceneGraph components under src/components/Shapes/,
each backed by a private BGE.Renderer over its own roBitmap, sharing
redraw/caching logic (SHA1-hashed tmp:/ PNG cache) via
src/source/utils/ShapeComponentHelpers.bs. Adds examples/scenegraph, a pure
SceneGraph demo app (no roScreen/BGE.Game anywhere) exercising all four shapes
plus an Animation-driven per-shape redraw/sec measurement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real-hardware fixes found via examples/scenegraph:
- roFileSystem is MAIN|TASK-only and crashes on the render thread; use
  MatchFiles() for the cache-hit check instead.
- Poster's async image load starves under fast/repeated uri changes,
  leaving a shape blank; force loadSync=true.
- Each component's XML already wires onChange to onShapeFieldChanged;
  the redundant m.top.observeField() calls in init() doubled every
  redraw (including the initial paint). Removed.

Adds per-redraw wall-clock timing (BGE.ShapeComponentHelpers.startRoundTripTimer/
logRoundTrip, a lastRoundTripMs field on each component) exposed on screen in
examples/scenegraph, revealing all four shapes cost ~150-200ms per redraw
regardless of complexity (PNG encode/write/decode dominates over the draw
call), not just Polygon as originally guessed.

Adds a Task-based prototype (examples/scenegraph/src/components/Experimental/
CircleTask, TaskCircle - not part of the shipped components) proving a Task
frees the render thread during a redraw (confirmed via a heartbeat Timer) at
the cost of a ~600ms one-time Task thread spin-up.

Confirmed and reverted: Poster.uri does not accept a roBitmap directly.

docs/scenegraph-shapes.md updated with all findings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rray)

The build subagent left vertices as a plain array field, documented as
'can't be expressed as a plain XML attribute' - untrue, and the whole
point of the request. Switch to vector2darray so it's a literal XML
attribute; verticesFromField() now accepts both that (index [0]/[1])
and the imperative {x,y} associative-array form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…height race)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nder Task

Fixes two bugs found during animation experimentation (issue #61):
- Poster width/height auto-scale race: RoundedRectangle/Circle/Triangle/Polygon now
  extend Group with one internal child Poster whose width/height are never set, only
  its uri swapped once a render is ready - no more stretching a stale bitmap to a
  newly-set size.
- Render-thread blocking: rendering now runs on one shared, generic ShapeRenderTask
  component (dispatches on a shapeType field), one persistent instance per shape
  component reused across redraws via control="RUN".

ShapeComponentHelpers.beginShapeRender() split into checkShapeCache() (synchronous
cache-hit check, callable from the render thread) and createShapeRenderState()
(bitmap/renderer setup for a confirmed miss, callable from the Task thread).

Removes the experimental CircleTask/TaskCircle prototype from examples/scenegraph
(findings folded into docs/scenegraph-shapes.md and the design spec) and updates
MainScene accordingly.

Consumer-facing XML tags/fields are unchanged.
…surement

Verified on real hardware: the shared-Task redesign eliminates render-thread
blocking for the draw+encode+write work, but does not reduce it to zero once
all four shapes animate concurrently - 100/~400 heartbeats exceeded 70ms over
a sustained 20s run (worst case ~287ms), vs the original synchronous design's
worst case of ~316ms under the same test. The residual cost tracks to the
internal Poster's required loadSync="true" (synchronous image decode) plus
Task/render-thread scheduling contention, not the draw+encode+write work.

Also confirmed by hardware experiment: switching the internal Poster to
Poster's async loadSync default (to test whether the Task's own redraw
throttling makes it safe, as it is for the single-shape TaskCircle prototype)
reintroduces the original 'shape goes blank' bug under this four-shape
concurrent-animation design. loadSync="true" stays required; no source change
needed since it was already the shipped default - docs/spec updated to record
the experiment and stop overclaiming zero blocking.
Follow-up from independent verification: a repeating Animation over a
fixed range converges to near-all-cache-hits after its first cycle, so
sustained throughput is well below the raw ~5-7 redraws/sec ceiling.
Also note the shape cache has no eviction, fine for bounded/repeating
values but not for genuinely unbounded ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Filenames are a pure content hash of the shape's inputs, so identical
inputs are always safe to reuse - tmp:/ is cleared every session,
cachefs:/ persists across relaunches until the OS evicts it under
storage pressure. checkShapeCache()'s existence check already handles
an eviction the same as any other cache miss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SceneGraph fires onChange once per attribute during XML construction,
not once for the whole tag - so a shape with several attributes set at
once (color, width, height, ...) redrew 2-4 times on construction, each
a real cache-miss render. Switch from XML onChange= to observeField(),
registered once in init() after construction finishes (so it can't fire
on construction-time values), plus one explicit initial redraw() call -
now exactly one redraw per shape per construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…alone

The previous fix (7b23385) assumed SceneGraph batches a tag's XML
attributes into one field-change notification after construction -
disproven on real hardware with call-count diagnostics: observeField()
fires exactly once per field, exactly like onChange= did. Its apparent
success was an accident of Task pre-emption (reusing one
ShapeRenderTask means only the last of several redraws actually
completes), not a real fix.

Real fix: onShapeFieldChanged (and init()'s own initial trigger) never
call redraw() directly - they restart a duration="0" Timer child node;
only the timer's fire event calls the real redraw(). Every attribute on
an XML tag applies synchronously before the render thread next
processes timer callbacks, so N field changes restart the timer N times
but only the last restart survives to fire, coalescing into exactly one
real redraw.

Verified on real hardware with temporary diagnostic call counts (removed
before this commit): every shape showed the targeted N:1 ratio (e.g.
RoundedRectangle's 4 XML attributes -> 4 restarts, 1 real redraw; a
zero-attribute <Circle /> -> 0 restarts, 1 real redraw from init()
alone). A single post-construction imperative field change still
redraws correctly with ~1ms added latency. The sustained Animation-driven
redraw path was re-verified over a 20s run and still works.

Corrects the false "batching" claim in code comments, docs/scenegraph-shapes.md,
and specs/2026-08-24-scenegraph-shape-components-design.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…#61)

Adds StressScene to examples/scenegraph (reachable via `scene=stress`
launch param, not the default view) to build a grid of 20-30 shape
component instances at once and measure mass-construction behavior that
the 4-shape MainScene demo never exercises - each shape owns its own
persistent ShapeRenderTask, so this is 20-30 near-simultaneous Task
spin-ups.

Found and fixed a real bug on real hardware: each shape component's
private Renderer used the default useBitmapPooling: true, eagerly
preallocating a ~44MB ScratchBitmapPool per instance regardless of
whether it's ever needed. At 25+ concurrent shapes this exhausted
bitmap memory - "Failed to create bitmap" failures, 10x render-time
blowup, and shapes permanently stuck mid-render. Fixed in
ShapeComponentHelpers.createShapeRenderState() by passing
useBitmapPooling: false, since a shape component's Renderer is
one-shot and disposable. Verified: 25 and 30 concurrent
RoundedRectangle instances now complete with zero bitmap-creation
failures (2213ms/2498ms total, max heartbeat gap 848ms/627ms).

Also found, documented, but not root-caused: mixing all four shape
types at count 30 still measured 4-6 shapes silently never completing,
with no error anywhere (including after adding a diagnostic print to
ShapeRenderTask.doRender()'s one known silent-failure path). Bisected
clean at mixed:10/20/25 and rects:30; only mixed:30 reproduced the
hang, non-deterministically. See docs/scenegraph-shapes.md's "Mass
construction" section and the design spec's stress-test findings.

Debounce coalescing (multi-attribute -> one redraw, commit cab3f74)
was spot-checked under this load and still holds exactly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@markwpearce

Copy link
Copy Markdown
Owner Author

All the "code behind" files should declare the type of m

for example:

typecast m as ICircle

interface ICircle
  top as INodeCircle
  image as roSGNodePoster
  redrawTimer as roSGNodeTimer
  ' etc.
end interface


interface INodeCircle extends roSGNode
    color as integer
    width as float
    height as float
    ' etc.
end interface

' @param {string} uri the cache file URI to render into (from `checkShapeCache()`)
' @param {integer} width bitmap width in pixels
' @param {integer} height bitmap height in pixels
' @return {object} `{uri, renderer, bitmap, shapeType, timer}` - `renderer`/`bitmap` are

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use an interface for exact typing here

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with typed ShapeCacheCheck/ShapeRenderState interfaces for checkShapeCache()/createShapeRenderState()/finishShapeRender(), and roTimespan typing for the round-trip timer helpers, in 4a3b00f.

Addresses PR review feedback: every SceneGraph shape component's own
script (RoundedRectangle/Circle/Triangle/Polygon/ShapeRenderTask) now
declares typecast m as I<Name>, with INode<Name> extends roSGNode
listing its own XML interface fields and I<Name> typing top/children
precisely (roSGNodePoster, roSGNodeTimer, the shared
BGE.ShapeComponentHelpers.INodeShapeRenderTask).

Also replaces loosely-typed 'as object' returns in
ShapeComponentHelpers.bs (checkShapeCache -> ShapeCacheCheck,
createShapeRenderState -> ShapeRenderState, startRoundTripTimer ->
roTimespan) per the reviewer's inline comment on that file.
…ilds

- Clamp/default the parsed shape count (val() returns 0 for a
  non-numeric string) - spec=rects:0 or rects:abc previously collapsed
  buildGrid()'s grid-row math to a division by zero.
- Normalize spec's variant to lowercase, matching main.bs's own
  lowercasing of the scene launch param - spec=Mixed:30 previously
  fell through to the wrong (rects) variant silently.
- Add a build-generation guard so a still-in-flight observer from a
  torn-down previous build (e.g. cycling spec quickly) can't corrupt
  the new build's counters. Encoded into each shape's id field, not a
  custom field, since a typed shape component silently rejects any
  field not in its own declared XML interface (confirmed on hardware).
- Replace three near-identical onSpotCheckNRedrawn subs with one
  parameterized handler keyed off the observer event's node id.
docs/ is published unreviewed to the JSDoc site (see CLAUDE.md) and
this repo's convention is fundamentals-over-gotchas asides, not
standalone investigation logs. Trims the 'Mass construction' section
to a short factual aside and keeps the detailed
bisection/measurements/timings in specs/, which already had most of
it - plus records this PR review pass's follow-up investigation
(ruled out the Renderer/bitmap-pooling path as the mixed:30 cause via
a real-hardware retest after adding failure diagnostics there; the
missing shapes' doRender() never runs at all, still unresolved).
@markwpearce

Copy link
Copy Markdown
Owner Author

Applied across all five component codebehinds (`RoundedRectangle`/`Circle`/`Triangle`/`Polygon`/`ShapeRenderTask`) in 4a3b00f: each now starts with `typecast m as I`, backed by an `INode extends roSGNode` interface (typed against that shape's actual XML fields) and an `I` interface typing `top` plus every child node found via `findNode()` (`roSGNodePoster`, `roSGNodeTimer`, a shared `INodeShapeRenderTask` for the render Task). `ShapeComponentHelpers.bs`'s loosely-typed `as object` returns are now `ShapeCacheCheck`/`ShapeRenderState` interfaces too. Compiles clean under `npm run validate`.

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.

Ship a SceneGraph component that renders images with BGE.Renderer (Poster-based, Task if needed)

1 participant