Using SVG Icons with Pixi.JS

Step by step guide on integrating SVG icons efficiently in Pixi.js in Javascript

Β·

7 min read

πŸ“š Pre-requisites

1. Basic understanding of PixiJS

PixiJS is an open-source, web-based rendering system that provides blazing-fast performance for graphics-intensive projects.

This blog assumes the user has set up a basic Pixi.js application. If not, it's highly recommended to go through the official Pixi.js tutorials

2. SVG

SVG is an XML-based vector image format for defining two-dimensional graphics, having support for interactivity and animation.

  • Basic understanding of <symbol> and <use> element

    The <symbol> element is used to define graphical template objects which can be instantiated by a <use> element. Refer to MDN for reference.

3. How to use spritesheet

In layman's terms, spritesheet is a collection of small images, like icons in this case. Instead of keeping each picture separately, you tile them on a single large document. To view an image from this spritesheet, you just need the position of the image.

βš”οΈ The Challenge

If you directly use SVG icons within Pixi JS, they get "Rasterized" (meaning they lose their vector data). Which means if you scale them, they get blurred out. To avoid this, we can:

  1. Either convert them into native Pixi Objects using something like pixi-svg library.

    • The problem with this approach is that these conversions don't translate 1:1 to original SVG due to lack of certain features. So this option can be used if your SVG is not using any of the unsupported features listed by the library.
  2. Or we can set the SVG scale beforehand so we can rasterize them at higher resolution, before they get converted to textures.

For the scope of this blog post, we will be implementing the solution based on the second approach, while making sure that we have:

  • Efficient Icon Rendering Performance - Icons are loaded quickly and rendered without any hassle.

  • Smoother Icon Management - Adding new icons should not require significant extra effort

πŸ”– The Action Plan

Let's get started, with our action plan.

  1. NodeJS Script to generate Spritesheet

    1. The first step is to create a spritesheet from available SVG icons in our workspace. The advantage of this tooling is rich DX and a better way to manage SVG icons.

    2. Assumption is that we already have a set of SVG icons with us

    3. The script will read these icons and compile them into a single spritesheet.json file, which we'll use within our code.

    4. The script itself will be responsible for:

      1. loading SVG icons as strings

      2. Wrapping <symbol> around them so they become "reusable".

      3. Finally, using them by <use> and laying them horizontally. (with the assumption that every icon is of the same size and aspect ratio)

      4. Following will be format for spritesheet.json

           // Spritesheet.json
           {
               "spriteSheetSVGData": "..." // Entire SVG as string
               "iconSequence":[] // sequence of icons in the spritesheet.
           }
        
  2. Using SVGs in Pixi.JS

    1. Now coming to our main logic, we will use the generated spritesheet JSON to:

    2. Create texture from the spritesheet

    3. Create a function to crop the spritesheet based on the provided iconName and return the resultant sprite.

    4. Render the icon in a PixiJS Stage.

πŸš€ Code Walkthroughs

πŸ§‘πŸ½β€πŸ’» Spritesheet generation

  • spritesheet.json consists of the following properties

    • spritesheetSVGData: stringified SVG Data of the spritesheet.

    • iconSequence : An ordered array of distinct svg-ids used in the spritesheet. This is a critical attribute, whose index will be used to fetch an Icon, which is going to be used in Pixi.js app.

      /**
       * Note:
       * The script runs in node-js environment.
       */

      import path from "path";
      import fs from "fs-extra";

      /**
       * Converts string to PascalCase
       */

      const toPascalCase = (str) => {
        return `${str}`
          .replace(/[-_]+/g, " ")
          .replace(/[^\w\s]/g, "")
          .replace(
            /\s+(.)(\w*)/g,
            ($1, $2, $3) => `${$2.toUpperCase() + $3.toLowerCase()}`,
          )
          .replace(/\w/, (s) => s.toUpperCase());
      };

      // Directory where spritesheet.json will be generated
      const spriteSheetDirectoryPath = "";

      // spritesheet object, to be loaded in JSON.
      const spriteSheet = {
        spriteSheetSVGData: "",
        iconSequence: [],
      };

      // Utility function to replace SVG with <symbol>
      const replaceSvgWithSymbol = (svg, id) => {
        // Replace <svg> opening tag with <symbol> opening tag
        let modifiedSvg = svg.replace(/<svg([^>]*)>/, `<symbol$1 id="${id}">`);
        // Replace </svg> closing tag with </symbol> closing tag
        modifiedSvg = modifiedSvg.replace("</svg>", "</symbol>");
        // Remove xmlns property from <symbol>
        modifiedSvg = modifiedSvg.replace(' xmlns="http://www.w3.org/2000/svg"', "");

        return modifiedSvg;
      };

      export const createSpriteSheet = (svgList) => {
        /* 
           Used to store <symbol>(s). 
           Every root <symbol> represents a svg icon.
           For example:
           <symbol id="iconName">...</symbol>
        */
        let symbols = "";

        // conversion of SVG to <symbol>, and
        // updating IconSequence in spritesheet
        svgList.forEach((svg) => {
          const svgCode = svg.data;
          const iconName = toPascalCase(svg.name);
          const symbolCode = replaceSvgWithSymbol(svgCode, iconName);
          symbols += symbolCode;
          spriteSheet.iconSequence.push(iconName);
        });

        /* 
           Create SpriteSheet SVG Data
           Our icons are of size: 16 X 16

           A linear spritesheet having a
           height: 16 units (based on icon height).
           width: 16 * total icons
           gap between icons: 8px

          The data part of spritesheet svg
          We use entity reference method, to put images in the spritesheet.
          <use href="symbol-id" y=0 x={index * 24}/>

          Finally write the spriteSheet to the respective JSON file

        */

        spriteSheet.spriteSheetSVGData = `<svg xmlns="http://www.w3.org/2000/svg" 
      height="16" width="${16 * svgList.length}" viewbox="0 0 ${
          16 * svgList.length
        } 16">${symbols}${spriteSheet.iconSequence
          .map((symbolID, index) => {
            return `<use href="#${symbolID}" x="${index * 24}" y="0"/>`;
          })
          .join(" ")}</svg>`;

        // Create Spritesheet.json
        fs.writeFileSync(
          path.resolve(spriteSheetDirectoryPath, "spritesheet.json"),
          JSON.stringify(spriteSheet),
        );
        // Create Spritesheet.svg
        fs.writeFileSync(
          path.resolve(spriteSheetDirectoryPath, "spritesheet.svg"),
          JSON.stringify(spriteSheet.spriteSheetSVGData),
        );
      };

