24
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ゆめみAdvent Calendar 2020

Day 17

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

Last updated at Posted at 2020-12-16

昨年の球体デッサンに続き今年は立方体をデッサンしてみた。
今回は写真や画像を一切使わない正真正銘完全コーディングによるデッサンに拘った。(前回は一部写真を歪めてはめ込むチート技を使ってしまった)

結果から貼るとこうなった。
image.png

木の角材に見えるかな?
主に CoreAnimation CoreGraphics CoreImage を利用している。

プロジェクトは以下にアップした。
https://github.com/yumemi-ajike/Cube

  • Xcode 12.2
  • Swift 5.1

立方体の面

前面、背面、上面、底面、左側面、右側面のそれぞれをCALayerとして定義、正方形で追加する。
サイズは適当に一辺200とした。

WireCubeView.swift
    let size: CGFloat = 200
    lazy var frontLayer: CALayer = {
        let transform = CATransform3DMakeTranslation(0, 0, size / 2)
        return createFaceLayer(with: transform)
    }()
    lazy var rightLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(size / 2, 0, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 0, 1, 0)
        return createFaceLayer(with: transform)
    }()
    lazy var topLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(0, -size / 2, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 1, 0, 0)
        return createFaceLayer(with: transform)
    }()
    lazy var leftLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(-size / 2, 0, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 0, 1, 0)
        return createFaceLayer(with: transform)
    }()
    lazy var bottomLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(0, size / 2, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 1, 0, 0)
        return createFaceLayer(with: transform)
    }()
    lazy var backLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(0, 0, -size / 2)
        transform = CATransform3DRotate(transform, CGFloat.pi , 0, 1, 0)
        return createFaceLayer(with: transform)
    }()
    
    func createFaceLayer(with transform: CATransform3D) -> CALayer {
        
        let layer = CALayer()
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
        layer.borderColor = UIColor.white.cgColor
        layer.borderWidth = 1
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }
WireCubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()
        ...
        baseLayer.addSublayer(frontLayer)
        baseLayer.addSublayer(rightLayer)
        baseLayer.addSublayer(topLayer)
        baseLayer.addSublayer(leftLayer)
        baseLayer.addSublayer(bottomLayer)
        baseLayer.addSublayer(backLayer)
        baseLayer.position = CGPoint(x: bounds.midX, y: bounds.midY)
        
        ...
        layer.addSublayer(baseLayer)
        ...
    }

前面はZ方向に100移動。

        let transform = CATransform3DMakeTranslation(0, 0, size / 2)

右側面はX方向に100移動、Y軸を CGFloat.pi / 2 つまり90°回転させる。

        var transform = CATransform3DMakeTranslation(size / 2, 0, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 0, 1, 0)

上面はY方向に-100移動、X軸を90°回転させる。

        var transform = CATransform3DMakeTranslation(0, -size / 2, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 1, 0, 0)

左側面は右側面とは逆にX方向に-100移動、Y軸を-90°回転させる。

        var transform = CATransform3DMakeTranslation(-size / 2, 0, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 0, 1, 0)

底面は上面とは逆にY方向に100移動、X軸を-90°回転させる。

        var transform = CATransform3DMakeTranslation(0, size / 2, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 1, 0, 0)

背面はZ方向に-100移動、Y軸を CGFloat.pi つまり180°回転させる。

        var transform = CATransform3DMakeTranslation(0, 0, -size / 2)
        transform = CATransform3DRotate(transform, CGFloat.pi , 0, 1, 0)

この時点でビルド実行すると前面(=frontLayer)しか見えていない状態となる。
figure1.png

立方体として認識できる見栄えにする必要があるため、全体の角度を調整する。

        override func layoutSubviews() {
        ...
        var transform = CATransform3DIdentity
        ...
        transform = CATransform3DRotate(transform, -30 * CGFloat.pi / 180, 0, 1, 0)
        transform = CATransform3DRotate(transform, -30 * CGFloat.pi / 180, 1, 0, 0)
        transform = CATransform3DRotate(transform, 15 * CGFloat.pi / 180, 0, 0, 1)
        baseLayer.transform = transform

6面全面の親である、baseLayerにtransformをかける。
Y軸を−30°、X軸を-30°、Z軸を15°傾けるとなんとか良い感じに立方体に見えなくもない。
因みに傾けると線が微妙な角度で描画されジャギって汚くなるため各レイヤーにはアンチエイリアスをかけておく。

layer.allowsEdgeAntialiasing = true

figure2.png

このままだとパースペクティブが効いていないため奥行きが感じられずどっち方向にあるのか判別できない。

        transform.m34 = -1.0 / 1000

CATransform3D.m34 に値を入れることでパースがかかりどういう方向に存在しているのかわかるようになる。値は適当。
figure3.png

しっかり立方体に見えるようになった。
各面の見えるバランスも良さげ。

面を作る

グラデーションで面の微妙な明暗を表現する。

見えている前面、上面、右面を CAGradientLayer にして色を付ける。

CubeView.swift
    lazy var frontLayer: CAGradientLayer = {
        let transform = CATransform3DMakeTranslation(0, 0, size / 2)
        return createGradientFaceLayer(with: transform,
                                       colors: [UIColor(white: 0.4, alpha: 1.0),
                                                UIColor(white: 0.6, alpha: 1.0)])
    }()
    lazy var rightLayer: CAGradientLayer = {
        var transform = CATransform3DMakeTranslation(size / 2, 0, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 0, 1, 0)
        return createGradientFaceLayer(with: transform,
                                       colors: [UIColor(white: 0.6, alpha: 1.0),
                                                UIColor(white: 0.8, alpha: 1.0)])
    }()
    lazy var topLayer: CAGradientLayer = {
        var transform = CATransform3DMakeTranslation(0, -size / 2, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 1, 0, 0)
        return createGradientFaceLayer(with: transform,
                                       colors: [UIColor(white: 1.0, alpha: 1.0),
                                                UIColor(white: 0.8, alpha: 1.0)])
    }()
CubeView.swift
    func createGradientFaceLayer(with transform: CATransform3D, colors: [UIColor]) -> CAGradientLayer {
        
        let layer = CAGradientLayer()
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
        layer.colors = colors.map { $0.cgColor }
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }

光源はやや右奥上にある想定で
前面は上から下まで40%→60%、
右面は上から下まで60%→80%、
上面は奥から手前まで100%→80%の白地にグレーのグラデーションがかかるようにしている。
figure4.png
前面と右面を下方向に明るくしているのは接地面の反射光が当たり明るくなっているという表現。

背景/地面を作る

CubeView の後ろに GroundView を追加する。
背景/地面(=GroundView)は立方体(=CubeView)とは別の階層として定義し、立方体そのものの再利用性を高めている。

GroundView.swift
final class GroundView: UIView {
    
    lazy var groundLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.colors = [UIColor(white: 1.0, alpha: 1.0).cgColor,
                        UIColor(white: 0.7, alpha: 1.0).cgColor]
        layer.locations = [0.5, 1.0]
        return layer
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        layer.addSublayer(groundLayer)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        groundLayer.frame = bounds
    }
}

