SKEffectNode - CIFilter Blur Size Limit - Big Black Box

1k Views Asked by At

I am trying to blur multiple SKNode objects. I do this by having a parent SKEffectNode with a CIFilter set to @"CIGaussianBlur". Like so:

- (SKEffectNode *)createBlurNode
{
    SKEffectNode *blurNode = [[SKEffectNode alloc] init];
    blurNode.shouldRasterize = YES;
    [blurNode setShouldEnableEffects:NO];

    [blurNode setFilter:[CIFilter filterWithName:@"CIGaussianBlur"
                                   keysAndValues:@"inputRadius", @10.0f, nil]];
    return blurNode;
}

This works fine for a bunch of nodes currently onscreen. But when I space these notes far away from each other (about 3000 pixels), the blurring no longer happens and I get a big black box. This happens regardless of whether the SKNodes I'm blurring are SKShapeNodes or SKSpriteNodes. Here's a sample project with this issue: Sample Project. (By the way, thanks to BobMoff for the initial version found here):

Here's happy blur (when nodes are less than 3000 pixels away from each other):

Happy blur!

Sad blur (when nodes are more than 3000 pixels away from each other):

enter image description here

UPDATE

This behavior occurs whenever an SKEffectNode is the parent. It doesn't matter if it's enabling effects, blurring, etc. If the parent node is an SKNode, it's fine. i.e. Even if the parent blur node is created like it is below, you will get the blackness:

- (SKEffectNode *)createBlurNode
{
    SKEffectNode *blurNode = [[SKEffectNode alloc] init];

//    blurNode.shouldRasterize = YES;
//    [blurNode setShouldEnableEffects:NO];
//    [blurNode setFilter:[CIFilter filterWithName:@"CIGaussianBlur"
//                                   keysAndValues:@"inputRadius", @10.0f, nil]];
    return blurNode;
}
4

There are 4 best solutions below

0
On

SKEffectNode renders into a texture. In most iOS systems the maximum size for a texture is 2048x2048. If an SKEffectNode is trying to render content larger than that, it will just use a 2048x2048 texture and anything outside of it will just not appear in the texture. It won't give you any error or warning about this happening; it simply does it silently.

And no, there is no way to tell SKEffectNode to use a texture of a specific size, and pan&clamp the content into it. It always uses a texture that will cover all the child nodes, and if the texture would be too large, it just silently uses that 2048x2048 texture.

0
On

I had a similar problem, with a very wide, panning scene that I wanted to blur.

To get the blur effect to work, I removed any nodes that were sticking out too far past the edges of the scene:

// Property declarations, elsewhere in the class:
var blurNode: SKEffectNode
var mainScene: SKScene
var exParents: [SKNode : SKNode] = [:]


/**
 * Remove outlying nodes from the scene and activate the SKEffectNode
 */
func blurScene() {
    let FILTER_MARGIN: CGFloat = 100
    let widthMax: CGFloat = mainScene.size.width + FILTER_MARGIN
    let heightMax: CGFloat = mainScene.size.height + FILTER_MARGIN

    // Recursively iterate through all blurNode's children
    blurNode.enumerateChildNodesWithName(".//*", usingBlock: {
        [unowned self]
        node, stop in

        if node.parent != nil && node.scene != nil { // Ignore nodes we already removed
            if let sprite = node as? SKSpriteNode {

                // Calculate sprite node position in scene coordinates
                let sceneOrig = sprite.scene!.convertPoint(sprite.position, fromNode: sprite.parent!)

                // Find left, right, bottom and top edges of sprite
                let l = sceneOrig.x - sprite.size.width*sprite.anchorPoint.x
                let r = l + sprite.size.width
                let b = sceneOrig.y - sprite.size.height*sprite.anchorPoint.y
                let t = b + sprite.size.height

                if l < -FILTER_MARGIN || r > widthMax || b < -FILTER_MARGIN || t > heightMax {
                    self.exParents[sprite] = sprite.parent!
                    sprite.removeFromParent()
                }
            }
        }
    })

    blurNode.shouldEnableEffects = true
}

/**
 * Disable blur and reparent nodes we removed earlier
 */
func removeBlur() {
    self.blurNode.shouldEnableEffects = false

    for (kid, parent) in exParents {
        parent.addChild(kid)
    }

    exParents = [:]
}


NOTES:

This does remove content from your effect node, so extremely wide nodes won't show up in the final result:

SKEffectNode cropping example

You can see the mountain highlighted in red stuck out too far and was removed from the resulting blur.

This code only considers SKSpriteNodes. Empty SKNodes don't seem to break the effect node, but if you're using other visible nodes like SKShapeNodes or SKLabelNodes, you'll have to modify this code to include them.

If you have ignoreSiblingOrder = false, this code might mess up your z-ordering since you can't guarantee what order the nodes are added back to the scene.


Stuff I tried that didn't work

Simply saying node.hidden = true instead of using removeFromParent() doesn't work. That would be WAY too easy ;)

Using an SKCropNode to crop out outlying content didn't work for me. I tried having the SKEffectNode parent the SKCropNode and the other way around, but the black square appeared no matter how small I made the cropped area. This might still be worth looking into if you're desperate for a cleaner solution.

As noted here, SKScenes are secretly SKEffectNodes and you can set their filter just like our blurNode above. SKScenes don't show a black screen when their content is too big. Unfortunately, they seem to just silently disable the filter instead. Again, I might have missed something, so you could explore this option further if you're trying to apply an effect across the entire scene.


Alternate Solutions

You can capture an image of the whole screen and apply a filter to that, as suggested here. I ended up going with an even simpler solution; I took a generic screenshot of the stuff I wanted to blur, then applied a very heavy blur so you can't see the precise details. I used that as the blurred background and you can hardly tell it's not the real thing ;) This also saves a healthy chunk of memory and avoids a small UI hiccup.


Musings

This is a pretty nasty bug, and I hope Apple comes up with a solution soon. You can click this cute picture of a camera to get a GPU trace and some insight on what's happening:

GPU Trace Button

The device seems to be discarding the framebuffer for the effect node because it takes up too much memory. This is affirmed by the fact that when there's more memory pressure on the device, it's easier to get the 'black square' on smaller content in the SKEffectNode.

0
On

Possible workaround for the bug:

Use a camera, zoom WAY out, so you can see most everything of your background, take a screenshot style rendering of this image. Crop it to your needs, and then blur it. Then rasterise this.

Then scale this image back up, and slice it up if needs be, and place accordingly.

0
On

I used a method that worked for my game but it requires the blurred area to be static without movement.

On iOS 10 using Swift 3 I used SKSpriteNode, SKView, SKEffectNode, CIFilter. I created a sprite from a texture returned from the SKView method "texture from node" and passed the current scene as the parameter because it inherits from SKNode. So essentially I was taking a "screenshot" of the scene and creating a sprite from it. I then put it in an SKEffectNode with a blur filter. (set "should rasterize" to true for better performance as I only needed to blur once). Finally I added the new sprite to the scene. From there you could add sprites to the scene and place them above the new blurred node.

let blurFilter = CIFilter(name: "CIGaussianBlur")!
let blurAmount = 15.0
blurFilter.setValue(blurAmount, forKey: kCIInputRadiusKey)

let blurEffect = SKEffectNode()
blurEffect.shouldRasterize = true

let screenshotNode = SKSpriteNode(texture: gameScene.view!.texture(from: gameScene))

blurEffect.addChild(screenshotNode)
blurEffect.filter = blurFilter

gameScene.addChild(blurEffect)