Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Subpixel SVG costume handling: a writeup #550

Open
adroitwhiz opened this issue Jan 29, 2020 · 4 comments · Fixed by #745
Open

Subpixel SVG costume handling: a writeup #550

adroitwhiz opened this issue Jan 29, 2020 · 4 comments · Fixed by #745

Comments

@adroitwhiz
Copy link
Contributor

adroitwhiz commented Jan 29, 2020

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.

@adroitwhiz
Copy link
Contributor Author

adroitwhiz commented Jan 29, 2020

/cc @fsih @cwillisf

It looks like you're doing some paint editor and possibly some more renderer work, which ties into this somewhat, so I figured it was a good time for me to stop procrastinating and do The Writeup!

@fsih
Copy link
Contributor

fsih commented Jan 30, 2020

Thank you for this really thorough documentation and all this research! It really helps put all the PRs into context.

@benjiwheeler
Copy link
Contributor

This is another manifestation of the problem, or maybe a slightly different problem: sometimes jagged alias lines appear, or disappear, as a result of slightly modifying the image

smooth render med

@adroitwhiz
Copy link
Contributor Author

@cwillisf I accidentally put another autoclose keyword in #745; mind reopening this?

I really appreciate you taking the time to review my renderer PRs, by the way. Thanks for coming back to them!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants