Skip to content

Make window resizing smooth on Windows with Direct3D - #1243

Merged
Alexander Maryanovsky (m-sasha) merged 38 commits into
masterfrom
m-sasha/fix-resize-d3d
Aug 6, 2026
Merged

Make window resizing smooth on Windows with Direct3D#1243
Alexander Maryanovsky (m-sasha) merged 38 commits into
masterfrom
m-sasha/fix-resize-d3d

Conversation

@m-sasha

@m-sasha Alexander Maryanovsky (m-sasha) commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The mechanism here is similar to the Metal fix.

During live resize draw the frame synchronously when receiving the native event. There's no transaction, but with some careful timing and vsync, it works well too.

The normal (async) rendering path is disabled during the live-resize.

The functionality is enabled by the skiko.rendering.windows.direct3DSynchronousLiveResize system property (disabled by default for now)

2026-07-21.16-11-33.mp4

Fixes https://youtrack.jetbrains.com/issue/CMP-10423/DirectX-Smooth-window-resize

Test code:

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import org.jetbrains.skia.*
import org.jetbrains.skia.paragraph.*
import org.jetbrains.skia.tests.makeFromResource
import org.jetbrains.skiko.util.uiTest
import org.junit.Test
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Dimension
import java.awt.Window
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.JFrame
import javax.swing.WindowConstants.DISPOSE_ON_CLOSE
import kotlin.coroutines.resume
import kotlin.math.absoluteValue
import kotlin.test.BeforeTest
import kotlin.time.Duration.Companion.nanoseconds

class WindowResizeTest {

    @BeforeTest
    fun setup() {
        System.setProperty("skiko.fps.enabled", "true")
        System.setProperty("skiko.rendering.windows.direct3DSynchronousLiveResize", "true")
        System.setProperty("skiko.test.ui.enabled", "true")
        System.setProperty("skiko.test.ui.renderApi", "DIRECT3D")
    }

    @Test(timeout = 1_000_000)
    fun `white borders on resize`() = uiTest {
        println(System.getProperty("java.vendor"))
        println(System.getProperty("java.version"))
        val window = UiTestWindow {
            layer.renderDelegate = SolidColorRenderer(layer, Color.BLACK)
//            layer.renderDelegate = HeavyRenderRenderer()
            contentPane.add(layer, BorderLayout.CENTER)

            preferredSize = Dimension(800, 800)
            defaultCloseOperation = DISPOSE_ON_CLOSE
            pack()
            isVisible = true
        }

        suspendCancellableCoroutine { continuation ->
            window.addWindowListener(object : WindowAdapter() {
                override fun windowClosed(e: WindowEvent?) {
                    continuation.resume(Unit)
                }
            })
        }
    }
}

private class SolidColorRenderer(
    private val layer: SkiaLayer,
    color: Color,
) : SkikoRenderDelegate {

    val paint1 = Paint().also { it.color = color.rgb }
    val red = Paint().also { it.color = Color.RED.rgb }

    val style = ParagraphStyle().apply {
        height = 40.0f
        maxLinesCount = 8
        textStyle = TextStyle().apply {
            fontFamilies = arrayOf("Inter")
            fontSize = 42.0f
            this.color = Color.WHITE.rgb
            this.baselineShift = baselineShift
        }
    }

    val fontCollection = runBlocking {
        FontCollection().apply {
            setDefaultFontManager(TypefaceFontProvider().apply {
                val inter = Typeface.makeFromResource("./fonts/Inter-Hinted-Regular.ttf", 0)
                registerTypeface(inter, "Inter")
            })
        }
    }

    var window: Window? = null
    var windowShowTime = 0L

    override fun onRender(canvas: Canvas, width: Int, height: Int, nanoTime: Long) {
        Logger.debug { "Rendering at size $width x $height" }
        canvas.drawRect(Rect(0f, 0f, width.toFloat(), height.toFloat()), paint1)
        canvas.drawRect(Rect(0f, 0f, width.toFloat(), 2f), red)
        canvas.drawRect(Rect(width - 2f, 0f, width.toFloat(), height.toFloat()), red)
        canvas.drawRect(Rect(0f, height-2f, width.toFloat(), height.toFloat()), red)
        canvas.drawRect(Rect(0f, 0f, 2f, height.toFloat()), red)

        val milliTime = nanoTime.nanoseconds.inWholeMilliseconds
        val progress = milliTime.mod(4000)
        val rectPaint = Paint().also {
            val hue = progress / 4000f
            it.color = Color.HSBtoRGB(hue, 1f, 1f)
        }
        val hueRectCenterX = (width / 2f) - ((progress - 2000f).absoluteValue / 1000f - 1f) * width/6
        canvas.drawRect(Rect(hueRectCenterX - width/6f, height/3f, hueRectCenterX + width/6f, 2*height/3f), rectPaint)

        val builder = ParagraphBuilder(style, fontCollection)
        builder.addText("Hello, Skiko")
        val paragraph = builder.build().layout(1600f)

        paragraph.paint(canvas, width/2f - 150, 100f)
        paragraph.paint(canvas, width/2f - 150, height - 100f)
        paragraph.close()

        layer.needRender(throttledToVsync = false)

//         Test calling an AWT method which calls into AppKit
        if ((window != null) && (milliTime - 5000 > windowShowTime)) {
            window!!.dispose()
            window = null
        }
        if ((milliTime + 5000).mod(10_000) in 0..50) {
            if (window == null) {
//                window = JDialog(null as Frame?, true)  // Test modal dialog
                window = JFrame("Another frame")
                window!!.size = Dimension(400, 400)
                windowShowTime = milliTime
                window!!.isVisible = true
            }
        }
    }
}

