Search code examples
iosswiftshaderscenekitmetal

How to render a SceneKit shader at a lower resolution?


I'm adding some visual elements to my app with SceneKit shader modifiers like this:

// A SceneKit scene with orthographic projection

let shaderBundle = Bundle(for: Self.self)
let shaderUrl = shaderBundle.url(forResource: "MyShader.frag", withExtension: nil)!
let shaderString = try! String(contentsOf: shaderUrl)

let plane = SCNPlane(width: 512, height: 512)  // 1024x1024 pixels on devices with x2 screen resolution
plane.firstMaterial!.shaderModifiers = [SCNShaderModifierEntryPoint.fragment: shaderString]

let planeNode = SCNNode(geometry: plane)
rootNode.addChildNode(planeNode)

The problem is slow performance because SceneKit is painstakingly rendering every single pixel of the plane that's screening the shader. How do I decrease the resolution of the shader keeping the plain's size unchanged?

I've already tried making plane smaller and using an enlarging scale transformation on planeNode but fruitless, the rendition of the shader remained as highly detailed as before.

Using plane.firstMaterial!.diffuse.contentsTransform didn't help either (or maybe I was doing it wrong).

I know I could make the global SCNView smaller and then apply an affine scale transform if that shader was the only node in the scene but it's not, there are other nodes (that aren't shaders) in the same scene and I'd prefer to avoid altering their appearance in any way.


Solution

  • Seems like I managed to solve it using a sort of "render to texture" approach by nesting a SceneKit scene inside a SpriteKit scene being displayed by the top level SceneKit scene.

    Going into more detail, the following subclass of SCNNode is placing a downscaled shader plane within a SpriteKit's SK3DNode, then taking that SK3DNode and putting it inside a SpriteKit scene as a SceneKit's SKScene, and then using that SKScene as the diffuse contents of an upscaled plane put inside the top level SceneKit scene.

    Strangely, for keeping the native resolution I need to use scaleFactor*2, so for halving the rendering resolution (normally scale factor 0.5) I actually need to use scaleFactor = 1.

    If anyone happens to know the reason for this strange behavior or a workaround for it, please let me know in a comment.

    import Foundation
    import SceneKit
    import SpriteKit
    
    class ScaledResolutionFragmentShaderModifierPlaneNode: SCNNode {
    
        private static let nestedSCNSceneFrustumLength: CGFloat = 8
    
        // For shader parameter input
        let shaderPlaneMaterial: SCNMaterial
    
        // shaderModifier: the shader
        // planeSize: the size of the shader on the screen
        // scaleFactor: the scale to be used for the shader's rendering resolution; the lower, the faster
        init(shaderModifier: String, planeSize: CGSize, scaleFactor: CGFloat) {
            let scaledSize = CGSize(width: planeSize.width*scaleFactor, height: planeSize.height*scaleFactor)
    
            // Nested SceneKit scene with orthographic projection
            let nestedSCNScene = SCNScene()
            let camera = SCNCamera()
            camera.zFar = Double(Self.nestedSCNSceneFrustumLength)
            camera.usesOrthographicProjection = true
            camera.orthographicScale = Double(scaledSize.height/2)
            let cameraNode = SCNNode()
            cameraNode.camera = camera
            cameraNode.simdPosition = simd_float3(x: 0, y: 0, z: Float(Self.nestedSCNSceneFrustumLength/2))
            nestedSCNScene.rootNode.addChildNode(cameraNode)
            let shaderPlane = SCNPlane(width: scaledSize.width, height: scaledSize.height)
            shaderPlaneMaterial = shaderPlane.firstMaterial!
            shaderPlaneMaterial.shaderModifiers = [SCNShaderModifierEntryPoint.fragment: shaderModifier]
            let shaderPlaneNode = SCNNode(geometry: shaderPlane)
            nestedSCNScene.rootNode.addChildNode(shaderPlaneNode)
    
            // Intermediary SpriteKit scene
            let nestedSCNSceneSKNode = SK3DNode(viewportSize: scaledSize)
            nestedSCNSceneSKNode.scnScene = nestedSCNScene
            nestedSCNSceneSKNode.position = CGPoint(x: scaledSize.width/2, y: scaledSize.height/2)
            nestedSCNSceneSKNode.isPlaying = true
            let intermediarySKScene = SKScene(size: scaledSize)
            intermediarySKScene.backgroundColor = .clear
            intermediarySKScene.addChild(nestedSCNSceneSKNode)
            let intermediarySKScenePlane = SCNPlane(width: scaledSize.width, height: scaledSize.height)
            intermediarySKScenePlane.firstMaterial!.diffuse.contents = intermediarySKScene
            let intermediarySKScenePlaneNode = SCNNode(geometry: intermediarySKScenePlane)
            let invScaleFactor = 1/Float(scaleFactor)
            intermediarySKScenePlaneNode.simdScale = simd_float3(x: invScaleFactor, y: invScaleFactor, z: 1)
    
            super.init()
    
            addChildNode(intermediarySKScenePlaneNode)
        }
    
        required init?(coder: NSCoder) {
            fatalError()
        }
    
    }