SceneGraph Shape Components
BGE ships four SceneGraph components under components/Shapes/ - RoundedRectangle, Circle, Triangle, Polygon - that render shapes SceneGraph has no native node for, using BGE.Renderer internally. Drop one in your scene and get a rendered shape with no offscreen-bitmap plumbing of your own: no BGE.Game, no Room, no roScreen. See examples/scenegraph for a runnable demo of all four.
Internally each component is a Group wrapping a child Poster and a shared render Task, but none of that is visible to a consumer - same tags, same fields, same positioning via translation as a simpler Poster-extends design would have. That's the point: it hides an off-render-thread redraw behind an interface indistinguishable from a native node's.
Usage
<RoundedRectangle
translation="[100, 100]"
width="220" height="160" cornerRadius="24"
color="0xFF6B35FF"
outlineColor="0x1B1B1BFF" outlineWidth="4" />
<Circle
translation="[380, 100]"
width="180" height="180"
color="0x3AAFA9FF"
outlineColor="0x1B1B1BFF" outlineWidth="4" outlineSegments="48" />Triangle and Polygon take a vertices field (vector2darray) - points local to the shape's own bitmap - settable directly from XML:
<Polygon
translation="[860, 180]"
width="200" height="200"
color="0xE85D75FF"
vertices="[[100,0],[200,60],[160,200],[40,200],[0,60]]" />or imperatively as an array of {x, y} associative arrays (both forms are accepted):
m.polygon.vertices = [{x: 100, y: 0}, {x: 200, y: 100}, {x: 100, y: 200}, {x: 0, y: 100}]Triangle.vertices is optional - when empty (the default), it draws a right triangle sized from width/height instead. Polygon.vertices is required; nothing is drawn until it's set.
Common fields
| Field | Type | Notes |
|---|---|---|
color | color | Fill color, packed RGBA (0xRRGGBBAA) - the same convention every BGE.Renderer.draw* call uses. |
width / height | float | The shape's own bitmap size. |
outlineColor | color | Packed RGBA. |
outlineWidth | integer | Outline is only drawn when this is > 0 - there's no separate on/off flag. RoundedRectangle/Polygon stroke a real outlineWidth-thick ring; Circle/Triangle always draw a single-pixel stroke regardless of this value (it just gates whether one is drawn), since drawCircleOutline/drawTriangleOutlineTo have no thickness parameter. |
Shape-specific fields
RoundedRectangle.cornerRadius(float) - clamped to at most half ofwidth/height.Circle.outlineSegments(integer) - number of line segments approximating the outline circle.Triangle.vertices/Polygon.vertices(vector2darray, optional/required respectively) - see above.
How it works
Each shape component is a Group wrapping one child Poster (id="image") and one persistent child BGE_ShapeRenderTask-style Task node created once in init() and reused across redraws. The child Poster's width/height are never set - only its uri is ever swapped, and only once a render has actually finished - so there's no frame where a stale image gets stretched into a newly-set box (see "A fixed bug" below). Every field that affects the rendered shape is observed by an onShapeFieldChanged handler, but that handler never redraws directly - it restarts a duration="0" Timer (stop then start); only the timer's own fire event calls the real redraw(). This matters because SceneGraph fires a field's change notification once per field, exactly like an onChange= XML attribute would - there is no batching of an XML tag's several attributes into one notification (see "Hardware findings" below). So setting several redraw-triggering fields as attributes on one tag (e.g. width, height, cornerRadius, color all on one <RoundedRectangle> tag) still calls onShapeFieldChanged once per attribute, but each call just restarts the timer rather than redrawing - since SceneGraph applies every attribute on a tag synchronously before the render thread next processes timer/event callbacks, only the last restart survives to actually fire, so N attribute values coalesce into exactly one real redraw() (confirmed on real hardware by counting each call site directly - see "Hardware findings"). init() itself goes through the same path (calling the debounce-restart function, not redraw() directly), so a shape built with zero explicit attributes (e.g. <Circle />, relying entirely on defaults) still gets exactly one initial render, not zero. redraw(), once it actually runs, does the real work:
- Hashes the shape type plus the current field values (
roEVPDigestSHA1) into acachefs:/bge_shape_<hash>.pngfilename and checks whether that file already exists (BGE.ShapeComponentHelpers.checkShapeCache()) - synchronously, on the render thread, with no bitmap/Taskwork either way. - On a cache hit, skips rendering entirely and just points the internal
Poster.uri(and the component's ownurifield) at the existing file. - On a miss, pushes the draw parameters onto the component's persistent render
Taskand setscontrol="RUN". TheTask(off the render thread) draws via its own privateBGE.Renderer/roBitmap, callsFinish()(there's noroScreenanywhere in a pure-SceneGraph process to force a draw through, but plainFinish()alone is enough to realize the queued draws here - confirmed on real hardware, see below), encodes to PNG (GetPng()), and writes it tocachefs:/. The component observes theTask'sresultUrifield and, once it fires, sets the internalPoster.uriand the component's ownuri.
This makes repeated/discrete field values (toggling between a couple of colors, resizing to a value you've already used) free after the first render - and, since the filename is a pure function of the shape's inputs, permanently free across app relaunches too: cachefs:/ (unlike tmp:/, which is cleared every session) persists until the OS evicts it under storage pressure. A continuously-varying value - most notably an Animation-driven tween - produces a new hash every single frame and never benefits from the cache; see "Animating a shape" below for what that costs in practice. Note an Animation that repeats over the same range (as examples/scenegraph's demo does) only pays that cost during its first cycle - once every value in the range has been rendered once, later cycles are almost entirely cache hits, so real sustained throughput ends up far below the ~5-7 redraws/second ceiling below. The cache has no eviction of its own - fine for a bounded/repeating range of values (the OS itself may evict under storage pressure, which checkShapeCache()'s existence check already handles safely by just re-rendering), but a consumer driving genuinely unbounded values (e.g. a continuously random color) will grow cachefs:/ without bound between OS evictions.
All four shape components share one ShapeRenderTask component (components/Shapes/ ShapeRenderTask.xml/.bs) rather than four near-duplicate Task subclasses - it takes a shapeType field plus the union of draw parameters every shape might need, and dispatches internally to the matching BGE.Renderer.draw*To/draw*OutlineTo call(s).
Animating a shape (not recommended)
You can drive any field with a SceneGraph Animation/*FieldInterpolator node the same way you would any other node field, but measured on real hardware (examples/scenegraph, a roTimespan timing each redraw end-to-end), every shape costs roughly the same ~150-200ms per redraw, regardless of shape complexity:
| Shape | Draw+encode+write | Full round trip (redraw -> visible) |
|---|---|---|
RoundedRectangle | ~55-70ms | ~150-200ms |
Circle | ~55-75ms | ~150-165ms |
Triangle | ~50-60ms | ~150-165ms |
Polygon (4-vertex diamond) | ~55-75ms | ~150-180ms |
The draw call itself is cheap and does scale with shape complexity as expected, but it's a small fraction of the total - PNG encoding (GetPng()), the cachefs:/ file write, and the Poster's own image decode dominate for every shape alike. That caps every shape at roughly 5-7 redraws/second, too slow for smooth continuous animation - all four are redraw-on-change components, not animate-safe ones, regardless of shape type. A discrete/repeated value (toggling between a couple of colors) is still free after the first render, per the caching above.
Because the draw+encode+write work now runs on the shared Task (see below), a continuous Animation blocks the render thread far less than the original synchronous design did - but not to zero, measured with all four shapes animating at once (examples/scenegraph's demo, a 50ms heartbeat Timer): moving the redraw off-thread removes the ~150-200ms draw+encode+write blocking window entirely, but the internal Poster's own image decode still runs synchronously on the render thread (loadSync="true", required - see "A fixed bug" and "Hardware findings" below for why), and with four shapes redrawing concurrently that residual cost still produced heartbeat gaps up to ~287ms over a 20-second sustained run (100 gaps over 70ms in that window), not far below the original fully-synchronous design's worst case of ~316ms. Treat "moves redraw off the render thread" as a real, measured improvement in the common case (one shape redrawing occasionally), not as a guarantee of zero blocking under sustained multi-shape animation - the remaining cost is dominated by synchronous image decode and/or thread scheduling contention between four concurrent Tasks and the render thread on real hardware, not anything the Task itself left undone. Regardless, the shape itself still only visibly updates at ~5-7 redraws/second, since each individual render still costs ~150-200ms - expect a shape driven by a fast Animation to visibly lag a step or two behind the interpolator's actual current value, not to redraw at the interpolator's own frame rate.
Hardware findings (issue #61)
Finish()alone is sufficient to realize aTriangle/Polygondraw with zeroroScreenanywhere in the process.BGE.Renderer.forceDraw()(used internally by the lazygetRightTriangleResource()build the first time any triangle is drawn) has a dummy-screen fallback path for realizing queued draws on real hardware, but that path only runs whenm.dummyScreenis valid - which it never is for aRendererconstructed without aGame, as every shape component's privateRendereris. PlainFinish()on the bitmap was enough; no workaround (constructing a throwawayroScreeninside the component) was needed.roFileSystemis MAIN|TASK-only -CreateObject("roFileSystem")fails on the SceneGraph render thread every shape component script runs on, confirmed by a real crash on hardware ('Dot' Operator attempted with invalid BrightScript Component or interface reference). The globalMatchFiles()function has no such restriction and is used for the cache-hit check instead.Poster.uridoes not accept aroBitmapdirectly - assigning one instead of a file URI silently renders nothing (confirmed on hardware). The PNG-file round trip isn't an optional optimization opportunity; it's the only path found to get pixels into aPosterhere.- A field's
onChangeXML attribute and a same-namem.top.observeField()call both fire - registering both (an early draft of these components did, redundantly) doubles every redraw, including the initial paint. Fixed by relying ononChangealone. - SceneGraph does not batch a tag's several XML attributes into one field-change notification - confirmed on real hardware with call-count diagnostics:
observeField()fires exactly once per field, exactly likeonChange=did, whether the value came from an XML attribute or an imperative assignment. Settingwidth,height,cornerRadius,coloras four attributes on one<RoundedRectangle>tag produces four separate field-change notifications, not one - an earlier attempt at this fix assumed construction-time XML attributes get batched into a single post-construction signal; they don't. The actual order observed:init()runs first (queuing its own initial redraw), then each XML attribute is applied afterward, one at a time, each independently notifying its observer. The real fix is a debounce:onShapeFieldChangednever redraws directly, it restarts aduration="0"Timer(m.redrawTimer.control = "stop"then"start"); only the timer'sfireevent calls the realredraw(). Because every attribute on one XML tag (andinit()'s own initial trigger) applies synchronously before the render thread next processes timer/event callbacks, only the last timer restart survives to fire. Measured directly (not inferred from render/cache log lines, which can look artificially low for an unrelated reason - see below) by counting both "field changed, timer restarted" and "timer fired, real redraw ran" calls at construction: every shape showed exactly the N-changed-fields : 1-real-redraw ratio the fix targets (e.g.RoundedRectanglewith 4 attributes set: 4 restarts, 1 real redraw; a zero-attribute<Circle />relying entirely on defaults: 0 restarts, 1 real redraw frominit()alone). A single post-construction imperative field change (e.g. the demo's Left-button color toggle) still redraws correctly, with the debounce itself adding on the order of 1ms of latency (measured on real hardware) - negligible next to the ~150-200ms render round trip. The previous (incorrect) fix attempt happened to look like it worked because reusing oneShapeRenderTaskinstance means an in-flight run's fields get silently overwritten by a laterredraw()call before it finishes, so only the last of several redraws actually completed and got cached -redraw()itself was still being called once per attribute, it just wasn't visible in the render-completion counts. The debounce fix removes the extraredraw()calls entirely rather than relying on that timing accident. control="RUN"on an already-idleTasknode can be set again to re-run it - nocontrol="STOP"needed first. Confirmed on real hardware: each shape component keeps oneShapeRenderTaskinstance for its whole lifetime and just reassigns its fields pluscontrol="RUN"on every cache-miss redraw.- The internal
Poster'sloadSyncmust stay"true", even withTask-based rendering - tried removing it (since theTasknow naturally throttles how oftenuriactually changes, to ~5-7/second, the same rate that made the originalTaskCircleprototype's default-async load safe). Confirmed on real hardware that this does not hold for the shipped multi-shape design: withloadSyncleft atPoster's async default, all four shapes went completely blank underexamples/scenegraph's concurrent 4-shapeAnimation(redraw counts kept climbing normally - the render/cache/Taskpipeline was working - but nothing ever displayed). Reverting toloadSync="true"fixed it. Root cause not fully isolated, but the width/height race this component design already exists to avoid (see below) is the likely mechanism: something about very-close-together async decodes on the samePosternode under load apparently loses image data rather than just lagging. Async is not worth the risk here even though it measurably reduces render-thread blocking (see "Task-based rendering" below) - a blank shape is a worse failure than an occasionally-janky heartbeat.
Mass construction (20-30 concurrent shapes, issue #61 stress test)
Each shape component instance owns its own persistent ShapeRenderTask, not a shared pool, so a scene with many shape instances means that many near-simultaneous Task spin-ups. A dedicated stress test (examples/scenegraph's StressScene, reachable via scene=stress/spec=<variant>: <count> launch params, not the app's default view) exercises this. A real memory issue turned up and was fixed: each shape's private Renderer no longer preallocates a scratch-bitmap pool it would never use (useBitmapPooling: false in BGE.ShapeComponentHelpers.createShapeRenderState()), since a shape's Renderer does one disposable render and pooling has nothing to amortize there. 20-30 same-type shapes at once are measured-safe after that fix. Mixing all four shape types at count 30 can still non-deterministically leave a handful of shapes never completing, with no error printed; this is a known, unresolved limitation at that specific scale - see specs/2026-08-24-scenegraph-shape-components-design.md for the full investigation, bisection, and measurements.
A fixed bug: width/height auto-scale race
An earlier version of these components extended Poster directly and reused Poster's own native width/height fields as the redraw trigger. Poster auto-scales its currently-loaded uri image to fit those fields on every composited frame - so the instant an Animation (or any other caller) changed width/height, Poster immediately stretched the previous (stale) bitmap into the newly-set box, and only once the redraw finished ~150-200ms later did uri catch up to a correctly-proportioned image. This was visible as RoundedRectangle's corners briefly distorting into ellipses (and similar distortion for the other shapes) during any width/height change - moving that redraw onto a Task (see below) made the window longer, not shorter, so this had to be fixed regardless of sync-vs-Task.
The fix: every shape component is a Group, not a Poster, wrapping one child Poster (id="image") whose width/height are never explicitly set - it always displays its loaded image at native pixel size, so there's no frame where size and image can disagree. Only the child Poster's uri is ever swapped, and only once a render has actually finished.
Task-based rendering (issue #61)
Moving a shape's draw+encode+write work onto a Task node was prototyped first (CircleTask/ TaskCircle, since removed) to check whether it frees up the render thread during a redraw before committing to the architecture change - it measurably does, and this is now the shipped design (see "How it works" above: one persistent ShapeRenderTask per component instance, reused via control="RUN"). Measured with a 50ms heartbeat Timer on the render thread (a gap much larger than 50ms means the render thread was blocked):
- The original direct/synchronous shape redraw blocked the render thread for its whole ~150-200ms - confirmed by heartbeat gaps of up to 316ms while 4 shapes redrew in sequence at startup.
- A
Task-based redraw's first ever run costs ~780ms (Task thread spin-up overhead) but a subsequent ("warm") run costs ~200ms, matching the direct approach's own cost. - With a single shape redrawing occasionally (the original
TaskCircleprototype's test), no large heartbeat gap was produced at all - the render thread stayed fully responsive. - With all four shapes redrawing concurrently and continuously (
examples/scenegraph'sAnimationdemo, the harder and more realistic stress case), the render thread was not fully insulated: over a sustained 20-second run, 100 heartbeat gaps exceeded 70ms (out of ~400 expected heartbeats), with a worst case of ~287ms - close to (if usually somewhat better than) the original synchronous design's own worst case of ~316ms measured the same way. This residual cost is not the draw+encode+write work (confirmed moved off-thread), but is consistent with the internalPoster's synchronous image decode (loadSync="true", required - see "Hardware findings" above) plus general OS-level thread-scheduling contention between four concurrentTasks and the render thread on real hardware.
So a Task is a real, measured improvement - it eliminates the single largest cost (draw+encode+write) from the render thread and keeps things fully smooth for the common case of one shape occasionally redrawing - but does not eliminate render-thread blocking down to zero once several shapes redraw concurrently and continuously. Don't repeat "moves rendering off the render thread" as a blanket "the UI never stutters" claim; it's a substantial mitigation, not a complete fix, for this shipped multi-shape design. Separately, there's still a one-time ~780ms latency hit the first time each component instance actually redraws (its ShapeRenderTask's first control="RUN"), which a latency-sensitive consumer may want to pay upfront (e.g. constructing the shape off-screen ahead of when it needs to appear) rather than on the shape's first real use.
ROPM component names
Following this engine's README guidance and adding brighterscript-game-engine to your own project's ropm.noprefix, the components are available under their plain names: <RoundedRectangle>, <Circle>, <Triangle>, <Polygon>. Without noprefix, ropm prefixes every component name with your installed package alias, e.g. <brighterscriptgameengine_RoundedRectangle> - ropm install output tells you the exact alias it chose (ropm: Copying brighterscript-game-engine@x.y.z as <alias>).