figure5.png

影を落とす

背景/地面ができたので立方体の影を落とす。
影は背景/地面 or 立方体どちらのView階層に存在すべきか議論が分かれそうだが、
影も立方体と同じパース具合にしたいのでtransformの記述が1箇所で済みそうなCubeViewのbaseLayerに追加することにした。

どうやって影を表現するか試行錯誤したけど、立方体の各面から影を作り出すことが難しかったので結局影としての面を全く別に設けることにした。

影のベースレイヤーを手前に広がるように追加する。

CubeView.swift
    lazy var shadowLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(size / 2, size / 2, size / 2)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 1, 0, 0)
        let layer = CALayer()
        layer.frame = CGRect(x: -size, y: -size, width: size * 2, height: size * 2)
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
CubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()
        ...
        baseLayer.addSublayer(shadowLayer)
        ...
    }

figure6.png

影となるグラデーションを落とす。

CubeView.swift
    lazy var shadowGradientLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.frame = CGRect(x: 0, y: 0, width: size * 2, height: size * 2)
        layer.colors = [UIColor(white: 0, alpha: 0.4), .clear].map { $0.cgColor }
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
CubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()
        
        shadowLayer.addSublayer(shadowGradientLayer)
        ...
    }

figure7.png

影の形をパスでクリップする。

CubeView.swift
    lazy var shadowShapeLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.frame = CGRect(x: 0, y: 0, width: size * 2, height: size * 2)
        layer.fillColor = UIColor.black.cgColor
        let path = CGMutablePath()
        path.move(to: CGPoint(x: 0, y: size))
        path.addLine(to: CGPoint(x: size, y: size))
        path.addLine(to: CGPoint(x: size, y: 0))
        path.addLine(to: CGPoint(x: size * 1.5, y: size))
        path.addLine(to: CGPoint(x: size / 2 * 3, y: size * 2))
        path.addLine(to: CGPoint(x: size / 2, y: size * 2))
        path.addLine(to: CGPoint(x: 0, y: size))
        path.closeSubpath()
        layer.path = path
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
CubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()
        
        ...
        shadowGradientLayer.mask = shadowShapeLayer
        ...

figure9.png

グラデーションに対してこのようなパスでクリップすることで影にみせかける。
figure8.png

それらしくなっているが何の立方体かわからないし、リアルさにかけるためCG丸出し感が否めないので詳細を作り込んでいく。

形状の作り込み

立方体の形状などディティールを追求していくことで画としての説得力を上げていく。

角を落とす

各面同士が直角に接している部分が現実の物体ではありえないほど鋭い。
この時点ではまだ素材は明らかになっていないが現実のものなら金属でも木材でもプラスティックでも必ずわずかな角の丸みがあるはずなので角を落としていく。

角を丸めて内容をクリップしたいため CAGradientLayerCALayer のsublayerにして、cornerRadius を設定する。

    let cornerRadius: CGFloat = 2.0
    ...

    func createGradientFaceLayer(with transform: CATransform3D, colors: [UIColor]) -> CALayer {
        
        let layer = CALayer()
        let gradientLayer = CAGradientLayer()
        gradientLayer.frame = CGRect(x: 0, y: 0, width: size, height: size)
        gradientLayer.colors = colors.map { $0.cgColor }
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
        layer.cornerRadius = cornerRadius
        layer.masksToBounds = true
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        layer.addSublayer(gradientLayer)
        return layer
    }

次に前面/上面/右側のそれぞれが接する部分にハイライトとして白→透明のグラデーションを置く。

    lazy var frontTopLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
        layer.colors = [UIColor(white: 1, alpha: 0.8),
                        UIColor(white: 1, alpha: 0)].map { $0.cgColor }
        layer.transform = CATransform3DMakeTranslation(size / 2, size / 2, 0)
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
    lazy var frontLeftLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        let transform = CATransform3DMakeTranslation(size / 2, size / 2, 0)
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: cornerRadius, height: size)
        layer.colors = [UIColor(white: 1, alpha: 0.3),
                        UIColor(white: 1, alpha: 0)].map { $0.cgColor }
        layer.startPoint = CGPoint(x: 0, y: 0.5)
        layer.endPoint = CGPoint(x: 1, y: 0.5)
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
    lazy var frontRightLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        let transform = CATransform3DMakeTranslation(size * 1.5 - cornerRadius, size / 2, 0)
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: cornerRadius, height: size)
        layer.colors = [UIColor(white: 1, alpha: 0),
                        UIColor(white: 1, alpha: 0.3)].map { $0.cgColor }
        layer.startPoint = CGPoint(x: 0, y: 0.5)
        layer.endPoint = CGPoint(x: 1, y: 0.5)
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
    lazy var frontBottomLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
        layer.colors = [UIColor(white: 1, alpha: 0),
                        UIColor(white: 1, alpha: 0.2)].map { $0.cgColor }
        layer.transform = CATransform3DMakeTranslation(size / 2, size * 1.5 - cornerRadius, 0)
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
    override func layoutSubviews() {
        ...
        frontLayer.addSublayer(frontTopLayer)
        frontLayer.addSublayer(frontRightLayer)
        frontLayer.addSublayer(frontLeftLayer)
        frontLayer.addSublayer(frontBottomLayer)
        rightLayer.addSublayer(rightTopLayer)
        rightLayer.addSublayer(rightLeftLayer)
        rightLayer.addSublayer(rightRightLayer)
        rightLayer.addSublayer(rightBottomLayer)
        topLayer.addSublayer(topTopLayer)
        topLayer.addSublayer(topBottomLayer)
        topLayer.addSublayer(topRightLayer)
        topLayer.addSublayer(topLeftLayer)
        ...
    }

