Skip to content

Commit b0ee40c

Browse files
committed
Add hot brush-swap fast-path for Materials changes
When only the Materials DP changes (e.g. async cover image fades in), avoid a full rebake: reuse existing sprite trees and reassign brushes in place via BakedAspectGraphRenderer.UpdateBindings. Tracks the baked materials reference and the visible-quad index per tree to map children back to source quads without re-deriving.
1 parent c47e108 commit b0ee40c

2 files changed

Lines changed: 112 additions & 4 deletions

File tree

src/Combobulate/Combobulate.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,14 @@ private void OnSourceChanged(string? newValue)
720720
private ObjGeometry? _bakedGeometry;
721721
private float _bakedScale, _bakedHostW, _bakedHostH;
722722
private global::Combobulate.Sorting.SortAlgorithm _bakedAlgorithm;
723+
/// <summary>
724+
/// Reference identity of the <see cref="ObjMaterialPack"/> the last bake's
725+
/// sprite trees were built against. When the consumer assigns a new
726+
/// <see cref="Materials"/> instance (e.g. cover image fades in after
727+
/// async download) this token differs from the live one and we trigger
728+
/// a fresh bake so the new brushes propagate to the materialised cells.
729+
/// </summary>
730+
private object? _bakedMaterialsToken;
723731
private CompositionPropertySet? _spinYawSourceProps;
724732
private string _spinYawScalarName = "SpinYaw";
725733
private string _yawValScalarName = "YawVal";
@@ -1428,6 +1436,27 @@ private void UpdateBakeIfNeeded()
14281436
|| _bakedAlgorithm != SortAlgorithm
14291437
|| secondaryChanged;
14301438
if (_baked != null && _baked.BakeInFlight) needRebake = false;
1439+
1440+
// Hot brush-swap fast-path: only Materials changed (geometry,
1441+
// scale, host size, sort algorithm, transform AST and axes are
1442+
// all unchanged). The bake's signature set + cell predicates +
1443+
// sprite tree topology are still valid; only the per-quad
1444+
// CompositionBrush needs to swap. No teardown / no compositor
1445+
// expression reinstall.
1446+
if (!needRebake && _baked != null && !_baked.BakeInFlight
1447+
&& !ReferenceEquals(_bakedMaterialsToken, pack))
1448+
{
1449+
var hotBindings = MaterialResolver.Resolve(_compositor, geometry, pack);
1450+
if (_baked.UpdateBindings(hotBindings))
1451+
{
1452+
_bakedMaterialsToken = pack;
1453+
return;
1454+
}
1455+
// UpdateBindings refused (quad-count mismatch or no current
1456+
// trees); fall through to a full rebake.
1457+
needRebake = true;
1458+
}
1459+
14311460
if (!needRebake) return;
14321461

14331462
if (_baked == null)
@@ -1451,6 +1480,7 @@ private void UpdateBakeIfNeeded()
14511480
_bakedHostW = hostW;
14521481
_bakedHostH = hostH;
14531482
_bakedAlgorithm = SortAlgorithm;
1483+
_bakedMaterialsToken = pack;
14541484
CaptureSecondaryProbe();
14551485
}
14561486

src/Combobulate/Rendering/BakedAspectGraphRenderer.cs

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ internal sealed class BakedAspectGraphRenderer : IDisposable
4545
private readonly ContainerVisual _parent;
4646

