Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
94 changes: 64 additions & 30 deletions src/figdraw/metal/metal_context.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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),
)
)
Expand Down
46 changes: 46 additions & 0 deletions tests/bench_metal_memory.nim
Original file line number Diff line number Diff line change
@@ -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"
72 changes: 72 additions & 0 deletions tests/tmetal_memory.nim
Original file line number Diff line number Diff line change
@@ -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()
Loading