cornerRadiusを大袈裟な値にするとこんな感じでサイコロのようになる。
figure10.png

立方体の形状と影が合わないため、立方体に合った影の形に shadowShapeLayer のパスを修正する。

    lazy var shadowShapeLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.frame = CGRect(x: 0, y: 0, width: size * 2, height: size * 2)
        layer.fillColor = UIColor.black.cgColor
        let path = CGMutablePath()
        path.move(to: CGPoint(x: cornerRadius, y: size))
        path.addLine(to: CGPoint(x: size - cornerRadius, y: size))
        // curve
        path.addCurve(to: CGPoint(x: size, y: size - cornerRadius), control1: CGPoint(x: size, y: size), control2: CGPoint(x: size, y: size - cornerRadius))
        path.addLine(to: CGPoint(x: size, y: cornerRadius))
        // curve
        path.addCurve(to: CGPoint(x: size + cornerRadius, y: cornerRadius * 2), control1: CGPoint(x: size, y: 0), control2: CGPoint(x: size + cornerRadius, y: cornerRadius * 2))
        path.addLine(to: CGPoint(x: size * 1.5, y: size))
        path.addLine(to: CGPoint(x: size / 2 * 3, y: size * 2))
        path.addLine(to: CGPoint(x: size / 2, y: size * 2))
        path.addLine(to: CGPoint(x: cornerRadius, y: size + cornerRadius * 2))
        // curve
        path.addCurve(to: CGPoint(x: cornerRadius, y: size), control1: CGPoint(x: 0, y: size), control2: CGPoint(x: cornerRadius, y: size))
        path.closeSubpath()
        layer.path = path
        layer.allowsEdgeAntialiasing = true
        return layer
    }()

figure11.png

角丸の値を小さくして目立たなくする。
figure12.png

接地面の影

立方体の接地面の影をしっかり描くことで物体がある面に接地していることをより表現できる上に、画にパシッと締りができる。

立方体の底面である bottomLayer にshadowを設定することで実現してみる。。。
figure13.png

一見、おかしくないようだがよく見ると画面左端の影の落ち方がおかしい。
また角を丸めているはずなのに影が濃すぎるという問題も浮かび上がる。
figure13-1.png

bottomLayer のsublayerに影の対象となる面 shadowBaseLayer を追加し背景色をつけることでbottomLayer にshadowを設定した際、この shadowBaseLayer に対してのみ影がかかるようになる。

    lazy var bottomLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(0, size / 2, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 1, 0, 0)
        let layer = createFaceLayer(with: transform, color: .clear)
        let shadowRadius: CGFloat = 3
        let shadowBaseLayer = CALayer()
        shadowBaseLayer.frame = CGRect(x: cornerRadius, y: cornerRadius, width: size - cornerRadius * 2, height: size - cornerRadius * 2)
        shadowBaseLayer.backgroundColor = UIColor.white.cgColor
        layer.addSublayer(shadowBaseLayer)
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowRadius = shadowRadius
        layer.shadowOpacity = 1
        layer.shadowOffset = CGSize(width: 1, height: -2)
        return layer
    }()

shadowBaseLayerbottomLayer の矩形に対して角丸分だけ内側にレイアウトされるようにすることで底面の周囲が接地していない表現を行う。台に置かれたサイコロの周囲がぴったり接地していないのと同じ。
figure14.png
figure15-2.png

一旦、作り込み前後の比較

ハイライト/影なし ハイライト/影あり
figure15-1.png figure15-2.png

画のリアル感はやや向上した。形状や状態がより伝わりやすくもなったが、如何せん何でできているのか素材や質感が全くわからないのでデッサンとしてはNGもう一歩。

素材や質感

立方体の素材を描くことによって質感を表現し、さらに説得力を上げていく。

ガラス製だと立方体が置かれている周りの風景の映り込みを表現する必要があり、これをコードのみで表現するのは難しそう。また前回のように写真を使ってしまいそうだ。
プラスティック製も表面がツルツルしているため、映り込みの表現が必要だしなんか味気ない。
金属製も同じく。
石膏は表面についた傷や汚れなどを表現する必要があり、これもコードで表現するのは過酷。

木ならどうか。
年輪はある程度規則性があるので表現できるかもしれないということで角材の木目を描いてみることにした。

準備

まずは各面にテクスチャを貼る準備。
各面のsublayerに加えたグラデーションの色指定をグレースケールから白のアルファでの表現に変更して、
各面のCALayer.contentsに CGImage を入れられるようにする。

CubeView.swift
    lazy var frontLayer: CALayer = {
        ...
        return createGradientFaceLayer(with: transform,
                                       colors: [UIColor(white: 0, alpha: 0.6),
                                                UIColor(white: 0, alpha: 0.4)])
    }()

テクスチャの作成

LumberTexture クラスを追加。
木の色や年輪の間隔、幅、濃さ、色を管理し、最終的にCGImageを吐くオブジェクト。
吐いたImageを各レイヤーにセットする寸法。

設計的には
立方体の角度や陰影は CubeView が管理していて、LumberTexture はテクスチャ情報のみを管理することになるので本来は6面すべてのイメージを生成するオブジェクトでなければならないが、時間の都合で見えている3面だけにした。

LumberTexture.swift
final class LumberTexture {
    // 年輪
    struct Ring {
        let distance: CGFloat          // 間隔
        let width: CGFloat             // 幅
        let depth: CGFloat             // 濃さ
        let colorComponents: [CGFloat] // 色
    }
    // 1辺の長さ
    let side: CGFloat
    // 木の色
    let baseColorComponents: [CGFloat] = [(226 / 255), (193 / 255), (146 / 255)]
    // 細かい年輪の色 RGB
    let smoothRingColorComponents: [CGFloat] = [(199 / 255), (173 / 255), (122 / 255)]
    // 荒い年輪の色 RGB
    let roughRingColorComponents: [[CGFloat]] = [
        [(176 / 255), (106 / 255), (71 / 255)],
        [(194 / 255), (158 / 255), (96 / 255)],
    ]
    // 細かい年輪の情報
    private var roughRings: [Ring] = []
    // 荒い年輪の情報
    private var smoothRings: [Ring] = []
}