βš™οΈ Using SVGs in Pixi.JS

  1. Assumptions

    • spritesheet.svg is generated from the above script.

    • iconSequence is extracted from the spriteSheet.json

  2. Example App Structure

    1. We will be using Vanilla Javascript as an example for simplicity and easier for understanding

          app/
           β”œβ”€ index.html
           │─ js/
           β”‚  β”œβ”€ app.js
           β”œβ”€ static/
           β”‚  β”œβ”€spritesheet.json
      
  3. As we are using vanilla javascript. We will be using the native DOM to load the Pixi.JS. Henceforth, here is what index.html looks like.

    • Note: The script, app.js is loaded as a module. This is intentionally done to prevent scope ambiguity issues with global context. Henceforth, we have to explicitly mention what variables we want to add to the global context. For example: globalThis.__PIXI_APP__ = app;
    <!--index.html-->
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <title>PixiJS and Icons Integration</title>
      </head>
      <body>
        <script src="https://cdn.jsdelivr.net/npm/pixi.js@6.x/dist/browser/pixi.min.js"></script>
        <script type="module" src="js/app.js"></script>
      </body>
    </html>
  1. app.js performs the following operations.

    1. Initialize PixiJS

    2. Load SVG Resource for spritesheet

    3. Create texture from the SVG resource.

    4. A function that takes input as icon name, and returns the relevant icon sprite.

    5. Create a sprite from the texture.

    6. Crop the sprite generated, based on the required index found in iconSequence

    7. If no such icon exists, return undefined.

       // App.js
       // File where our svg is stored.
       const SVG_URL = "/images/spriteSheet.svg";
       const ICON_SEQUENCE = ["House", "Tasks", "Chart"];
      
       // Initializing the PIXIJS
       let app = new PIXI.Application({
         width: 400,
         height: 300,
         resolution: window.devicePixelRatio,
         antialias: true,
         backgroundColor: 0xeeeeee,
         autoDensity: true
       });
       globalThis.__PIXI_APP__ = app;
       document.body.appendChild(app.view);
      
       // Created SVG Resource from the SVG
       const svgRes = new PIXI.SVGResource(SVG_URL, { scale: 3 });
      
       // Created Texture from the SVG Resource
       const texture = PIXI.Texture.from(svgRes);
      
       /* Returns the relevant Icon Sprite
        * @param iconName: string
        * @param options: {x:number, y:number, height:number, width:number}
        */
       function getIcon(iconName, options) {
         const sprite = new PIXI.Sprite(texture);
      
         const iconIndex = ICON_SEQUENCE.findIndex(
                           (iName) => iName === iconName);
      
         if (iconIndex < 0) {
           console.error("No icon found with name:", iconName);
           return undefined;
         }
      
         texture.on("update", () => {
           if (texture.valid) {
             texture.frame = new PIXI.Rectangle(70 * iconIndex, 0, 50, 48);
             sprite.width = options?.width || 16;
             sprite.height = options?.height || 16;
             sprite.x = options?.x || 175;
             sprite.y = options?.y || 125;
           }
         });
      
         return sprite;
       }
       const iconSprite = getIcon("Tasks", { height: 30, width: 30, x: 25, y: 25 });
       app.stage.addChild(iconSprite);
      

πŸ‘¨πŸΌβ€πŸ’» CodeSandbox

Here's a live example of our above implementation.

Conclusion

So let's look at the aspects we discussed before starting the blog.

  1. Icon Rendering Performance

    • Now, we do not calculate textures for every icon. Instead, we calculate it once for the spritesheet, and then we crop the spritesheet based on the icon we need.
  2. Icon Management

    • Spritesheets can be now easily generated and updated with new icons from our new script.

    • And every generated spritesheet, can be versioned via git.

    • In the end, the whole spritesheet is just a big SVG file, and we need not worry about having multiple files for multiple SVG icons, providing a smoother icon management experience, without any overhead of fetching individual resources.

I hope you found the blog helpful and got to learn something new today. Plus give a read to our detailed blog on building a layout engine in Pixi.JS here

We are hiring! πŸŽ‰

Solving challenging problems at scale in a fully remote team interests you, head to our careers page and apply for the position of your liking!

Β