-
Notifications
You must be signed in to change notification settings - Fork 350
Add JavaScript MIPs for scaling textures larger and smaller #431
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
Add JavaScript MIPs for scaling textures larger and smaller #431
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great! I'm so glad to have this handled in a more intelligent way :D
I understand we're going to discuss this in an upcoming, but here are a few initial thoughts:
- Wrapping the fancy stuff into a new
SVGMIP
class makes sense to me. I'm tempted to suggest moving the whole mip chain into a separate handler class but sincegetTexture
already encapsulates the code into a function moving it into a class would be of limited practical benefit. I guess. ;) - I'm a little surprised by the memory usage numbers but I'm glad you took the time to collect them. Your math is accurate -- the extra texture memory needed for an "infinite" MIP chain tends toward 33%.
- Did you consider using OpenGL's built-in mipmap support? There might be a reason not to choose this that I haven't thought of, but it seems like it would simplify the code. I think you and @mzgoddard convinced me that we shouldn't auto-generate mipmaps but by using the
level
parameter ofglTexImage2D
we could upload the individual images all into one GL texture and let the GPU choose the mip level automatically. We could even have the GPU interpolate between mip levels but that might not lead to good results with vector images.
Sorry for the delay in responding to this; I've been focusing hard on extensionification and let my PR duties fall behind.
IIRC, |
We discussed this a bit offline, but just to follow up for anyone watching the issue: @adroitwhiz has a good point, for WebGL 1, automatic mipmaps only work for images with dimensions that are a power of two. We'd need to use an OpenGL extension or upgrade our WebGL version to be able to automatically generate mipmaps for non-power of two images. |
9b039ce
to
5e625b8
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍code looks good but tests are failing... marking as "needs work" for the tests
e20f682
to
b00e0c4
Compare
1cffc87
to
8851b8c
Compare
Hooray for tests! They helped me catch a couple bugs, which I fixed with the following changes:
@cwillisf let me know what you think! |
8f1d47c
to
231cae1
Compare
src/SVGSkin.js
Outdated
this._silhouette.update(textureData); | ||
} | ||
|
||
if (scale === 1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems a bit flaky-- it looks like it assumes createMIP
will first be called with scale 1, which may not be the case. Could this be moved elsewhere, perhaps to setSVG
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, it's not meant to assume createMIP
will be called with a scale 1. The intention instead is if it gets called with a scale of 1, use the textureData
to set the _maxTextureScale
and update the silhouette. We need it to be in the callback in createMIP
instead of setSVG
because we need the textureData
that is passed to the callback in SVGMIP.draw
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like _maxTextureScale
could be uninitialized (stuck at 1) if createMIP
is never called with a scale of exactly 1. Is it possible to get in that situation, like if you load a project where a sprite's scale is >1 or something like that? It's important for _maxTextureScale
to be as high as reasonable to avoid reintroducing this issue: scratchfoundation/scratch-gui#4211
After looking through the code some more I think this sequence would cause some sort of bad behavior, though I'm not sure exactly what:
- Have a vector costume on a sprite (
createMIP
will be called with scale 1) - Use a
looks
block toset size to
something big (createMIP
will be called with scale >1) - Open that costume in the paint editor
- Make a change to the costume which changes its dimensions significantly (
setSVG
will be called, dirtying most MIPs, butcreateMIP
won't be called with scale 1, so_maxTextureScale
will be based on the old dimensions)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This thing I saw may have been scratchfoundation/scratch-gui#5216 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this._callback = callback; | ||
this.dirty = false; | ||
|
||
this.draw(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think setting dirty
to true by default and skipping this.draw()
here might allow this to skip drawing sometimes, or at worst wouldn't hurt.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't have to be for every MIP. But we should at least call draw in setSVG on the 1.0 scale MIP. This "primes" the SVGRenderer by loading an Image instance and drawing it to a canvas. The loading part can need a lot of time, so we should load the svg during the initial project load to avoid potentially large draw times when you switch to a SVG costume that hasn't been drawn before.
src/SVGSkin.js
Outdated
|
||
/** @type {Number} */ | ||
this._maxTextureScale = 0; | ||
this._largestTextureScale = 1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's confusing to have _maxTextureScale
and _largestTextureScale
in the same class. Maybe rename one or both to clarify the difference?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Combined with another comment, we could get rid of _largestTextureScale. It is needed if we keep already created MIPs. If we dump all MIPs on setSVG calls, we don't need _largestTextureScale.
src/SVGSkin.js
Outdated
this._renderer.gl.deleteTexture(this._texture); | ||
for (const mip of this._scaledMIPs) { | ||
if (mip) { | ||
this._renderer.gl.deleteTexture(mip.getTexture()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might make sense to move this into a dispose
method on SVGMIP.
src/SVGSkin.js
Outdated
this._silhouette.update(textureData); | ||
} | ||
|
||
if (scale === 1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like _maxTextureScale
could be uninitialized (stuck at 1) if createMIP
is never called with a scale of exactly 1. Is it possible to get in that situation, like if you load a project where a sprite's scale is >1 or something like that? It's important for _maxTextureScale
to be as high as reasonable to avoid reintroducing this issue: scratchfoundation/scratch-gui#4211
After looking through the code some more I think this sequence would cause some sort of bad behavior, though I'm not sure exactly what:
- Have a vector costume on a sprite (
createMIP
will be called with scale 1) - Use a
looks
block toset size to
something big (createMIP
will be called with scale >1) - Open that costume in the paint editor
- Make a change to the costume which changes its dimensions significantly (
setSVG
will be called, dirtying most MIPs, butcreateMIP
won't be called with scale 1, so_maxTextureScale
will be based on the old dimensions)
src/SVGSkin.js
Outdated
for (testScale; maxDimension * testScale <= MAX_TEXTURE_DIMENSION; testScale *= 2) { | ||
this._maxTextureScale = testScale; | ||
} | ||
this._silhouette.update(textureData); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it could be confusing to have a silhouette update in this branch as well as the if (scale > this._largestTextureScale)
branch. Also, what if this callback gets called with scale > 1
first, then scale === 1
later -- it seems like we'd end up with an undesirable(?) update to a smaller silhouette.
this._texture = twgl.createTexture(gl, textureOptions); | ||
this._silhouette.update(textureData); | ||
} | ||
this._svgRenderer.loadString(svgData); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of using a dirty flag on the SVGMIP instances, why not just dump all the SVGMIPs here? That might take some extra time (deleting WebGL textures) but unless it's a LOT of time I'd rather have simpler code => less chance of breaking it later :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could:
- Call dispose on each current MIPs.
- Set the array length to 0.
- Create the 1.0 scale MIP at INDEX_OFFSET.
- Force the 1.0 MIP to draw.
this.setRotationCenter.apply(this, rotationCenter); | ||
this.emit(Skin.Events.WasAltered); | ||
}); | ||
if (typeof rotationCenter === 'undefined') rotationCenter = this.calculateRotationCenter(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In a different change we realized that setting the rotationCenter before the image has drawn will cause a "flash" of the old image at the wrong scale. We need to setRotationCenter after drawing.
One way we might do that is move the rotationCenter set logic in createMIP?
231cae1
to
895467d
Compare
895467d
to
c520e41
Compare
const textureCallback = textureData => { | ||
// Check if we have the largest MIP | ||
// eslint-disable-next-line no-use-before-define | ||
if (!this._scaledMIPs.length || this._scaledMIPs[this._scaledMIPs.length - 1]._scale <= scale) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can make this a little simpler.
One way would be if you pass another argument to createMIP. Pass the scaled index and we can this._scaledMIPs.length - 1 === scaleIndex
to test for this.
super.setEmptyImageData(); | ||
return; | ||
} | ||
resetMIPs (mip, rotationCenter) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this method is pretty slick.
…s at a different scale and updating them Check scale for largest MIP
c520e41
to
f172526
Compare
Test Plan: Description: We will now take vector images and render them at several different sizes in memory to make scaling images look better. To test:
It's important to get Browser coverage for this issue |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
Resolves
#374, potentially also #334
Proposed Changes
As discussed on other issues, adding mipmap functionality will be helpful for scaling images at a good resolution in Scratch. This PR:
_scaledMIPs
using an index offset to allow for mips that have a scale of less than one. We're using an array here for better performance instead of an object with more descriptive keysSVGSkin.getTexture
to get smaller scales in addition to larger onesHere is an example repro project. After the green flag click, the original sprite is scaled up and down while the clone remains unscaled. Both have a better resolution because they no longer share a texture at all times and use the one at its requested scale. https://scratch.mit.edu/projects/295219677/editor/
The screenshots' differences are a little tough to see at a smaller size, but it's clear when you click them and see them in a new tab. Also these were taken on a lower resolution monitor. If your screen has a hidpi, these differences are harder to see.
Memory usage
In our previous discussion of this topic, one point against scaling mips with JavaScript instead of automatically on the GPU is that this method would use more memory. From some profiling I did with Chrome and Firefox, it seems like this test project hovers around the same amount of memory on the
develop
branch and this feature branch. Here are the results I found:develop
javascript-scaled-textures
develop
javascript-scaled-textures
Theoretically there should be a memory usage increase because there are more textures. I added the surface areas of power of 2 sized images between 2x2 and 1024x1024 and found that they would use about 33% more memory than just a single 1024x1024 image, but perhaps in the way we're using them in this branch, the browser is able to release some of that memory. From what I can see using Chrome and Firefox's tools, multiple JavaScript scaled textures hasn't had much of an impact on a tab's memory usage. I'm curious to hear more thoughts on this though!
What this hasn't solved yet
I've found this implementation of mips successfully scales sprites that have been changes by a "looks" block, but sprites that haven't been scaled by a block still don't scale well. For example, when you reduce the browser's width while using the editor, the stage scales down and the sprite's quality is reduced:
Fullscreen mode is another example of this:
Thoughts on how to scale sprites in this scenario and also thoughts on this approach for mipmaps are much appreciated!