年輪の情報生成

細かい年輪は狭い感覚/細い幅で対角線の長さ分だけランダムに用意する。
色は木の色に対してあまり目立たない色を指定している。
色は適当にググって出てきた檜材からサンプリングしたRGB。

LumberTexture.swift
    private func createSmoothRings() -> [Ring] {
        
        var smoothRings: [Ring] = []
        var pointer: CGFloat = 0
        
        repeat {
            let distance = CGFloat(Float.random(in: 2 ... 3))
            let width = CGFloat(Float.random(in: 0.5 ... 2))
            let depth = CGFloat(Float.random(in: 0.8 ... 1.0))
            let colorComponents = smoothRingColorComponents
            if (pointer + distance + width / 2) < (side * sqrt(2)) {
                smoothRings.append(Ring(distance: distance, width: width, depth: depth, colorComponents: colorComponents))
                pointer += distance
            } else {
                break
            }
            
        } while(pointer < (side * sqrt(2)))
        
        return smoothRings
    }

荒い年輪は広い間隔/太い幅も入るようにして木の色に対して目立つ色を指定している。

LumberTexture.swift
    private func createRoughRings() -> [Ring] {
        
        var roughRings: [Ring] = []
        var pointer: CGFloat = 0
        
        repeat {
            let distance = CGFloat(Float.random(in: 5 ... 30))
            let width = CGFloat(Float.random(in: 2 ... 12))
            let depth = CGFloat(Float.random(in: 0.4 ... 0.6))
            let colorComponents = roughRingColorComponents[Int.random(in: 0 ... 1)]
            if (pointer + distance + width / 2) < (side * sqrt(2)) {
                roughRings.append(Ring(distance: distance, width: width, depth: depth, colorComponents: colorComponents))
                pointer += distance
            } else {
                break
            }
            
        } while(pointer < (side * sqrt(2)))
        
        return roughRings
    }

上面と側面が別のイメージになるがそれぞれ同じ年輪幅でないと辻褄が合わなくなってくるため年輪の情報を保持している。
イメージの取得funcを用意してこんな感じでパスで年輪を描く。

上面のイメージ

LumberTexture.swift
func lumberTopImage() -> CGImage? {
        
        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        
        // Draw base color
        context.setFillColor(UIColor(red: baseColorComponents[0],
                                     green: baseColorComponents[1],
                                     blue: baseColorComponents[2],
                                     alpha: 1).cgColor)
        context.fill(CGRect(x: 0, y: 0, width: side, height: side))
        
        // Draw annual tree rings
        [smoothRings, roughRings].forEach { rings in
            var pointer: CGFloat = 0
            rings.forEach { ring in
                pointer += ring.distance
                
                context.setLineWidth(ring.width)
                let startPoint = CGPoint(x: pointer, y: side)
                let endPoint = CGPoint(x: 0, y: side - pointer)
                context.move(to: startPoint)
                context.addCurve(to: endPoint,
                                  control1: CGPoint(x: pointer, y: side - pointer),
                                  control2: endPoint)
                let components: [CGFloat] = ring.colorComponents
                context.setStrokeColor(UIColor(red: components[0],
                                               green: components[1],
                                               blue: components[2],
                                               alpha: ring.depth).cgColor)
                context.strokePath()
            }
        }
        
        return context.makeImage()
    }

ランダムで生成しているので毎回異なる模様になるけど大体上面のイメージはこんな感じになる。
バームクーヘン。
top_image.png

前面のイメージ

前面にくる木目は上面のこの部分を引き伸ばしたものが垂直に落ちる形になる。
figure17-1.png

保持している年輪の情報から前面の木目を描く。

LumberTexture.swift
    func lumberSideImage() -> CGImage? {
        
        UIGraphicsBeginImageContext(CGSize(width: side * sqrt(2), height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        
        // Draw base color
        context.setFillColor(UIColor(red: baseColorComponents[0],
                                     green: baseColorComponents[1],
                                     blue: baseColorComponents[2],
                                     alpha: 1).cgColor)
        context.fill(CGRect(x: 0, y: 0, width: side * sqrt(2), height: side))
        
        // Draw smooth annual tree rings
        var pointer: CGFloat = 0
        smoothRings.forEach { ring in
            pointer += ring.distance
            
            context.setLineWidth(ring.width)
            let startPoint = CGPoint(x: pointer, y: 0)
            let endPoint = CGPoint(x: pointer, y: side)
            context.move(to: startPoint)
            context.addLine(to: endPoint)
            let components: [CGFloat] = ring.colorComponents
            context.setStrokeColor(UIColor(red: components[0],
                                           green: components[1],
                                           blue: components[2],
                                           alpha: ring.depth).cgColor)
            context.strokePath()
        }
        
        // Draw rough annual tree rings
        pointer = 0
        roughRings.forEach { ring in
            pointer += ring.distance
            
            context.setLineWidth(ring.width)
            let startPoint = CGPoint(x: pointer, y: 0)
            let endPoint = CGPoint(x: pointer, y: side)
            context.move(to: startPoint)
            context.addLine(to: endPoint)
            let components: [CGFloat] = ring.colorComponents
            context.setStrokeColor(UIColor(red: components[0],
                                           green: components[1],
                                           blue: components[2],
                                           alpha: ring.depth).cgColor)
            context.strokePath()
        }
        
        ...
    }

上面と同じ年輪情報で、上から下に描画する。
figure17-2.png

但し、人工物のように全ての年輪が並行/垂直に描かれるのは不自然なため、
CIFilterCITwirlDistortion を使って少し歪める。

LumberTexture.swift
    func lumberSideImage() -> CGImage? {
        
        ...
        
        // Distort the pattern
        if let image = context.makeImage() {
            
            let ciimage = CIImage(cgImage: image)
            let filter = CIFilter(name: "CITwirlDistortion")
            filter?.setValue(ciimage, forKey: kCIInputImageKey)
            filter?.setValue(CIVector(x: side * 1.2, y: -side / 3), forKey: kCIInputCenterKey)
            filter?.setValue(side * 1.3, forKey: kCIInputRadiusKey)
            filter?.setValue(CGFloat.pi / 8, forKey: kCIInputAngleKey)
            
            if let outputImage = filter?.outputImage {
                let cicontext = CIContext(options: nil)
                return cicontext.createCGImage(outputImage, from: CGRect(x: 0, y: 0, width: side, height: side))
            }
            
            return image
        }
        return nil
    }

side_image.png

上面と前面にイメージをはめてみる。
figure17-3.png

右側面のイメージ

ここの表現が非常に難しい。。
上面と前面の年輪の断面を同時に表現する必要があってさらに前面には歪みも設けているため、全てパスで表現すると相当複雑な計算が必要になると思われる。
知識もないし工数もないのでなんとか試行錯誤して誤魔化すことにした。

まずは上面の断面である年輪を描く。
ここから続く模様を生成しなければならない。
figure18-1.png

上面イメージの右端1pxからタイリングして90°回転したイメージを作り出す。

LumberTexture.swift
    private func topTilingImage(topImage: CGImage) -> CGImage? {
        
        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        
        if let cropImage = topImage.cropping(to: CGRect(x: side - 1, y: 0, width: 1, height: side)) {
            
            context.saveGState()
            context.rotate(by: -CGFloat.pi / 2)
            context.draw(cropImage, in: CGRect(x: 0, y: 0, width: side, height: side), byTiling: true)
            context.restoreGState()
            
            return context.makeImage()
        }
        return nil
    }

figure18-2.png

次に前面の断面である年輪を描く。
ここから続く模様を生成しなければならないが、歪めた影響でやや複雑。
figure18-3.png

こちらも同じくイメージの右端1pxからタイリングしてイメージを作り出す。

LumberTexture.swift
    private func sideTilingImage(sideImage: CGImage) -> CGImage? {
        
        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        
        if let cropImage = sideImage.cropping(to: CGRect(x: side - 1, y: 0, width: 1, height: side)) {
            
            context.saveGState()
            context.draw(cropImage, in: CGRect(x: 0, y: 0, width: side, height: side), byTiling: true)
            context.restoreGState()
            return context.makeImage()
        }
        return nil
    }

figure18-4.png

上断面に合わせると 前断面に合わせると
figure18-5.png figure18-6.png
一見良さそうだが、前面の歪み部分と合ってこない。。 前面の歪みから続く部分はこちらで良さそうだが。。

3D的に考えると前断面は少しで、あとは基本 上断面模様になるはず?こんな感じ?やや不自然だがまあいいか。
stretch_image.png

上断面イメージにグラデーションマスクした前断面イメージを合成する。

LumberTexture.swift
    func lumberStrechImage(topImage: CGImage?, sideImage: CGImage?) -> CGImage? {
        
        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        
        if let topImage = topImage,
           let tilingImage = topTilingImage(topImage: topImage) {
            
            context.saveGState()
            context.draw(tilingImage, in: CGRect(x: 0, y: 0, width: side, height: side))
            context.restoreGState()
        }
        
        if let sideImage = sideImage,
            let tilingImage = sideTilingImage(sideImage: sideImage),
            let maskedImage = gradientMaskedImage(image: tilingImage) {
            
            context.saveGState()
            context.draw(maskedImage, in: CGRect(x: 0, y: 0, width: side, height: side))
            context.restoreGState()
        }
        
        return context.makeImage()
    }
LumberTexture.swift
    private func gradientMaskedImage(image: CGImage) -> CGImage? {
        
        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        
        if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceGray(),
                                     colors: [UIColor.black.cgColor,
                                              UIColor.white.cgColor] as CFArray,
                                     locations: [0.8, 1.0]) {

            context.saveGState()
            context.drawLinearGradient(gradient,
                                       start: CGPoint(x: 0, y: 0),
                                       end: CGPoint(x: side / 4, y: side / 8),
                                       options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
            context.restoreGState()
            if let maskImage = context.makeImage(),
               let mask = CGImage(maskWidth: maskImage.width,
                                  height: maskImage.height,
                                  bitsPerComponent: maskImage.bitsPerComponent,
                                  bitsPerPixel: maskImage.bitsPerPixel,
                                  bytesPerRow: maskImage.bytesPerRow,
                                  provider: maskImage.dataProvider!,
                                  decode: nil,
                                  shouldInterpolate: false) {
                
                return image.masking(mask)
            }
        }
        return nil
    }

あまり自信ないけどこんな感じで誤魔化した。
figure18-7.png

木目の保存

木目の描画は毎回ランダムで別の幅や色太さになる。
気に入った木目を継続できるようにすれば作り込み時の比較にも一役買いそう。

Ring 構造体をCodableにする。

LumberTexture.swift
final class LumberTexture {
    struct Ring: Codable {
        let distance: CGFloat
        let width: CGFloat
        let depth: CGFloat
        let colorComponents: [CGFloat]
    }
    ...

年輪の情報を保持している部分に保存機能を付ける。
Ring をCodableにしたため、JSONDecoder/JSONEncoderを使ってそのままUserDefaultsに読み書きする。

LumberTexture.swift
    private func createSmoothRings() -> [Ring] {
        
        // Restore saved rings from UserDefaults
        if let data = UserDefaults.standard.data(forKey: "SmoothRings"),
              let rings = try? JSONDecoder().decode([Ring].self, from: data) {
            
            return rings
        }
        
        // 生成処理
        
        // Save rings to UserDefaults
        UserDefaults.standard.setValue(try? JSONEncoder().encode(smoothRings), forKeyPath: "SmoothRings")
        
        return smoothRings
    }

タップした時にのみ木目を作り直すようにする。

CubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()
        
        ...
        
        updateTexture()
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(updateTextureAction)))
    }
CubeView.swift
    private func updateTexture() {
        
        let topImage = texture.lumberTopImage()
        let sideImage = texture.lumberSideImage()
        topLayer.contents = topImage
        frontLayer.contents = sideImage
        rightLayer.contents = texture.lumberStrechImage(topImage: topImage, sideImage: sideImage)
    }
    
    @objc private func updateTextureAction() {
        
        texture.updateRings()
        updateTexture()
    }
LumberTexture.swift
final class LumberTexture {

    ...
    
