TASK 31: SceneGraph component + baked-crust compositor optimization
- Add ElementKind (Static/Dynamic) to SceneElement base; Source/WebcamSceneConfig classify - Services/SceneGraph.cs: owns the Scenes collection (ViewModel's Scenes delegates to it), the element mutation surface (Add/Insert/Remove/Move, each invalidating the bake), and the queries that were scattered LINQ (GetBackground/GetWebcam/GetChatBoxes/GetSplitPoint/IsStatic) - SceneCompositor: split-aware BakeStaticBase + CompositeLayers + Render(.., staticBase, split); builds/caches the static base below the split point in source-rect space - FramePump: optional SceneGraph -> optimized bake+composite path; falls back to full render - MainViewModel: routes element mutations through the graph; invalidates the bake on static layout/opacity/visibility/useDefaultBackground changes and after background heal/Ensure - Integration test SceneGraphTests.BakedStaticBase_WithDynamicLayer_CompositesCorrectly Derivative survey (mandated): tried before writing — OBS does per-source opacity/visibility caching and static-scene baking; this mirrors OBS's 'cached static source' optimization. 3 documented defensive deviations from the spec (ChatOverlayLayer stays decoupled; background helpers stay VM-static for direct testability; full facade peel deferred post-1.0) in TASKS.md + ai.md. Scope check passed; clean build 0 warnings.
This commit is contained in:
@@ -388,6 +388,7 @@ public partial class MainViewModel
|
||||
NormalizeBackgrounds(Scenes);
|
||||
foreach (var scene in Scenes)
|
||||
{
|
||||
_sceneGraph.InvalidateBake(scene);
|
||||
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (background != null)
|
||||
StampDefaultBackgroundAsset(background, scene.Name);
|
||||
|
||||
@@ -16,7 +16,10 @@ public partial class MainViewModel
|
||||
// TASK 20 step 2 — hotkey bindings (custom chord per action)
|
||||
private Dictionary<HotkeyId, HotkeyChord> _hotkeyBindings = new();
|
||||
|
||||
public ObservableCollection<Scene> Scenes { get; } = new();
|
||||
// TASK 31: the scene collection is owned by the SceneGraph (single owner of the
|
||||
// element inventory + mutation surface). The ViewModel exposes it as-is so the
|
||||
// XAML bindings and MainViewModel call sites keep working unchanged.
|
||||
public ObservableCollection<Scene> Scenes => _sceneGraph.Scenes;
|
||||
|
||||
public Scene? StagedScene
|
||||
{
|
||||
@@ -36,6 +39,7 @@ public partial class MainViewModel
|
||||
var background = EnsureBackground(value);
|
||||
if (background != null)
|
||||
StampDefaultBackgroundAsset(background, value.Name);
|
||||
_sceneGraph.InvalidateBake(value);
|
||||
}
|
||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||
@@ -251,6 +255,22 @@ public partial class MainViewModel
|
||||
{
|
||||
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
|
||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||
|
||||
// A Static element's baked pixels depend on its layout, opacity and
|
||||
// visibility — any of those changing invalidates the cached base.
|
||||
if (sender is SceneElement { Kind: ElementKind.Static } el)
|
||||
{
|
||||
var bakedSurface = e.PropertyName is nameof(SceneElement.X) or nameof(SceneElement.Y)
|
||||
or nameof(SceneElement.Width) or nameof(SceneElement.Height)
|
||||
or nameof(SceneElement.Opacity) or nameof(SceneElement.IsVisible)
|
||||
or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored);
|
||||
if (bakedSurface)
|
||||
{
|
||||
var containingScene = Scenes.FirstOrDefault(s => s.Elements.Contains(el));
|
||||
if (containingScene != null)
|
||||
_sceneGraph.InvalidateBake(containingScene);
|
||||
}
|
||||
}
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ public partial class MainViewModel
|
||||
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : 0,
|
||||
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : 0,
|
||||
};
|
||||
scene.Elements.Add(source);
|
||||
_sceneGraph.AddElement(scene, source);
|
||||
|
||||
if (sourceType == SourceType.WebSource)
|
||||
{
|
||||
@@ -208,7 +208,7 @@ public partial class MainViewModel
|
||||
source.Y = (1080 - source.Height) / 2;
|
||||
}
|
||||
|
||||
scene.Elements.Add(source);
|
||||
_sceneGraph.AddElement(scene, source);
|
||||
SelectedElement = source;
|
||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
@@ -233,7 +233,7 @@ public partial class MainViewModel
|
||||
}
|
||||
if (element is Source { Type: SourceType.WebSource } webSource)
|
||||
_webView2Manager?.Unregister(webSource.Id);
|
||||
scene.Elements.Remove(element);
|
||||
_sceneGraph.RemoveElement(scene, element);
|
||||
|
||||
if (element is WebcamSceneConfig && _webcam != null
|
||||
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
|
||||
|
||||
@@ -187,7 +187,7 @@ public partial class MainViewModel
|
||||
Y = MasterFrameHeight - 270 - 32,
|
||||
};
|
||||
|
||||
scene.Elements.Add(config);
|
||||
_sceneGraph.AddElement(scene, config);
|
||||
SelectedElement = config;
|
||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
|
||||
@@ -37,6 +37,11 @@ public partial class MainViewModel : ViewModelBase
|
||||
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
||||
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
|
||||
private readonly Func<string?> _rtmpUrlProvider;
|
||||
|
||||
// Scene graph (TASK 31): owns the scene collection + element inventory and the
|
||||
// baked-crust compositor cache. The ViewModel exposes Scenes/StagedScene/LiveScene
|
||||
// on top of it; the scene collection itself lives here.
|
||||
private SceneGraph _sceneGraph = null!;
|
||||
private readonly TransitionService _transition = new();
|
||||
|
||||
private WebView2Manager? _webView2Manager;
|
||||
@@ -70,6 +75,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
if (e.PropertyName == nameof(SceneElement.UseDefaultBackground))
|
||||
{
|
||||
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
|
||||
UpdateBackgroundImage();
|
||||
RefreshSnapshotsAsync();
|
||||
ScheduleSave();
|
||||
@@ -276,6 +282,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
_chatLayer = new ChatOverlayLayer(new ChatBoxRenderer());
|
||||
|
||||
_sceneGraph = new SceneGraph();
|
||||
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
|
||||
_framePump = new FramePump(
|
||||
sceneProvider: () => StagedScene,
|
||||
@@ -286,7 +293,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
log: message => AppLog.Write(message),
|
||||
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
|
||||
transition: _transition,
|
||||
fromSceneProvider: () => LiveScene);
|
||||
fromSceneProvider: () => LiveScene,
|
||||
sceneGraph: _sceneGraph);
|
||||
_framePump.Failed += OnFramePumpFailed;
|
||||
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user