4747
private ContainerVisual[]? _trees;
48+
/// <summary>
49+
/// For each entry of <see cref="_trees"/>, the cached-quad indices of
50+
/// the visible sprites in painter order (matches the order in which
51+
/// they were inserted as children of the tree). Used by
52+
/// <see cref="UpdateBindings"/> to map a child position back to its
53+
/// source quad so a hot brush swap can find the right entry in
54+
/// <see cref="ResolvedQuadMaterials"/> without touching the bake.
55+
/// </summary>
56+
private int[][]? _treeVisibleQuadIndices;
57+
/// <summary>
58+
/// Geometry the current trees were built against; needed by
59+
/// <see cref="UpdateBindings"/> when reusing existing trees so we
60+
/// can validate the new bindings have the same quad count.
61+
/// </summary>
62+
private ObjGeometry? _treesGeometry;
4863
// Staging trees from the in-flight bake. They are inserted into
4964
// _parent.Children at Opacity=0 as ChunkBuild progresses; on Dispose
5065
// (or on stale-generation bail-out) we walk this and remove them so
@@ -257,8 +272,9 @@ private void Materialise(
257272
EnsureBakedMatrixSource();
258273
var newTrees = new ContainerVisual[computed.Cells.Length];
259274
var newOpacityExprs = new Microsoft.Toolkit.Uwp.UI.Animations.ExpressionsFork.ScalarNode[computed.Cells.Length];
275+
var newTreeIndices = new int[computed.Cells.Length][];
260276
ChunkBuild(computed, geometry, bindings, scale, hostW, hostH, axes,
261-
newTrees, newOpacityExprs, startIndex: 0, ui, generation);
277+
newTrees, newOpacityExprs, newTreeIndices, startIndex: 0, ui, generation);
262278
}
263279
catch (Exception ex)
264280
{
@@ -283,6 +299,7 @@ private void ChunkBuild(
283299
TransformAnimationAxis[] axes,
284300
ContainerVisual[] newTrees,
285301
Microsoft.Toolkit.Uwp.UI.Animations.ExpressionsFork.ScalarNode[] newOpacityExprs,
302+
int[][] newTreeIndices,
286303
int startIndex,
287304
Microsoft.UI.Dispatching.DispatcherQueue ui,
288305
int generation)
@@ -304,7 +321,7 @@ private void ChunkBuild(
304321
var c = computed.Cells[i];
305322
var tree = _compositor.CreateContainerVisual();
306323
tree.Opacity = 0; // hidden until swap
307-
BuildTreeContent(tree, geometry, bindings, scale, hostW, hostH, c.Sig.Order, c.Sig.Visibility);
324+
newTreeIndices[i] = BuildTreeContent(tree, geometry, bindings, scale, hostW, hostH, c.Sig.Order, c.Sig.Visibility);
308325
// Build (but don't start) the opacity expression — we'll start
309326
// them all in the swap step so the new tree set lights up
310327
// atomically and the old one disappears in the same compositor
@@ -322,7 +339,7 @@ private void ChunkBuild(
322339
{
323340
// More to do — schedule the next chunk.
324341
ui.TryEnqueue(() => ChunkBuild(computed, geometry, bindings, scale, hostW, hostH, axes,
325-
newTrees, newOpacityExprs, end, ui, generation));
342+
newTrees, newOpacityExprs, newTreeIndices, end, ui, generation));
326343
}
327344
else
328345
{
@@ -332,6 +349,8 @@ private void ChunkBuild(
332349
if (_disposed || generation != _bakeGeneration) { EvictStaging(); return; }
333350
DisposeTrees();
334351
_trees = newTrees;
352+
_treeVisibleQuadIndices = newTreeIndices;
353+
_treesGeometry = geometry;
335354
_stagingTrees = null;
336355
// Stash signatures + geometry so diagnostics can compare the
337356
// current live sign vector against what was baked.
@@ -381,7 +400,7 @@ private static ScalarNode BuildAxisInCellExpression_OBSOLETE(
381400
return (ScalarNode)0f;
382401
}
383402

384-
private void BuildTreeContent(
403+
private int[] BuildTreeContent(
385404
ContainerVisual tree,
386405
ObjGeometry geometry,
387406
ResolvedQuadMaterials bindings,
@@ -411,12 +430,69 @@ private void BuildTreeContent(
411430
sprite.IsVisible = visibility[q];
412431
sprites[q] = sprite;
413432
}
433+
// Build the painter-order list of *visible* quads and parent them
434+
// in that order. Return the index list so UpdateBindings can map
435+
// children back to source quads later without re-deriving.
436+
var visibleOrder = new System.Collections.Generic.List<int>(order.Length);
414437
for (int i = 0; i < order.Length; i++)
415438
{
416439
int qi = order[i];
417440
if (!visibility[qi]) continue;
418441
tree.Children.InsertAtTop(sprites[qi]);
442+
visibleOrder.Add(qi);
443+
}
444+
return visibleOrder.ToArray();
445+
}
446+
447+
/// <summary>
448+
/// Hot brush-swap path. When the host's <c>Materials</c> dependency
449+
/// property changes but everything else (geometry, scale, host size,
450+
/// sort algorithm, transform AST, axes) stays the same, the bake's
451+
/// signature set + cell predicates + sprite tree topology are still
452+
/// valid — only the per-quad <see cref="CompositionBrush"/> needs to
453+
/// change. This walks each existing cell's child list in lock-step
454+
/// with its cached visible-quad-index mapping and reassigns brushes
455+
/// from <paramref name="newBindings"/> in place. Cost is O(cells ×
456+
/// visible-faces) brush writes, no allocation, no compositor expression
457+
/// reinstall.
458+
///
459+
/// <para>Returns true if the swap was applied. Returns false when the
460+
/// renderer has no current trees (caller should fall back to a full
461+
/// bake) or when the new bindings' quad count differs from what the
462+
/// trees were built against (caller should likewise full-rebake; this
463+
/// only happens if the geometry changed, which already invalidates
464+
/// the trees).</para>
465+
/// </summary>
466+
public bool UpdateBindings(ResolvedQuadMaterials newBindings)
467+
{
468+
if (_trees is null || _treeVisibleQuadIndices is null || _treesGeometry is null) return false;
469+
var quads = _treesGeometry.Quads;
470+
if (newBindings.Bindings.Length != quads.Length) return false;
471+
for (int t = 0; t < _trees.Length; t++)
472+
{
473+
var tree = _trees[t];
474+
var visibleQuads = _treeVisibleQuadIndices[t];
475+
var children = tree.Children;
476+
int childIdx = 0;
477+
// VisualCollection enumerates in render (z) order, bottom-to-top.
478+
// InsertAtTop adds to the top, so the FIRST inserted ends up at
479+
// the bottom and is enumerated first. We added to visibleQuads
480+
// in the same loop that called InsertAtTop, so child index k
481+
// pairs directly with visibleQuads[k].
482+
int n = visibleQuads.Length;
483+
foreach (var child in children)
484+
{
485+
if (child is SpriteVisual sprite && childIdx < n)
486+
{
487+
int qi = visibleQuads[childIdx];
488+
var newBrush = newBindings.Bindings[qi].Brush;
489+
if (!ReferenceEquals(sprite.Brush, newBrush))
490+
sprite.Brush = newBrush;
491+
}
492+
childIdx++;
493+
}
419494
}
495+
return true;
420496
}
421497

422498
/// <summary>
@@ -681,5 +757,7 @@ private void DisposeTrees()
681757
t.Dispose();
682758
}
683759
_trees = null;
760+
_treeVisibleQuadIndices = null;
761+
_treesGeometry = null;
684762
}
685763
}

0 commit comments

Comments
 (0)