    func updateRings() {
        
        UserDefaults.standard.removeObject(forKey: "SmoothRings")
        UserDefaults.standard.removeObject(forKey: "RoughRings")
        self.smoothRings = createSmoothRings()
        self.roughRings = createRoughRings()
    }
}

これでタップしない限り木目が更新されない形になる。

ハイライトの調整

figure19-0.png
上面に接する部分のハイライトが強すぎるため明度調整。

--- a/Cube/CubeView.swift
+++ b/Cube/CubeView.swift
@@ -65,7 +65,7 @@ final class CubeView: UIView {
     lazy var frontTopLayer: CAGradientLayer = {
         let layer = CAGradientLayer()
         layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
-        layer.colors = [UIColor(white: 1, alpha: 0.8),
+        layer.colors = [UIColor(white: 1, alpha: 0.3),
                         UIColor(white: 1, alpha: 0)].map { $0.cgColor }
         layer.transform = CATransform3DMakeTranslation(size / 2, size / 2, 0)
         layer.allowsEdgeAntialiasing = true
@@ -107,7 +107,7 @@ final class CubeView: UIView {
     lazy var rightTopLayer: CAGradientLayer = {
         let layer = CAGradientLayer()
         layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
-        layer.colors = [UIColor(white: 1, alpha: 0.8),
+        layer.colors = [UIColor(white: 1, alpha: 0.3),
                         UIColor(white: 1, alpha: 0)].map { $0.cgColor }
         layer.transform = CATransform3DMakeTranslation(size / 2, size / 2, 0)
         layer.allowsEdgeAntialiasing = true
@@ -172,7 +172,7 @@ final class CubeView: UIView {
         let transform = CATransform3DMakeTranslation(size * 1.5 - cornerRadius, size / 2, 0)
         layer.frame = CGRect(x: -size / 2, y: -size / 2, width: cornerRadius, height: size)
         layer.colors = [UIColor(white: 1, alpha: 0),
-                        UIColor(white: 1, alpha: 1)].map { $0.cgColor }
+                        UIColor(white: 1, alpha: 0.3)].map { $0.cgColor }
         layer.startPoint = CGPoint(x: 0, y: 0.5)
         layer.endPoint = CGPoint(x: 1, y: 0.5)
         layer.transform = transform
@@ -183,7 +183,7 @@ final class CubeView: UIView {
         let layer = CAGradientLayer()
         layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
         layer.colors = [UIColor(white: 1, alpha: 0),
-                        UIColor(white: 1, alpha: 1)].map { $0.cgColor }
+                        UIColor(white: 1, alpha: 0.3)].map { $0.cgColor }
         layer.transform = CATransform3DMakeTranslation(size / 2, size * 1.5 - cornerRadius, 0)
         layer.allowsEdgeAntialiasing = true
         return layer

figure19-1.png

グレースケールにして確認してみる。

grayscale.diff
diff --git a/Cube/LumberTexture.swift b/Cube/LumberTexture.swift
index a5cdbb4..1df3b2c 100644
--- a/Cube/LumberTexture.swift
+++ b/Cube/LumberTexture.swift
@@ -116,7 +116,7 @@ extension LumberTexture {
         context.setFillColor(UIColor(red: baseColorComponents[0],
                                      green: baseColorComponents[1],
                                      blue: baseColorComponents[2],
-                                     alpha: 1).cgColor)
+                                     alpha: 1).convertToGrayScaleColor().cgColor)
         context.fill(CGRect(x: 0, y: 0, width: side, height: side))
         
         // Draw annual tree rings
@@ -136,7 +136,7 @@ extension LumberTexture {
                 context.setStrokeColor(UIColor(red: components[0],
                                                green: components[1],
                                                blue: components[2],
-                                               alpha: ring.depth).cgColor)
+                                               alpha: ring.depth).convertToGrayScaleColor().cgColor)
                 context.strokePath()
             }
         }
@@ -153,7 +153,7 @@ extension LumberTexture {
         context.setFillColor(UIColor(red: baseColorComponents[0],
                                      green: baseColorComponents[1],
                                      blue: baseColorComponents[2],
-                                     alpha: 1).cgColor)
+                                     alpha: 1).convertToGrayScaleColor().cgColor)
         context.fill(CGRect(x: 0, y: 0, width: side * sqrt(2), height: side))
         
         // Draw smooth annual tree rings
@@ -170,7 +170,7 @@ extension LumberTexture {
             context.setStrokeColor(UIColor(red: components[0],
                                            green: components[1],
                                            blue: components[2],
-                                           alpha: ring.depth).cgColor)
+                                           alpha: ring.depth).convertToGrayScaleColor().cgColor)
             context.strokePath()
         }
         
@@ -188,7 +188,7 @@ extension LumberTexture {
             context.setStrokeColor(UIColor(red: components[0],
                                            green: components[1],
                                            blue: components[2],
-                                           alpha: ring.depth).cgColor)
+                                           alpha: ring.depth).convertToGrayScaleColor().cgColor)
             context.strokePath()
         }
         
@@ -301,3 +301,12 @@ extension LumberTexture {
         return nil
     }
 }
+
+extension UIColor {
+    func convertToGrayScaleColor() -> UIColor {
+        var grayscale: CGFloat = 0
+        var alpha: CGFloat = 0
+        self.getWhite(&grayscale, alpha: &alpha)
+        return UIColor(white: grayscale, alpha: alpha)
+    }
+}

grayscale.png
明度の問題はなさそう。
もう少し木目のリアル感を上げ作り込みたい。

木目のリアル感を向上させる

木目のベースとなる色の幅が単色なためか単調な印象があるのと、少し色が暗い感じがするので色数を増やす。

