ViewとLayerで球体をデッサンしてみる

ViewとLayerだけで球体をデッサンしてみた。
デッサンはもう20年くらいやってないけど、当時の記憶を元にどういう表現が必要だったか思い出しつつ、それをレイヤーとして重ねることで表現してみたかったのでやってみた。

ボブ・ロスの絵画教室的なノリで記事にしたかったけど演出とか忘れたので普通に書く。

何を言ってるのかわからない可能性があるので完成したものを貼っておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/99e89b83-c41a-8a26-1674-5e8a7e8d81a2.png)
これをView（Layer）の重なりだけで表現するという話。

* Xcode 11.1
* Swift 5
* サンプルプロジェクト https://github.com/yumemi-ajike/Atelier

AutoLayoutはちゃんと対応しておきたいという前提でViewベースで進めることにした。Layerベースだとレイアウト周りが異常にめんどくさいので。

# 球体の輪郭

まずベースとなるViewを240x240で中心に配置する。
デッサン的にはキャンバスに対しての物体の美しい構図を決めるという作業があるけど、
キャンバス（画面サイズ）はいろいろサイズや比率が変わるのでそこは完全に無視する。

物体の色は何でもいいけど彩度高めでQiitaっぽい `UIColor.green` を指定。
このViewの中に様々な表現を加えていくので `clipsToBounds = true` してはみ出さないように設定しておく。

```Swift:CanvasView.swift
    override init(frame: CGRect) {
        super.init(frame: frame)
        let circleSize = CGSize(width: 240, height: 240)

        // 球体の輪郭
        let circleView = UIView()
        circleView.backgroundColor = .green
        circleView.clipsToBounds = true
        circleView.translatesAutoresizingMaskIntoConstraints = false
        circleView.layer.cornerRadius = circleSize.width / 2
        addSubview(circleView)
        ...
    }
```

普段SnapKit使うことが多いけど今回は単純なレイアウトなのでたまには生のAutoLatout。うーん、長いw

```Swift:CanvasView.swift
        NSLayoutConstraint.activate([
            circleView.centerXAnchor.constraint(equalTo: centerXAnchor, constant: 0),
            circleView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: 0),
            circleView.widthAnchor.constraint(equalToConstant: circleSize.width),
            circleView.heightAnchor.constraint(equalToConstant: circleSize.height),
            ...
        ])
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/e886c7ff-7258-16b4-d4c6-f46aa2112d02.png)

ただの円

# 暗い部分

レイヤーの重なり順の関係もあるので全体の明暗の基礎となる暗い部分を追加する。
下から球体よりやや大きめの円で放射状に黒いグラデーションをかけることにより面が湾曲しているように表現する。

```Swift:CanvasView.swift
        let bottomGradientView = GradientView()
        bottomGradientView.translatesAutoresizingMaskIntoConstraints = false
        bottomGradientView.style = .radial
        bottomGradientView.centerPoint = CGPoint(x: circleSize.width / 2, y: 0)
        bottomGradientView.endRadius = circleSize.width / 0.7
        bottomGradientView.colors = [UIColor(white: 0, alpha: 0),
                                     UIColor(white: 0, alpha: 1.0)]
        bottomGradientView.locations = [0.4, 1]
        circleView.addSubview(bottomGradientView)
```

今回、グラデーションを多用するので汎用クラスとして `GradientView` を定義。
Styleによってグラデーションのかけ方、色や中心点、半径や位置などを設定できるようにしている。

```Swift:CanvasView.swift
final class GradientView: UIView {
    enum Style {
        case linear, radial
    }
    var style: Style = .linear
    var colors: [UIColor] = [.white, .black]
    var centerPoint: CGPoint?
    var endRadius: CGFloat?
    var locations: [CGFloat] = [0, 1]

    ...

