Skip to content

perf(items): render item icons from a GPU atlas - #982

Open
anolithme wants to merge 1 commit into
Polyfrost:v1from
anolithme:perf/item-icons-direct-gpu-atlas
Open

perf(items): render item icons from a GPU atlas#982
anolithme wants to merge 1 commit into
Polyfrost:v1from
anolithme:perf/item-icons-direct-gpu-atlas

Conversation

@anolithme

Copy link
Copy Markdown
Contributor

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 TextureTarget on 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

  • ran tests on all supported versions
  • ran ./gradlew buildAndCollect
  • tested the item picker on 1.21.11 by opening it and scrolling through the list
  • compared it with the old version using JFR; readback samples went from 708 to 0 and item-related allocation samples were about 85% lower

Documentation

No documentation changes needed.

@saadndm saadndm 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.

Thanks for the PR, looks good but I think it can be simplified

Comment on lines +60 to +66
private interface CachedIcon {
fun acquire(): ItemIconHandle?
}

private class RasterCachedIcon(private val data: ItemIconData) : CachedIcon {
override fun acquire(): ItemIconHandle = data.copy()
}

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.

If we remove the Vulkan readback fallback, these can be removed

Comment on lines 618 to 763
@@ -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()
}
*///? }
}

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.

This entire readTarget function can be removed too

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.

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

Comment on lines +94 to +168
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)
}
}

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.

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

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.

Suggested change
override val supportsDirectOffscreenSampling = true

Comment on lines +23 to +24
val supportsDirectOffscreenSampling: Boolean get() = false

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.

Suggested change
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.

@Wyvest Wyvest moved this to In progress in OneConfig Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants