SceneGraph shape components: RoundedRectangle, Circle, Triangle, Polygon (#61) - #164
SceneGraph shape components: RoundedRectangle, Circle, Triangle, Polygon (#61)#164markwpearce wants to merge 15 commits into
Conversation
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>
|
All the "code behind" files should declare the type of for example: |
| ' @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 |
There was a problem hiding this comment.
Use an interface for exact typing here
There was a problem hiding this comment.
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).
|
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`. |
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, usingBGE.Rendererinternally.ropm installthe 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 aGroupwrapping a childPosterand one persistent, sharedShapeRenderTaskinstance (src/components/Shapes/ShapeRenderTask.{xml,bs}).cornerRadius,outlineSegments,vertices).Triangle/Polygon'sverticesis avector2darray, settable directly as an XML attribute.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 (noroScreen/BGE.Gameanywhere), 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 (noroScreenanywhere in the process) is sufficient to realize aTriangle/Polygon's one-timegetRightTriangleResource()build — the load-bearing risk this issue called out up front.roFileSystemfails on the render thread;MatchFiles()doesn't have that restriction.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 inGroupwith an internalPosterwhosewidth/heightare never set.Taskremoves 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 internalPoster's required synchronous image decode (loadSync="true"— needed to avoid a worse bug: async loads went blank under rapidurichurn).duration="0"debounceTimer; 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).useBitmapPooling: truepreallocated ~44MB per shape instance, exhausting memory past ~20-25 concurrent shapes. Fixed withuseBitmapPooling: false(each shape'sRendereris 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) andmixed:20are both clean.Testing
npm run lint/npm run validate/npm run test:ci(744 passed) /npm run test:ropm-consumerall pass, matching this repo's documented baseline (one pre-existing unrelated lint warning, 3 known upstreamemitDefinitionstypedef errors).🤖 Generated with Claude Code