    override func draw(_ rect: CGRect) {
        let cgColors = colors.map({ $0.cgColor }) as CFArray
        let space = CGColorSpaceCreateDeviceRGB()
        if let context = UIGraphicsGetCurrentContext(),
            let gradient = CGGradient(colorsSpace: space, colors: cgColors, locations: locations) {
            switch style {
            case .linear:
                context.drawLinearGradient(gradient,
                                           start: .zero,
                                           end: CGPoint(x: 0, y: rect.maxY),
                                           options: [])
            case .radial:
                context.drawRadialGradient(gradient,
                                           startCenter: centerPoint ?? CGPoint(x: rect.midX, y: rect.midY),
                                           startRadius: 0,
                                           endCenter: centerPoint ?? CGPoint(x: rect.midX, y: rect.midY),
                                           endRadius: endRadius ?? rect.width / 2,
                                           options: [])
            }
        }
    }
}
```

クリップ解除するとこんな感じでかける。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/b8b8b03e-f0ed-21e9-e119-6e2e6ba9ca5c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/ed3b03ef-9402-8734-a155-f061b8ab3ebd.png)

まだ立体感ないしベタッと貼りついてる感じがする。


# 回り込み
球体の周囲から背景へと抜けるイメージを表現するため、中心から外側にかけてグラデーションをかけ回り込みを表現する。
中心を0%として80%〜100%に対して入れることで円の内側にかかる感じになる。
但し、球体の底面は接地面になるので微妙にサイズを大きくして下方向にずらすことで底面にはあまりかからないように調整している。

```Swift:CanvasView.swift
        let outlineGradientView = GradientView()
        outlineGradientView.translatesAutoresizingMaskIntoConstraints = false
        outlineGradientView.style = .radial
        outlineGradientView.colors = [UIColor(white: 1, alpha: 0),
                                      UIColor(white: 1, alpha: 0),
                                      UIColor(white: 1, alpha: 1)]
        outlineGradientView.locations = [0, 0.8, 1]
        circleView.addSubview(outlineGradientView)
```

110%の大きさでtopに付くようにしている。

```Swift:CanvasView.swift
            outlineGradientView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor, constant: 0),
            outlineGradientView.topAnchor.constraint(equalTo: circleView.topAnchor, constant: 0),
            outlineGradientView.widthAnchor.constraint(equalTo: circleView.widthAnchor, multiplier: 1.1),
            outlineGradientView.heightAnchor.constraint(equalTo: circleView.heightAnchor, multiplier: 1.1),
```

わかりやすく示すと球体の円に対してこのようにグラデーションをかけることによって底面に掛けて徐々に効果をなくすようにしている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/1017e6d4-0e3a-5b8c-2cf0-62d0b620619c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/94ec83fa-7992-4368-1469-cca727e4262d.png)

ちょっと立体感でてきたけど、まだ接地面がないため宙に浮いているしあまり球体の感じもしない。

# 陰影
球体の影を再現することで接地面を表現する。
影はCATransform3Dでperspectiveをかけ、影自体の描写は放射状のグラデーションを描画する。

```Swift:CanvasView.swift
        let shadowView = GradientView()
        shadowView.translatesAutoresizingMaskIntoConstraints = false
        shadowView.style = .radial
        shadowView.colors = [UIColor(white: 0, alpha: 0.7),
                             UIColor(white: 0, alpha: 0.4),
                             UIColor(white: 0, alpha: 0)]
        shadowView.locations = [0, 0.6, 1]
        var shadowTransform = CATransform3DIdentity
        shadowTransform.m34 = -1.0 / 500
        shadowTransform = CATransform3DRotate(shadowTransform, CGFloat(Double.pi * 0.4), 1, 0, 0)
        shadowView.layer.transform = shadowTransform
        insertSubview(shadowView, belowSubview: circleView)
```

`m34` の指定でパースのかかり具合をコントロールしているが、分母を大きくするとかかり方は弱くなる。数値は適当に合わせた。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/1151ab08-eb58-59a6-04e5-2b32c98eeac8.png)

例として `shadowTransform.m34 = -1.0 / 3000` だとパースのかかり方が弱くなる。影だけ見てもボヤッとした円形なのでパースのかかり具合の違いが分かりにくいけど。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/e89d3d55-e848-06fd-7fc3-f4d74b27da66.png)

レイアウトはY軸の中心が球体の底面に来るようにしている。

```Swift:CanvasView.swift
            shadowView.centerXAnchor.constraint(equalTo: centerXAnchor, constant: 0),
            shadowView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: circleSize.height * 0.5),
            shadowView.widthAnchor.constraint(equalTo: circleView.widthAnchor),
            shadowView.heightAnchor.constraint(equalTo: circleView.heightAnchor),
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/7b016eb5-2855-8efd-c2ad-aadcb0280b12.png)
ぽくなってきたけど質感が見えてこないしまだちょっと球体には見えない。

# 反射光
設置面があって球体の表面がツルっとした質感の場合、設置面の明るさや色合いが球体の底面に反射して映り込む現象がある。
この表現のために球体の下から上にかけて白でグラデーションをかける。
リニアにかけてるけど放射状でも良いかもしれない。

