Transform/Cull Pass Plan
Goals
- Make scroll/drag movement cheap by avoiding full layout work on every frame.
- Preserve correct clipping/culling when only transforms change.
- Keep existing widget composition semantics intact.
- Prepare renderers for future local‑space geometry + world transforms.
Non‑Goals (for the first phase)
- No change to public widget APIs.
- No rework of text/rectangle/image renderers yet.
- No batching or uniform‑driven transforms in phase 1.
Current Layout Pipeline (summary)
LayoutRoot:updateruns:- Measure pass (recomputes sizes)
- Layout pass (writes world positions/rotations, clip visibility)
- Scroll/move currently marks layout‑dirty, forcing full layout.
Proposed Pipeline
- Measure Pass (unchanged)
- Computes
layout.measurefor local sizes.
- Computes
- Layout Pass (local only)
- Computes local sizes/offsets and stores them in new fields.
- Avoids writing world position/rotation.
- Transform/Cull Pass (new)
- Walks tree in depth-first order (parent before children).
- Composes world transforms:
world = parent.world × local. - Calls
update-clip-regionto recomputeclip.boundsfrom new world positions. - Updates
clip-visibilityviacompute-clip-visibility. - Applies
parent-culled?propagation.
Data Model Changes
- Add
layout.local-position,layout.local-rotation.- Initial values:
local-position = vec3(0),local-rotation = quat(1,0,0,0).
- Initial values:
- Keep
layout.sizeas the single size field (nolocal-size; see Design Decisions). - Keep existing
layout.position,layout.rotation,layout.sizeas world values (size is both local and world). - Add
layout.transform-dirtyflag +transform-dirtqueue inLayoutRoot.
Dirt Rules
mark-measure-dirty: unchanged; still triggers layout + transform for subtree.mark-layout-dirty: triggers layout + transform for subtree.mark-transform-dirty(new): skips measure/layout, runs transform/cull only.
Phase 1: Transform/Cull Pass Scaffolding
1.1 LayoutRoot
- Add
transform-dirtqueue (same bucket-queue structure as existing queues). - Add
transform-timerand stats counters. - Update
update:- Run measure pass (existing).
- Run layout pass (existing).
- Run transform pass on
transform-dirtqueue. - Ensure measure/layout enqueue transform for affected roots.
1.2 Layout
- Add
local-position,local-rotationfields. - Add
run-transformermethod (mirrorsrun-measurer/run-layouterpattern):fennel(fn run-transformer [self skip-dirt-clear?] (local root self.root) (when (and root root.transform-dirt (not skip-dirt-clear?)) (root.transform-dirt:remove self)) ;; Compose world from parent + local (local parent self.parent) (if parent (do (set self.position (+ parent.position (parent.rotation:rotate self.local-position))) (set self.rotation (* parent.rotation self.local-rotation))) (do (set self.position self.local-position) (set self.rotation self.local-rotation))) ;; Update clip bounds and visibility (when self.update-clip-region (self:update-clip-region)) (local own-visibility (self:compute-clip-visibility)) (local effective-visibility (if self.parent-culled? :culled own-visibility)) (set self.clip-visibility effective-visibility) (if (= own-visibility :outside) (self:set-self-culled true) (self:set-self-culled false)) ;; Recurse children (when self.children (each [_ child (ipairs self.children)] (child:run-transformer true)))) - Update setters:
set-local-position(new): markstransform-dirtyonly.set-local-rotation(new): markstransform-dirtyonly.set-position/set-rotation: continue to marklayout-dirty(world position changes imply layout invalidation).
- Update
run-layouterto writelocal-*fields instead of world values directly.- Note: until Phase 4, leaf widgets that bake world-space vertex data must either:
- continue to run their layouters after transform changes, or
- opt out of transform composition with an
absolute?flag.
- Note: until Phase 4, leaf widgets that bake world-space vertex data must either:
1.3 Clip Region Handling
update-clip-regioninscroll-area.fnlcurrently readslayout.position/rotation/size.- After transform pass refactor, these remain world values—no change needed to
update-clip-regionitself. - Key insight:
update-clip-regionmust be callable from the transform pass, not just fromrun-layouter. Add an optionallayout.update-clip-regionmethod thatScrollAreasets (e.g.,layout.update-clip-region = update-clip-region), whichrun-transformerwill invoke.
1.4 Defensive Assertions
- Add assertion in transform pass: if a node being transformed still has
layout-dirtyset, error immediately. This catches ordering bugs where transform runs before layout.fennel(when (and root root.layout-dirt (root.layout-dirt:contains self)) (error (.. self.name " has stale layout-dirty during transform pass")))
1.5 Rooting/Reparenting
- Update
set-rootto enqueuetransform-dirtyfor nodes that weretransform-dirtybefore rooting. - Ensure
remove-child/add-childcorrectly recomputes transform dirt for subtree roots.
Phase 2: Convert Container Layouters to Local Space
Update layouters to set:
child.local-position(offset within parent)child.local-rotation(relative rotation)child.size(size computed from parent + measure—nolocal-sizeneeded, see Design Decisions)
Target widgets (good candidates):
Stack,Flex,Grid,Padding,Sized,Aligned,Positioned,ListView,ScrollView,ScrollArea,Container.
Migration pattern (example for Stack):
;; Before
(set child.position self.position)
(set child.rotation self.rotation)
(set child.size self.size)
;; After
(set child.local-position (glm.vec3 0 0 0)) ; stack children share parent origin
(set child.local-rotation (glm.quat 1 0 0 0))
(set child.size self.size) ; size stays as single fieldPhase 3: Scroll/Drag Wiring
3.1 ScrollArea
set-scroll-offsetshould callmark-transform-dirtyinstead ofmark-layout-dirty.- Since scroll offset affects
child.local-position, this is purely a transform change.
3.2 Movables
Current behavior (movables.fnl line 224):
(drag.entry.target:set-position new-position)This sets world position directly. Two options:
Option A: Keep world position for movables
set-positioncontinues to marklayout-dirty.- Drag operations don't benefit from transform-only updates.
- Simpler, acceptable if drag doesn't need optimization.
Option B: Convert movables to local space (recommended for full optimization)
- Drag target stores offset relative to its parent.
set-local-positionmarkstransform-dirtyonly.- Requires tracking parent's world position to compute local offset from world hit point.
- More complex, but unlocks cheap drag.
Recommendation: Start with Option A. If drag performance is a problem, implement Option B in a follow-up phase.
- Alternative: add
layout.absolute?to skip transform composition for nodes that must stay in world space until movables are migrated.
3.3 Clip Visibility
- Ensure
run-transformerinvokesupdate-clip-regionfor scroll containers. - Test that scrolling updates
clip-visibilitywithout triggeringlayout-dirty.
Phase 4: Renderer Prep (future)
- Allow leaf widgets to emit local‑space geometry once.
- Renderers apply
worldtransform via uniform or matrix stack. - This unlocks "move without touching glyph buffers."
Validation Steps
Unit Tests
- Transform pass updates world position without rerunning layouter.
- Clip visibility updates on scroll without layout.
mark-transform-dirtydoes not enqueue to measure or layout queues.- Assertion fires if transform pass runs on a node with stale layout-dirty.
Regression Tests
- Existing layout tests continue to pass.
- ScrollView tests still clamp offsets and update scrollbar.
Performance Tests
- Scroll a
ScrollAreafor 100 frames; assertlayout-dirtcount is 0 for frames 2–100. - Capture
layout-deltatiming before/after to measure improvement. - Compare frame times for drag operations on movables.
Risks / Open Questions
Clip Regions
update-clip-regioninscroll-area.fnlreads world positions. After refactor, world values are populated by transform pass—ensure transform pass runs before any code that reads clip bounds.- Document which clip-region updates happen in layout pass vs transform pass.
Leaf Widgets
- Text/Rectangle/Image still write world-space vertex data in their layouters.
- Transform pass won't help them until Phase 4 (renderer prep).
- Acceptable: scroll/drag optimization benefits containers, not leaf geometry.
Transform Pass Ordering
- Transform pass must run after layout pass to avoid stale locals.
- Add defensive assertion (see 1.4) to catch bugs.
Parent Transform Chain
- When composing
world = parent.world × local, if parent is culled, children still need world positions for correct clip testing. parent-culled?propagation is separate from world position composition—both must happen.
Design Decisions
No local-size field
Decision: Skip local-size, keep only size as a single field.
Rationale:
- Size doesn't compose hierarchically like position/rotation. A child's size is computed independently from its measure and parent's available space—it's not
parent.size × local-size. - Looking at layouters (
flex.fnl,stack.fnl), they writechild.sizedirectly based on layout logic, not as an offset from parent. - Position/rotation compose:
world = parent.world × local. Size does not:child.size = layouter_computed_value. - Adding
local-sizewould create confusion about what it means and when to use it.
Keep position + rotation, no transform matrix
Decision: Keep separate position + rotation fields (no combined matrix) for Phases 1–3. Consider adding a cached matrix in Phase 4.
Rationale:
- Current usage pattern: widgets read
layout.positionandlayout.rotationseparately. A matrix would require changing all call sites. - Composition cost:
vec3 + quat.rotate(vec3)is cheap. Full 4x4 matrix multiply adds complexity without significant benefit. - No scale: the current system doesn't have scale—just position and rotation. A matrix would be overkill.
- Phase 4 option: when renderers start using transforms, compute
world-matrixlazily on read, caching alongside position/rotation.
World position via local-space conversion
Decision: Widgets that need to set absolute world position should convert to local space on set.
Rationale:
Positionedalready works this way—it stores anoffsetand composes with parent's world position.- For movables (drag), compute
local = inverse(parent.rotation).rotate(world - parent.position). - Alternative for fixed HUD elements: add a
layout.absolute?flag where the transform pass skips composition. Only needed if there are HUD elements that must ignore parent transforms.
Migration for Positioned (already local-space):
;; Current (writes world values via composition)
(set child.layout.position (+ self.position (self.rotation:rotate offset)))
(set child.layout.rotation (* self.rotation local-rotation))
;; After (writes local values, transform pass composes)
(set child.layout.local-position offset)
(set child.layout.local-rotation local-rotation)