Drawing
drawing.js is a graphics library I'm developing for use in simple 2D demos. Most of the graphics libraries available are heavy in terms of size and dependencies. drawing.js on the other hand is designed be a simple, single file library that supports a wide variety of shapes, high resolutions, and even embedded fonts.
The library is modeled after javascript canvases. Everything from polygons, to circles, to letters are composed of just 2 things: lines and bezier curves.
Defining Shapes
Defining even simple polygons programmatically can be tedious, so the library allows SVG path notation: M move to, L line, C curve, Z close path. This allows us to make use of SVG editors (like yqnn's) to easily define polygons. Using this notation we can draw a simple cat:
Left shows the control points, right shows the final result.
In addition to geometric shapes we can represent letters, numbers, and symbols as SVG paths. At 5 lines and 22 curves, this is the most complex character in drawing.js - as expected of a real g.
Optimizations
Curve Segmentation
The cubic bezier curves we use are represented as cubic polynomials. This makes determining what pixels they overlap computationally expensive. Instead, the curves are decomposed into lines immediately before rasterization. This conversion ends up being faster and simpler than rasterizing the curves directly.
A good segmentation algorithm will create the fewest lines possible while closely approximating the curve. drawing.js does this by picking 3 points on a section of the curve and finding their distance to the line. If the distance is too great that section is split. We continue doing this until all lines are close enough to the curve.
Pixel Runs
While processing each line on a row, we can calculate how many pixels we are able to draw (or skip) before reaching the next line. This allows us to skip empty portions of the polygon and also enter a tight loop to allow the polygon filler to focus on drawing pixels.
Line Ends
When calculating pixel coverage, one of the biggest optimizations comes from noting that lines that span multiple pixels on a row can be divided into 3 sections: a beginning, middle, and end. Since the middle section's area will change at a constant rate (depending on the slope), we can skip calculating area coverage - we just need to add a constant value to the area as we process each pixel along the row. This means that the worst case complexity per row can be reduced from width*lines to 2*lines. We only need to calculate the non-constant area changes at each end of the line.
Heaps
The filler needs to keep track of where each line starts and ends on each row to determine where to draw and where to skip. In order to efficiently keep track of all these lines, which can easily number in the hundreds, the filler uses a binary heap to track on what pixel a line starts. To determine where to draw next, we pop the top entry off the heap and jump to that row and column. We then look at the next entry to determine how far we can draw before we encounter another line. This allows log(n) complexity when managing lines.
Line Caching
Although a binary heap makes managing lines efficient, it still eats up about 29% of fillpoly's run time. Mathematically, we would expect shorter lines (per row) to be more common, and this is what I also observed. In order to reduce the time spent on these short lines, I expanded the variables used in the Line Ends section to handle 2 pixel wide lines. This ended being as fast as the basic 1 pixel wide algorithm while being more complex, so it was abandoned.
Bounding Boxes
Ideally, we'd like to avoid drawing as many polygons as possible. To do this, when building an SVG path, we also keep track of the axis aligned bounding box around its points. When it's time to draw, we apply any transforms to this bounding box and can easily determine if this oriented box overlaps the screen. If it doesn't we skip drawing altogether in O(1) time.
WebAssembly
One attempt at speeding up polygon filling was by using webassembly. In good conditions, it offers a 1.2x to 2.0x speed up in browsers. The downsides are needing to move images to the sandboxed WASM memory, maintain a separate version of fillpoly, and handle the scaffolding to support swapping to WASM during runtime. Currently drawing.js has WASM module removed until it becomes faster.
Pixel Blending
Pixels on the edge of the polygon might not be entirely covered by the polygon. When this happens, we don't want to completely replace the background pixel's color. Instead, we want to blend it with the polygon's color. This can be incredibly slow due to the math involved. The fastest algorithm I've found so far involves using imul to blend multiple rgb components at once:
Alpha Tables
In order to speed up pixel compositing, I looked into precomputing the source and dest alpha values. Ultimately, it was as fast as direct computation while being more complex, so it's been abandoned.