22

In learning 3d graphics programming for games I decided to start off simple by using the Scene Kit 3D API. My first gaming goal was to build a very simplified mimic of MineCraft. A game of just cubes - how hard can it be.

Below is a loop I wrote to place a ride of 100 x 100 cubes (10,000) and the FPS performance was abysmal (~20 FPS). Is my initial gaming goal too much for Scene Kit or is there a better way to approach this?

I have read other topics on StackExchange but don't feel they answer my question. Converting the exposed surface blocks to a single mesh won't work as the SCNGeometry is immutable.

func createBoxArray(scene : SCNScene, lengthCount: Int, depthCount: Int) {
    let startX : CGFloat = -(CGFloat(lengthCount) * CUBE_SIZE) + (CGFloat(lengthCount) * CUBE_MARGIN) / 2.0
    let startY : CGFloat = 0.0
    let startZ : CGFloat = -(CGFloat(lengthCount) * CUBE_SIZE) + (CGFloat(lengthCount) * CUBE_MARGIN) / 2.0

    var currentZ : CGFloat = startZ

    for z in 0 ..< depthCount {
        currentZ += CUBE_SIZE + CUBE_MARGIN

        var currentX = startX
        for x in 0 ..< lengthCount {
            currentX += CUBE_SIZE + CUBE_MARGIN

            createBox(scene, x: currentX, y: startY, z: currentZ)
        }
    }
}


func createBox(scene : SCNScene, x: CGFloat, y: CGFloat, z: CGFloat) {
    var box = SCNBox(width: CUBE_SIZE, height: CUBE_SIZE, length: CUBE_SIZE, chamferRadius: 0.0)
    box.firstMaterial?.diffuse.contents = NSColor.purpleColor()

    var boxNode = SCNNode(geometry: box)
    boxNode.position = SCNVector3Make(x, y, z)
    scene.rootNode.addChildNode(boxNode)
}

UPDATE 12-30-2014: I modified the code so the SCNBoxNode is created once and then each additional box in the array of 100 x 100 is created via:

var newBoxNode = firstBoxNode.clone()
newBoxNode.position = SCNVector3Make(x, y, z)

This change appears to have increased FPS to ~30fps. The other statistics are as follows (from the statistics displayed in the SCNView):

10K (I assume this is draw calls?) 120K (I assume this is faces) 360K (Assuming this is the vertex count)

