Skip to content

Do not let a dropped frame kill the application - #173

Open
joseangelmt wants to merge 1 commit into
opentk:masterfrom
joseangelmt:fix/d3dimage-swapped-during-render
Open

Do not let a dropped frame kill the application#173
joseangelmt wants to merge 1 commit into
opentk:masterfrom
joseangelmt:fix/d3dimage-swapped-during-render

Conversation

@joseangelmt

Copy link
Copy Markdown

Render can die with this, taking the whole process down:

System.InvalidOperationException: Cannot call this method while the image
is unlocked.
   at System.Windows.Interop.D3DImage.AddDirtyRect(Int32Rect dirtyRect)
   at OpenTK.Wpf.GLWpfControlRenderer.Render(DrawingContext drawingContext)
   at System.Windows.UIElement.Arrange(Rect finalRect)

It is fatal and not merely ugly because Render runs inside the layout pass:
there is no application code above it, so nothing can catch it. We hit it four
times in two weeks while maximizing a window over a large point cloud, whose
GLRender callback takes tens of milliseconds.

I could NOT establish how the image ends up unlocked. My first guess was a
layout pass re-entering mid-render and reallocating the image, but that turns
out to be impossible: the dispatcher is suspended during layout, and trying to
pump it there throws "Cannot perform this operation while dispatcher processing
is suspended". So this does not claim a cause.

What it does is make the outcome survivable, whatever the cause: AddDirtyRect
is allowed to fail and drop that frame, and Unlock always runs. The next
OnRender is already queued and paints a correct frame. Leaving the image locked
would be worse than the dropped frame, because every later frame fails too.

The catch is only around AddDirtyRect: wrapping the whole render would also
swallow whatever the user's GLRender throws, and that is theirs to see.

Render also takes the D3dImage property into a local instead of reading it
eight times. That is not the fix and I am not claiming it is one -- just one
fewer way for a method to work on two different objects.

Verified both ways, by forcing the exact condition with a temporary extra
Unlock() before AddDirtyRect, over 400 window resizes: without the catch the
process dies with the exception above; with it, it survives and keeps drawing.

Maximizing a window over a slow GLRender callback kills the process. We hit it
repeatedly over three weeks with a large point cloud, in two different shapes:

    System.InvalidOperationException: Cannot call this method while the image
    is unlocked.
       at System.Windows.Interop.D3DImage.AddDirtyRect(Int32Rect dirtyRect)
       at OpenTK.Wpf.GLWpfControlRenderer.Render(DrawingContext drawingContext)
       at System.Windows.UIElement.Arrange(Rect finalRect)

    System.ArgumentOutOfRangeException: dirtyRect ('1593') must be less than
    or equal to '870'. (Parameter 'dirtyRect')
       at System.Windows.Interop.D3DImage.AddDirtyRect(Int32Rect dirtyRect)
       at OpenTK.Wpf.GLWpfControlRenderer.Render(DrawingContext drawingContext)
       at System.Windows.UIElement.Arrange(Rect finalRect)

Either one is fatal rather than merely ugly, because Render runs inside the
layout pass: there is no application code above it, so nothing can catch it.

Both are the same thing seen from two sides. Render read the D3dImage property
eight times, and ReallocateFramebufferIfNeeded assigns a brand new D3DImage --
and new FramebufferWidth/Height -- whenever the size changes, so the method
could lock one image and dirty another. Reading the property once instead of
eight times is what turned the first exception into the second: with the local,
AddDirtyRect gets the image we did lock, but the rectangle was still being
built from FramebufferWidth/Height, which by then belonged to the new
generation. An unlocked image and a rectangle that does not fit are the two
ways that mismatch can surface.

So, three changes:

  - Take the image ONCE into a local and use that same reference throughout.
    Lock, AddDirtyRect and Unlock only mean anything on one and the same
    image.

  - Build the dirty rectangle from the image's own PixelWidth/PixelHeight
    rather than from FramebufferWidth/Height. That is what we mean anyway --
    the whole image is what we just drew -- and it cannot be out of range by
    construction. The two sources agreeing was an assumption, and the numbers
    above are it not holding.

  - Let AddDirtyRect fail without taking the process with it, and always give
    the lock back in a finally. Dropping one frame is harmless: the next
    OnRender is already queued and paints a correct one. Leaving the image
    locked would not be, because then every later frame fails too. The catch
    is only around AddDirtyRect, on purpose -- wrapping the whole render would
    also swallow whatever the user's GLRender throws, and that is theirs to
    see.

What this does NOT do is explain how the two generations interleave.
ReallocateFramebufferIfNeeded assigns FramebufferWidth/Height before it creates
the new render target and the new D3DImage, so there is a window in which they
disagree, but I have not reproduced the path that reaches this line inside it;
a layout pass cannot re-enter here, since the dispatcher is suspended during
layout. The mismatch is now logged with both sizes so a future report comes
with something to go on.

Verified both ways with a minimal WPF app that forces the mismatch from the
GLRender callback -- the only user code that runs inside Render, between
SetBackBuffer and AddDirtyRect -- while resizing the window in a loop. Without
these changes the process dies with the exception above; with them it survives
400 frames with the mismatch forced on every single one.
@joseangelmt
joseangelmt force-pushed the fix/d3dimage-swapped-during-render branch from 8da5706 to 7f8acbf Compare August 5, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant