Skip to content

Add parallax/scrolling background layers (#67) - #87

Merged
markwpearce merged 24 commits into
mainfrom
feature/parallax-layers-v2
Aug 1, 2026
Merged

Add parallax/scrolling background layers (#67)#87
markwpearce merged 24 commits into
mainfrom
feature/parallax-layers-v2

Conversation

@markwpearce

@markwpearce markwpearce commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds BGE.DrawableParallaxLayer/BGE.SceneObjectParallaxLayer - a bitmap-based drawable that scrolls at a configurable per-axis fraction of the camera's movement, with optional tiling to cover the viewport. Closes Scrolling / parallax background layers #67.
  • Fixes a frustum-culling bug (found via real on-device testing) where a parallax layer could vanish entirely once the camera traveled far enough from its owning entity - exactly the scenario parallax backgrounds exist for.
  • Adds examples/parallax: a real Game/Room with camera-follow gameplay, using a real 5-layer CC0 art pack (Luis Zuno / @ansimuz, attributed in-repo), a debug info toggle, and draw-mode cycling.
  • Updates docs/drawables-and-scene-objects.md.
  • Design spec and implementation plan committed under specs/ for reference.

Test plan

  • npm run check (lint + validate + full Rooibos suite, 457 tests) - clean
  • examples/parallax builds and validates clean (npm run build, bsc --validate)
  • Verified on real Roku hardware via rokubot: 5 layers scroll at visibly distinct depth-matched rates, correct front-to-back ordering, no seams, no vanishing after sustained camera movement in either direction, debug overlay toggle and draw-mode cycling both work
  • npm run check:all - examples/parallax itself passes; several unrelated examples (3d, asteroids, canvas, hybrid, pixels, pong, snake, terrain) fail due to a pre-existing stale brighterscript version in their own node_modules (unrelated to this branch - none of them were touched here)

🤖 Generated with Claude Code

markwpearce and others added 24 commits July 31, 2026 18:22
Covers the SetWrap() spike result (compositor-only, not usable with this
engine's direct ifDraw2D rendering path), the resulting manual-tiling
approach, a new DrawableParallaxLayer/SceneObjectParallaxLayer pair, and
the camera-movement dirty-checking gap that a parallax layer needs but
ordinary drawables don't.

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

Deeper reading of SceneObject.bs during planning found the camera-
movement recompute gap doesn't need touching update()/forceRecompute at
all - draw()'s existing objMovedInRelationToCamera() already ORs in
camera movement by default, so the fix lives entirely in an overridden
findCanvasPosition(), no base-class changes required. Also updates scope:
a dedicated examples/parallax replaces the originally-deferred
rendererTest demo, since rendererTest never exercises Drawable/
SceneObject/GameEntity for any existing pair.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the skeleton for Task 1 of the parallax layers feature:
- Add SceneObjectType.ParallaxLayer enum value
- Implement DrawableParallaxLayer class with region, parallaxFactor (default {1,1}),
  repeatX (default true), and repeatY (default false) properties
- Implement minimal SceneObjectParallaxLayer class to satisfy test requirements
- Add comprehensive test suite for construction and addToScene behavior

All tests pass (442 passed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
findCanvasPosition computes a parallax-shifted effective world position
(camera-delta scaled by 1-factor, relative to a captured reference
position) and converts it to canvas space; performDraw blits the
region at each computed position. computeTilePositions is a stub
returning a single position for now - tiling lands in a later task.
Camera2d's y axis is inverted relative to world space, so ordinary
(factor 1) scrolling moves canvas y in the same direction as the
camera's own y movement - the opposite sign relationship from x. The
plan's test asserted the x-axis sign convention for y too.

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

The test assertion for the 'x and y factors are independent' test was
incorrect. Camera2d's y-axis is inverted relative to world space (canvas y
increases downward, world y increases upward), so ordinary (factor 1)
scrolling moves canvas y in the SAME direction as camera movement, not
opposite. This is the opposite sign relationship from x-axis.

Changed assertion from 'first.y - 20' to 'first.y + 20' to match the
actual Camera2d behavior documented in worldPointToCanvasPoint().

All 448 tests now pass.
…dent test

Fix() truncates toward zero, not floor, so -Fix(-x) doesn't equal
ceil(x) for positive x as the ceiling trick requires - needed Int()
(true floor) instead. Separately, the "draws one renderer draw call
per tile" test asserted an exact draw-call count that only holds at
one specific tile phase (the +1 safety-margin tile lands off-canvas
at most other phases, including the default entity position the test
used) - picked a fixture position that lands on that phase instead of
weakening the assertion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
computeTilePositions now enumerates a full tile grid honoring
DrawableParallaxLayer.repeatX/repeatY and the region's size (scaled by
Drawable.scale), via a new computeAxisTilePositions per-axis helper.

Two corrections found and confirmed against the plan along the way:
- The tile-count ceiling formula needs Int() rather than Fix() -
  BrightScript's Fix() truncates toward zero, not floor, so
  -Fix(-x) doesn't compute ceil(x) for non-integer ratios.
- The pre-existing "draws at the entity's own canvas position" baseline
  test now expects a Float canvas position, not an Integer: every tile
  position (tiled or not) is built via BGE.Math.VectorOps.create(),
  which reliably produces a Float, correctly matching BGE.Math.Vector's
  declared `x as float` field. The old single-tile stub had let an
  Integer canvas position slip through by accident.

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

The test's layerB used the shared runFrame() helper, which hardcodes
m.entity.updateTransformationMatrix() - not entityB's - leaving entityB
stuck at its default identity transform (world position (0,0,0))
instead of its real (100,50). Its second frame also omitted
renderer.setupCameraForFrame() entirely, leaving Camera2d's cached
projection matrix stale for the moved camera position. Replaced with
an explicit, entity-correct sequence for both of layerB's frames.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
findCanvasPosition() recomputes the parallax shift fresh each frame
from a frozen referencePosition and the camera's current absolute
position - it's a stateless function of the current frame's inputs,
not an incremental accumulator. A "several small moves vs one big
move" comparison therefore evaluates the identical final expression
against the identical final camera position on both paths, so a
uniformly-applied premature-rounding bug would affect both equally and
the comparison would still pass - caught by a task reviewer working
through the arithmetic rather than just trusting the two paths differ.

Replaced with a single-frame test whose exact pre-truncation canvas
position is deliberately fractional at a point where truncating it
(fix(), the real behavior) and rounding it to nearest (cint(), a
plausible premature-rounding bug) land on different integers - the
only design that can actually catch that class of regression.

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

The two-path (ten 1px moves vs. one 10px move) comparison couldn't
actually fail: findCanvasPosition() recomputes the parallax shift fresh
each frame from a frozen referencePosition and the camera's current
absolute position, so both paths evaluate the identical final expression
against the identical final camera position - a uniformly-applied
premature round() would affect both equally.

Replaced with a single frame whose pre-truncation canvas position is
deliberately fractional at a point where truncating (fix(), correct)
and rounding to nearest (cint(), the bug this guards against) land on
different integers (94 vs 95), verified by temporarily reintroducing
the bug and confirming the test fails as expected.

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

Scaffolds a dedicated example: a controllable Player entity, Camera2d.setTarget
following it, and three procedurally-painted parallax layers (distant mountains,
closer hills, and a fast foreground) at different depths/parallaxFactors. Uses
repeatX+repeatY tiling on every layer since DrawableParallaxLayer anchors each
tile from a top-left canvas position rather than centering on the owning
entity - repeatY:false (as in the original plan sketch) leaves roughly half the
canvas uncovered depending on where that corner lands; repeating on both axes
keeps the sky-colored tile top covering the gap seamlessly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 7's on-device testing surfaced a real correctness gap:
SceneObjectParallaxLayer inherits the default frustum check, which
tests the raw (un-shifted) entity position rather than the
parallax-shifted one it actually draws at, and doesn't account for
tiling making a repeating layer visually infinite regardless of
absolute distance. Both cause a layer to vanish once the camera roams
far enough from its owning entity - exactly the scenario parallax
backgrounds exist for. Fixing this in the engine (Task 8) rather than
shipping the example with a documented limitation.

Task 9 swaps the example's procedural art for a real 5-layer CC0 asset
pack the user provided, renumbering the final quality-gate/PR task to
Task 10.

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

Fixes the black-screen bug found during Task 7's on-device testing: a layer's
inherited frustum check tested the raw, un-shifted entity position instead of
the parallax-shifted position it actually draws at, culling the whole layer
once the camera drifted far enough from a static owning entity - regardless
of parallax factor or tiling.

- computeEffectiveWorldPosition() factors the parallax-shift math out of
  findCanvasPosition() so the new frustum-check override can share it.
- getPositionsForFrustumCheck() now tests the effective (shifted) position
  instead of the raw one.
- isPotentiallyOnScreen() short-circuits true whenever either axis repeats,
  since a repeating axis is visually infinite regardless of raw distance.
- lastCameraPosition is refreshed in isPotentiallyOnScreen() itself, not just
  in findCanvasPosition(): the brief's original design only updated it in
  findCanvasPosition(), which doesn't run on a culled frame, so a same-frame
  camera jump would frustum-test against a stale pre-jump camera position and
  cull the layer permanently once the camera stopped moving. Verified by hand
  against the brief's own worked example (100,50 shifting to 385 once the
  camera reaches 400) before landing on this correction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces Task 7's three procedurally-painted layers with 5 real CC0 images
(foreground-trees, trees, mountains, mountain-far, bg), scaled up to fill
the canvas edge-to-edge, with per-layer offsets compensating for
DrawableParallaxLayer's lack of anchor/centering support so the scene is
framed correctly at spawn rather than relying on repeatY's wraparound.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
User-requested follow-up after seeing examples/parallax running:
toggleable debug info overlay and draw-mode cycling, both matching
conventions already established in examples/3d's BaseRoom. Also
removes util.bs's dead goToNextRoom (flagged during Task 7's review -
a single-room example never needs a room cycler). Renumbers the final
quality-gate/PR task to Task 11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires the "options" button (rokubot/ECP alias "info") to Game.debugShowUi()
and the "play" button to cycle every entity's draw mode via the existing
updateDrawMode() helper in util.bs, matching examples/3d's BaseRoom
conventions. Also removes util.bs's dead goToNextRoom, unused scaffold
boilerplate since this example has exactly one room.

