Skip to content

perf(app): copy sliced sprite frames into standalone bitmaps (~1.5GB -> ~131MB)#20

Merged
ntd4996 merged 1 commit into
ntd4996:mainfrom
aiexkwan:fix/spritesheet-frame-deep-copy
Jun 15, 2026
Merged

perf(app): copy sliced sprite frames into standalone bitmaps (~1.5GB -> ~131MB)#20
ntd4996 merged 1 commit into
ntd4996:mainfrom
aiexkwan:fix/spritesheet-frame-deep-copy

Conversation

@aiexkwan

Copy link
Copy Markdown
Contributor

Summary

Fixes #19 — AgentPet sat at ~1.5 GB physical footprint with a single pet displayed, because SpriteSlicer.slice() produced frames with CGImage.cropping(to:). Cropping returns a view that references the parent image's storage: every frame retained the full decoded 1536×1872 sheet, and ImageIO cached one 11 MB full-sheet decode per drawn frame (measured 158 copies = 1.3 GB resident).

This PR renders each sliced frame into its own frame-sized CGContext instead, so a frame owns a ~156 KB standalone backing store and the parent sheet becomes reclaimable.

Measured impact

Same machine, same pet (nika), equal ~8 min uptime, measured with vmmap:

before after
Physical footprint 1.5 GB 131 MB
Image IO regions (11.0 MB full-sheet decodes) 158 0

Visual behaviour is unchanged — frames are pixel-identical, only the backing store changes.

Changes

  • Sources/App/SpriteSlicer.swift — draw each crop into a frame-sized bitmap context (bytesPerRow = width * 4, same colorspace/alpha as pixelData()) and append makeImage()'s result; context/makeImage failures skip the frame, matching the existing nil-crop behaviour. Public signature and the gutter-detection logic are untouched.
  • Tests/AgentPetAppTests/SpriteSlicerTests.swift — new tests using a procedurally generated spritesheet (no fixture files): grid detection, per-frame pixel fidelity, the standalone-backing-store contract (bytesPerRow == width * 4, which fails against the old cropping-view implementation), and the fully-transparent-sheet edge case.

swift test: 152 tests, 0 failures.

🤖 Generated with Claude Code

SpriteSlicer.slice() returned CGImage.cropping(to:) views that each
retained the full decoded 1536x1872 spritesheet; at runtime ImageIO
cached one 11MB full-sheet decode per drawn frame (158 copies = 1.3GB).
Render each frame into its own frame-sized CGContext instead, dropping
app footprint from ~1.5GB to ~130MB. Fixes upstream issue ntd4996#19 locally.
@ntd4996 ntd4996 merged commit aeabf8e into ntd4996:main Jun 15, 2026
@ntd4996

ntd4996 commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Merged, thank you for the thorough investigation and fix. The vmmap breakdown and the standalone-bitmap approach were exactly right, and the SpriteSlicerTests are a great addition. Memory drops from ~1.5 GB to ~131 MB on my end too. This also closes #19. Added you to the contributors page (agentpet.thenightwatcher.online/contributors). 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory: ~1.3 GB of cached full-sheet decodes — SpriteSlicer frames via CGImage.cropping(to:) retain and re-decode the entire spritesheet

2 participants