1

I’m trying to create a lighting bolt using scenekit and I’m following this guide. So far I’ve got a vertical line in my scene using UIBezierPath with an extrusion to make it 3d but I’m not sure how to bend the “line” at the midpoint as described in the link.

func createBolt() {
     let path = UIBezierPath()
     path.move(to: CGPoint(x: 0, y: 0))
     path.addLine(to: CGPoint(x: 0, y: 1))
     path.close()


    let shape = SCNShape(path: path, extrusionDepth 0.2)
    let color = UIColor.red
    shape.firstMaterial?.diffuse.contents = color


    let boltNode = SCNNode(geometry: shape)
    boltNode.position.z = 0
    sceneView.scene.rootNode.addChildNode(boltNode)
}
Junaid
  • 321
  • 1
  • 4
  • 15

2 Answers2

2

Algorithm is pretty straightforward:
You start with list of 1 segment from A to B, then on each generation you split each segment on 2 segments by shifting middle point on random offset on his norm

struct Segment {
    let start: CGPoint
    let end: CGPoint
}

/// Calculate norm of 2d vector
func norm(_ v: CGPoint) -> CGPoint {
    let d = max(sqrt(v.x * v.x + v.y * v.y), 0.0001)
    return CGPoint(x: v.x / d, y: v.y / -d)
}

/// Splitting segment on 2 segments with middle point be shifted by `offset` on norm
func split(_ segment: Segment, by offset: CGFloat) -> [Segment] {
    var midPoint = (segment.start + segment.end) / 2
    midPoint = norm(segment.end - segment.start) * offset + midPoint
    return [
        Segment(start: segment.start, end: midPoint),
        Segment(start: midPoint, end: segment.end)
    ]
}

/// Generate bolt-like line from `start` to `end` with maximal started frequence of `maxOffset`
/// and `generation` of split loops
func generate(from start: CGPoint, to end: CGPoint, withOffset maxOffset: CGFloat, generations: Int = 6) -> UIBezierPath {
    var segments = [Segment(start: start, end: end)]
    var offset = maxOffset
    for _ in 0 ..< generations {
        segments = segments.flatMap { split($0, by: CGFloat.random(in: -offset...offset)) }
        offset /= 2
    }
    let path = UIBezierPath()
    path.move(to: start)
    segments.forEach { path.addLine(to: $0.end) }
    return path
}

// MARK: - Example
let start = CGPoint(x: 10, y: 10)
let end = CGPoint(x: 90, y: 90)
let path = generate(from: start, to: end, withOffset: 30, generations: 5)

// MARK: - Helpers
func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
    return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}
func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
    return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
}
func / (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
    return CGPoint(x: lhs.x / rhs, y: lhs.y / rhs)
}
func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
    return CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
}

enter image description here

ManWithBear
  • 2,787
  • 15
  • 27
  • 1
    Thanks. For the branches how would you do this "Rotate(direction, randomSmallAngle)*lengthScale + midPoint" I couldn't find how to rotate a line – Junaid Mar 23 '19 at 09:49
  • 1
    @Junaid https://stackoverflow.com/a/4780134/4280337 Here you could find how to rotate 2d vector – ManWithBear Mar 23 '19 at 12:36
2

SceneKit make Lightning Bolts

This here gives you another approach how to create randomized, full 3D Lightning Bolts in SceneKit (thank you Harry!).

Create a new SceneKit project (for iOS) in Xcode using the default Game template - the one that shows the aircraft in 3D space - delete the aircraft and create an empty scene with a black background. Also globally define your sceneView (to be able to access it from other classes).

Add the following classes and extensions to a new Swift file (import SceneKit):

Classes

class LightningStrike:

class LightningStrike : Geometry {
    
    var bolt:[Lightning] = []
    
    var start = SCNVector3() // stores start position of the Bolt
    var end = SCNVector3() // stores end position of the Bolt
    
    static var delayTime = 0.0
    
    override init() {
        start = SCNVector3(0.0, +5.0, 0.0) // default, to be changed
        end   = SCNVector3(0.0, -5.0, 0.0) // default, to be changed
        
        print("Lightning Strike initialized")
    }
    
