Skip to content

Replace RMagick rendering with a pure-Ruby indexed PNG encoder - #1

Open
epistrephein wants to merge 1 commit into
mainfrom
optimize-generation
Open

Replace RMagick rendering with a pure-Ruby indexed PNG encoder#1
epistrephein wants to merge 1 commit into
mainfrom
optimize-generation

Conversation

@epistrephein

@epistrephein epistrephein commented Aug 5, 2026

Copy link
Copy Markdown
Owner

This PR removes the RMagick gem dependency and use pure-Ruby PNG generation, improving performance by a lot. Produced with Fable.

Problem

Generating the default 4020×4020 image took ~1.33s, almost entirely spent in the rendering pipeline. Profiling broke the time down as:

Phase Time
Ruby + bundler startup ~180ms
draw! — 256 separate Magick::Draw rectangle passes ~416ms
picture.write — ImageMagick PNG encode ~755ms

The PNG encode has a ~650ms floor caused by ImageMagick's per-pixel palette scan over 16M pixels; no combination of encoder options (compression level, filter, strategy, explicit color-type) gets below it.

Approach

The image is a known quantity: at most a handful of flat colors on a regular grid. Rasterizing 16M pixels and then re-scanning them to discover a palette is unnecessary — the indexed PNG can be emitted directly from sequence and the grid geometry using only stdlib Zlib. Only rows + 1 distinct scanline patterns exist (the background row plus one per grid row), so each is built once and shared across all pixel rows.

Two details keep the output identical to the RMagick renderer:

  • Draw#rectangle fills its coordinates inclusively, so the square side is ((size - gap) * multiplier).round + 1.
  • Squares are spliced into each row left to right, preserving painter's order on overlap.

Output for the default seed is byte-for-byte pixel-identical to the previous renderer (magick compare -metric AE = 0). The one behavioral difference: custom geometries whose pixel coordinates land on fractions get crisp rounded edges instead of ImageMagick's anti-aliasing (defaults and the classic geometry are unaffected).

Results

before after
avg of 10 runs 1.3286s 0.2384s (−82%)
default image size ~42KB ~27KB

Deflate level 9 proved both faster and ~2.6× smaller than level 6 on this data, so Zlib::BEST_COMPRESSION is used.

Changes

  • lib/png.rb (new) — Squarecraft::Png, a minimal indexed-color PNG writer: signature + IHDR/PLTE/IDAT/IEND chunks, bit depth 4 for ≤16 palette colors (8 for up to 256), one deflate call over filter-0 scanlines. Repeated scanline Strings are packed once via a compare_by_identity memo.
  • lib/generator.rbdraw! now builds palette-index scanlines from sequence + the existing coords math and returns a PNG blob (String) instead of a Magick::Image; require "rmagick" dropped.
  • bin/squarecraft, bin/examples — write the blob with File.binwrite instead of Magick::Image#write.
  • Gemfile/Gemfile.lock — RMagick dependency removed; the project is now pure Ruby.
  • .github/workflows/main.yml — ImageMagick apt install step removed (it existed only for RMagick's native extension).
  • spec/generator_spec.rbpicture expectations updated to check the PNG signature and the IHDR dimensions.
  • README.md — ImageMagick/RMagick prerequisites removed, draw! description updated.

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.

1 participant