// Renders a very GPU-heavy picture
private class HeavyRenderRenderer : SkikoRenderDelegate {
    private val paint = Paint().also { it.color = Color.RED.rgb }
    private val loadFill = Paint().also { it.color = Color.GRAY.rgb }

    override fun onRender(canvas: Canvas, width: Int, height: Int, nanoTime: Long) {
        val rect = Rect(0f, 0f, width.toFloat(), height.toFloat())
        repeat(1000) {
            val layerPaint = Paint().also {
                it.imageFilter = ImageFilter.makeBlur(30f, 30f, FilterTileMode.CLAMP)
            }
            canvas.saveLayer(rect, layerPaint)
            canvas.drawRect(rect, loadFill)
            canvas.restore()
        }
        canvas.drawRect(rect, paint)
    }
}

Comment thread skiko/src/awtMain/kotlin/org/jetbrains/skiko/redrawer/EdtInvoker.kt Outdated
Comment thread skiko/src/awtMain/kotlin/org/jetbrains/skiko/SkiaLayer.awt.kt
Comment thread skiko/src/awtMain/cpp/windows/edtInvoker.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/edtInvoker.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in synchronous rendering path during interactive window live-resize on Windows/Direct3D to reduce “white border” artifacts by rendering inside the native resize loop and gating the normal async EDT-driven rendering while the drag is active.

Changes:

  • Introduces a Windows Direct3D live-resize hook (native WndProc subclass) that synchronously renders/presents during WM_NCCALCSIZE / WM_PAINT and disables the normal async render loop while active.
  • Adds a Win32 “invoke-and-wait while pumping” helper to safely run EDT work from the toolkit thread during live-resize without deadlocking cross-thread window operations.
  • Refactors/aligns live-resize gating across Direct3D and Metal (naming + behavior), and updates test window setup to use fillsWindow = true.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
skiko/src/jvmMain/kotlin/org/jetbrains/skiko/SkikoProperties.kt Adds skiko.rendering.windows.direct3DSynchronousLiveResize property toggle and documentation.
skiko/src/awtTest/kotlin/org/jetbrains/skiko/util/UiTest.kt Sets fillsWindow = true for UI test layers (required for live-resize mode).
skiko/src/awtMain/objectiveC/macos/MetalRedrawer.mm Refactors native→Java calls into cached helper invokers (Metal live-resize path).
skiko/src/awtMain/kotlin/org/jetbrains/skiko/SkiaLayer.awt.kt Avoids Direct3D “render immediately during reshape” path while synchronous live-resize is active; adds re-entrancy guard in update().
skiko/src/awtMain/kotlin/org/jetbrains/skiko/redrawer/WinApiEdtInvoker.kt Introduces JVM-side API and runnable shim for pump-wait EDT invocation on Windows.
skiko/src/awtMain/kotlin/org/jetbrains/skiko/redrawer/MetalRedrawer.kt Renames and clarifies live-resize gating flag to match behavior (“handling live resize now”).
skiko/src/awtMain/kotlin/org/jetbrains/skiko/redrawer/Direct3DRedrawer.kt Implements Windows synchronous live-resize lifecycle + gating, installs/uninstalls native hook, and adds composition wait for resize frames.
skiko/src/awtMain/kotlin/org/jetbrains/skiko/context/Direct3DContextHandler.kt Simplifies size-change tracking logic for Direct3D surfaces.
skiko/src/awtMain/cpp/windows/winApiEdtInvoker.h Declares isPumpingEdt() to prevent re-entrant rendering during nested message pumping.
skiko/src/awtMain/cpp/windows/winApiEdtInvoker.cc Implements Win32 message pumping invoke-and-wait to prevent deadlocks during cross-thread EDT operations.
skiko/src/awtMain/cpp/windows/directXRedrawer.cc Adds Direct3D synchronous live-resize WndProc hook + related JNI entrypoints and swapchain scaling support.
Comments suppressed due to low confidence (1)

skiko/src/awtMain/cpp/windows/winApiEdtInvoker.cc:44

  • javaNewEdtInvocationTask doesn't validate FindClass / NewGlobalRef / GetMethodID results before calling NewObject. If any lookup fails (e.g., due to a pending exception), NewObject may be invoked with a null class or constructor ID and crash.
            jclass local = env->FindClass("org/jetbrains/skiko/redrawer/EdtInvocationTask");
            cls = (jclass)env->NewGlobalRef(local);
            env->DeleteLocalRef(local);
            ctor = env->GetMethodID(cls, "<init>", "(Ljava/lang/Runnable;J)V");
        }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread skiko/src/awtMain/cpp/windows/winApiEdtInvoker.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/directXRedrawer.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/winApiEdtInvoker.cc Outdated
Comment thread skiko/src/awtMain/cpp/windows/winApiEdtInvoker.cc Outdated
@m-sasha
Alexander Maryanovsky (m-sasha) merged commit b52e0cb into master Aug 6, 2026
20 checks passed
@m-sasha
Alexander Maryanovsky (m-sasha) deleted the m-sasha/fix-resize-d3d branch August 6, 2026 10:32
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.

3 participants