    private func fadeOutBolt() {
        for b in bolt {
            SCNTransaction.begin()
            SCNTransaction.animationDuration = 2.0
            b.face.geometry?.firstMaterial?.transparency = 0.0
            SCNTransaction.commit()
        }
    }
    
    func strike() {
        for b in bolt { b.face.removeFromParentNode() }
        bolt.removeAll()
        
        // Create Main Bolt
        bolt.append(Lightning())
        bolt[0].createBolt(start,end)
        sceneView.scene?.rootNode.addChildNode(bolt[0].face)
        
        // Create child Bolts
        for _ in 0 ..< 15 { // number of child bolts
            // let parent = Int.random(in: 0 ..< bolt.count)  // random parent bolt, an other method
            let parent : Int = 0
            
            let start = bolt[parent].centerLine[10 + Int.random(in: 0 ..< 15)] // random node to start from off of parent, pay attention to: numSegments - changing numbers here can cause out of index crash
            let length:SCNVector3 = bolt[parent].end.minus(start) // length from our start to end of parent
            var end = SCNVector3()
            end.x = start.x + length.x / 1.5 + Float.random(in: 0 ... abs(length.x) / 3) // adjust by playing with this numbers
            end.y = start.y + length.y / 1.5 + Float.random(in: 0 ... abs(length.y) / 3) // adjust by playing with this numbers
            end.z = start.z + length.z / 1.5 + Float.random(in: 0 ... abs(length.z) / 3) // adjust by playing with this numbers

            bolt.append(Lightning())
            let index = bolt.count-1
            bolt[index].width = bolt[parent].width * 0.2
            bolt[index].deviation = bolt[parent].deviation * 0.3
            bolt[index].createBolt(start,end)
            sceneView.scene?.rootNode.addChildNode(bolt[0].face)
        }
        
        // Reset delay time and schedule fadeOut
        LightningStrike.delayTime = 0.0 // reset delay time
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { self.fadeOutBolt() }
        
        // Here you can add a Sound Effect
    }
    
    deinit {
        for b in bolt { b.face.removeFromParentNode() }
        bolt.removeAll()
        print("Lightning Strike deinitialized")
    }
    
}

class Lightning:

class Lightning : Geometry {
    let UNASSIGNED:Float = 999
    var start = SCNVector3()
    var end = SCNVector3()
    var numSegments = Int() // use => 3,5,9,17,33,65
    var width = Float()
    var deviation = Float()
    var vertices:[SCNVector3] = []
    var normals:[SCNVector3] = []
    var indices:[Int32] = []
    var centerLine:[SCNVector3] = []
    var face:SCNNode! = nil
    
    override init() {
        
        numSegments = 33 // 17
        width = 0.1
        deviation = 1.5
        
        centerLine = Array(repeating: SCNVector3(), count: numSegments)
        
        // indexed indices never change
        var j:Int = 0
        for i  in 0 ..< numSegments-1 {
            j = i * 3
            indices.append(Int32(j + 0))  // 2 triangles on side #1
            indices.append(Int32(j + 2))
            indices.append(Int32(j + 3))
            indices.append(Int32(j + 2))
            indices.append(Int32(j + 5))
            indices.append(Int32(j + 3))
            
            indices.append(Int32(j + 2))  // side #2
            indices.append(Int32(j + 1))
            indices.append(Int32(j + 5))
            indices.append(Int32(j + 1))
            indices.append(Int32(j + 4))
            indices.append(Int32(j + 5))
            
            indices.append(Int32(j + 1))  // side #3
            indices.append(Int32(j + 0))
            indices.append(Int32(j + 4))
            indices.append(Int32(j + 0))
            indices.append(Int32(j + 3))
            indices.append(Int32(j + 4))
        }
    }
    
