Unverified Commit 74ce67b5 authored by Sebastiano Poggi's avatar Sebastiano Poggi Committed by GitHub

[SKIKO-1082] Fix Metal Swing offscreen rendering crash when drawing to a SW renderer (#1190)

This PR fixes Metal Swing offscreen rendering behavior when the active
`GraphicsConfiguration` is not compatible with JBR shared textures, and
adds focused regression tests for the fallback path.
`AcceleratedSwingPainter` now explicitly falls back to software painting
for incompatible graphics configurations (for example,
offscreen/`BufferedImage` paint flows).

## Changes

- Introduced `SharedTexturesAdapter` as a small abstraction over JBR
shared textures so production code keeps using JBR while tests can
inject a fake adapter.
- Updated `MetalSwingRedrawer` to create and pass the shared texture
adapter into `AcceleratedSwingPainter`.
- Updated `AcceleratedSwingPainter` to support test injection and
preserve fallback behaviour for incompatible graphics configurations.
- Added minimal test hooks in `AcceleratedSwingPainter` with
`org.jetbrains.annotations.VisibleForTesting` so tests can validate
cache-reset behaviour.
- Added `org.jetbrains:annotations` as an `awtMain` dependency to use
`VisibleForTesting`. I can undo this and remove the annotation, but I
think it's worth keeping...
- Added `AcceleratedSwingPainterTest` covering:
  - fallback is used for incompatible `GraphicsConfiguration`
  - accelerated cache state is cleared on fallback
  - `dispose()` delegates to fallback painter

---

Fixes https://youtrack.jetbrains.com/issue/SKIKO-1082

---------
Co-authored-by: 's avatarIgor Demin <igordmn@users.noreply.github.com>
parent a616ae9b
...@@ -33,4 +33,4 @@ githubApi = { module = "org.kohsuke:github-api", version.ref = "githubApi" } ...@@ -33,4 +33,4 @@ githubApi = { module = "org.kohsuke:github-api", version.ref = "githubApi" }
buildHelpers-publishing-gradlePlugin = { module = "org.jetbrains.compose.internal.build-helpers:publishing", version.ref = "buildHelpers-publishing" } buildHelpers-publishing-gradlePlugin = { module = "org.jetbrains.compose.internal.build-helpers:publishing", version.ref = "buildHelpers-publishing" }
crypto-checksum-gradlePlugin = { module = "gradle.plugin.org.gradle.crypto:checksum", version.ref = "cryptoChecksumPlugin" } crypto-checksum-gradlePlugin = { module = "gradle.plugin.org.gradle.crypto:checksum", version.ref = "cryptoChecksumPlugin" }
kotlinx-benchmark-gradlePlugin = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-plugin", version.ref = "kotlinxBenchmark" } kotlinx-benchmark-gradlePlugin = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-plugin", version.ref = "kotlinxBenchmark" }
kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinxBenchmark"} kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinxBenchmark"}
\ No newline at end of file
package org.jetbrains.skiko.swing package org.jetbrains.skiko.swing
import com.jetbrains.SharedTextures
import org.jetbrains.skia.Surface import org.jetbrains.skia.Surface
import java.awt.Graphics2D import java.awt.Graphics2D
import com.jetbrains.JBR
import com.jetbrains.SharedTextures
import org.jetbrains.skiko.RenderException
import java.awt.GraphicsConfiguration import java.awt.GraphicsConfiguration
import java.awt.GraphicsEnvironment import java.awt.GraphicsEnvironment
import java.awt.Image import java.awt.Image
internal class AcceleratedSwingPainter : SwingPainter { internal class AcceleratedSwingPainter(
private val sharedTextures = internal val sharedTextures: SharedTexturesAdapter,
if (JBR.isSharedTexturesSupported() && private val fallbackPainterCreator: () -> SwingPainter,
JBR.getSharedTextures().textureType == SharedTextures.METAL_TEXTURE_TYPE ) : SwingPainter {
) JBR.getSharedTextures() var imageWrapper: Image? = null
else throw RenderException("Shared textures are not supported") private set
var texturePtr: Long = 0L
private set
private var imageWrapper: Image? = null
private var texturePtr: Long = 0L
private var gc: GraphicsConfiguration = GraphicsEnvironment.getLocalGraphicsEnvironment() private var gc: GraphicsConfiguration = GraphicsEnvironment.getLocalGraphicsEnvironment()
.defaultScreenDevice.defaultConfiguration .defaultScreenDevice.defaultConfiguration
private var fallbackPainter: SwingPainter? = null
override fun paint(g: Graphics2D, surface: Surface, texture: Long) { override fun paint(g: Graphics2D, surface: Surface, texture: Long) {
if (g.deviceConfiguration != gc || texturePtr != texture || imageWrapper == null) { val deviceConfiguration = g.deviceConfiguration
gc = g.deviceConfiguration if (!deviceConfiguration.isSharedTextureCompatibleConfiguration()) {
imageWrapper = null
texturePtr = 0L
if (fallbackPainter == null) fallbackPainter = fallbackPainterCreator()
fallbackPainter?.paint(g, surface, texture)
return
}
if (deviceConfiguration != gc || texturePtr != texture || imageWrapper == null) {
gc = deviceConfiguration
texturePtr = texture texturePtr = texture
imageWrapper = sharedTextures.wrapTexture(gc, texturePtr) imageWrapper = sharedTextures.wrapTexture(gc, texturePtr)
} }
...@@ -32,5 +42,19 @@ internal class AcceleratedSwingPainter : SwingPainter { ...@@ -32,5 +42,19 @@ internal class AcceleratedSwingPainter : SwingPainter {
} }
override fun dispose() { override fun dispose() {
fallbackPainter?.dispose()
}
internal fun setCachedStateForTesting(imageWrapper: Image?, texturePtr: Long, gc: GraphicsConfiguration) {
this.imageWrapper = imageWrapper
this.texturePtr = texturePtr
this.gc = gc
} }
}
private fun GraphicsConfiguration.isSharedTextureCompatibleConfiguration(): Boolean =
if (sharedTextures.textureType == SharedTextures.METAL_TEXTURE_TYPE) {
javaClass.name == "sun.java2d.metal.MTLGraphicsConfig"
} else {
false
}
}
\ No newline at end of file
package org.jetbrains.skiko.swing package org.jetbrains.skiko.swing
import org.jetbrains.skia.* import org.jetbrains.skia.BackendRenderTarget
import org.jetbrains.skiko.* import org.jetbrains.skia.Color
import org.jetbrains.skia.ColorSpace
import org.jetbrains.skia.DirectContext
import org.jetbrains.skia.PixelGeometry
import org.jetbrains.skia.Surface
import org.jetbrains.skia.SurfaceColorFormat
import org.jetbrains.skia.SurfaceOrigin
import org.jetbrains.skia.SurfaceProps
import org.jetbrains.skiko.GraphicsApi
import org.jetbrains.skiko.Library
import org.jetbrains.skiko.MetalAdapter
import org.jetbrains.skiko.RenderException
import org.jetbrains.skiko.SkiaLayerAnalytics
import org.jetbrains.skiko.SkikoRenderDelegate
import org.jetbrains.skiko.autoCloseScope
import org.jetbrains.skiko.autoreleasepool
import org.jetbrains.skiko.chooseMetalAdapter
import org.jetbrains.skiko.dispose
import org.jetbrains.skiko.swing.SharedTexturesAdapter.Companion.createSharedTexturesAdapter
import java.awt.Graphics2D import java.awt.Graphics2D
/** /**
* Provides a way to draw on Skia canvas rendered off-screen with Metal GPU acceleration and then pass it to [java.awt.Graphics2D]. * Provides a way to draw on Skia canvas rendered off-screen with Metal
* It provides better interoperability with Swing, but it is less efficient than on-screen rendering. * GPU acceleration and then pass it to [java.awt.Graphics2D]. It provides
* better interoperability with Swing, but it is less efficient than
* on-screen rendering.
* *
* For now, it uses drawing to [java.awt.image.BufferedImage] that cause VRAM <-> RAM memory transfer and so increased CPU usage. * For now, it uses drawing to [java.awt.image.BufferedImage] that cause
* VRAM <-> RAM memory transfer and so increased CPU usage.
* *
* Content to draw is provided by [SkikoRenderDelegate]. * Content to draw is provided by [SkikoRenderDelegate].
* *
* For on-screen rendering see [org.jetbrains.skiko.redrawer.MetalRedrawer]. * For on-screen rendering see
* [org.jetbrains.skiko.redrawer.MetalRedrawer].
* *
* @see SwingRedrawerBase * @see SwingRedrawerBase
* @see SoftwareSwingPainter * @see SoftwareSwingPainter
...@@ -28,8 +50,10 @@ internal class MetalSwingRedrawer( ...@@ -28,8 +50,10 @@ internal class MetalSwingRedrawer(
} }
private fun createSwingPainter(swingLayerProperties: SwingLayerProperties): SwingPainter = try { private fun createSwingPainter(swingLayerProperties: SwingLayerProperties): SwingPainter = try {
AcceleratedSwingPainter() AcceleratedSwingPainter(
} catch (_ : RenderException) { sharedTextures = createSharedTexturesAdapter()
) { SoftwareSwingPainter(swingLayerProperties) }
} catch (_: RenderException) {
SoftwareSwingPainter(swingLayerProperties) SoftwareSwingPainter(swingLayerProperties)
} }
} }
...@@ -92,8 +116,8 @@ internal class MetalSwingRedrawer( ...@@ -92,8 +116,8 @@ internal class MetalSwingRedrawer(
override fun rendererInfo(): String { override fun rendererInfo(): String {
return super.rendererInfo() + return super.rendererInfo() +
"Video card: ${adapter.name}\n" + "Video card: ${adapter.name}\n" +
"Total VRAM: ${adapter.memorySize / 1024 / 1024} MB\n" "Total VRAM: ${adapter.memorySize / 1024 / 1024} MB\n"
} }
private fun makeRenderTarget() = BackendRenderTarget( private fun makeRenderTarget() = BackendRenderTarget(
...@@ -109,9 +133,9 @@ internal class MetalSwingRedrawer( ...@@ -109,9 +133,9 @@ internal class MetalSwingRedrawer(
private external fun makeMetalRenderTargetOffScreen(texture: Long): Long private external fun makeMetalRenderTargetOffScreen(texture: Long): Long
/** /**
* Provides Metal texture taking given [oldTexture] into account * Provides Metal texture taking given [oldTexture] into account since it
* since it can be reused if width and height are not changed, * can be reused if width and height are not changed, or the new one will
* or the new one will be created. * be created.
*/ */
private external fun makeMetalTexture(adapter: Long, oldTexture: Long, width: Int, height: Int): Long private external fun makeMetalTexture(adapter: Long, oldTexture: Long, width: Int, height: Int): Long
private external fun disposeMetalTexture(texture: Long): Long private external fun disposeMetalTexture(texture: Long): Long
......
package org.jetbrains.skiko.swing
import com.jetbrains.JBR
import com.jetbrains.SharedTextures
import org.jetbrains.skiko.RenderException
import java.awt.GraphicsConfiguration
import java.awt.Image
internal interface SharedTexturesAdapter {
val textureType: Int
fun wrapTexture(gc: GraphicsConfiguration, texturePtr: Long): Image
companion object {
fun createSharedTexturesAdapter(): SharedTexturesAdapter {
if (!JBR.isSharedTexturesSupported()) {
throw RenderException("Shared textures are not supported")
}
val sharedTextures = JBR.getSharedTextures()
if (sharedTextures.textureType == SharedTextures.METAL_TEXTURE_TYPE) {
return JbrSharedTexturesAdapter(sharedTextures)
}
throw RenderException("Shared textures are not supported")
}
}
}
private class JbrSharedTexturesAdapter(
private val delegate: SharedTextures
) : SharedTexturesAdapter {
override val textureType: Int
get() = delegate.textureType
override fun wrapTexture(gc: GraphicsConfiguration, texturePtr: Long): Image =
delegate.wrapTexture(gc, texturePtr)
}
\ No newline at end of file
...@@ -1531,4 +1531,4 @@ class SkiaLayerTest { ...@@ -1531,4 +1531,4 @@ class SkiaLayerTest {
} }
} }
private fun JFrame.close() = dispatchEvent(WindowEvent(this, WindowEvent.WINDOW_CLOSING)) internal fun JFrame.close() = dispatchEvent(WindowEvent(this, WindowEvent.WINDOW_CLOSING))
\ No newline at end of file
package org.jetbrains.skiko.swing
import com.jetbrains.SharedTextures
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.jetbrains.skia.Canvas
import org.jetbrains.skia.Paint
import org.jetbrains.skia.Rect
import org.jetbrains.skia.Surface
import org.jetbrains.skiko.MainUIDispatcher
import org.jetbrains.skiko.OS
import org.jetbrains.skiko.SkikoRenderDelegate
import org.jetbrains.skiko.toImage
import org.jetbrains.skiko.util.ScreenshotTestRule
import org.junit.Assume
import org.junit.Rule
import org.junit.Test
import java.awt.Color
import java.awt.Graphics2D
import java.awt.GraphicsConfiguration
import java.awt.Image
import java.awt.image.BufferedImage
import javax.swing.JFrame
import kotlin.test.assertEquals
import kotlin.test.assertNull
class AcceleratedSwingPainterTest {
@get:Rule
val screenshots = ScreenshotTestRule()
@Test
fun `falls back for incompatible GraphicsConfiguration`() {
val fallbackPainter = RecordingSwingPainter()
val fallbackPainterCreator = { fallbackPainter }
val sharedTextures = FakeSharedTextures()
val painter = AcceleratedSwingPainter(sharedTextures, fallbackPainterCreator)
val image = BufferedImage(8, 8, BufferedImage.TYPE_INT_ARGB_PRE)
val g = image.createGraphics()
val surface = Surface.makeRasterN32Premul(8, 8)
try {
painter.paint(g, surface, 42L)
assertEquals(1, fallbackPainter.paintCalls)
assertEquals(0, sharedTextures.wrapTextureCalls)
} finally {
g.dispose()
surface.close()
}
}
@Test
fun `fallback path clears accelerated cache`() {
val fallbackPainter = RecordingSwingPainter()
val fallbackPainterCreator = { fallbackPainter }
val sharedTextures = FakeSharedTextures()
val painter = AcceleratedSwingPainter(sharedTextures, fallbackPainterCreator)
val image = BufferedImage(8, 8, BufferedImage.TYPE_INT_ARGB_PRE)
val g = image.createGraphics()
val surface = Surface.makeRasterN32Premul(8, 8)
try {
painter.setCachedStateForTesting(
imageWrapper = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB_PRE),
texturePtr = 777L,
gc = g.deviceConfiguration
)
painter.paint(g, surface, 42L)
assertNull(painter.imageWrapper)
assertEquals(0L, painter.texturePtr)
assertEquals(1, fallbackPainter.paintCalls)
} finally {
g.dispose()
surface.close()
}
}
@Test
fun `does not crash when painting to sw bitmap before initialization`() {
runBlocking(MainUIDispatcher) {
val window = JFrame()
try {
val layer = SkiaSwingLayer(FakeRenderer(window, 100, 100, Color.RED))
window.contentPane.add(layer)
window.setSize(100, 100)
delay(1000)
val image = BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB)
val g2d = image.createGraphics()
window.contentPane.paint(g2d)
g2d.dispose()
} finally {
window.dispose()
}
}
}
@Test
fun `can paint to sw bitmap after initialization`() {
runBlocking(MainUIDispatcher) {
val window = JFrame()
try {
val layer = SkiaSwingLayer(FakeRenderer(window, 100, 100, Color.RED))
window.contentPane.add(layer)
window.setSize(100, 100)
window.isUndecorated = true
window.isVisible = true
delay(1000)
val image = BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB)
val g2d = image.createGraphics()
layer.paint(g2d)
g2d.dispose()
screenshots.assert(image.toImage(), "windowBitmap")
} finally {
window.dispose()
}
}
}
private class RecordingSwingPainter : SwingPainter {
var paintCalls = 0
var disposeCalls = 0
override fun paint(g: Graphics2D, surface: Surface, texture: Long) {
paintCalls++
}
override fun dispose() {
disposeCalls++
}
}
private class FakeSharedTextures : SharedTexturesAdapter {
var wrapTextureCalls = 0
override val textureType: Int = SharedTextures.METAL_TEXTURE_TYPE
override fun wrapTexture(gc: GraphicsConfiguration, texturePtr: Long): Image {
wrapTextureCalls++
return BufferedImage(8, 8, BufferedImage.TYPE_INT_ARGB_PRE)
}
}
private class FakeRenderer(
private val getContentScale: () -> Float,
var rectWidth: Int,
var rectHeight: Int,
private val rectColor: Color
) : SkikoRenderDelegate {
constructor(
layer: JFrame,
rectWidth: Int,
rectHeight: Int,
rectColor: Color
) : this(
{ layer.graphicsConfiguration.defaultTransform.scaleX.toFloat() }, rectWidth, rectHeight, rectColor
)
private val contentScale get() = getContentScale()
override fun onRender(canvas: Canvas, width: Int, height: Int, nanoTime: Long) {
canvas.drawRect(Rect(0f, 0f, width.toFloat(), height.toFloat()), Paint().apply {
color = Color.WHITE.rgb
})
canvas.drawRect(Rect(0f, 0f, rectWidth * contentScale, rectHeight * contentScale), Paint().apply {
color = rectColor.rgb
})
}
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment