ViewとLayerだけで球体をデッサンしてみた。
デッサンはもう20年くらいやってないけど、当時の記憶を元にどういう表現が必要だったか思い出しつつ、それをレイヤーとして重ねることで表現してみたかったのでやってみた。
ボブ・ロスの絵画教室的なノリで記事にしたかったけど演出とか忘れたので普通に書く。
何を言ってるのかわからない可能性があるので完成したものを貼っておく。
これをView(Layer)の重なりだけで表現するという話。
- Xcode 11.1
- Swift 5
- サンプルプロジェクト https://github.com/yumemi-ajike/Atelier
AutoLayoutはちゃんと対応しておきたいという前提でViewベースで進めることにした。Layerベースだとレイアウト周りが異常にめんどくさいので。
球体の輪郭
まずベースとなるViewを240x240で中心に配置する。
デッサン的にはキャンバスに対しての物体の美しい構図を決めるという作業があるけど、
キャンバス(画面サイズ)はいろいろサイズや比率が変わるのでそこは完全に無視する。
物体の色は何でもいいけど彩度高めでQiitaっぽい UIColor.green
を指定。
このViewの中に様々な表現を加えていくので clipsToBounds = true
してはみ出さないように設定しておく。
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
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),
...
])
ただの円
暗い部分
レイヤーの重なり順の関係もあるので全体の明暗の基礎となる暗い部分を追加する。
下から球体よりやや大きめの円で放射状に黒いグラデーションをかけることにより面が湾曲しているように表現する。
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によってグラデーションのかけ方、色や中心点、半径や位置などを設定できるようにしている。
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: [])
}
}
}
}
まだ立体感ないしベタッと貼りついてる感じがする。
回り込み
球体の周囲から背景へと抜けるイメージを表現するため、中心から外側にかけてグラデーションをかけ回り込みを表現する。
中心を0%として80%〜100%に対して入れることで円の内側にかかる感じになる。
但し、球体の底面は接地面になるので微妙にサイズを大きくして下方向にずらすことで底面にはあまりかからないように調整している。
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に付くようにしている。
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),
わかりやすく示すと球体の円に対してこのようにグラデーションをかけることによって底面に掛けて徐々に効果をなくすようにしている。
ちょっと立体感でてきたけど、まだ接地面がないため宙に浮いているしあまり球体の感じもしない。
陰影
球体の影を再現することで接地面を表現する。
影はCATransform3Dでperspectiveをかけ、影自体の描写は放射状のグラデーションを描画する。
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
の指定でパースのかかり具合をコントロールしているが、分母を大きくするとかかり方は弱くなる。数値は適当に合わせた。
例として shadowTransform.m34 = -1.0 / 3000
だとパースのかかり方が弱くなる。影だけ見てもボヤッとした円形なのでパースのかかり具合の違いが分かりにくいけど。
レイアウトはY軸の中心が球体の底面に来るようにしている。
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),
ぽくなってきたけど質感が見えてこないしまだちょっと球体には見えない。
反射光
設置面があって球体の表面がツルっとした質感の場合、設置面の明るさや色合いが球体の底面に反射して映り込む現象がある。
この表現のために球体の下から上にかけて白でグラデーションをかける。
リニアにかけてるけど放射状でも良いかもしれない。
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)
効果がわかりにくいけど底面が若干白ががっている。
映り込み1
実際にはガラスやプラスティックのような表面がツルツルした質感の場合は物体が存在する場所の周りの景色が微かに写りこむ。
これを表現するのはデジタルな世界では難しいがそれっぽい表現を行うしかない。よくある表現として物体が屋内にあると仮定してその壁面や天井面が映り込んでいる表現がある。
球体が存在する屋内の壁面が球体の面に沿って映り込むようにしたいため、
薄い黒でリニアにグラデーションをかけ、maskして反転することで映った壁を描いている。
// 映り込みの表現
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)
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することによってくり抜いている形。楕円にすることで描画内容を球体の面にのせる。
映り込みの効果としては弱い。。
波平っぽい効果としては高い。。
映り込み2
前述の映り込み1だとリアル感がなくてイマイチ。
しかし想像できない場所にある物体への映り込みの表現にはやはり限界がある。
そこで写真を元に魚眼レンズのような効果があるCIFilterをかけて映り込みを表現するようにしてみた。
数値は適当に合わせている。
(デッサンすると言っておきながら写真使うのでちょっとチートっぽいけどw)
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),
])
}
元のイメージはこれ(家のキッチンカウンター)。
ちょうどいい感じの効果が出る写真を見繕うのに割と時間を要した。
あまり映り込みすぎると球体の質感が鏡面ぽくなってしまうのでフィルタしたイメージに放射状のグラデーションマスクすることによって端に行くに従って消えていくようにする。
他の表現の邪魔になってしまうのでさらに透明度を上げて効果を薄くする。
ハイライト
最後にもっとも強く光が当たる明るい部分を入れる。レイヤーの重なり順序的に最後に入れる。
白い放射状のグラデーションをパースをかけることで球体の上面にのせるイメージ。CATransform3Dじゃなくてただの楕円でも良いかも。
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)
ハイライトが入ったらグッと締まった。
けど波平感に拍車が掛かった。
調整
ベースカラーを変えて調整してみる。アナログのデッサンではできない調整方法。
ベースカラーの色味は関係ないけど、明度が変わることにより上に重ねた効果が本当に適切なのか見ることができる。
ややハイライト部分のグラデーションがキツい印象がある。
70%までしかかからないようにしていたグラデーションを100%までに変更してみる。
-- topGradientView.locations = [0, 0.5, 0.7]
++ topGradientView.locations = [0, 0.5, 1]
ハイライト部分はいい感じになったけど、
映り込み1の表現がやや主張しすぎているように感じてきた。
グレイに変える。
-- 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)]
いい感じになった。
.green
に戻してみる。
最後に色味だけちょっとクリスマス感出せた。
感想
グラフィックアプリでの作業をコードベースにしただけの話ではあるんだろうけど、コードになってることでダイナミックに変更可能だし、リソース容量も食わない。ユーザによるカスタマイズが可能になる部分も出てくる。
デメリットとしては描画に時間がかかる場合があるのでパフォーマンス面はシビアかも。
グラフィックアプリはポインタをピクセル単位で動かして作業していくので案外手も目もやられる。アプリによっては操作方法が異なったり出来なかったりがあるので厄介なこともある。
何を描くべきかわかっていたら意外とコードでやってしまった方が楽なことは結構ある気がしている。