```Swift:CanvasView.swift
        let reflectionGradientView = GradientView()
        reflectionGradientView.translatesAutoresizingMaskIntoConstraints = false
        reflectionGradientView.colors = [UIColor(white: 1, alpha: 0.3),
                                         UIColor(white: 1, alpha: 0),
                                         UIColor(white: 1, alpha: 0)]
        reflectionGradientView.locations = [1, 0.4, 0]
        circleView.addSubview(reflectionGradientView)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/7dbfa90c-62f4-834f-cbc1-485c36eb80dd.png)

効果がわかりにくいけど底面が若干白ががっている。

# 映り込み１
実際にはガラスやプラスティックのような表面がツルツルした質感の場合は物体が存在する場所の周りの景色が微かに写りこむ。
これを表現するのはデジタルな世界では難しいがそれっぽい表現を行うしかない。よくある表現として物体が屋内にあると仮定してその壁面や天井面が映り込んでいる表現がある。

球体が存在する屋内の壁面が球体の面に沿って映り込むようにしたいため、
薄い黒でリニアにグラデーションをかけ、maskして反転することで映った壁を描いている。

```Swift:CanvasView.swift
        // 映り込みの表現
        let mirroredView = GradientView()
        mirroredView.translatesAutoresizingMaskIntoConstraints = false
        mirroredView.clipsToBounds = true
        mirroredView.colors = [UIColor(white: 0, alpha: 0.1),
                                     UIColor(white: 0, alpha: 0)]
        mirroredView.locations = [0, 0.8]
        mirroredView.layer.cornerRadius = circleSize.width * 0.7 / 2
        let maskLayer = CAShapeLayer()
        let mutablePath = CGMutablePath()
        // 反転するために外枠をaddEllipse
        mutablePath.addEllipse(in: CGRect(x: 0, y: 0, width: circleSize.width * 0.9, height: circleSize.height * 0.6))
        mutablePath.addEllipse(in: CGRect(x: circleSize.width * 0.1, y: 0, width: circleSize.width * 0.7, height: circleSize.height * 0.6))
        maskLayer.path = mutablePath
        // 反転するために外枠を.eventOddを指定
        maskLayer.fillRule = .evenOdd
        // マスクする
        mirroredView.layer.mask = maskLayer
        circleView.addSubview(mirroredView)
```

```Swift:CanvasView.swift
            mirroredView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor, constant: 0),
            mirroredView.topAnchor.constraint(equalTo: circleView.topAnchor, constant: 0),
            mirroredView.widthAnchor.constraint(equalTo: circleView.widthAnchor, multiplier: 0.9),
            mirroredView.heightAnchor.constraint(equalTo: circleView.heightAnchor, multiplier: 0.6),
```

やや複雑で説明しにくいけど、楕円にリニアにかけたグラデーションの中心をmaskすることによってくり抜いている形。楕円にすることで描画内容を球体の面にのせる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/df925ffc-bdd9-a6a8-27c5-f08eff4edc8c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/6222870a-f8ab-9b76-554c-4f753898654f.png)

映り込みの効果としては弱い。。
波平っぽい効果としては高い。。

# 映り込み２
前述の映り込み１だとリアル感がなくてイマイチ。
しかし想像できない場所にある物体への映り込みの表現にはやはり限界がある。
そこで写真を元に魚眼レンズのような効果があるCIFilterをかけて映り込みを表現するようにしてみた。
数値は適当に合わせている。
（デッサンすると言っておきながら写真使うのでちょっとチートっぽいけどw）

```Swift:CanvasView.swift
        let filter: CIFilter? = {
            if let image = UIImage(named: "room.jpg"),
                let inputImage = CIImage(image: image) {
                let filter = CIFilter(name: "CIBumpDistortion")
                filter?.setValue(inputImage, forKey: kCIInputImageKey)
                filter?.setValue(CIVector(x: circleSize.width / 2, y: circleSize.height / 2), forKey: kCIInputCenterKey)
                filter?.setValue(NSNumber(value: Float(circleSize.width / 2)), forKey: kCIInputRadiusKey)
                filter?.setValue(NSNumber(value: 1.5), forKey: kCIInputScaleKey)
                return filter
            }
            return nil
        }()
        if let outputImage = filter?.outputImage {
            let imageMirroredView = UIImageView(image: UIImage(ciImage: outputImage))
            imageMirroredView.contentMode = .scaleAspectFill
            let imageMaskLayer = CAGradientLayer()
            imageMaskLayer.frame.size = circleSize
            // 中心から外にかかるようにする
            imageMaskLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
            imageMaskLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
            // 40%->30%->0%とかかるようにする
            imageMaskLayer.colors = [UIColor(white: 0, alpha: 0.1).cgColor,
                                     UIColor(white: 0, alpha: 0.1).cgColor,
                                     UIColor.clear.cgColor]
            imageMaskLayer.type = .radial
            imageMirroredView.layer.mask = imageMaskLayer
            circleView.addSubview(imageMirroredView)

            NSLayoutConstraint.activate([
                imageMirroredView.widthAnchor.constraint(equalTo: circleView.widthAnchor),
                imageMirroredView.heightAnchor.constraint(equalTo: circleView.heightAnchor),
                imageMirroredView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor, constant: 0),
                imageMirroredView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor, constant: 0),
            ])
        }
