diff --git a/CHANGES.md b/CHANGES.md index e5155b52..00ccb3a5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## 0.41.0 +- Allocate Metal backdrop and rect-mask storage on first use, release optional + textures on resize or after three frames without blur, and avoid duplicate CPU + index and screenshot buffers. + - Move the ownership-safe fixed-ring RChan implementation and its regression suite to Sigils 0.31.0; keep a compatibility import for existing FigDraw callers and exercise image-message replacement through the shared channel. diff --git a/src/figdraw/metal/metal_context.nim b/src/figdraw/metal/metal_context.nim index c5943f74..ed323d6b 100644 --- a/src/figdraw/metal/metal_context.nim +++ b/src/figdraw/metal/metal_context.nim @@ -84,6 +84,8 @@ type MetalContext* = ref object of figbackend.BackendContext # Metal objects offscreenTexture: ObjcOwned[MTLTexture] backdropTexture: ObjcOwned[MTLTexture] backdropBlurTempTexture: ObjcOwned[MTLTexture] + backdropUsedThisFrame: bool + backdropUnusedFrames: int atlasTexture: ObjcOwned[MTLTexture] maskTextures: seq[ObjcOwned[MTLTexture]] maskTextureWrite: int ## Index of active mask stack (0 means no mask). @@ -106,7 +108,7 @@ type MetalContext* = ref object of figbackend.BackendContext # Metal objects pixelScale*: float32 # Buffer data mirrored on CPU and uploaded each flush. - indices: tuple[buffer: ObjcOwned[MTLBuffer], data: seq[uint16]] + indices: ObjcOwned[MTLBuffer] positions: CpuBuffer[float32] colors: CpuBuffer[uint8] fillMidColors: CpuBuffer[uint8] @@ -764,6 +766,15 @@ proc setRectMaskVert4(ctx: MetalContext, offset: int, params, radii, matX, matY: ctx.rectMaskMatX.data.setVert4(offset + i, matX) ctx.rectMaskMatY.data.setVert4(offset + i, matY) +proc ensureRectMaskBuffers(ctx: MetalContext) = + if ctx.rectMaskParams.data.len > 0: + return + let vertexComponents = 4 * ctx.maxQuads * 4 + ctx.rectMaskParams.data = newSeq[float32](vertexComponents) + ctx.rectMaskRadii.data = newSeq[float32](vertexComponents) + ctx.rectMaskMatX.data = newSeq[float32](vertexComponents) + ctx.rectMaskMatY.data = newSeq[float32](vertexComponents) + proc setDisabledRectMaskVerts(ctx: MetalContext, firstVertex, vertexCount: int) = let params = vec4(0.0'f32, 0.0'f32, -1.0'f32, -1.0'f32) @@ -797,6 +808,7 @@ proc setRectMaskVert4(ctx: MetalContext, offset: int) = break if hasRectMask: + ctx.ensureRectMaskBuffers() if not ctx.batchHasRectMask: ctx.setDisabledRectMaskVerts(0, offset) ctx.batchHasRectMask = true @@ -1616,6 +1628,7 @@ method drawBackdropBlur*( return ctx.ensureBackdropTexture(ctx.frameSize) + ctx.backdropUsedThisFrame = true ctx.blitToTexture(ctx.offscreenTexture.borrow, ctx.backdropTexture.borrow) ctx.runBackdropSeparableBlur(blurRadius) @@ -1785,20 +1798,30 @@ proc beginFrame*( ctx.maskTextureWrite = 0 ctx.rectMaskStack.setLen(0) ctx.batchHasRectMask = false + ctx.backdropUsedThisFrame = false ctx.proj = proj ctx.frameSize = frameSize ctx.ensureOffscreen(frameSize) - ctx.ensureBackdropTexture(frameSize) - # Resize any existing mask textures > 0. + # Release mismatched optional textures; recreate them only when used. + let frameWidth = max(1, frameSize.x.int) + let frameHeight = max(1, frameSize.y.int) + if not ctx.backdropTexture.isNil and ( + ctx.backdropTexture.borrow.width.int != frameWidth or + ctx.backdropTexture.borrow.height.int != frameHeight + ): + ctx.backdropTexture.clear() + if not ctx.backdropBlurTempTexture.isNil and ( + ctx.backdropBlurTempTexture.borrow.width.int != frameWidth or + ctx.backdropBlurTempTexture.borrow.height.int != frameHeight + ): + ctx.backdropBlurTempTexture.clear() for i in 1 ..< ctx.maskTextures.len: let cur = ctx.maskTextures[i] - if cur.isNil or cur.borrow.width.int != frameSize.x.int or - cur.borrow.height.int != frameSize.y.int: - ctx.maskTextures[i].resetRetained( - ctx.createMaskTexture(frameSize.x.int, frameSize.y.int) - ) + if not cur.isNil and + (cur.borrow.width.int != frameWidth or cur.borrow.height.int != frameHeight): + ctx.maskTextures[i].clear() ctx.commandBuffer.resetBorrowed(commandBuffer(ctx.queue.borrow)) if ctx.commandBuffer.isNil: @@ -1867,6 +1890,14 @@ method endFrame*(ctx: MetalContext) = ctx.inFlightFrames.add(inFlight) ctx.activeArena = -1 + if ctx.backdropUsedThisFrame: + ctx.backdropUnusedFrames = 0 + elif not ctx.backdropTexture.isNil or not ctx.backdropBlurTempTexture.isNil: + inc ctx.backdropUnusedFrames + if ctx.backdropUnusedFrames >= maxFramesInFlight: + ctx.backdropTexture.clear() + ctx.backdropBlurTempTexture.clear() + ctx.commandBuffer.clear() ctx.frameAutoreleasePool.stop() @@ -1939,15 +1970,15 @@ proc readPixels*(ctx: MetalContext, frame: Rect = rect(0, 0, 0, 0)): Image = h = clamp(h, 0, texH - y) result = newImage(w, h) - var tmp = newSeq[uint8](w * h * 4) + static: + doAssert sizeof(ColorRGBX) == 4 ctx.offscreenTexture.borrow.getBytes( - tmp[0].addr, NSUInteger(w * 4), mtlRegion2D(x, y, w, h), 0 + result.data[0].addr, NSUInteger(w * 4), mtlRegion2D(x, y, w, h), 0 ) # Offscreen is BGRA8; Pixie expects RGBA. - for i in 0 ..< w * h: - let bi = i * 4 - result.data[i] = rgbx(tmp[bi + 2], tmp[bi + 1], tmp[bi + 0], tmp[bi + 3]) + for pixel in result.data.mitems: + swap(pixel.r, pixel.b) proc ensureFlushBufferCapacity( ctx: MetalContext, @@ -2137,14 +2168,21 @@ proc flush(ctx: MetalContext, maskTextureRead: int = ctx.maskTextureWrite) = setFragmentTexture(enc, ctx.atlasTexture.borrow, 0) let maskIndex = clamp(maskTextureRead, 0, ctx.maskTextures.high) setFragmentTexture(enc, ctx.maskTextures[maskIndex].borrow, 1) - setFragmentTexture(enc, ctx.backdropTexture.borrow, 2) + setFragmentTexture( + enc, + if not ctx.backdropUsedThisFrame or ctx.backdropTexture.isNil: + ctx.atlasTexture.borrow + else: + ctx.backdropTexture.borrow, + 2, + ) drawIndexedPrimitives( enc, MTLPrimitiveTypeTriangle, NSUInteger(indexCount), MTLIndexTypeUInt16, - ctx.indices.buffer.borrow, + ctx.indices.borrow, 0, ) @@ -2203,29 +2241,25 @@ proc newContext*( result.sdfRadii.data = newSeq[float32](4 * maxQuads * 4) result.sdfModeAttr.data = newSeq[SdfModeData](1 * maxQuads * 4) result.sdfFactors.data = newSeq[float32](2 * maxQuads * 4) - result.rectMaskParams.data = newSeq[float32](4 * maxQuads * 4) - result.rectMaskRadii.data = newSeq[float32](4 * maxQuads * 4) - result.rectMaskMatX.data = newSeq[float32](4 * maxQuads * 4) - result.rectMaskMatY.data = newSeq[float32](4 * maxQuads * 4) result.rectMaskStack = @[] # Indices are static. - result.indices.data = newSeq[uint16](maxQuads * 6) + var indices = newSeq[uint16](maxQuads * 6) for i in 0 ..< maxQuads: let offset = i * 4 let base = i * 6 - result.indices.data[base + 0] = (offset + 3).uint16 - result.indices.data[base + 1] = (offset + 0).uint16 - result.indices.data[base + 2] = (offset + 1).uint16 - result.indices.data[base + 3] = (offset + 2).uint16 - result.indices.data[base + 4] = (offset + 3).uint16 - result.indices.data[base + 5] = (offset + 1).uint16 - - result.indices.buffer.resetRetained( + indices[base + 0] = (offset + 3).uint16 + indices[base + 1] = (offset + 0).uint16 + indices[base + 2] = (offset + 1).uint16 + indices[base + 3] = (offset + 2).uint16 + indices[base + 4] = (offset + 3).uint16 + indices[base + 5] = (offset + 1).uint16 + + result.indices.resetRetained( newBufferWithBytes( result.device.borrow, - result.indices.data[0].addr, - NSUInteger(result.indices.data.len * sizeof(uint16)), + indices[0].addr, + NSUInteger(indices.len * sizeof(uint16)), MTLResourceOptions(0), ) ) diff --git a/tests/bench_metal_memory.nim b/tests/bench_metal_memory.nim new file mode 100644 index 00000000..91bf0451 --- /dev/null +++ b/tests/bench_metal_memory.nim @@ -0,0 +1,46 @@ +## nim c --mm:arc -d:release -o:/tmp/figdraw-metal-bench tests/bench_metal_memory.nim +## /usr/bin/time -l /tmp/figdraw-metal-bench plain +## Reports current Metal allocations; time reports peak process memory. +import std/os + +import figdraw/commons + +when UseMetalBackend: + import pkg/[pixie, chroma] + import darwin/objc/runtime + import metalx/metal + import figdraw/figbasics + import figdraw/metal/metal_context + + proc currentAllocatedSize( + device: MTLDevice + ): NSUInteger {.objc: "currentAllocatedSize".} + + if paramCount() != 1 or paramStr(1) notin ["init", "plain", "blur", "shot", "idle"]: + quit "usage: bench_metal_memory init|plain|blur|shot|idle" + + let scenario = paramStr(1) + let ctx = newContext(atlasSize = 1024, maxQuads = 1024) + echo "metal_bytes_init=", currentAllocatedSize(ctx.metalDevice()) + + if scenario != "init": + ctx.beginFrame(vec2(1024, 768), clearMain = true) + for i in 0 ..< 64: + ctx.drawRect(rect(float32(i * 8), 0, 8, 64), color(1, 0, 0, 1)) + if scenario in ["blur", "idle"]: + ctx.drawBackdropBlur(rect(64, 64, 128, 128), default(CornerRadii2D[float32]), 4) + ctx.endFrame() + discard ctx.readPixels(rect(0, 0, 1, 1)) + echo "metal_bytes_frame=", currentAllocatedSize(ctx.metalDevice()) + if scenario == "shot": + let image = ctx.readPixels() + echo "screenshot_pixels=", image.width * image.height + elif scenario == "idle": + for _ in 0 ..< 3: + ctx.beginFrame(vec2(1024, 768), clearMain = true) + ctx.drawRect(rect(0, 0, 8, 8), color(1, 0, 0, 1)) + ctx.endFrame() + discard ctx.readPixels(rect(0, 0, 1, 1)) + echo "metal_bytes_idle=", currentAllocatedSize(ctx.metalDevice()) +else: + quit "Metal backend is required" diff --git a/tests/tmetal_memory.nim b/tests/tmetal_memory.nim new file mode 100644 index 00000000..a360fe28 --- /dev/null +++ b/tests/tmetal_memory.nim @@ -0,0 +1,72 @@ +import std/unittest + +import figdraw/commons + +when UseMetalBackend: + # Inspect optional allocations alongside their public rendering paths. + include ../src/figdraw/metal/metal_context + + suite "Metal optional memory": + test "allocates backdrop and rect-mask storage only when used": + let device = fromRetained(MTLCreateSystemDefaultDevice()) + if device.isNil: + skip() + else: + let ctx = newContext(atlasSize = 32, maxQuads = 8) + check ctx.backdropTexture.isNil + check ctx.rectMaskParams.data.len == 0 + + ctx.beginFrame(vec2(32, 32), clearMain = true) + ctx.drawRect(rect(0, 0, 32, 32), color(1, 0, 0, 1)) + ctx.endFrame() + check ctx.backdropTexture.isNil + check ctx.rectMaskParams.data.len == 0 + let pixels = ctx.readPixels() + check pixels[8, 8].r == 255 + check pixels[8, 8].g == 0 + check pixels[8, 8].b == 0 + + ctx.beginFrame(vec2(32, 32), clearMain = true) + ctx.beginRectMask(rect(0, 0, 16, 16), default(CornerRadii2D[float32])) + ctx.drawRect(rect(0, 0, 16, 16), color(0, 1, 0, 1)) + ctx.popRectMask() + check ctx.rectMaskParams.data.len == 4 * 8 * 4 + ctx.drawBackdropBlur(rect(0, 0, 16, 16), default(CornerRadii2D[float32]), 4) + ctx.beginMask(rect(0, 0, 16, 16), default(CornerRadii2D[float32])) + ctx.endMask() + ctx.drawRect(rect(0, 0, 16, 16), color(0, 0, 1, 1)) + ctx.popMask() + ctx.endFrame() + check not ctx.backdropTexture.isNil + check not ctx.backdropBlurTempTexture.isNil + check ctx.maskTextures.len == 2 + check not ctx.maskTextures[1].isNil + + for unusedFrame in 1 .. maxFramesInFlight: + ctx.beginFrame(vec2(32, 32), clearMain = true) + ctx.drawRect(rect(0, 0, 8, 8), color(1, 0, 0, 1)) + ctx.endFrame() + if unusedFrame < maxFramesInFlight: + check not ctx.backdropTexture.isNil + check not ctx.backdropBlurTempTexture.isNil + check ctx.backdropTexture.isNil + check ctx.backdropBlurTempTexture.isNil + + ctx.beginFrame(vec2(32, 32), clearMain = true) + ctx.drawBackdropBlur(rect(0, 0, 16, 16), default(CornerRadii2D[float32]), 4) + ctx.endFrame() + check not ctx.backdropTexture.isNil + + ctx.beginFrame(vec2(16, 16), clearMain = true) + check ctx.backdropTexture.isNil + check ctx.backdropBlurTempTexture.isNil + check ctx.maskTextures[1].isNil + ctx.beginMask(rect(0, 0, 8, 8), default(CornerRadii2D[float32])) + ctx.endMask() + check not ctx.maskTextures[1].isNil + ctx.popMask() + ctx.endFrame() +else: + suite "Metal optional memory": + test "Metal backend not enabled": + skip()