Note: the task brief called for checking input.isButton("info"), but BGE's
own button-name table (utils.bs buttonNameFromCode) never produces "info" -
it produces "options" for that physical key, and every other example in the
repo checks isButton("options"). Used "options" to match actual engine/example
convention, confirmed by on-device testing (isButton("info") never fired).
Conflated rokubot's ECP key alias "info" with BGE's actual internal
button name for that key, which buttonNameFromCode() confirms is
"options" - the name isButton() actually checks. Caught by Task 10's
implementer, who built it per the (wrong) brief first, found it
silently never fired on-device, and corrected it.

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

The docs still described SceneObjectParallaxLayer as having a single
overridden findCanvasPosition(), but a later task also added
isPotentiallyOnScreen() and getPositionsForFrustumCheck() overrides for
correct frustum culling. Also add a construction snippet, a link to
examples/parallax, and a note that DrawableParallaxLayer ignores
Drawable's anchor/rotation/color/outline/drawMode fields.

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

- Add a comment above newLayer()'s offset computation noting factor/
  factorY must stay non-zero (the offset scales as 1/factor and
  diverges toward divide-by-zero as it approaches 0).
- Add an optional zOffset param to newLayer() and give the four
  background layers (bg, mountainFar, mountains, trees) distinct Z
  depths instead of all sharing the background entity's single Z -
  their front-to-back draw order now actually comes from the
  documented Z-based renderer sort, not from array-sort stability
  among equal sort keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No existing test exercised the scale-aware tile-spacing math in
computeTilePositions() or the drawScaledObject() branch in
performDraw(), even though every layer in examples/parallax uses a
non-1 scale. Adds one test with scale 2.0 asserting tile spacing
reflects the scaled tile width and that a draw call actually happens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@markwpearce markwpearce changed the title Add design spec for parallax/scrolling background layers (#67) Add parallax/scrolling background layers (#67) Aug 1, 2026
@markwpearce
markwpearce merged commit 999a3c7 into main Aug 1, 2026
3 checks passed
@markwpearce
markwpearce deleted the feature/parallax-layers-v2 branch August 1, 2026 14:09
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.

Scrolling / parallax background layers

1 participant