Skip to content

Instantly share code, notes, and snippets.

@pommdau
Last active September 30, 2024 08:30
Show Gist options
  • Save pommdau/a6eca10e940a44f557e077eaf528dc11 to your computer and use it in GitHub Desktop.
Save pommdau/a6eca10e940a44f557e077eaf528dc11 to your computer and use it in GitHub Desktop.

General

image

Refs

Code

  • Kavsoftのメタボールのコードをベースに、別途シャドウ用のCanvasを追加することで表現してみた
  • ボールの数*2つのTimerを使うのでパフォーマンスは怪しいか…?
    • animationのmodifierで置き換えるとか改善はできそう
//
//  FormglowView.swift
//  MetaBallDemos
//
//  Created by HIROKI IKEUCHI on 2024/09/28.
//

import SwiftUI

struct FormglowView: View {
    
    // MARK: - Properties
    
    static let ballShadowColors: [Color] = [.blue, .green, .indigo, .mint, .orange, .pink, .purple, .red, .yellow]
    static let ballDiameter: CGFloat = 200
    static let numberOfBalls = 5
    static let animationDuration: CGFloat = 1.0
    
    @State private var ballOffsets: [CGPoint] = Array(repeating: .zero, count: Self.numberOfBalls)
    @State private var ballShadowColors: [Color] = Array(repeating: .clear, count: Self.numberOfBalls)
    @State private var ballAnimationTimers: [Timer] = []
    @State private var ballShadowAnimationTimers: [Timer] = []
    
    // MARK: - Views
    
    var body: some View {
        metaBalls()
            .background {
                metaBallsShadow()
            }
    }
    
    @ViewBuilder
    private func metaBalls() -> some View {
        Canvas { context, size in
            // メタボール表現
            context.addFilter(.alphaThreshold(min: 0.5, color: .black))
            context.addFilter(.blur(radius: 30))
            context.drawLayer { ctx in
                for index in 0...Self.numberOfBalls {
                    guard let resolvedView = context.resolveSymbol(id: index) else {
                        return
                    }
                    let center = CGPoint(x: size.width / 2, y: size.height / 2)
                    ctx.draw(resolvedView, at: center)
                }
            }
        } symbols: {
            ForEach(0..<Self.numberOfBalls, id: \.self) { ballIndex in
                Circle()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: Self.ballDiameter)
                    .offset(x: ballOffsets[ballIndex].x, y: ballOffsets[ballIndex].y)
                    .animation(.easeInOut(duration: Self.animationDuration), value: ballOffsets[ballIndex])
                    .onAppear {
                        let timer = Timer.scheduledTimer(withTimeInterval: Self.animationDuration, repeats: true) { _ in
                            let offsetValueMax = Self.ballDiameter / 4.0
                            self.ballOffsets[ballIndex] = .init(
                                x: CGFloat.random(in: -offsetValueMax...offsetValueMax),
                                y: CGFloat.random(in: -offsetValueMax...offsetValueMax)
                            )
                        }
                        ballAnimationTimers.append(timer)
                    }
                    .tag(ballIndex)
            }
        }
    }
    
    @ViewBuilder
    private func metaBallsShadow() -> some View {
        Canvas { context, size in
            context.drawLayer { ctx in
                for index in 0...Self.numberOfBalls {
                    guard let resolvedView = context.resolveSymbol(id: index) else {
                        return
                    }
                    let center = CGPoint(x: size.width / 2, y: size.height / 2)
                    ctx.draw(resolvedView, at: center)
                }
            }
        } symbols: {
            ForEach(0..<Self.numberOfBalls, id: \.self) { ballIndex in
                Circle()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: Self.ballDiameter + 20)
                    .foregroundStyle(ballShadowColors[ballIndex])
                    .blur(radius: 20)
                    .shadow(color: ballShadowColors[ballIndex].opacity(0.8), radius: 100, x: 0.0, y: 0.0)
                    .blendMode(.hardLight)
                    .offset(x: ballOffsets[ballIndex].x, y: ballOffsets[ballIndex].y)
                    .animation(.easeInOut(duration: Self.animationDuration), value: ballOffsets[ballIndex])
                    .animation(.easeInOut(duration: Self.animationDuration), value: ballShadowColors[ballIndex])
                    .onAppear {
                        let timer = Timer.scheduledTimer(withTimeInterval: Self.animationDuration, repeats: true) { _ in
                            self.ballShadowColors[ballIndex] = Self.ballShadowColors.randomElement()!
                        }
                        ballShadowAnimationTimers.append(timer)
                    }
                    .tag(ballIndex)
            }
        }
    }
}

#Preview {
    FormglowView()
}
@pommdau
Copy link
Author

pommdau commented Sep 28, 2024

  • Timerの数を減らしてみた
import SwiftUI

struct FormglowView: View {
    
    // MARK: - Properties
    
    static let ballShadowColors: [Color] = [.blue, .green, .indigo, .mint, .orange, .pink, .purple, .red, .yellow]
    static let ballDiameter: CGFloat = 100
    static let numberOfBalls = 6
    static let animationDuration: CGFloat = 1.0
    
    @State private var ballOffsets: [CGPoint] = Array(repeating: .zero, count: Self.numberOfBalls)
    @State private var ballShadowColors: [Color] = Array(repeating: .clear, count: Self.numberOfBalls)
    @State private var ballAnimationTimer: Timer?
    
    // MARK: - Views
    
    var body: some View {
        let _ = print("metaball Updated!")
        metaBalls()
            .background {
                metaBallsShadow()
            }
            .onAppear {
                if ballAnimationTimer != nil {
                    return
                }
                ballAnimationTimer = Timer.scheduledTimer(withTimeInterval: Self.animationDuration, repeats: true) { _ in
                    let offsetValueMax = Self.ballDiameter / 4.0
                    withAnimation(Animation.easeInOut(duration: Self.animationDuration)) {
                        for i in 0..<Self.numberOfBalls {
                            self.ballOffsets[i] = .init(
                                x: CGFloat.random(in: -offsetValueMax...offsetValueMax),
                                y: CGFloat.random(in: -offsetValueMax...offsetValueMax)
                            )
                            self.ballShadowColors[i] = Self.ballShadowColors.randomElement()!
                        }
                    }
                }
            }
    }
    
    @ViewBuilder
    private func metaBalls() -> some View {
        Canvas { context, size in
            // メタボール表現
            context.addFilter(.alphaThreshold(min: 0.5, color: .black))
            context.addFilter(.blur(radius: Self.ballDiameter / 6))
            context.drawLayer { ctx in
                for index in 0...Self.numberOfBalls {
                    guard let resolvedView = context.resolveSymbol(id: index) else {
                        return
                    }
                    let center = CGPoint(x: size.width / 2, y: size.height / 2)
                    ctx.draw(resolvedView, at: center)
                }
            }
        } symbols: {
            ForEach(0..<Self.numberOfBalls, id: \.self) { ballIndex in
                Circle()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: Self.ballDiameter)
                    .offset(x: ballOffsets[ballIndex].x, y: ballOffsets[ballIndex].y)
                    .tag(ballIndex)
            }
        }
    }
    
    @ViewBuilder
    private func metaBallsShadow() -> some View {
        Canvas { context, size in
            context.drawLayer { ctx in
                for index in 0...Self.numberOfBalls {
                    guard let resolvedView = context.resolveSymbol(id: index) else {
                        return
                    }
                    let center = CGPoint(x: size.width / 2, y: size.height / 2)
                    ctx.draw(resolvedView, at: center)
                }
            }
        } symbols: {
            ForEach(0..<Self.numberOfBalls, id: \.self) { ballIndex in
                Circle()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: Self.ballDiameter + Self.ballDiameter / 10)
                    .foregroundStyle(ballShadowColors[ballIndex])
                    .blur(radius: Self.ballDiameter / 20)
                    .shadow(color: ballShadowColors[ballIndex].opacity(0.8), radius: Self.ballDiameter / 4, x: 0.0, y: 0.0)
                    .blendMode(.screen)
                    .offset(x: ballOffsets[ballIndex].x, y: ballOffsets[ballIndex].y)
                    .tag(ballIndex)
            }
        }
    }        
}

#Preview {
    FormglowView()
}

@pommdau
Copy link
Author

pommdau commented Sep 30, 2024

  • 上記のタイマーをまとめたやつも、どうにもパフォーマンスがよくなくて、CPU%が30%を超える
  • メタボール表現のためのフィルターを使うためにCanvasを利用しているが、動的なものには向いていないかも

Canvas
Use a canvas to improve performance for a drawing that doesn’t primarily involve text or require interactive elements.

  • Canvasを外してみると10-15%くらいに改善した
  • なのでCanvasのメタボール表現をSwiftUI Shaderに任せるとかすればいいかも
    • そこまでいくと全部Shader側で書いたら…という本末転倒な話にはなる orz

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment