RUNE-1017: Story Layer 3D Transform Proposed
Summary
Non-root LayerSprite instances may flatten their complete child subtree as one planar image and project it with semantic X/Y/Z rotation, Z translation, and perspective. The implementation keeps Story painter order, child-local clips, effects, dynamic content, and isolated layer opacity. V1 is not a general 3D scene graph: it has no raw matrix authoring, nested projective layers, root projection, near-plane clipping, depth sorting, or cross-frame layer cache.
Motivation
Applying a 4x4 matrix to individual primitives would change overlap, group opacity, clips, text, effects, SVG, image, and video behavior. A card or window must rotate as one rigid plane:
LayerSprite subtree
-> capture to one ID2D1CommandList image
-> apply one CLSID_D2D13DTransform
-> draw once in parent painter orderStory Authoring Surface
- create@storePlane:
layer:
- image@storeWindow: assets/store-window.png
width: 1200
height: 720
x: 360
y: 180
rotationX: 8
rotationY: -32
rotation: 3
translateZ: -120
perspective: 1200
transformOrigin: 50%,50%
perspectiveOrigin: 50%,50%
planeBounds: 0,0,1200,720
backface: visibleProperties
| Property | Type | Default | Contract |
|---|---|---|---|
rotationX | animatable number | 0 | degrees around local X |
rotationY | animatable number | 0 | degrees around local Y |
existing rotation | animatable number | 0 | Z rotation in the same 4x4 matrix |
translateZ | animatable number | 0 | DIPs; positive moves toward viewer |
perspective | animatable number | 1200 | finite positive depth in DIPs |
existing transformOrigin | static origin | 0,0 | scale/rotation pivot |
perspectiveOrigin | static origin | 50%,50% | independent vanishing-point origin |
planeBounds | static optional rect | automatic | absolute finite pixels, positive size |
backface | static enum | visible | visible or hidden |
Existing scale, x, and y become animatable fields in the 4x4 descriptor while 3D is active. x/y remains top-left positioning; LayerSprite does not gain align. Authors use transformOrigin: 50%,50% for a centered rotation pivot and perspectiveOrigin: 50%,50% for a centered vanishing point.
Without planeBounds, finite native pre-projective local bounds define capture, projected corners, and relative origins. Explicit bounds are recommended for stable window/card/video planes and clip the captured source to the rectangle.
Activation
Assigning any 3D-specific property permanently activates the layer instance, including assignment of an identity/default value:
rotationX, rotationY, translateZ, perspective,
perspectiveOrigin, planeBounds, backfaceExisting position, scale, rotation, transform origin, opacity, and child content do not activate 3D alone. Permanent activation prevents command-shape changes when numeric animations return to identity. Invalid property values fail before mutation or activation.
Snapshot and Matrix Contract
An active layer records no ordinary layer translation/scale/rotation commands. Its children record in layer-local coordinates, and the snapshot carries group_opacity plus the 76-byte RuneLayerComposite3D ABI defined by RUNE-303.
Rune uses row vectors:
q = [x y 0 1] * Final
screen = [q.x / q.w, q.y / q.w]
translation = M41, M42, M43
World =
T(-O) * S * Rx * Ry * Rz * T(0,0,translateZ)
* T(O.x + positionX, O.y + positionY, 0)
V = (positionX, positionY) + perspectiveOrigin
Projection = T(-V) * Perspective(depth) * T(V)
Final = World * ProjectionThe Direct2D matrix is not transposed. Existing Story rotation appears exactly once as Rz. Perspective produces w = 1 - z/depth.
All nine descriptor RuneValues and group_opacity participate in node-local ownership validation, liveness, settle time, snapshot copying, and shell publication. Animated matrices publish conservative infinite projected bounds. Clean parent shells recompute local, transformed, and projected bounds plus aggregate settle time from replacement child snapshots.
Direct2D Compositing
When replay reaches an active child, the backend prepares and draws it just in time:
- Resolve its descriptor and projection plane.
- Reject singular, camera-crossing, edge-on, or hidden-backface frames.
- Create an auxiliary D2D device context and command list.
BeginDraw, capture under a local plane clip,EndDraw, and detach the auxiliary target.- Create
CLSID_D2D13DTransformon the main context. - Set the final matrix, fixed linear interpolation, and soft border.
- Draw the effect at the current child boundary.
The main context's target, ancestor clips/layers, and transform remain untouched during capture. The implementation rebuilds the auxiliary context, command list, and effect for each active child draw; no V1 cross-frame or repeated-reference cache exists.
Layer-local clips are captured and rotate with the plane. Ancestor clips remain in target space and apply after projection. The parent applies only ancestor affine state when drawing the effect; layer position is already in the 4x4 matrix.
Backface and Singular Frames
The projected plane is skipped for a frame when any transformed/divided coordinate is non-finite, any |w| <= 1e-6, an edge crosses w=0, all corner W values are negative, or projected area is non-finite/edge-on. Hidden backfaces additionally skip reversed winding. Visible backfaces retain reversed winding; negative scale counts as a reflection.
V1 skips a camera-crossing plane instead of clipping it against a near plane. It never allocates an unbounded application-owned offscreen texture.
Opacity
LayerSprite opacity is corrected isolated group opacity for both inactive and active layers. The engine composes overlapping descendants first, then applies layer opacity once. An active layer applies the clamped product of legacy node opacity, snapshot group opacity, and inherited opacity once around the projected output. Nested ordinary layer opacities continue to multiply once per child boundary. Root 3D remains forbidden, but ordinary StoryScene.RootLayer group opacity is isolated once around complete root replay.
Root, Nesting, and Painter Order
Story rejects an active root layer when attaching the render tree. It also rejects activation below an active ancestor, activation above an active descendant, and addition of an active 3D subtree below an active layer. These checks are transactional. The native backend independently skips an active root or an active plane containing another active plane as a defensive rule.
Multiple active siblings are supported. They remain in Story/CMD_DRAW_CHILD painter order. V1 does not sort by translateZ, share a Z-buffer, or intersect planes.
Animation, Morph, and Lifecycle
rotationX,rotationY,translateZ, andperspectiveuse ordinary presentation seeding, retargeting, settle, and snapshot animation behavior.transformOrigin,perspectiveOrigin,planeBounds, andbackfaceare static.- Layer-to-layer morph animates numeric 3D targets and applies static target metadata immediately.
- An active source layer transfers omitted numeric/static 3D state and activation to a layer target.
- Inactive-to-inactive layer morph does not activate default numeric 3D properties.
- Morphing an active layer to a non-layer is rejected before replacement.
- Inherited targets are validated before the original is renamed. Later morph failures remove the replacement and restore the original public identity.
- Failed render attachment unwinds partial resources/node/handle state and leaves the layer detached and retryable.
- Detach/reattach preserves model values and activation; no persistent D2D effect resource exists.
- Frozen snapshots retain the complete descriptor and group opacity.
V1 Scope
Projection sampling is fixed linear. V1 exposes no raw Matrix4x4, sampling option, root projection, nested active 3D, near-plane clipping, true depth sorting, Z-buffer, plane intersections, separate back texture, or explicit layer cache.
Alternatives Considered
| Approach | Rejected because |
|---|---|
| Transform every primitive | Breaks rigid-subtree overlap, opacity, clips, and effects |
| Raw 4x4 Story property | Hard to validate and animate; semantic properties cover target transitions |
| D3D textured quad | Unnecessary until true clipping/depth/shaders or large plane batches are required |
| Windows Composition visual per layer | Conflicts with exact-time snapshot replay and export architecture |
| Cross-frame command-list/effect cache | Requires subtree generation and dynamic resource invalidation |
Dependencies
- RUNE-303 defines immutable metadata, ABI layout, liveness, settle time, and shell recomputation.
- RUNE-305 defines D2D child-boundary compositing, group opacity, auxiliary capture, and skip rules.
- RUNE-1003 defines morph replacement.
- RUNE-1014 defines the Story layer tree and painter order.
Test Strategy
- Verify identity activation, top-left position, centered origins, explicit/automatic plane bounds, and row-vector X/Y/Z matrix order.
- Verify numeric animation and rest preserve permanent activation.
- Verify just-in-time auxiliary-context capture and fixed linear projection of image, path, text, SVG, effects, clips, and dynamic content.
- Verify visible/hidden backfaces, negative scale, edge-on area, singular W, camera crossings, and all-negative W skip rules.
- Verify layer opacity applies once to overlapping descendants and nested group opacity multiplies.
- Verify active siblings preserve painter order and root/nested activation is rejected transactionally.
- Verify transactional attachment and morph inheritance/static transfer, non-layer rejection, frozen snapshots, and detach/reattach lifecycle.
- Verify child-only commits recompute shell bounds and aggregate settle time while preserving metadata.