Skip to content

Subpixel SVG costume handling: a writeup #550

Open
@adroitwhiz

Description

@adroitwhiz

The Problem

When editing a vector sprite, weird errors appear. Sometimes, columns/rows appear doubled up, and the very edges of a sprite may get cut off.

jaggies

This becomes particularly noticeable if you're using vector sprites in an animation, and you draw several different frames:

jitter_old

Notice how unstable this appears-- the cat is jittering around, and sometimes the very bottom of its feet get cut off.

The Root Cause

This is due to a fundamental difference between vector and raster graphics: subpixels.

An SVG costume's viewBox, which describes its bounds, can have non-integer dimensions. You can, for instance, have a sprite that's 2.5 pixels wide. However, eventually the sprite will need to be rendered to a texture, and textures necessarily have integer dimensions.

That's not the only problem, however. To further complicate matters, costumes can have non-integer rotation centers. These rotation centers shift a sprite over by a sub-pixel amount. Consider this SVG of a 3x3 circle (viewbox in red):

circle-1

Let's say we rasterize this circle as-is:

circle-1-rasterized

This appears fine. However, the center of this circle is at (1.5, 1.5). This will cause problems if we want to render the circle in the center of a, say, 6x6 stage:

circle-1-rasterized-centered

Now, we need to do some resampling of the already-rasterized SVG! If we do nearest-neighbor resampling, we get the "jaggies" shown earlier. If we do linear resampling, the SVG appears quite blurry. Clearly, we need to take care of this before rasterization.

The Theory

What we need to do is:

  1. Shift the SVG so that its rotation center lies on integer coordinates:

circle-1-shift-1 circle-1-shift-2

  1. Expand the bounding out to integer coordinates. Note that this step also solves the problem of non-integer viewBoxes, although it's not shown here:

circle-1-shift-3

  1. Adjust the rotation center of the sprite passed to scratch-render so that it is relative to the new bounding box. For instance, the above circle's rotation center is now (2, 2).

The Practice (Attempt 1)

Now comes the implementation. We modify SVGRenderer.loadSVG to take the costume's intended rotation center as an argument, and before creating the SVG image, apply the necessary transformations to its viewBox. Then, we offset the rotation center used by scratch-render by adding it to SVGRenderer.viewOffset.

Unfortunately, this doesn't work with mipmaps.

If you render the costume at above 100% size, it works fine, but if you render a mipmap under the costume's "native" size, then the rasterized costume may once again have a non-integer size or rotation center--for instance, if you rasterize at half the costume's normal size, you're dividing the rotation center and dimensions by 2.

The Practice (Attempt 2)

What if we instead applied this bounding-box adjustment each time the costume was rasterized? SVGSkin would pass the costume's rotation center to the draw method rather than the loadSVG method, and in _drawFromImage, the offsetting and resizing would be applied to the canvas that the SVG is being rendered to.

This works fine... in Chrome. But to implement this, you need to call ctx.drawImage at non-integer coordinates. And in Firefox, the drawImage method won't render SVGs with the proper subpixel offset, instead resampling them and making them blurry.

So the only cross-browser way to offset an SVG by a subpixel amount is to set its viewBox, like we did in our first attempt. But how do we do this without breaking mipmaps?

The Practice (Attempt 3)

This is mostly the same as attempt 1, but with one key difference: instead of changing the viewBox and rotation center to be round numbers, we ensure they are multiples of the smallest mipmap's size.

For instance, if the smallest mipmap is 1/8th the SVG's native scale, we ensure that the viewBox and rotation center are multiples of 8. This ensures that, even at the smallest mipmap's size, the SVG is rendered at the proper subpixel position. This is the best cross-browser solution I've found.

The Demo

I have a rough version of this working in my subpixel-v2 branches of scratch-render and scratch-svg-renderer. The code is somewhat old (preceding SVG mipmaps, the removal of getDrawRatio, and the synchronous scratch-svg-renderer API), but it works well enough to eliminate the jitter and jaggies entirely:

jitter_new

The Remaining Problem

There's a kink that still needs to be worked out, however. Expanding the viewbox out to multiples of n pixels will increase the size of costumes' bounds. This isn't a problem for getFastBounds (which doesn't return consistent results and doesn't need to) and getConvexHullPointsForDrawable (the size of the convex hull won't be affected at all), but it presents a problem for getAABB, which is used (or rather, should be used) for fencing sprites.

With the viewbox-expanding behavior in place, there will be a split between a costume's "logical bounds" (aka the viewBox), and its "texture bounds" (the dimensions of the quadrilateral rendered by WebGL).

In order to calculate both the "logical bounds" (for fencing behavior) and "texture bounds" (for rendering), it looks to me like Drawable._calculateTransform will have to be refactored in some way-- by changing it to calculate both the "logical" and "texture" bounds, by splitting it into two different methods which calculate the two different bounds, or in some other (hopefully less complex) way.

EDIT: Distortion effects will also have to change what they consider the "center" of the sprite.

If you have any ideas for avoiding complexity here, I'd greatly appreciate them.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions