Skip to content

Commit 47fe49a

Browse files
larsbrubakerclaude
andcommitted
webgpu: split the surface-acquire statuses the way agg-gui-wgpu does
Timeout was lumped in with Outdated/Lost and tore down a perfectly good swapchain; the C-API's Error status fell through to the default case and took the window down over a validation error a retry cannot fix. Both now follow the same policy table as agg-gui-wgpu's surface_acquire_action, as a pure ActionFor() that is testable without a live GPU. Also from that comparison: swapchain sizes are clamped to the device's max 2D texture dimension (and Width/Height report the clamped values, so the depth and scratch targets inherit it), and the surface format falls back to any non-sRGB format before taking the surface's own preference - Bgra8Unorm still wins outright, which keeps the goldens on this machine. A recoverable skip and a successful device rebuild now ask the host for another paint, so a window that only paints on demand cannot be left sitting on a stale frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d057c44 commit 47fe49a

9 files changed

Lines changed: 453 additions & 51 deletions

File tree

PlatformLinux/linux/X11SystemWindow.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,10 @@ public void ShowSystemWindow(SystemWindow systemWindow)
689689
this.CreateNativeWindow(systemWindow);
690690

691691
this.webGpuLayer.UseSoftwareAdapter = ShouldUseSoftwareAdapter(systemWindow);
692+
693+
// The swapchain can drop a frame for something that clears itself; this is how it asks the
694+
// pumped loop for another paint instead of leaving the window on its last presented frame.
695+
this.webGpuLayer.RequestRedraw = () => this.needsRedraw = true;
692696
this.webGpuLayer.InitializeWebGpu();
693697

694698
// Also seeds SystemWindow.DisplayScale, since aggSystemWindow is already attached. On an

PlatformLinux/linux/X11WebGpuLayer.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ public X11WebGpuLayer(IntPtr display, ulong window, uint pixelWidth, uint pixelH
117117
/// </summary>
118118
public bool UseSoftwareAdapter { get; set; }
119119

120+
/// <summary>
121+
/// Gets or sets what to call when a frame was dropped for a reason that can still clear itself, or
122+
/// when the device was rebuilt after a loss - the host's "paint again soon". The Windows control
123+
/// calls <c>Control.Invalidate</c> here; the host sets this to whatever schedules its next paint.
124+
/// Unset means the host paints continuously and does not need waking.
125+
/// </summary>
126+
public Action RequestRedraw { get; set; }
127+
120128
/// <summary>The facade the 2D stack draws through, or null before initialization.</summary>
121129
public MatterHackers.RenderGl.OpenGl.GL Gl { get; private set; }
122130

@@ -248,11 +256,12 @@ public void BeginFrame()
248256
}
249257

250258
IGpuTexture frame;
259+
bool redrawRequested;
251260
try
252261
{
253262
using (FrameProfiler.Time("AcquireTexture"))
254263
{
255-
frame = this.surface.AcquireCurrentTexture();
264+
frame = this.surface.AcquireCurrentTexture(out redrawRequested);
256265
}
257266
}
258267
catch (Exception) when (this.TryRecoverIfDeviceLost())
@@ -261,6 +270,14 @@ public void BeginFrame()
261270
return;
262271
}
263272

273+
if (redrawRequested)
274+
{
275+
// The swapchain dropped this frame for something that clears itself (a Timeout, or a
276+
// reconfigure that has not taken yet). Without asking for a paint, the window would sit on
277+
// the last presented frame until some unrelated event happened to invalidate it.
278+
this.RequestRedraw?.Invoke();
279+
}
280+
264281
this.frameIsPresentable = frame != null;
265282
this.compat.SetRenderTarget(frame ?? this.EnsureScratchTarget(), this.depthTarget);
266283
}
@@ -359,6 +376,13 @@ public bool TryRecoverDevice()
359376
this.InitializeWebGpu();
360377
this.deviceRecoveryCount++;
361378

379+
if (this.isInitialized)
380+
{
381+
// The frame that hit the loss was abandoned and the new swapchain has never presented,
382+
// so ask for a paint on the new device rather than waiting to be invalidated.
383+
this.RequestRedraw?.Invoke();
384+
}
385+
362386
return this.isInitialized;
363387
}
364388
catch

PlatformMac/mac/MacSystemWindow.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,10 @@ private void ShowSystemWindowOnMainThread(SystemWindow systemWindow)
639639
this.CreateNativeWindow(systemWindow);
640640

641641
this.webGpuLayer.UseSoftwareAdapter = ShouldUseSoftwareAdapter(systemWindow);
642+
643+
// The swapchain can drop a frame for something that clears itself; this is how it asks the
644+
// pumped loop for another paint instead of leaving the window on its last presented frame.
645+
this.webGpuLayer.RequestRedraw = () => this.needsRedraw = true;
642646
this.webGpuLayer.InitializeWebGpu();
643647