The bulk of the run loop is in Rendering (I'm guesstimating 98%). The total loop time is 26.7ms (ouch). I'm running on a Mac Pro Late 2013 (6-core w/Dual D500 GPU).

Given that a MineCraft style game has a landscape that constantly changes based on the players actions I don't see how I can optimize this within the confines of Scene Kit. A big disappointment as I really like the framework. I'd love to hear someone's ideas on how I can address this issue - without that, I'm forced to go with OpenGL.

UPDATE 12-30-2014 @ 2:00pm ET: I am seeing a significant performance improvement when using flattenedClone(). The FPS is now a solid 60fps even with more boxes and TWO drawing calls. However, accommodating a dynamic environment (as MineCraft supports) is still proving problematic - see below.

Since the array would change composition over time I added a keyDown handler to add an even larger box array to the existing and timed the difference between adding the array of boxes resulting in far more calls versus adding as a flattenedClone. Here's what I found:

On keyDown I add another array of 120 x 120 boxes (14,400 boxes)

// This took .0070333 milliseconds
scene?.rootNode.addChildNode(boxArrayNode)
// This took .02896785 milliseconds
scene?.rootNode.addChildNode(boxArrayNode.flattenedClone())

Calling flattenedClone() again is 4x slower than adding the array.

This results in two drawing calls having 293K faces and 878K vertices. I'm still playing with this and will update if I find anything new. Bottom line, with my additional testing I still feel Scene Kit's immutable geometric constraints mean I can't leverage the framework.

Andy Jazz
  • 49,178
  • 17
  • 136
  • 220
Dead Pixel
  • 433
  • 4
  • 9
  • 1
    What environment are you testing in? Where is your performance bottleneck? See the Building a Game with SceneKit talk [from WWDC 2014](http://developer.apple.com/videos/wwdc/2014) for tips on tracking down the latter. – rickster Dec 31 '14 at 05:19
  • 2
    i don't know scenekit but generally the "naive" approach will be rather slow. Consider that a game like minecraft probably ensures not to render any blocks that are completely hidden by others, that it implements instancing (drawing the same blocks in one go) and other general and game-specific optimizations. SceneKit is a general purpose renderer so you will have to try and see what kind of optimizations can be implemented, and what works best for SceneKit. If you determine you need more low level control you may need to revert to GLKit or raw OpenGL. – CodeSmile Dec 31 '14 at 09:07
  • 2
    10K draw calls is *way* too much. Try aiming for something closer to 100. You can greatly reduce the number of draw calls by flattening the geometry (`flattenedClone()`). If one box should later be separated by a user's action, I would deal with that box upon that action and not leave your entire scene in a separated state, just because the user might interact with it. – David Rönnqvist Dec 31 '14 at 17:33
  • What did you decide? Were you able to use SceneKit or did you need to use OpenGL? – Crashalot Jan 26 '16 at 09:10
  • I decided SceneKit wouldn't meet my needs. I love the idea and think Apple has done a great job designing the framework but it's not flexible enough for what I want. Learning the Metal framework is my current direction. A much higher learning curve to be sure but I've always enjoyed close to the metal programming (pun intended - Assembler used to be my favorite language). – Dead Pixel Feb 17 '16 at 01:11
  • I am struggling with the number of draw calls too. I have a simple scene in which I have roughly ~40 nodes, 2 lights (ambient and directional). As a result, I have unstable 55 FPS and about 100+ draw calls. iOS 12 SceneKit. @DavidRönnqvist Have you guys seen such error in the console: `objc[88539]: __weak variable at 0x6000037d0f30 holds 0xffffffff89abcdef instead of 0x6000032db180. This is probably incorrect use of objc_storeWeak() and objc_loadWeak(). Break on objc_weak_error to debug.` when using `flattenedClone()` ? I am facing this issue. Without flattening no errors in the console :( – piotr_ch Jan 03 '19 at 23:23

2 Answers2

0

As you mentionned Minecraft, I think it's worth looking at how it works.

I have no technical details or code sample for you, but everything should be pretty straightfoward:

Have you ever played minecraft online, and the terrain is not loading allowing you to see through? That's because there is no geometry inside.

let's assume I have a 2x2x2 array of cubes. That makes 2*2*2*6*2 = 96 triangles.

However, if you test and draw only the polygons on the visible from the camera point of view, maybe by testing the normals (easy since it's cubes), this number goes down to 48 triangles.

If you find a way to see which faces are occluded by other ones (which shouldn't be too hard either considering you're working with flat, quared, grid based faces) you can only draw these. that way, we're drawing between 8 and 24 triangleS. That's up to 90% optimisation.

If you want to get really deep, you can even combine faces, to make a single N-gon out of the visible, flat faces. You can do that if you create a new way to generate the geometry on the fly that combines the two previous methods and test for adgacent visible faces on the same plane.

If you succeed, we're talking 2 to 6 polygons instead of 96, to render 8 cubes.

Note that the last method only works if your blocks are touching each other.

There is probably a ton of Minecraft-like renderer papers, a few googles will help you figure it out!

Moustach
  • 2,904
  • 1
  • 13
  • 23
  • I agree with your comments Moustach but the challenge with this approach is the SCNGeometry class. It supports everything you said but is immutable. Therefore you'd have to create a mesh for SCNGeometry representing the terrain, and as soon as something changes you dump that mesh (I'm betting huge memory release cycle) and recreate everything to accommodate just one small 'block' change. – Dead Pixel Jan 04 '15 at 17:29
  • True, but you can use Minecraft's approach of using "Chunks". By separating the geometry in various elements, you can test each of them individually and see if they need to be updated considering the current POV. Unless the user moves a lot very quick, a Chunk should not be updated more than every few frames considering a single generated mesh can be used for many points of views. Not that you can also Render full objects as well but still optimise by combining coplanar polygons and getting rid of inside geometry. – Moustach Jan 05 '15 at 17:30
  • If you watch someone 'playing' Minecraft 'well', what astonishes me is just how rapidly they do move, and how incredibly responsive Minecraft is. I think part of its lasting attraction is that it's incredibly performant. There's rarely an annoyance to the user experience in terms of slowdown. Even the menus for items pop and swap at extraordinary speed. – Confused Feb 08 '15 at 18:40
  • Considering it's made with Java, it's pretty impressive... But once again, we're only talking about a few thousands polygons at most, little to no depth testing, no real particles, physics... – Moustach Feb 09 '15 at 04:43
  • Agreed. There's not much to it graphically or geometrically. But what is there is a compelling, engaging experience, partly (largely?) because of the responsiveness. The kids I've seen playing it grown audibly when it lags. They value its performance as part and parcel of the experience. – Confused Sep 25 '16 at 13:28
0

Why does drop-frame occur?

September 04, 2022

Almost 8 years passed since you asked this question, but not much has changed...


enter image description here


1. Polygons' count

The number of polygons in SceneKit or RealityKit scene must not exceed 100,000 triangular polygons. An ideal SceneKit's scene, that is capable of rendering all the models faster, should contain less than 50,000 polygons. Your scene contains 120,000 polygons. Do not forget that SceneKit renders models using single thread (unlike multi-threaded RealityKit renderer).


2. Shaders

In Xcode 14.0+, SceneKit's default .lightingModel of any 3D library's primitive set in Material Inspector (UI version) is .physicallyBased material. This is the most computationally intensive shader. Programmatic version of the .lightingModel for any SCN procedural geometry is .blinn shading model. The least computationally intensive shader is .constant (it doesn't depend on lighting).


3. What's inside a frustum

If all 10,000 cubes are inside the SceneKit camera frustum, then the frame rate will be 20-30 fps. But if you dollied in the cubes' matrix and see no more than a ninth part of it, then the frame rate will be 60 fps. Thus, SceneKit does not render those objects that are outside the frustum's bounds.


4. Number of meshes in SCNScene

Each model mesh results in a draw call. To achieve 60 fps each draw call should be 16 milliseconds or less. For best performance, Apple engineers advise to limit the number of meshes in a .usdz file to around 50. Unfortunately, I did not find a value for .scn files in the official documentation.


5. Lighting and shadows

Lighting and shadowing (especially shadowing) are very computationally intensive tasks. The general advice is the following – avoid using .forward shadows and hi-rez textures with fake shadows.

Look at this post for details.


SwiftUI code for testing

Xcode 14.0+, SwiftUI 4.0+, Swift 5.7

import SwiftUI
import SceneKit

struct ContentView: View {

    var scene = SCNScene()
    var options: SceneView.Options = [.allowsCameraControl]

    var body: some View {
        ZStack {
            ForEach(-50...49, id: \.self) { x in
                ForEach(-50...49, id: \.self) { z in
                    let _ = DispatchQueue.global().async {
                        scene.rootNode.addChildNode(createCube(x, 0, z))
                    }
                }
            }
            SceneView(scene: scene, options: options)
                .ignoresSafeArea()
            let _ = scene.background.contents = UIColor.black
        }
    }
    
    func createCube(_ posX: Int, _ posY: Int, _ posZ: Int) -> SCNNode {
        let geo = SCNBox(width: 0.5, height: 0.5, length: 0.5, 
                                           chamferRadius: 0.0)
        geo.firstMaterial?.lightingModel = .constant
        let boxNode = SCNNode(geometry: geo)
        boxNode.position = SCNVector3(posX, posY, posZ)
        return boxNode
    }
}


Here, all cubes are within the viewing frustum, so there are obvious reasons for a drop-frame.

enter image description here

And here, just a part of a scene is within the viewing frustum, so there is no drop-frame.

enter image description here

Andy Jazz
  • 49,178
  • 17
  • 136
  • 220