perf(items): render item icons from a GPU atlas - #982
Conversation
saadndm
left a comment
There was a problem hiding this comment.
Thanks for the PR, looks good but I think it can be simplified
| private interface CachedIcon { | ||
| fun acquire(): ItemIconHandle? | ||
| } | ||
|
|
||
| private class RasterCachedIcon(private val data: ItemIconData) : CachedIcon { | ||
| override fun acquire(): ItemIconHandle = data.copy() | ||
| } |
There was a problem hiding this comment.
If we remove the Vulkan readback fallback, these can be removed
| @@ -378,74 +654,85 @@ class MinecraftItemCatalogService : ItemCatalogService { | |||
| width * height * pixelSize, | |||
| ) | |||
| *///? } | |||
| //? if < 26.2 | |||
| //val readEncoder = device.createCommandEncoder() | |||
| val onCopied = Runnable { | |||
| try { | |||
| //? if >= 26.2 { | |||
| buffer.map(true, false).use { view -> | |||
| completeReadback( | |||
| placements, | |||
| guiWidth, | |||
| guiHeight, | |||
| width, | |||
| height, | |||
| pixelSize, | |||
| iconSize, | |||
| view.data(), | |||
| batch, | |||
| ) | |||
| } | |||
| //? } else if >= 1.21.8 { | |||
| /*readEncoder.mapBuffer(buffer, true, false).use { view -> | |||
| completeReadback( | |||
| placements, | |||
| guiWidth, | |||
| guiHeight, | |||
| width, | |||
| height, | |||
| pixelSize, | |||
| iconSize, | |||
| view.data(), | |||
| batch, | |||
| ) | |||
| } | |||
| *///? } else { | |||
| /*readEncoder.readBuffer(buffer).use { view -> | |||
| completeReadback( | |||
| placements, | |||
| guiWidth, | |||
| guiHeight, | |||
| width, | |||
| height, | |||
| pixelSize, | |||
| iconSize, | |||
| view.data(), | |||
| batch, | |||
| ) | |||
| val terminalOwner = AtomicBoolean(false) | |||
| try { | |||
| // keep setup here so the buffer is closed if it fails | |||
| //? if < 26.2 | |||
| //val readEncoder = device.createCommandEncoder() | |||
| val onCopied = Runnable { | |||
| if (!terminalOwner.compareAndSet(false, true)) return@Runnable | |||
| try { | |||
| //? if >= 26.2 { | |||
| buffer.map(true, false).use { view -> | |||
| completeReadback( | |||
| placements, | |||
| guiWidth, | |||
| guiHeight, | |||
| width, | |||
| height, | |||
| pixelSize, | |||
| iconSize, | |||
| view.data(), | |||
| batch, | |||
| ) | |||
| } | |||
| //? } else if >= 1.21.8 { | |||
| /*readEncoder.mapBuffer(buffer, true, false).use { view -> | |||
| completeReadback( | |||
| placements, | |||
| guiWidth, | |||
| guiHeight, | |||
| width, | |||
| height, | |||
| pixelSize, | |||
| iconSize, | |||
| view.data(), | |||
| batch, | |||
| ) | |||
| } | |||
| *///? } else { | |||
| /*readEncoder.readBuffer(buffer).use { view -> | |||
| completeReadback( | |||
| placements, | |||
| guiWidth, | |||
| guiHeight, | |||
| width, | |||
| height, | |||
| pixelSize, | |||
| iconSize, | |||
| view.data(), | |||
| batch, | |||
| ) | |||
| } | |||
| *///? } | |||
| } catch (throwable: Throwable) { | |||
| LOG.warn("Failed to read item selector icons from the GPU", throwable) | |||
| completeBatch(batch, emptyMap()) | |||
| } finally { | |||
| runCatching { buffer.close() } | |||
| .onFailure { LOG.warn("Failed to release the item icon readback buffer", it) } | |||
| closeRenderResource(renderResource) | |||
| renderTarget.destroy() | |||
| } | |||
| *///? } | |||
| } catch (throwable: Throwable) { | |||
| LOG.warn("Failed to read item selector icons from the GPU", throwable) | |||
| completeBatch(batch, emptyMap()) | |||
| } finally { | |||
| buffer.close() | |||
| closeRenderResource(renderResource) | |||
| target.destroyBuffers() | |||
| } | |||
| } | |||
| try { | |||
| //? if >= 1.21.11 { | |||
| device.createCommandEncoder().copyTextureToBuffer(texture, buffer, 0L, onCopied, 0) | |||
| //? } else | |||
| //device.createCommandEncoder().copyTextureToBuffer(texture, buffer, 0, onCopied, 0) | |||
| } catch (throwable: Throwable) { | |||
| buffer.close() | |||
| throw throwable | |||
| if (terminalOwner.compareAndSet(false, true)) { | |||
| runCatching { buffer.close() } | |||
| .onFailure { closeError -> | |||
| if (closeError !== throwable) throwable.addSuppressed(closeError) | |||
| } | |||
| throw throwable | |||
| } | |||
| // the callback can be queued before a late error | |||
| LOG.warn("Item icon readback reported an error after completion was queued", throwable) | |||
| } | |||
| //? } else { | |||
| /*val image = NativeImage(target.width, target.height, false) | |||
| val previousTexture = RenderSystem.getShaderTexture(0) | |||
| /*val previousTexture = RenderSystem.getShaderTexture(0) | |||
| val image = NativeImage(target.width, target.height, false) | |||
| try { | |||
| RenderSystem.bindTexture(target.colorTextureId) | |||
| image.downloadTexture(0, false) | |||
| @@ -463,12 +750,14 @@ class MinecraftItemCatalogService : ItemCatalogService { | |||
| //? } else | |||
| //abgrToArgb(image.getPixelRGBA(x, y)) | |||
| } | |||
| completeBatch(batch, icons) | |||
| completeBatch(batch, icons.mapValues { RasterCachedIcon(it.value) }) | |||
| } finally { | |||
| RenderSystem.bindTexture(previousTexture) | |||
| image.close() | |||
| runCatching { RenderSystem.bindTexture(previousTexture) } | |||
| .onFailure { LOG.warn("Failed to restore the item icon readback texture", it) } | |||
| runCatching { image.close() } | |||
| .onFailure { LOG.warn("Failed to release the item icon readback image", it) } | |||
| closeRenderResource(renderResource) | |||
| target.destroyBuffers() | |||
| renderTarget.destroy() | |||
| } | |||
| *///? } | |||
| } | |||
There was a problem hiding this comment.
This entire readTarget function can be removed too
There was a problem hiding this comment.
Atlas ownership should remain inside this service, the UI only needs the item ID and can call something like drawIcon(id, ...) so we won't need leases or reference counting
| private inner class AtlasPage( | ||
| val serial: Long, | ||
| private val renderTarget: ItemRenderTarget, | ||
| private val image: Image, | ||
| ) { | ||
| private val references = AtomicInteger(1) // cache ownership | ||
| private val destroyed = AtomicBoolean(false) | ||
| private val paint = Paint() | ||
|
|
||
| fun acquire(source: Rect): ItemIconHandle? { | ||
| while (true) { | ||
| val current = references.get() | ||
| if (current <= 0 || destroyed.get()) return null | ||
| if (references.compareAndSet(current, current + 1)) { | ||
| return AtlasIconLease(this, source) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fun draw( | ||
| canvas: org.jetbrains.skia.Canvas, | ||
| source: Rect, | ||
| left: Float, | ||
| top: Float, | ||
| right: Float, | ||
| bottom: Float, | ||
| alpha: Float, | ||
| ): Boolean { | ||
| if (destroyed.get()) return false | ||
| paint.alpha = (alpha.coerceIn(0f, 1f) * 255f).roundToInt() | ||
| canvas.drawImageRect( | ||
| image, | ||
| source, | ||
| Rect.makeLTRB(left, top, right, bottom), | ||
| SamplingMode.LINEAR, | ||
| paint, | ||
| true, | ||
| ) | ||
| return true | ||
| } | ||
|
|
||
| fun release() { | ||
| val remaining = references.decrementAndGet() | ||
| check(remaining >= 0) { "Item atlas page released too many times" } | ||
| if (remaining == 0) destroyOnRenderThread() | ||
| } | ||
|
|
||
| private fun destroyOnRenderThread() { | ||
| if (!destroyed.compareAndSet(false, true)) return | ||
| val destroy = Runnable { | ||
| var skiaResourcesClosed = false | ||
| val closeSkiaResources = { | ||
| skiaResourcesClosed = true | ||
| runCatching { image.close() } | ||
| renderTarget.destroyWithinBackendState() | ||
| } | ||
| if (SkiaCtx.isReady) { | ||
| runCatching { | ||
| SkiaCtx.withBackendState { | ||
| runCatching { SkiaCtx.directContext.flush() } | ||
| if (SkiaCtx.isVulkanMode) { | ||
| runCatching { SkiaCtx.directContext.submit(true) } | ||
| } | ||
| closeSkiaResources() | ||
| } | ||
| } | ||
| } | ||
| // close skia wrappers before their target | ||
| if (!skiaResourcesClosed) closeSkiaResources() | ||
| runCatching { paint.close() } | ||
| } | ||
| // a lease can be released mid-frame, so always defer this | ||
| scheduleAtlasDisposal(destroy) | ||
| } | ||
| } |
There was a problem hiding this comment.
This should reuse one persistent atlas instead of creating an immutable page every batch, then we can remove page eviction, leases and deferred disposal logic
| private val LOG = LoggerFactory.getLogger(GLVulkanService::class.java) | ||
| private val client get() = Minecraft.getInstance() | ||
| override val isVulkan = false | ||
| override val supportsDirectOffscreenSampling = true |
There was a problem hiding this comment.
| override val supportsDirectOffscreenSampling = true |
| val supportsDirectOffscreenSampling: Boolean get() = false | ||
|
|
There was a problem hiding this comment.
| val supportsDirectOffscreenSampling: Boolean get() = false |
Why use the old render fallback for Vulkan? It already supports offscreen sampling from SkiaOffscreenTarget.kt, so this shouldn't be needed. We can remove a lot of code by always using offscreen sampling.
Description
item icons used to be rendered on the GPU, copied back to the CPU, and then uploaded to Skia again. this keeps the rendered
TextureTargeton the GPU and lets Skia use it as an atlas instead.this is enabled on OpenGL for now. Vulkan and other backends still use the old readback path. i also changed the internal item catalog to return handles that keep atlas pages alive while they're on screen.
Related Issue(s)
Follow-up to #919
Testing
./gradlew buildAndCollectDocumentation
No documentation changes needed.