644648
// Also seeds SystemWindow.DisplayScale, since aggSystemWindow is already attached. On a 1x

PlatformMac/mac/MacWebGpuLayer.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ public MacWebGpuLayer(IntPtr metalLayer, uint pixelWidth, uint pixelHeight)
108108
/// </summary>
109109
public bool UseSoftwareAdapter { get; set; }
110110

111+
/// <summary>
112+
/// Gets or sets what to call when a frame was dropped for a reason that can still clear itself, or
113+
/// when the device was rebuilt after a loss - the host's "paint again soon". The Windows control
114+
/// calls <c>Control.Invalidate</c> here; the host sets this to whatever schedules its next paint.
115+
/// Unset means the host paints continuously and does not need waking.
116+
/// </summary>
117+
public Action RequestRedraw { get; set; }
118+
111119
/// <summary>The facade the 2D stack draws through, or null before initialization.</summary>
112120
public MatterHackers.RenderGl.OpenGl.GL Gl { get; private set; }
113121

@@ -238,11 +246,12 @@ public void BeginFrame()
238246
}
239247

240248
IGpuTexture frame;
249+
bool redrawRequested;
241250
try
242251
{
243252
using (FrameProfiler.Time("AcquireTexture"))
244253
{
245-
frame = this.surface.AcquireCurrentTexture();
254+
frame = this.surface.AcquireCurrentTexture(out redrawRequested);
246255
}
247256
}
248257
catch (Exception) when (this.TryRecoverIfDeviceLost())
@@ -251,6 +260,14 @@ public void BeginFrame()
251260
return;
252261
}
253262

263+
if (redrawRequested)
264+
{
265+
// The swapchain dropped this frame for something that clears itself (a Timeout, or a
266+
// reconfigure that has not taken yet). Without asking for a paint, the window would sit on
267+
// the last presented frame until some unrelated event happened to invalidate it.
268+
this.RequestRedraw?.Invoke();
269+
}
270+
254271
this.frameIsPresentable = frame != null;
255272
this.compat.SetRenderTarget(frame ?? this.EnsureScratchTarget(), this.depthTarget);
256273
}
@@ -349,6 +366,13 @@ public bool TryRecoverDevice()
349366
this.InitializeWebGpu();
350367
this.deviceRecoveryCount++;
351368

369+
if (this.isInitialized)
370+
{
371+
// The frame that hit the loss was abandoned and the new swapchain has never presented,
372+
// so ask for a paint on the new device rather than waiting to be invalidated.
373+
this.RequestRedraw?.Invoke();
374+
}
375+
352376
return this.isInitialized;
353377
}
354378
catch

PlatformWin32/win32/WebGpuControl.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ public class WebGpuControl : Control
7777
/// <summary>
7878
/// Where a frame goes when the swapchain has none to give. Drawing has to land somewhere legal or
7979
/// every widget draw in that frame throws; this is that somewhere.
80+
/// <para>
81+
/// agg-gui-wgpu has no equivalent yet - its shell can tell the paint "not this time", ours cannot -
82+
/// so this is the reference implementation of the idea, which the Rust side plans to adopt
83+
/// (agg-gui's <c>porting_update.md</c>). Do not delete it to "match" that port.
84+
/// </para>
8085
/// </summary>
8186
private IGpuTexture scratchTarget;
8287

@@ -255,11 +260,12 @@ public void BeginFrame()
255260
}
256261

257262
IGpuTexture frame;
263+
bool redrawRequested;
258264
try
259265
{
260266
using (FrameProfiler.Time("AcquireTexture"))
261267
{
262-
frame = this.surface.AcquireCurrentTexture();
268+
frame = this.surface.AcquireCurrentTexture(out redrawRequested);
263269
}
264270
}
265271
catch (Exception) when (this.TryRecoverIfDeviceLost())
@@ -268,6 +274,14 @@ public void BeginFrame()
268274
return;
269275
}
270276

277+
if (redrawRequested)
278+
{
279+
// The swapchain dropped this frame for something that clears itself (a Timeout, or a
280+
// reconfigure that has not taken yet). Without asking for a paint, the window would sit on
281+
// the last presented frame until some unrelated event happened to invalidate it.
282+
this.Invalidate();
283+
}
284+
271285
this.frameIsPresentable = frame != null;
272286
this.compat.SetRenderTarget(frame ?? this.EnsureScratchTarget(), this.depthTarget);
273287
}
@@ -331,6 +345,14 @@ public bool TryRecoverDevice()
331345
this.InitializeWebGpu();
332346
this.deviceRecoveryCount++;
333347