    func createNode() -> SCNGeometry {
        for i  in 0 ..< numSegments { centerLine[i].x = UNASSIGNED }
        centerLine[0] = start
        centerLine[numSegments-1] = end
        
        var hop:Int = max(numSegments / 2,1)
        var currentDeviation = deviation
        while true {
            for i in stride(from:0, to: numSegments, by:hop) {
                if centerLine[i].x != UNASSIGNED { continue }
                let p1 = centerLine[i-hop]
                let p2 = centerLine[i+hop]
                centerLine[i] = SCNVector3(
                    (p1.x + p2.x)/2 + Float.random(in: -currentDeviation ... currentDeviation),
                    (p1.y + p2.y)/2 + Float.random(in: -currentDeviation ... currentDeviation),
                    (p1.z + p2.z)/2 + Float.random(in: -currentDeviation ... currentDeviation))
            }
            
            if hop == 1 { break }
            hop /= 2
            currentDeviation *= 0.6
        }
        
        vertices.removeAll()
        normals.removeAll()
        
        // triangle of vertices at each centerLine node on XZ plane
        let ss:[Float] = [ sin(0), sin(Float.pi * 2/3), sin(Float.pi * 4/3)]
        let cc:[Float] = [ cos(0), cos(Float.pi * 2/3), cos(Float.pi * 4/3)]
        
        var w = width
        for i  in 0 ..< numSegments {
            for j in 0 ..< 3 {
                vertices.append(SCNVector3(centerLine[i].x + cc[j] * w, centerLine[i].y, centerLine[i].z + ss[j] * w))
            }
            
            w *= 0.90 // bolt gets thinner towards endings
        }
        
        // normal for each vertex: position vs. position of neighbor on next node
        var index1 = Int()
        var index2 = Int()
        
        func norm(_ v: SCNVector3) -> SCNVector3 {
            let d = max(sqrt(v.x * v.x + v.y * v.y + v.z * v.z), 0.0001)
            return SCNVector3(v.x / d, v.y / -d, v.z / d)
        }
        
        for i  in 0 ..< numSegments {
            for j in 0 ..< 3 {
                index1 = i * 3 + j      // point on current node
                index2 = index1 + 3     // neighboring point on next node
                if index2 >= vertices.count { index2 -= 6 } // last node references previous node instead
                
                normals.append(norm(vertices[index1].minus(vertices[index2])))
            }
        }
        
        
        let geoBolt = self.createGeometry(
            vertices: vertices,
            normals: normals,
            indices: indices,
            primitiveType: SCNGeometryPrimitiveType.triangles)
        
        
        let boltMaterial : SCNMaterial = {
            let material = SCNMaterial()
            material.name                       = "bolt"
            material.diffuse.contents           = UIColor.init(hex: "#BAB1FFFF") // this is a very clear, almost white purple
            material.roughness.contents         = 1.0
            material.emission.contents          = UIColor.init(hex: "#BAB1FFFF") // this is a very clear, almost white purple
            material.lightingModel              = .physicallyBased
            material.isDoubleSided              = true
            material.transparency               = 0.0
            return material
        }()
        
        geoBolt.firstMaterial = boltMaterial
        
        // this makes the bolt not appearing all geometry at the same time - it's an animation effect
        DispatchQueue.main.asyncAfter(deadline: .now() + LightningStrike.delayTime) {
            boltMaterial.transparency = 1.0
        }
        
        LightningStrike.delayTime += 0.01665
        
        // geoBolt.subdivisionLevel = 1 // give it a try or not...
        
        return geoBolt
        
    }
    
    
    
    
    // Creates a Branch of the entire Bolt
    func createBolt(_ nstart:SCNVector3, _ nend:SCNVector3) {
        start = nstart
        end = nend
        face = SCNNode(geometry:createNode())
        
        // This will add some glow around the Bolt,
        // but it is **enourmous** performence and memory intense,
        // you could try to add some SCNTechnique instead
        // let gaussianBlur    = CIFilter(name: "CIGaussianBlur")
        // gaussianBlur?.name  = "blur"
        // gaussianBlur?.setValue(2, forKey: "inputRadius")
        // face.filters        = [gaussianBlur] as? [CIFilter]
        
        sceneView.scene?.rootNode.addChildNode(face)
    }
}

class Geometry:

