Skip to content

Commit dece756

Browse files
committed
Add composition-driven external rotation API
SetExternalRotation(ExpressionAnimation) wires a caller-supplied Vector3-yielding expression into a composition expression bound to the renderer's root TransformMatrix, so subsequent rotation updates flow on the composition thread without UI-thread involvement. ClearExternalRotation reverts to DP-driven rotation. RebuildForExternalRotation(Vector3) lets callers refresh the painter sort / mesh bake against a snapshot of the animated value. Wired Refresh button into both sample apps and documented the new API in docs/usage.md and docs/rendering-pipeline.md.
1 parent d383a99 commit dece756

9 files changed

Lines changed: 508 additions & 62 deletions

File tree

docs/rendering-pipeline.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,36 @@ changed, `Rebuild()` is never re-invoked, and the displayed image will exhibit
229229
the same depth artefacts that the topological sort exists to prevent — visibly
230230
wrong occlusion for as long as the animation runs.
231231

232-
Three mitigation strategies, in increasing order of effort:
232+
### The supported workaround: `SetExternalRotation`
233+
234+
The control exposes a composition-native rotation API that addresses this
235+
directly:
236+
237+
```csharp
238+
public void SetExternalRotation(ExpressionAnimation rotationDegrees);
239+
public void ClearExternalRotation();
240+
public void RebuildForExternalRotation(Vector3 rotationDegrees);
241+
```
242+
243+
`SetExternalRotation` binds an `ExpressionAnimation` (whose result is a
244+
`Vector3` of degrees `(pitch, yaw, roll)`) to the composition root's
245+
`TransformMatrix` via the engine. The visible 3D rotation then updates on
246+
every composition frame without UI-thread involvement, exactly as the user
247+
expects.
248+
249+
The painter sort and back-face cull *still* use the rotation snapshot from
250+
the last UI-thread `Rebuild`. When the animated value drifts far enough that
251+
the cached order is wrong, snapshot the current value on the UI thread and
252+
call `RebuildForExternalRotation(currentDegrees)` to re-cull and re-sort
253+
against it. The control deliberately does not read the animated value
254+
itself — it cannot cross the thread boundary.
255+
256+
See [Composition-driven rotation](usage.md#composition-driven-rotation) for
257+
a fuller walkthrough including property-set and time-driven examples.
258+
259+
### Other mitigation strategies
260+
261+
If `SetExternalRotation` doesn't fit your scenario:
233262

234263
### A. Tick the rebuild from `CompositionTarget.Rendering`
235264

docs/usage.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ bottom or jump to the section you need.
1717
- [Live-updating pixels](#live-updating-pixels)
1818
- [`MaterialMode`](#materialmode)
1919
- [Control properties](#control-properties)
20+
- [Composition-driven rotation](#composition-driven-rotation)
2021
- [Supported `.obj` subset](#supported-obj-subset)
2122
- [Supported `.mtl` subset](#supported-mtl-subset)
2223
- [The cache, in one minute](#the-cache-in-one-minute)
@@ -233,6 +234,85 @@ Controls how the active pack interacts with OBJ material data:
233234

234235
The control rebuilds its visual tree whenever any of these change.
235236

237+
## Composition-driven rotation
238+
239+
The `RotationX/Y/Z` dependency properties drive the same path internally, but
240+
each set forces a re-marshal back to the UI thread and a full visual
241+
rebuild. For animations that need to run independently of the UI thread —
242+
keyframe spins, expression-driven hover effects, scroll-linked tumbles — the
243+
controls also expose a composition-native API:
244+
245+
```csharp
246+
public void SetExternalRotation(ExpressionAnimation rotationDegrees);
247+
public void ClearExternalRotation();
248+
public void RebuildForExternalRotation(Vector3 rotationDegrees);
249+
```
250+
251+
`rotationDegrees` is any `ExpressionAnimation` whose result is a `Vector3`
252+
where `X = pitch`, `Y = yaw`, `Z = roll` in degrees. The control wires it
253+
into a composition expression bound to its root `TransformMatrix`; subsequent
254+
changes to anything that expression references (property sets, other visuals,
255+
time, …) flow entirely on the composition thread.
256+
257+
### Driving from a property set
258+
259+
The simplest pattern — push values from any thread without re-marshalling
260+
to the UI thread:
261+
262+
```csharp
263+
var compositor = ElementCompositionPreview.GetElementVisual(this).Compositor;
264+
var props = compositor.CreatePropertySet();
265+
props.InsertVector3("R", Vector3.Zero);
266+
267+
var rot = compositor.CreateExpressionAnimation("p.R");
268+
rot.SetReferenceParameter("p", props);
269+
270+
combobulate.SetExternalRotation(rot);
271+
272+
// Later, on any thread:
273+
props.InsertVector3("R", new Vector3(pitch, yaw, roll));
274+
```
275+
276+
### Driving from another visual / time / scroll
277+
278+
```csharp
279+
// Spin yaw at 60°/sec, no UI thread ever again:
280+
var spin = compositor.CreateExpressionAnimation(
281+
"Vector3(0, this.Target.GetGlobalTime() * 60, 0)");
282+
combobulate.SetExternalRotation(spin);
283+
284+
// Or rotate based on a ScrollViewer's offset:
285+
var scrollProps = ElementCompositionPreview
286+
.GetScrollViewerManipulationPropertySet(scrollViewer);
287+
var rot = compositor.CreateExpressionAnimation(
288+
"Vector3(scroll.Translation.Y * 0.5, scroll.Translation.X * 0.5, 0)");
289+
rot.SetReferenceParameter("scroll", scrollProps);
290+
combobulate.SetExternalRotation(rot);
291+
```
292+
293+
### Refreshing painter sort / mesh
294+
295+
While `SetExternalRotation` is active, the visible 3D rotation is correct on
296+
every composition frame, but back-face culling (`Combobulate`) and mesh
297+
baking (`CombobulateSceneVisual`) are *frozen* at whatever the last
298+
UI-thread `Rebuild` produced. For models where that matters, snapshot the
299+
animated value on the UI thread and call `RebuildForExternalRotation`:
300+
301+
```csharp
302+
// E.g. on a periodic timer, or when the animation reaches a known keyframe:
303+
var current = ReadCurrentRotationOnUiThread(); // app-supplied
304+
combobulate.RebuildForExternalRotation(current);
305+
```
306+
307+
The control cannot read the animated value itself — it lives on the
308+
composition thread — so the caller must supply it. This is the manual
309+
mitigation referenced in the [composition-thread animations
310+
section](rendering-pipeline.md#composition-thread-animations) of the
311+
rendering pipeline doc.
312+
313+
`ClearExternalRotation` detaches the expression and reverts to the
314+
DP-driven path.
315+
236316
## Supported `.obj` subset
237317

238318
- `v x y z` — vertex positions

src/Combobulate.Sample.Uwp/MainPage.xaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
OffContent="Internal (control DPs)"
3737
Toggled="ExternalRotationToggle_Toggled"
3838
ToolTipService.ToolTip="When ON, rotation sliders apply a Composition TransformMatrix to the controls' outer Visuals. The controls themselves stay at identity — so the SpriteVisual painter sort never re-runs."/>
39+
<Button Content="Refresh quads"
40+
Click="RefreshQuads_Click"
41+
ToolTipService.ToolTip="External-mode helper. Re-runs back-face cull / painter sort (Combobulate) and re-bakes mesh (CombobulateSceneVisual) for the current slider rotation."/>
3942
<TextBlock x:Name="StatusText"
4043
VerticalAlignment="Center"
4144
Opacity="0.7"

src/Combobulate.Sample.Uwp/MainPage.xaml.cs

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -81,50 +81,60 @@ private void ResetRotation_Click(object sender, RoutedEventArgs e)
8181

8282
private void ExternalRotationToggle_Toggled(object sender, RoutedEventArgs e) => ApplyRotation();
8383

84+
private void RefreshQuads_Click(object sender, RoutedEventArgs e)
85+
{
86+
var rot = new Vector3((float)PitchSlider.Value, (float)YawSlider.Value, (float)RollSlider.Value);
87+
combobulate.RebuildForExternalRotation(rot);
88+
combobulateSceneVisual.RebuildForExternalRotation(rot);
89+
}
90+
8491
/// <summary>
8592
/// Routes the slider values either through the controls' rotation
8693
/// dependency properties (which trigger a paint-order rebuild) or via a
8794
/// composition <see cref="Visual.TransformMatrix"/> on each control's outer
8895
/// Visual (which does NOT — exposing back-face / paint-order artefacts as
8996
/// the model spins).
9097
/// </summary>
98+
private CompositionPropertySet? _externalRotationProps;
99+
private ExpressionAnimation? _externalRotationExpr;
100+
101+
private (CompositionPropertySet props, ExpressionAnimation expr) GetOrCreateExternalRotation()
102+
{
103+
if (_externalRotationProps != null && _externalRotationExpr != null)
104+
return (_externalRotationProps, _externalRotationExpr);
105+
var compositor = ElementCompositionPreview.GetElementVisual(this).Compositor;
106+
_externalRotationProps = compositor.CreatePropertySet();
107+
_externalRotationProps.InsertVector3("Rotation", Vector3.Zero);
108+
_externalRotationExpr = compositor.CreateExpressionAnimation("p.Rotation");
109+
_externalRotationExpr.SetReferenceParameter("p", _externalRotationProps);
110+
return (_externalRotationProps, _externalRotationExpr);
111+
}
112+
91113
private void ApplyRotation()
92114
{
93115
if (combobulate == null) return;
94-
var x = PitchSlider.Value;
95-
var y = YawSlider.Value;
96-
var z = RollSlider.Value;
116+
var x = (float)PitchSlider.Value;
117+
var y = (float)YawSlider.Value;
118+
var z = (float)RollSlider.Value;
97119
bool external = ExternalRotationToggle?.IsOn == true;
98120

99121
if (external)
100122
{
101-
combobulate.RotationX = combobulate.RotationY = combobulate.RotationZ = 0;
102-
ApplyExternalRotation(combobulate, x, y, z);
103-
ApplyExternalRotation(combobulateSceneVisual, x, y, z);
123+
var (props, expr) = GetOrCreateExternalRotation();
124+
props.InsertVector3("Rotation", new Vector3(x, y, z));
125+
combobulate.SetExternalRotation(expr);
126+
combobulateSceneVisual.SetExternalRotation(expr);
104127
}
105128
else
106129
{
107-
ApplyExternalRotation(combobulate, 0, 0, 0);
108-
ApplyExternalRotation(combobulateSceneVisual, 0, 0, 0);
130+
combobulate.ClearExternalRotation();
131+
combobulateSceneVisual.ClearExternalRotation();
109132
combobulate.RotationX = x;
110133
combobulate.RotationY = y;
111134
combobulate.RotationZ = z;
112135
}
113136
}
114137

115-
private static void ApplyExternalRotation(FrameworkElement element, double xDeg, double yDeg, double zDeg)
116-
{
117-
var visual = ElementCompositionPreview.GetElementVisual(element);
118-
var w = (float)element.ActualWidth;
119-
var h = (float)element.ActualHeight;
120-
visual.CenterPoint = new Vector3(w / 2f, h / 2f, 0f);
121-
const float deg2rad = MathF.PI / 180f;
122-
visual.TransformMatrix = Matrix4x4.CreateFromYawPitchRoll(
123-
(float)yDeg * deg2rad,
124-
(float)xDeg * deg2rad,
125-
(float)zDeg * deg2rad);
126-
}
127-
128138
private async void LoadObjButton_Click(object sender, RoutedEventArgs e)
129139
{
130140
try

src/Combobulate.Sample.WinUI3/MainWindow.xaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
OffContent="Internal (control DPs)"
4646
Toggled="ExternalRotationToggle_Toggled"
4747
ToolTipService.ToolTip="When ON, rotation sliders apply a Composition TransformMatrix to the controls' outer Visuals. The controls themselves stay at identity — so the SpriteVisual painter sort never re-runs, exposing back-face / paint-order artefacts as the model spins."/>
48+
<Button Content="Refresh quads"
49+
Click="RefreshQuads_Click"
50+
ToolTipService.ToolTip="External-mode helper. Re-runs back-face cull / painter sort (Combobulate) and re-bakes mesh (CombobulateSceneVisual) for the current slider rotation."/>
4851
<TextBlock x:Name="StatusText"
4952
VerticalAlignment="Center"
5053
Opacity="0.7"

src/Combobulate.Sample.WinUI3/MainWindow.xaml.cs

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -55,53 +55,70 @@ private void ResetRotation_Click(object sender, RoutedEventArgs e)
5555

5656
private void ExternalRotationToggle_Toggled(object sender, RoutedEventArgs e) => ApplyRotation();
5757

58+
private void RefreshQuads_Click(object sender, RoutedEventArgs e)
59+
{
60+
var rot = new Vector3((float)PitchSlider.Value, (float)YawSlider.Value, (float)RollSlider.Value);
61+
combobulate.RebuildForExternalRotation(rot);
62+
combobulateSceneVisual.RebuildForExternalRotation(rot);
63+
}
64+
5865
/// <summary>
5966
/// Routes the slider values either through the controls' rotation
6067
/// dependency properties (which trigger a paint-order rebuild) or via a
61-
/// composition <see cref="Visual.TransformMatrix"/> on each control's outer
62-
/// Visual (which does NOT — exposing back-face / paint-order artefacts as
63-
/// the model spins).
68+
/// shared <see cref="CompositionPropertySet"/> wired to each control's
69+
/// composition root via <c>SetExternalRotationSource</c>. In external
70+
/// mode the property update propagates through the composition graph
71+
/// without any further UI-thread involvement \u2014 subsequent updates
72+
/// (including those issued from a non-UI thread or driven by another
73+
/// composition animation) re-evaluate the bound expression directly on
74+
/// the composition thread.
6475
/// </summary>
76+
// Backing storage for the external-rotation expression. The property
77+
// set holds the live Vector3 value; the expression `p.Rotation` is what
78+
// the renderers reference. Subsequent slider changes call InsertVector3
79+
// and the new value flows entirely through composition with no UI-thread
80+
// matrix work.
81+
private CompositionPropertySet? _externalRotationProps;
82+
private ExpressionAnimation? _externalRotationExpr;
83+
84+
private (CompositionPropertySet props, ExpressionAnimation expr) GetOrCreateExternalRotation()
85+
{
86+
if (_externalRotationProps != null && _externalRotationExpr != null)
87+
return (_externalRotationProps, _externalRotationExpr);
88+
var compositor = ElementCompositionPreview.GetElementVisual(this.Content).Compositor;
89+
_externalRotationProps = compositor.CreatePropertySet();
90+
_externalRotationProps.InsertVector3("Rotation", Vector3.Zero);
91+
_externalRotationExpr = compositor.CreateExpressionAnimation("p.Rotation");
92+
_externalRotationExpr.SetReferenceParameter("p", _externalRotationProps);
93+
return (_externalRotationProps, _externalRotationExpr);
94+
}
95+
6596
private void ApplyRotation()
6697
{
6798
if (combobulate == null) return;
68-
var x = PitchSlider.Value;
69-
var y = YawSlider.Value;
70-
var z = RollSlider.Value;
99+
var x = (float)PitchSlider.Value;
100+
var y = (float)YawSlider.Value;
101+
var z = (float)RollSlider.Value;
71102
bool external = ExternalRotationToggle?.IsOn == true;
72103

73104
if (external)
74105
{
75-
// Force the controls' own painting to identity so the only
76-
// rotation in effect is the external composition transform.
77-
combobulate.RotationX = combobulate.RotationY = combobulate.RotationZ = 0;
78-
ApplyExternalRotation(combobulate, x, y, z);
79-
ApplyExternalRotation(combobulateSceneVisual, x, y, z);
106+
var (props, expr) = GetOrCreateExternalRotation();
107+
props.InsertVector3("Rotation", new Vector3(x, y, z));
108+
combobulate.SetExternalRotation(expr);
109+
combobulateSceneVisual.SetExternalRotation(expr);
80110
}
81111
else
82112
{
83-
ApplyExternalRotation(combobulate, 0, 0, 0);
84-
ApplyExternalRotation(combobulateSceneVisual, 0, 0, 0);
113+
combobulate.ClearExternalRotation();
114+
combobulateSceneVisual.ClearExternalRotation();
85115
combobulate.RotationX = x;
86116
combobulate.RotationY = y;
87117
combobulate.RotationZ = z;
88118
// combobulateSceneVisual mirrors combobulate's rotation via x:Bind.
89119
}
90120
}
91121

92-
private static void ApplyExternalRotation(FrameworkElement element, double xDeg, double yDeg, double zDeg)
93-
{
94-
var visual = ElementCompositionPreview.GetElementVisual(element);
95-
var w = (float)element.ActualWidth;
96-
var h = (float)element.ActualHeight;
97-
visual.CenterPoint = new Vector3(w / 2f, h / 2f, 0f);
98-
const float deg2rad = MathF.PI / 180f;
99-
visual.TransformMatrix = Matrix4x4.CreateFromYawPitchRoll(
100-
(float)yDeg * deg2rad,
101-
(float)xDeg * deg2rad,
102-
(float)zDeg * deg2rad);
103-
}
104-
105122
private async void LoadObjButton_Click(object sender, RoutedEventArgs e)
106123
{
107124
try

src/Combobulate.Sample.WinUI3/Package.appxmanifest

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<Identity
1010
Name="Combobulate.Sample.WinUI3"
1111
Publisher="CN=Dev"
12-
Version="1.0.0.0" />
12+
Version="1.0.7.0" />
1313

1414
<mp:PhoneIdentity PhoneProductId="b0b0b0b0-b0b0-b0b0-b0b0-b0b0b0b0b001" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
1515

0 commit comments

Comments
 (0)