348+
if (this.isInitialized)
349+
{
350+
// The frame that hit the loss was abandoned and the new swapchain has never presented,
351+
// so the window is showing whatever the compositor kept. Ask for a paint on the new
352+
// device rather than waiting for the next thing to invalidate.
353+
this.Invalidate();
354+
}
355+
334356
return this.isInitialized;
335357
}
336358
catch
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
Copyright (c) 2026, Lars Brubaker
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
1. Redistributions of source code must retain the above copyright notice, this
9+
list of conditions and the following disclaimer.
10+
2. Redistributions in binary form must reproduce the above copyright notice,
11+
this list of conditions and the following disclaimer in the documentation
12+
and/or other materials provided with the distribution.
13+
14+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
18+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
19+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
20+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
21+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24+
*/
25+
26+
using System.Threading.Tasks;
27+
using MatterHackers.WebGpu;
28+
using MatterHackers.WebGpuRender;
29+
using TUnit.Core;
30+
31+
namespace MatterHackers.Agg.Tests
32+
{
33+
/// <summary>
34+
/// The swapchain policy decisions - what to do with each acquire status, how a surface size is
35+
/// clamped, and which surface format the swapchain is configured with - as pure functions, so they
36+
/// are pinned without a live GPU. The same three policies live in agg-gui-wgpu's <c>gpu.rs</c>
37+
/// (<c>surface_acquire_action</c>, <c>clamp_surface_size</c>, <c>pick_surface_format</c>) and the two
38+
/// implementations are kept in step deliberately.
39+
/// </summary>
40+
public class WebGpuSurfaceAcquireTests
41+
{
42+
[Test]
43+
public async Task AStaleSwapchainReconfiguresRatherThanSkipping()
44+
{
45+
// What a resize looks like from the acquire: reconfiguring at the known size is the only way
46+
// back, so these must not be a silent skip (the resize-black-screen regression).
47+
await Assert.That(WebGpuSurfaceTarget.ActionFor(WGPUSurfaceGetCurrentTextureStatus.Outdated))
48+
.IsEqualTo(SurfaceAcquireAction.Reconfigure);
49+
await Assert.That(WebGpuSurfaceTarget.ActionFor(WGPUSurfaceGetCurrentTextureStatus.Lost))
50+
.IsEqualTo(SurfaceAcquireAction.Reconfigure);
51+
}
52+
53+
[Test]
54+
public async Task ATimeoutSkipsTheFrameAndAsksForAnother()
55+
{
56+
// A Timeout means the compositor was simply not ready - the swapchain is still valid, so
57+
// reconfiguring it would throw away a perfectly good one and can itself provoke another
58+
// Timeout. Skip, but wake back up: a reactive host would otherwise idle forever.
59+
await Assert.That(WebGpuSurfaceTarget.ActionFor(WGPUSurfaceGetCurrentTextureStatus.Timeout))
60+
.IsEqualTo(SurfaceAcquireAction.SkipAndRetry);
61+
}
62+
63+
[Test]
64+
public async Task OccludedAndValidationErrorsSkipWithoutSpinning()
65+
{
66+
// Occluded: the window is not visible, so a self-requested redraw would just burn CPU.
67+
// Error: the C-API validation status - a retry gets the same answer, and it must not take the
68+
// window down the way an unknown status does.
69+
await Assert.That(WebGpuSurfaceTarget.ActionFor(WebGpuSurfaceTarget.OccludedStatus))
70+
.IsEqualTo(SurfaceAcquireAction.Skip);
71+
await Assert.That(WebGpuSurfaceTarget.ActionFor(WGPUSurfaceGetCurrentTextureStatus.Error))
72+
.IsEqualTo(SurfaceAcquireAction.Skip);
73+
}
74+
75+
[Test]
76+
public async Task ASuccessfulAcquirePresents()
77+
{
78+
await Assert.That(WebGpuSurfaceTarget.ActionFor(WGPUSurfaceGetCurrentTextureStatus.SuccessOptimal))
79+
.IsEqualTo(SurfaceAcquireAction.Present);
80+
await Assert.That(WebGpuSurfaceTarget.ActionFor(WGPUSurfaceGetCurrentTextureStatus.SuccessSuboptimal))
81+
.IsEqualTo(SurfaceAcquireAction.Present);
82+
}
83+
84+
[Test]
85+
public async Task AnUnknownStatusIsAFailure()
86+
{
87+
// Anything the header does not define is a driver or binding bug; the acquire throws so it is
88+
// seen rather than silently dropping every frame.
89+
await Assert.That(WebGpuSurfaceTarget.ActionFor((WGPUSurfaceGetCurrentTextureStatus)0x7FFF0001))
90+
.IsEqualTo(SurfaceAcquireAction.Fail);
91+
}
92+
93+
[Test]
94+
public async Task SurfaceSizesAreClampedToTheDeviceLimit()
95+
{
96+
// An over-large window, or a corrupted restored size, degrades to the GPU limit instead of
97+
// failing wgpu validation; zero (minimized) becomes one.
98+
await Assert.That(WebGpuSurfaceTarget.ClampSurfaceSize(0, 0, 8192)).IsEqualTo((1u, 1u));
99+
await Assert.That(WebGpuSurfaceTarget.ClampSurfaceSize(20000, 100, 8192)).IsEqualTo((8192u, 100u));
100+
await Assert.That(WebGpuSurfaceTarget.ClampSurfaceSize(320, 240, 8192)).IsEqualTo((320u, 240u));
101+
102+
// A device that reports a zero limit still has to produce a legal (1x1) configuration.
103+
await Assert.That(WebGpuSurfaceTarget.ClampSurfaceSize(320, 240, 0)).IsEqualTo((1u, 1u));
104+
}
105+
106+
[Test]
107+
public async Task Bgra8IsPreferredAndSrgbIsAvoided()
108+
{
109+
// Bgra8Unorm keeps the golden images the same pixels the window shows.
110+
await Assert.That(WebGpuRenderDevice.PickSurfaceFormat(new[]
111+
{
112+
WGPUTextureFormat.RGBA8Unorm,
113+
WGPUTextureFormat.BGRA8Unorm,
114+
}))
115+
.IsEqualTo(WGPUTextureFormat.BGRA8Unorm);
116+
117+
// Without Bgra8Unorm, any non-sRGB format beats the surface's own first preference: the 2D
118+
// stack already writes gamma-encoded bytes, so an sRGB view would encode them a second time.
119+
await Assert.That(WebGpuRenderDevice.PickSurfaceFormat(new[]
120+
{
121+
WGPUTextureFormat.BGRA8UnormSrgb,
122+
WGPUTextureFormat.RGBA8Unorm,
123+
}))
124+
.IsEqualTo(WGPUTextureFormat.RGBA8Unorm);
125+
126+
// All-sRGB surface: nothing better exists, so take the surface's first preference.
127+
await Assert.That(WebGpuRenderDevice.PickSurfaceFormat(new[]
128+
{
129+
WGPUTextureFormat.BGRA8UnormSrgb,
130+
WGPUTextureFormat.RGBA8UnormSrgb,
131+
}))
132+
.IsEqualTo(WGPUTextureFormat.BGRA8UnormSrgb);
133+
}
134+
}
135+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
Copyright (c) 2026, Lars Brubaker
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
1. Redistributions of source code must retain the above copyright notice, this
9+
list of conditions and the following disclaimer.
10+
2. Redistributions in binary form must reproduce the above copyright notice,
11+
this list of conditions and the following disclaimer in the documentation
12+
and/or other materials provided with the distribution.
13+
14+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
18+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
19+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
20+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
21+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24+
*/
25+
26+
namespace MatterHackers.WebGpuRender
27+
{
28+
/// <summary>
29+
/// What to do with the result of <c>wgpuSurfaceGetCurrentTexture</c>.
30+
/// <para>
31+
/// The same vocabulary as agg-gui-wgpu's <c>SurfaceAcquire</c> (<c>gpu.rs</c>), deliberately named the
32+
/// same way so the two ports can be compared line for line. The one extra member is
33+
/// <see cref="Fail"/>: the C binding's status is an open <c>int</c>, so unlike Rust's closed enum there
34+
/// is a "the header does not define this" case to answer for.
35+
/// </para>
36+
/// </summary>
37+
public enum SurfaceAcquireAction
38+
{
39+
/// <summary>The texture is usable - render into it.</summary>
40+
Present,
41+
42+
/// <summary>
43+
/// The swapchain is stale or gone (Outdated/Lost): reconfigure the surface and try once more
44+
/// this frame. This is what a window resize looks like from the acquire.
45+
/// </summary>
46+
Reconfigure,
47+
48+
/// <summary>
49+
/// Transient (Timeout): skip the frame without touching the swapchain, but ask for another frame
50+
/// so a host that only paints on demand does not idle forever.
51+
/// </summary>
52+
SkipAndRetry,
53+
54+
/// <summary>
55+
/// Skip the frame with no follow-up (Occluded/Error): the window is not visible, or the app has a
56+
/// validation error to fix, and a self-requested redraw would just burn CPU.
57+
/// </summary>
58+
Skip,
59+
60+
/// <summary>A status the binding does not define - a driver or binding bug, so the acquire throws.</summary>
61+
Fail,
62+
}
63+
}

0 commit comments

Comments
 (0)