This article is intended to introduce SVG concepts by showing how to create vector "pixels" and show interesting ways to optimize your SVG code. If you're familiar with SVG and want to see how I utilize path to make pixels, skip to Advanced Optimization. If you want to be impressed, check out the Animations. If you simply want to convert an image, try my Pixels.svg (Pixels to SVG Conversion via <canvas>).

Pixels have dominated my life. A childhood spent staring at a TV playing games on the NES, then SNES and Genesis led to staring at a PC for hours making my own (terrible) sprites for my own (terrible) games. Frankly I'm surprised I can still see shapes that aren't square (Minecraft certainly didn't help).

Unfortunately today's retina displays render pixels more like invisible atoms than chunky bricks. An 8x8 sprite is a speck by today's standards, leaving hand-crafted sprites of yesteryear practically invisible. An entire artform of creating beauty with a limited color pallete and miniscule resolution is being lost because it's simply too small to see.

Take this simple (and incredibly handsome) pixel art for example:

Output: 98 bytes, Gzip: 125 bytes. Yup, the Gzip is bigger.

Some browsers can scale images crisply with some CSS, but limited support for the feature may leave you with a blurry mess. Scaling the image in your graphics editor of choice still locks the image into a certain size and any responsive scaling in the browser may remove clarity from those beautiful pixels.

Enter SVG

Forget raster images; let's bring pixels into the present with Scalable Vector Graphics (SVG). SVG is a vector image format that uses code to draw an image with elements like <rect>, <circle>, <polygon> and <path>. These shapes are rendered live using math, so SVG images can scale to any size without losing quality.

Any elements we use need to be inside an <svg> container. The <svg> element can be dropped inline in your HTML or saved as raw text in a .svg file. We'll use the following container for our image:

  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7 8" shape-rendering="crispEdges">
  <!-- our shapes will go here -->
</svg>

Let's cover these attributes really quick:

  • xmlns tells the browser the 'namespace' of the code; how to interpret the data.
  • viewBox establishes the canvas size and location. Values are set in this order: x y width height. To make drawing easiest on ourselves we start at 0 for x & y and our width and height are the same as our source image.
  • shape-rendering set to crispEdges ensures the browser doesn't try to smooth the edges as the image scales. Without it, the SVG won't become blurry like an upscaled raster image, but you may see some fuzzy edges or gaps between vectorized 'pixels'.

Pixels in SVG

The simplest way to recreate pixels in SVG is to use the <rect> (rectangle) element. For example, <rect fill="#F00" x="0" y="0" width="1" height="1" /> creates a red pixel situated in the top left, but let's look closer at the attributes:

  • fill sets the rectangle's color
  • x for horizontal placement, distance from the left edge of the canvas
  • y for vertical placement, distance from the top edge of the canvas
  • width & height set to 1 gives a square

Adjusting the x and y attributes will change the starting point of the rectangle, so we can build out each pixel's position one-to-one from our source image.

Output: 3.14 KB, Gzip: 308 bytes. Simple animation added to show the individual elements

Beautiful! Our pixels are now infinitely scalable and the code is easy to understand, but golly gosh darn that's a lot of code for such a simple image. More complex sprites & graphics quickly balloon in size. Can we take advantage of vector pixels without adding excessively to the file size?

Basic Optimization

Let's explore a few ways we can trim this code.

Large contiguous rows & columns of the same color can be combined into one <rect> by bumping up the width and height. SVG's stacking order (like z-index) is determined by the source order, placing the first element on the bottom and the last element on top.

For the red hair, we can create a large block of the red hair color instead of individual chunks and add the fleshy pixels after so that they stack on top.

Output: 493 bytes, Gzip: 199 bytes

Much better!

We can reduce repetition in the code by utilizing the <g> element to group similar colors. Many attributes from the <g> cascade down to child elements unless overridden on the child itself, exactly like CSS properties. By placing the fill color on the <g>, we don't have to add it to each element.

Output: 458 bytes, Gzip: 209 bytes

Due to the way Gzip compression works, file size is slightly better on the version without the <g>, but complex images may benefit more from the grouping. If you use Gzip on your server as you should, then you may not gain much benefit some of these optimizations. SVG optimization is also about render speed and performance in the browser, usually aided by reducing the complexity (precision and number of elements) within the SVG as the following techniques will do.

Advanced Optimization

Your favorite vector program (Illustrator, Sketch, Affinity Designer) should be able to open SVGs built using the techniques shown so far. The following steps may break that ability, though the SVGs will render fine in browser.

Our code is already fairly optimized and perfectly usable, but we can do better!

Utilizing the <path> element we can draw arbitrary shapes, including squares and rectangles, using a short string of commands in the element's d attribute. I won't go very deep into the SVG path syntax (Read Joni Trythall's Pocket Guide to Writing SVG for a great overview), but here are the relevant bits we need to know:

  • M means "moveto", much like picking up your pen and placing it at a specific spot on the paper. The capital M uses absolute coordinates within the SVG canvas, making M0,0 the top left. The first coordinate is the x axis, the second, y. Coordinates can be separated by commas or spaces.
  • v draws a vertical line. Lowercase v makes the starting coordinates relative to the last position.
  • h draws a horizontal line. Lowercase h makes the starting coordinates relative to the last position.

With only those commands, we can draw a vector pixel equivalent to <rect x="0" y="0" height="1" width="1" />:

  <path d="M0,0 v1 h1 v-1" />

In the path data, M0,0 starts us in the top left, then using v1 we draw a vertical line down to 0,1. With h1 we draw a horizontal line from the current position ( 0,1 ) over to 1,1. Finally we go back up with v-1 (negative one) to end up at 1,0.

Animation showing how the square is drawn with path

The shape is technically "open" since the last coordinate is not the starting coordinate. Appending h-1 to move back to 0,0 or using the z (close path) command would fix that. However, we're using fill so the starting point & end will appear connected, helping us save some bytes by not manually closing the path.

We aren't limited to drawing just a 1x1 square. Contiguous rows & columns can be combined into one rectangle. For example, <path M0,1 v2h7v-2 /> is equivalent to <rect x="0" y="1" height="2" width="7" />

Like-colored rectangles can also be combined into one <path>, similar how we used <g>. The M moveto command in our path data will start a new shape within that path. So <path fill="red" d="M2,0 v1h4v-1 M0,1 v2h7v-2" /> is basically the same as <g fill="red"><rect x="2" y="0" height="1" width="4" /><rect x="0" y="1" height="2" width="7" /></g>.

Running our pixels through all of these optimizations leaves us with a much smaller code footprint:

Without whitespace, Output: 282 bytes, Gzip: 206 bytes

We've gone from 3.14kb to 282 bytes, an astounding savings of 91%. Comparing the 308 bytes Gzip to 206 bytes (33% savings) doesn't seem as drastic, but the differences add up significantly with large and complex images.

Crazy Pants Optimization

Can we take it further? Of course we can, but should we? These are the questions we need to ask ourselves as a society, but I can't leave well enough alone.

Four commands per rectangle seems excessive. We can cut that down. By using a stroke instead of fill, we can give that a line girth to make it appear as a pixel or row of pixels. The default stroke-width is 1, which works perfectly create pixels.

Here's one black vector pixel at 0,0 using only moveto and the horizontal line command:

  <path stroke="#000" d="M0 0 h1" />

We can make that line longer by changing the last value from h1 to h5 or however long we want our group of 'pixels' to be.

If you drop this into the SVG we established earlier, you'll notice the line is cut off vertically when starting at 0. SVG strokes go out from the center of the line, and we drew our line at 0,0 meaning the top half of the line is outside of the viewBox. We can fix this by moving our vector pixel down to 0.5 (half of the stroke width) on the y axis ( M0,0.5 h1 ), but we'd have to move ALL of our lines down 0.5 units cutting into our precious byte savings.

A better option is to change the viewBox from 0 0 {width} {height} to 0 -0.5 {width} {height}. This shifts the window peering into the canvas up to -0.5 on the y axis to reveal the top half of the first line.

Our final output, with whitespace added for readability:

Without whitespace, Output: 279 bytes, Gzip: 205 bytes

Wow. We shaved off 3 bytes! Aren't you glad you stuck around for this? This particular optimization is best for horizontal clusters of color so the mileage will vary with the image. More complex images may show greater savings. You can swap h lines for v lines and update the viewBox to -0.5 0 {width} {height} for vertical line optimization.

Comparison

Here are all of the generated images laid out side by side:

Animation

Like the spritesheets of yore, you can animate with plain ol' CSS using translate and step easing. Treehouse's CSS Sprite Sheet Animation Steps article is a great breakdown of how this works.

CSS animation using only translateX

Javascript animation with GSAP opens up the door for some really impressive effects on both the <rect> and <path> variants.

Animating `stroke` based vector pixels with GSAP's drawSVG plugin

Animation with GSAP's morphSVG plugin

If we leave our pixels as individual <rect>s, GSAP makes it easy to do some fun per-pixel effects.

Scale & rotate animation with GSAP

Final Thoughts

Is SVG the perfect format for pixel art? The answer mostly depends on your file size criteria. Raster images like .gif and .png run through ImageOptim will almost certainly have a smaller file size than the same image converted to SVG, but you gain many advantages with vectorized pixels such as scalability and animation.

Converting images manually will provide the best results, but I wrote a converter which takes advantage of the optimizations I've laid out in the article. Try out Pixels.svg (Pixels to SVG Conversion via <canvas>) with your favorite pixel art to see the results.

Related


1,103 1 16