class Geometry : NSObject {
    internal func createGeometry(
        vertices:[SCNVector3],
        normals:[SCNVector3],
        indices:[Int32],
        primitiveType:SCNGeometryPrimitiveType) -> SCNGeometry
    {
        
        // Computed property that indicates the number of primitives to create based on primitive type
        var primitiveCount:Int {
            get {
                switch primitiveType {
                case SCNGeometryPrimitiveType.line:
                    return indices.count / 2
                case SCNGeometryPrimitiveType.point:
                    return indices.count
                case SCNGeometryPrimitiveType.triangles,
                     SCNGeometryPrimitiveType.triangleStrip:
                    return indices.count / 3
                default : return 0
                }
            }
        }
        
        //------------------------
        let vdata = NSData(bytes: vertices, length: MemoryLayout<SCNVector3>.size * vertices.count)
        
        let vertexSource = SCNGeometrySource(
            data: vdata as Data,
            semantic: SCNGeometrySource.Semantic.vertex,
            vectorCount: vertices.count,
            usesFloatComponents: true,
            componentsPerVector: 3,
            bytesPerComponent: MemoryLayout<Float>.size,
            dataOffset: 0,
            dataStride:  MemoryLayout<SCNVector3>.size)
        
        //------------------------
        let ndata = NSData(bytes: normals, length: MemoryLayout<SCNVector3>.size * normals.count)
        
        let normalSource = SCNGeometrySource(
            data: ndata as Data,
            semantic: SCNGeometrySource.Semantic.normal,
            vectorCount: normals.count,
            usesFloatComponents: true,
            componentsPerVector: 3,
            bytesPerComponent: MemoryLayout<Float>.size,
            dataOffset: 0,
            dataStride:  MemoryLayout<SCNVector3>.size)

        let indexData = NSData(bytes: indices, length: MemoryLayout<Int32>.size * indices.count)
        let element = SCNGeometryElement(
            data: indexData as Data, primitiveType: primitiveType,
            primitiveCount: primitiveCount, bytesPerIndex: MemoryLayout<Int32>.size)
        
        return SCNGeometry(sources: [vertexSource, normalSource], elements: [element])
    }
}

Extensions:

for SCNVector3:

extension SCNVector3
{
    func length() -> Float { return sqrtf(x*x + y*y + z*z) }
    func minus(_ other:SCNVector3) -> SCNVector3 { return SCNVector3(x - other.x, y - other.y, z - other.z) }
    
    func normalized() -> SCNVector3 {
        let len = length()
        var ans = SCNVector3()
        ans.x = self.x / len
        ans.y = self.y / len
        ans.z = self.z / len
        return ans
    }
}

for UIColor:

extension UIColor {
    public convenience init?(hex: String) {
        let r, g, b, a: CGFloat
        
        if hex.hasPrefix("#") {
            let start = hex.index(hex.startIndex, offsetBy: 1)
            let hexColor = String(hex[start...])
            
            if hexColor.count == 8 {
                let scanner = Scanner(string: hexColor)
                var hexNumber: UInt64 = 0
                
                if scanner.scanHexInt64(&hexNumber) {
                    r = CGFloat((hexNumber & 0xff000000) >> 24) / 255
                    g = CGFloat((hexNumber & 0x00ff0000) >> 16) / 255
                    b = CGFloat((hexNumber & 0x0000ff00) >> 8) / 255
                    a = CGFloat(hexNumber & 0x000000ff) / 255
                    
                    self.init(red: r, green: g, blue: b, alpha: a)
                    return
                }
            }
        }
        
        return nil
    }
}

Usage:

Init the class in your ViewController like so:

let lightningStrike = LightningStrike()

Also add a tap gesture recogniser (in viewDidLoad) for easy testing:

let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
sceneView.addGestureRecognizer(tapGesture)

And the corresponding function that will trigger the Lightning Bolt:

@objc func handleTap(_ gestureRecognize: UIGestureRecognizer) {
    lightningStrike.strike() // will fire a Lighting Bolt
}

Results:

Example #1 without Glow

Example #2 without Glow

Example #1 with CIFilter Glow

Example #2 with CIFilter Glow

Have fun with it.

ZAY
  • 3,882
  • 2
  • 13
  • 21