LumberTexture.swift
final class LumberTexture {
    ...
    let baseColorComponents: [CGFloat] = [(255 / 255), (227 / 255), (220 / 255)]
    let centerBaseColorComponents: [[CGFloat]] = [
        [(205 / 255), (175 / 255), (131 / 255)],
        [(201 / 255), (138 / 255), (40 / 255)],
    ]
    ...

上面の年輪描画部分でベースとなる描画部分に80%の幅で2色のグラデーションを追加して自然に見せる。

LumberTexture.swift
    func lumberTopImage() -> CGImage? {
        
        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        
        // Draw base color
        ...
        
        if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
                                     colors: [
                                        UIColor(red: centerBaseColorComponents[0][0],
                                                green: centerBaseColorComponents[0][1],
                                                blue: centerBaseColorComponents[0][2],
                                                alpha: 0.3).cgColor,
                                        UIColor(red: centerBaseColorComponents[1][0],
                                                green: centerBaseColorComponents[1][1],
                                                blue: centerBaseColorComponents[1][2],
                                                alpha: 1).cgColor] as CFArray,
                                     locations: [0.7, 1.0]) {
            
            context.drawRadialGradient(gradient,
                                       startCenter: CGPoint(x: 0, y: side),
                                       startRadius: 0,
                                       endCenter: CGPoint(x: 0, y: side),
                                       endRadius: side * 0.8,
                                       options: [.drawsBeforeStartLocation])
        }
        ...
    }

上面の変更に追随するように前面の描画も併せて変更する。

LumberTexture.swift
    func lumberSideImage() -> CGImage? {
        
        UIGraphicsBeginImageContext(CGSize(width: side * sqrt(2), height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        
        // Draw base color
        ...
        
        if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
                                     colors: [
                                        UIColor(red: centerBaseColorComponents[0][0],
                                                green: centerBaseColorComponents[0][1],
                                                blue: centerBaseColorComponents[0][2],
                                                alpha: 0.3).cgColor,
                                        UIColor(red: centerBaseColorComponents[1][0],
                                                green: centerBaseColorComponents[1][1],
                                                blue: centerBaseColorComponents[1][2],
                                                alpha: 1).cgColor] as CFArray,
                                     locations: [0.7, 1.0]) {
            
            context.drawLinearGradient(gradient,
                                       start: CGPoint.zero,
                                       end: CGPoint(x: side * 0.8, y: 0),
                                       options: [.drawsBeforeStartLocation])
        }
        ...
    }

太い年輪の一部の色が目立ちすぎているため色味を調整する

LumberTexture.swift
    let roughRingColorComponents: [[CGFloat]] = [
        [(176 / 255), (130 / 255), (71 / 255)], <-
        [(194 / 255), (158 / 255), (96 / 255)],
    ]
上面 前面 右側面
figure20-1.png figure20-2.png figure20-3.png
Before After
figure20-5.png figure20-4.png
  • 基本色が明るくなった
  • 細かい年輪がより目立つようになった
  • 木目の色に幅ができた

切断跡

木材をよく観察すると木の繊維とは別の加工する際についた傷が残っていることがある。
木の繊維に逆らう方向に切ることにより後が残ると思われる。
やや人工的に等間隔で描く。

LumberTexture.swift
    func lumberTopImage() -> CGImage? {
        
        ...
        
        // Draw scratch
        var pointer: CGFloat = 0
        repeat {
            
            context.setLineWidth(1)
            let startPoint = CGPoint(x: 0, y: pointer * sqrt(2))
            let endPoint = CGPoint(x: pointer * sqrt(2), y: 0)
            context.move(to: startPoint)
            context.addLine(to: endPoint)
            let alpha = (1 - pointer / side * sqrt(2))
            context.setStrokeColor(UIColor(white: 1, alpha: alpha).cgColor)
            context.strokePath()
            
            pointer += 6
        } while(pointer < side * sqrt(2))
        
        return context.makeImage()
    }

上面の奥から手前に徐々に効果がなくなるように描画。
わかりやすく赤で描画するとこんな感じ。

figure20-7.png

Before After
figure20-4.png figure20-6.png

効果は高くないが上面に色幅と新たな方向が加わった。

遊ぶ

タップして木目を切り替える

movie.gif

色情報の定義を分離する

色情報を外から入れられるようにすることでカスタム可能な作りにする。
構造体として LumberColorSet を定義して指定できるようにする。

LumberColorSet.swift
struct LumberColorSet {
    let baseColorComponents: [CGFloat]
    let centerBaseColorComponents: [[CGFloat]]
    let smoothRingColorComponents: [CGFloat]
    let roughRingColorComponents: [[CGFloat]]
    
    static var `default`: LumberColorSet {
        return .init(baseColorComponents: [
                        (255 / CGFloat(255)), (227 / CGFloat(255)), (220 / CGFloat(255))
                     ],
                     centerBaseColorComponents: [
                        [(205 / CGFloat(255)), (175 / CGFloat(255)), (131 / CGFloat(255))],
                        [(201 / CGFloat(255)), (138 / CGFloat(255)), (40 / CGFloat(255))],
                     ],
                     smoothRingColorComponents: [
                        (199 / CGFloat(255)), (173 / CGFloat(255)), (122 / CGFloat(255))
                     ],
                     roughRingColorComponents: [
                        [(176 / CGFloat(255)), (130 / CGFloat(255)), (71 / CGFloat(255))],
                        [(194 / CGFloat(255)), (158 / CGFloat(255)), (96 / CGFloat(255))],
                     ])
    }
}
LumberTexture.swift
final class LumberTexture {
    ...
    private var colorSet: LumberColorSet = LumberColorSet.default
    
    init(side: CGFloat, colorSet: LumberColorSet = LumberColorSet.default) {
        self.side = side
        self.base = createBase()
        self.smoothRings = createSmoothRings()
        self.roughRings = createRoughRings()
    }

クリスマスカラー

クリスマスカラーを定義してみる。

LumberColorSet.swift
struct LumberColorSet {
    ...
    static var xmas: LumberColorSet {
        return .init(baseColorComponents: [
                        (255 / CGFloat(255)), (245 / CGFloat(255)), (193 / CGFloat(255))
                     ],
                     centerBaseColorComponents: [
                        [(105 / CGFloat(255)), (58 / CGFloat(255)), (24 / CGFloat(255))],
                        [(223 / CGFloat(255)), (176 / CGFloat(255)), (39 / CGFloat(255))],
                     ],
                     smoothRingColorComponents: [
                        (0 / CGFloat(255)), (162 / CGFloat(255)), (95 / CGFloat(255))
                     ],
                     roughRingColorComponents: [
                        [(160 / CGFloat(255)), (28 / CGFloat(255)), (34 / CGFloat(255))],
                        [(255 / CGFloat(255)), (0 / CGFloat(255)), (0 / CGFloat(255))],
                     ])
    }
    ...
}

#FFF5C1 #693A18 #DFB027 #00A25F #A01C22 #FF0000
いい感じの模様になるまでタップを繰り返す。なんかちょっと怖いかも。スイカっぽいし。
figure21-1.png

画像から色を抽出

RGBを定義するのが面倒なので画像から代表的な色を抽出するコードを書いてみた。

LumberColorSet.swift
    init?(image: UIImage) {
        
        guard let components = image.cgImage?.getPixelColors(count: 6),
              components.count == 6 else {
            return nil
        }
        
        self.init(baseColorComponents: [
                    components[0][0], components[0][1], components[0][2]
                  ],
                  centerBaseColorComponents: [
                    [components[1][0], components[1][1], components[1][2]],
                    [components[2][0], components[2][1], components[2][2]]
                  ],
                  smoothRingColorComponents: [
                    components[3][0], components[3][1], components[3][2]
                  ],
                  roughRingColorComponents: [
                    [components[4][0], components[4][1], components[4][2]],
                    [components[5][0], components[5][1], components[5][2]]
                  ])
    }

iOS14から追加された CIFilterCIKMeans を使用してイメージの代表色を抽出してみる。k-means法で代表色を抽出するアルゴリズムらしい。

6色必要なので "inputMeans" に代表的な6色を与え、 filter.outputImage が 6 x 1で得られるような属性にする。
属性はこちらのURLを参考にした。

LumberColorSet.swift
    private func colorSampleImage(count: Int) -> CGImage? {
        
        let inputImage = CIImage(cgImage: self)

        guard let filter = CIFilter(name: "CIKMeans") else { return nil }
        filter.setDefaults()
        filter.setValue(inputImage, forKey: kCIInputImageKey)
        filter.setValue(inputImage.extent, forKey: kCIInputExtentKey)
        filter.setValue(64, forKey: "inputCount")
        filter.setValue(10, forKey: "inputPasses")
        let seeds = [CIColor(red: 0, green: 0, blue: 0),    // black
                     CIColor(red: 1, green: 0, blue: 0),    // red
                     CIColor(red: 0, green: 1, blue: 0),    // green
                     CIColor(red: 1, green: 1, blue: 0),    // yellow
                     CIColor(red: 0, green: 0, blue: 1),    // blue
                     CIColor(red: 1, green: 1, blue: 1)]    // white
        filter.setValue(seeds, forKey: "inputMeans")
        guard let outputImage = filter.outputImage else { return nil }

        let context = CIContext(options: nil)
        return context.createCGImage(outputImage, from: CGRect(origin: .zero, size: outputImage.extent.size))
    }

上記で取得した6 x 1のイメージから1ピクセルずつRGBを取得する。

LumberColorSet.swift
    private func getPixelColors(count: Int) -> [[CGFloat]] {
        
        var components: [[CGFloat]] = []
        guard let importantColorImage = colorSampleImage(count: count) else { return components }
        
        (0...count).forEach { index in
            
            let scale: CGFloat = 1
            let rect = CGRect(x: CGFloat(index) * scale, y: 0, width: scale, height: scale)
            if let cropImage = importantColorImage.cropping(to: rect),
               let color = cropImage.averageColor() {
                
                var r: CGFloat = 0
                var g: CGFloat = 0
                var b: CGFloat = 0
                var a: CGFloat = 0
                color.getRed(&r, green: &g, blue: &b, alpha: &a)
                components.append([r, g, b])
            }
        }
        return components
    }

1 x 1のイメージから平均色を抽出する。

    private func averageColor() -> UIColor? {

        let inputImage = CIImage(cgImage: self)

        guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: inputImage.extent]) else { return nil }
        guard let outputImage = filter.outputImage else { return nil }

        var bitmap = [UInt8](repeating: 0, count: 4)
        let context = CIContext(options: nil)
        context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)

        return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
    }