```

元のイメージはこれ（家のキッチンカウンター）。
ちょうどいい感じの効果が出る写真を見繕うのに割と時間を要した。
![room.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/8b6345ce-3db7-d3a1-2a78-9dfaafc1a892.jpeg)

フィルタ適用してImageViewに入れるとこうなる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/54bc5fb6-bd93-f805-5d3e-e80adde6ed41.png)

あまり映り込みすぎると球体の質感が鏡面ぽくなってしまうのでフィルタしたイメージに放射状のグラデーションマスクすることによって端に行くに従って消えていくようにする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/5cf2797a-ac54-fab2-c26a-7798b07565f4.png)

他の表現の邪魔になってしまうのでさらに透明度を上げて効果を薄くする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/8988815a-3d19-b07b-974e-1d9af0c2bd5e.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/e78752c8-2b02-5567-4b7a-2bf23a4ed057.png)
かなりそれらしくなった（と信じたい）

# ハイライト
最後にもっとも強く光が当たる明るい部分を入れる。レイヤーの重なり順序的に最後に入れる。
白い放射状のグラデーションをパースをかけることで球体の上面にのせるイメージ。CATransform3Dじゃなくてただの楕円でも良いかも。

```Swift:CanvasView.swift
        let topGradientView = GradientView()
        topGradientView.translatesAutoresizingMaskIntoConstraints = false
        topGradientView.style = .radial
        topGradientView.centerPoint = CGPoint(x: circleSize.width / 2, y: circleSize.height / 2)
        topGradientView.colors = [UIColor(white: 1, alpha: 1),
                             UIColor(white: 1, alpha: 0.5),
                             UIColor(white: 1, alpha: 0)]
        topGradientView.locations = [0, 0.5, 0.7]
        var topGradientTransform = CATransform3DIdentity
        topGradientTransform.m34 = -1.0 / 500
        topGradientTransform = CATransform3DTranslate(topGradientTransform, 0, -circleSize.height * 0.4, 0)
        topGradientTransform = CATransform3DRotate(topGradientTransform, CGFloat(Double.pi * 0.2), 1, 0, 0)
        topGradientView.layer.transform = topGradientTransform
        circleView.addSubview(topGradientView)
```

わかりやすく示すとこんな感じで配置する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/bf322844-b899-0d51-24ef-0dab3e2bd9e8.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/56c8486e-b65c-8a4c-30b6-60abb9b2f470.png)
ハイライトが入ったらグッと締まった。
けど波平感に拍車が掛かった。

# 調整
ベースカラーを変えて調整してみる。アナログのデッサンではできない調整方法。
ベースカラーの色味は関係ないけど、明度が変わることにより上に重ねた効果が本当に適切なのか見ることができる。

仮に `UIColor.red` にするとこんな感じ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/be02d4f5-3ab6-e4f2-5c87-d5cc19c8d702.png)

ややハイライト部分のグラデーションがキツい印象がある。
70%までしかかからないようにしていたグラデーションを100%までに変更してみる。

```Swift:CanvasView.swift
-- topGradientView.locations = [0, 0.5, 0.7]
++ topGradientView.locations = [0, 0.5, 1]
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/9714630f-158c-1f9a-42c3-9201e359095f.png)

ハイライト部分はいい感じになったけど、
映り込み１の表現がやや主張しすぎているように感じてきた。
グレイに変える。

```Swift:CanvasView.swift
--        mirroredView.colors = [UIColor(white: 0, alpha: 0.1),
--                               UIColor(white: 0, alpha: 0)]
++        mirroredView.colors = [UIColor(white: 0.7, alpha: 0.2),
++                               UIColor(white: 0.7, alpha: 0)]
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/64cfb64b-6227-72a4-616a-03c8b4a34896.png)

いい感じになった。
`.green` に戻してみる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156085/1d941048-688e-c95e-d042-8a18bb2b19a1.png)

:tada:
最後に色味だけちょっとクリスマス感出せた。

# 感想
グラフィックアプリでの作業をコードベースにしただけの話ではあるんだろうけど、コードになってることでダイナミックに変更可能だし、リソース容量も食わない。ユーザによるカスタマイズが可能になる部分も出てくる。
デメリットとしては描画に時間がかかる場合があるのでパフォーマンス面はシビアかも。
グラフィックアプリはポインタをピクセル単位で動かして作業していくので案外手も目もやられる。アプリによっては操作方法が異なったり出来なかったりがあるので厄介なこともある。
何を描くべきかわかっていたら意外とコードでやってしまった方が楽なことは結構ある気がしている。