昨年の球体デッサンに続き今年は立方体をデッサンしてみた。
今回は写真や画像を一切使わない正真正銘完全コーディングによるデッサンに拘った。(前回は一部写真を歪めてはめ込むチート技を使ってしまった)
木の角材に見えるかな?
主に CoreAnimation
CoreGraphics
CoreImage
を利用している。
プロジェクトは以下にアップした。
https://github.com/yumemi-ajike/Cube
- Xcode 12.2
- Swift 5.1
立方体の面
前面、背面、上面、底面、左側面、右側面のそれぞれをCALayerとして定義、正方形で追加する。
サイズは適当に一辺200とした。
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
}
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)しか見えていない状態となる。
立方体として認識できる見栄えにする必要があるため、全体の角度を調整する。
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
このままだとパースペクティブが効いていないため奥行きが感じられずどっち方向にあるのか判別できない。
transform.m34 = -1.0 / 1000
CATransform3D.m34 に値を入れることでパースがかかりどういう方向に存在しているのかわかるようになる。値は適当。
しっかり立方体に見えるようになった。
各面の見えるバランスも良さげ。
面を作る
グラデーションで面の微妙な明暗を表現する。
見えている前面、上面、右面を CAGradientLayer
にして色を付ける。
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)])
}()
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%の白地にグレーのグラデーションがかかるようにしている。
前面と右面を下方向に明るくしているのは接地面の反射光が当たり明るくなっているという表現。
背景/地面を作る
CubeView
の後ろに GroundView
を追加する。
背景/地面(=GroundView)は立方体(=CubeView)とは別の階層として定義し、立方体そのものの再利用性を高めている。
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
}
}
影を落とす
背景/地面ができたので立方体の影を落とす。
影は背景/地面 or 立方体どちらのView階層に存在すべきか議論が分かれそうだが、
影も立方体と同じパース具合にしたいのでtransformの記述が1箇所で済みそうなCubeViewのbaseLayerに追加することにした。
どうやって影を表現するか試行錯誤したけど、立方体の各面から影を作り出すことが難しかったので結局影としての面を全く別に設けることにした。
影のベースレイヤーを手前に広がるように追加する。
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
}()
override func layoutSubviews() {
super.layoutSubviews()
...
baseLayer.addSublayer(shadowLayer)
...
}
影となるグラデーションを落とす。
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
}()
override func layoutSubviews() {
super.layoutSubviews()
shadowLayer.addSublayer(shadowGradientLayer)
...
}
影の形をパスでクリップする。
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
}()
override func layoutSubviews() {
super.layoutSubviews()
...
shadowGradientLayer.mask = shadowShapeLayer
...
グラデーションに対してこのようなパスでクリップすることで影にみせかける。
それらしくなっているが何の立方体かわからないし、リアルさにかけるためCG丸出し感が否めないので詳細を作り込んでいく。
形状の作り込み
立方体の形状などディティールを追求していくことで画としての説得力を上げていく。
角を落とす
各面同士が直角に接している部分が現実の物体ではありえないほど鋭い。
この時点ではまだ素材は明らかになっていないが現実のものなら金属でも木材でもプラスティックでも必ずわずかな角の丸みがあるはずなので角を落としていく。
角を丸めて内容をクリップしたいため CAGradientLayer
を CALayer
の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を大袈裟な値にするとこんな感じでサイコロのようになる。
立方体の形状と影が合わないため、立方体に合った影の形に 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
}()
接地面の影
立方体の接地面の影をしっかり描くことで物体がある面に接地していることをより表現できる上に、画にパシッと締りができる。
立方体の底面である bottomLayer
にshadowを設定することで実現してみる。。。
一見、おかしくないようだがよく見ると画面左端の影の落ち方がおかしい。
また角を丸めているはずなのに影が濃すぎるという問題も浮かび上がる。
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
}()
shadowBaseLayer
は bottomLayer
の矩形に対して角丸分だけ内側にレイアウトされるようにすることで底面の周囲が接地していない表現を行う。台に置かれたサイコロの周囲がぴったり接地していないのと同じ。
一旦、作り込み前後の比較
ハイライト/影なし | ハイライト/影あり |
---|---|
画のリアル感はやや向上した。形状や状態がより伝わりやすくもなったが、如何せん何でできているのか素材や質感が全くわからないのでデッサンとしてはNGもう一歩。
素材や質感
立方体の素材を描くことによって質感を表現し、さらに説得力を上げていく。
ガラス製だと立方体が置かれている周りの風景の映り込みを表現する必要があり、これをコードのみで表現するのは難しそう。また前回のように写真を使ってしまいそうだ。
プラスティック製も表面がツルツルしているため、映り込みの表現が必要だしなんか味気ない。
金属製も同じく。
石膏は表面についた傷や汚れなどを表現する必要があり、これもコードで表現するのは過酷。
木ならどうか。
年輪はある程度規則性があるので表現できるかもしれないということで角材の木目を描いてみることにした。
準備
まずは各面にテクスチャを貼る準備。
各面のsublayerに加えたグラデーションの色指定をグレースケールから白のアルファでの表現に変更して、
各面のCALayer.contentsに CGImage
を入れられるようにする。
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面だけにした。
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。
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
}
荒い年輪は広い間隔/太い幅も入るようにして木の色に対して目立つ色を指定している。
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を用意してこんな感じでパスで年輪を描く。
上面のイメージ
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()
}
ランダムで生成しているので毎回異なる模様になるけど大体上面のイメージはこんな感じになる。
バームクーヘン。
前面のイメージ
前面にくる木目は上面のこの部分を引き伸ばしたものが垂直に落ちる形になる。
保持している年輪の情報から前面の木目を描く。
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()
}
...
}
但し、人工物のように全ての年輪が並行/垂直に描かれるのは不自然なため、
CIFilter
の CITwirlDistortion
を使って少し歪める。
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
}
右側面のイメージ
ここの表現が非常に難しい。。
上面と前面の年輪の断面を同時に表現する必要があってさらに前面には歪みも設けているため、全てパスで表現すると相当複雑な計算が必要になると思われる。
知識もないし工数もないのでなんとか試行錯誤して誤魔化すことにした。
まずは上面の断面である年輪を描く。
ここから続く模様を生成しなければならない。
上面イメージの右端1pxからタイリングして90°回転したイメージを作り出す。
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
}
次に前面の断面である年輪を描く。
ここから続く模様を生成しなければならないが、歪めた影響でやや複雑。
こちらも同じくイメージの右端1pxからタイリングしてイメージを作り出す。
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
}
上断面に合わせると | 前断面に合わせると |
---|---|
一見良さそうだが、前面の歪み部分と合ってこない。。 | 前面の歪みから続く部分はこちらで良さそうだが。。 |
3D的に考えると前断面は少しで、あとは基本 上断面模様になるはず?こんな感じ?やや不自然だがまあいいか。
上断面イメージにグラデーションマスクした前断面イメージを合成する。
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()
}
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
}
木目の保存
木目の描画は毎回ランダムで別の幅や色太さになる。
気に入った木目を継続できるようにすれば作り込み時の比較にも一役買いそう。
Ring
構造体をCodableにする。
final class LumberTexture {
struct Ring: Codable {
let distance: CGFloat
let width: CGFloat
let depth: CGFloat
let colorComponents: [CGFloat]
}
...
年輪の情報を保持している部分に保存機能を付ける。
Ring
をCodableにしたため、JSONDecoder/JSONEncoderを使ってそのままUserDefaultsに読み書きする。
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
}
タップした時にのみ木目を作り直すようにする。
override func layoutSubviews() {
super.layoutSubviews()
...
updateTexture()
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(updateTextureAction)))
}
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()
}
final class LumberTexture {
...
func updateRings() {
UserDefaults.standard.removeObject(forKey: "SmoothRings")
UserDefaults.standard.removeObject(forKey: "RoughRings")
self.smoothRings = createSmoothRings()
self.roughRings = createRoughRings()
}
}
これでタップしない限り木目が更新されない形になる。
ハイライトの調整
--- 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
グレースケールにして確認してみる。
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)
+ }
+}
明度の問題はなさそう。
もう少し木目のリアル感を上げ作り込みたい。
木目のリアル感を向上させる
木目のベースとなる色の幅が単色なためか単調な印象があるのと、少し色が暗い感じがするので色数を増やす。
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色のグラデーションを追加して自然に見せる。
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])
}
...
}
上面の変更に追随するように前面の描画も併せて変更する。
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])
}
...
}
太い年輪の一部の色が目立ちすぎているため色味を調整する
let roughRingColorComponents: [[CGFloat]] = [
[(176 / 255), (130 / 255), (71 / 255)], <-
[(194 / 255), (158 / 255), (96 / 255)],
]
上面 | 前面 | 右側面 |
---|---|---|
Before | After |
---|---|
- 基本色が明るくなった
- 細かい年輪がより目立つようになった
- 木目の色に幅ができた
切断跡
木材をよく観察すると木の繊維とは別の加工する際についた傷が残っていることがある。
木の繊維に逆らう方向に切ることにより後が残ると思われる。
やや人工的に等間隔で描く。
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()
}
上面の奥から手前に徐々に効果がなくなるように描画。
わかりやすく赤で描画するとこんな感じ。
Before | After |
---|---|
効果は高くないが上面に色幅と新たな方向が加わった。
遊ぶ
タップして木目を切り替える
色情報の定義を分離する
色情報を外から入れられるようにすることでカスタム可能な作りにする。
構造体として LumberColorSet
を定義して指定できるようにする。
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))],
])
}
}
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()
}
クリスマスカラー
クリスマスカラーを定義してみる。
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
いい感じの模様になるまでタップを繰り返す。なんかちょっと怖いかも。スイカっぽいし。
画像から色を抽出
RGBを定義するのが面倒なので画像から代表的な色を抽出するコードを書いてみた。
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から追加された CIFilter
の CIKMeans
を使用してイメージの代表色を抽出してみる。k-means法で代表色を抽出するアルゴリズムらしい。
6色必要なので "inputMeans"
に代表的な6色を与え、 filter.outputImage
が 6 x 1で得られるような属性にする。
属性はこちらのURLを参考にした。
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を取得する。
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)
}
抽出した結果は以下の色になった。
#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
#010101
#560200
#035500
#545400
#000000
#000000
明度・彩度を弄った色
#804040
#800300
#058000
#808000
#804040
#804040
なんとなく近い色は取れたけどやはり意図しない補正がかかってしまう。
杉や赤松など他の木目イメージから色をサンプリングして適用してみたけど、
あまり良い結果が得られなかったので割愛する。。
感想
コードで木目という有機的なものを描こうと思ったことはなかった。
規則性を見出すことでコードに落とし込める発見があるのと、よりリアルに近づけていくにはどういうイレギュラーを加えれば良いのか試行錯誤するのが楽しかった。
木の節はどこかに入れてみたかったが難しくて断念した。