クリスマスツリーから抽出した結果
*適当に拾ったフリー素材
xmas_tree.png

抽出した結果は以下の色になった。
#063902 #241410 #070707 #080808 #050505 #797979

参考にしたサイトでも言及されている通り抽出した色はやや意図通りではなくk-means法による?補正があるようなので、以下で彩度と明度を調整してみる。

let color = cropImage.averageColor()?.color(mimimumBrightness: 0.5).color(mimimumSaturation: 0.5)
extension UIColor {
    
    func color(mimimumBrightness: CGFloat) -> UIColor {
        
        var h: CGFloat = 0
        var s: CGFloat = 0
        var b: CGFloat = 0
        var a: CGFloat = 0
        getHue(&h, saturation: &s, brightness: &b, alpha: &a)
        
        if b < mimimumBrightness {
            return UIColor(hue: h, saturation: s, brightness: mimimumBrightness, alpha: a)
        }
        return self
    }
    
    func color(mimimumSaturation: CGFloat) -> UIColor {
        
        var h: CGFloat = 0
        var s: CGFloat = 0
        var b: CGFloat = 0
        var a: CGFloat = 0
        getHue(&h, saturation: &s, brightness: &b, alpha: &a)
        
        if s < mimimumSaturation {
            return UIColor(hue: h, saturation: mimimumSaturation, brightness: b, alpha: a)
        }
        return self
    }
}

明度のみ調整した結果
#0D8004 #804739 #808080 #808080 #808080 #808080

明度も彩度も調整した結果
#0D8004 #804739 #804040 #804040 #804040 #804040

うーん、、、あまり期待値は得られなかった。
figure22-1.png

明らかに取れそうなイメージで試してみる。
ラスタカラー。
rasta.jpg

#010101 #560200 #035500 #545400 #000000 #000000
明度・彩度を弄った色
#804040 #800300 #058000 #808000 #804040 #804040

なんとなく近い色は取れたけどやはり意図しない補正がかかってしまう。
figure22-2.png

杉や赤松など他の木目イメージから色をサンプリングして適用してみたけど、
あまり良い結果が得られなかったので割愛する。。

感想

コードで木目という有機的なものを描こうと思ったことはなかった。
規則性を見出すことでコードに落とし込める発見があるのと、よりリアルに近づけていくにはどういうイレギュラーを加えれば良いのか試行錯誤するのが楽しかった。
木の節はどこかに入れてみたかったが難しくて断念した。

24
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?