Following Last year's sphere drawing, I tried to draw a cube this year. This time, I was particular about drawing with genuine ** complete coding ** without using any photos or images. (Last time I used a cheat technique that distorts and fits some photos)
From the result, it looks like this.
Does it look like a wooden square?
I mainly use CoreAnimation``
CoreGraphics`` CoreImage`.
The project is up below. https://github.com/yumemi-ajike/Cube
Define front, back, top, bottom, left side, and right side as CALayer, and add them as squares. The size was appropriately set to 200 on each side.
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)
...
}
The front moves 100 in the Z direction.
let transform = CATransform3DMakeTranslation(0, 0, size / 2)
The right side moves 100 in the X direction, and the Y axis is rotated CGFloat.pi / 2
, that is, 90 °.
var transform = CATransform3DMakeTranslation(size / 2, 0, 0)
transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 0, 1, 0)
Move -100 in the Y direction and rotate the X axis 90 °.
var transform = CATransform3DMakeTranslation(0, -size / 2, 0)
transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 1, 0, 0)
The left side moves -100 in the X direction, which is the opposite of the right side, and the Y axis is rotated by -90 °.
var transform = CATransform3DMakeTranslation(-size / 2, 0, 0)
transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 0, 1, 0)
The bottom surface moves 100 in the Y direction, which is the opposite of the top surface, and the X axis is rotated by -90 °.
var transform = CATransform3DMakeTranslation(0, size / 2, 0)
transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 1, 0, 0)
The back side moves -100 in the Z direction, and the Y axis is rotated CG Float.pi
, that is, 180 °.
var transform = CATransform3DMakeTranslation(0, 0, -size / 2)
transform = CATransform3DRotate(transform, CGFloat.pi , 0, 1, 0)
If you execute the build at this point, only the front (= frontLayer) will be visible.
Adjust the overall angle because it needs to look recognizable as a cube.
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
Transform baseLayer, which is the parent of all 6 sides. If you tilt the Y-axis by -30 °, the X-axis by -30 °, and the Z-axis by 15 °, it looks like a cube. By the way, if you tilt it, the line will be drawn at a delicate angle and it will be jagged and dirty, so apply antialiasing to each layer.
layer.allowsEdgeAntialiasing = true
At this rate, the perspective is not working, so the depth cannot be felt and it is not possible to determine which direction it is in.
transform.m34 = -1.0 / 1000
By entering a value in CATransform3D.m34, it will be parsed and you will be able to see in what direction it exists. The value is appropriate.
Now it looks like a cube. The balance that can be seen on each side is also good.
Color the visible front, top, and right sides with 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
}
Assuming that the light source is slightly on the upper right The front is 40% → 60% from top to bottom, The right side is 60% → 80% from top to bottom, The upper surface is 100% → 80% white from the back to the front with a gray gradation. Brightening the front and right sides downward is the expression that the reflected light from the ground plane hits and brightens.
Add GroundView
after CubeView
.
The background/ground (= GroundView) is defined as a layer separate from the cube (= CubeView) to improve the reusability of the cube itself.
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
}
}
Now that the background/ground is created, cast the shadow of the cube. There seems to be some controversy over whether shadows should be in the background/ground or cube View hierarchy. I want the shadow to have the same perspective as the cube, so I decided to add it to the baseLayer of CubeView, which seems to require only one description of transform.
I made a trial and error on how to express the shadow, but it was difficult to create a shadow from each side of the cube, so I decided to set up a completely separate side as a shadow.
Add a shadow base layer so that it spreads toward you.
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)
...
}
Drops the shadow gradation.
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)
...
}
Clip the shape of the shadow with a path.
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
...
By clipping the gradation with such a path, it looks like a shadow.
I don't know what kind of cube it is, but I can't deny the feeling that the CG is exposed because it's realistic, so I'll make details.
By pursuing details such as the shape of the cube, we will increase the persuasive power of the picture.
The part where each surface touches at a right angle is so sharp that it cannot be a real object. At this point, the material has not been clarified yet, but if it is a real thing, metal, wood, or plastic should always have a slight rounded corner, so I will drop the corner.
Since you want to round the corners and clip the content, set CAGradientLayer
to a sublayer of CALayer
and set 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
}
Next, place a white → transparent gradation as a highlight on the part where each of the front/top/right side touches.
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)
...
}
If you set cornerRadius to an exaggerated value, it will look like a dice like this.
Since the shape of the cube does not match the shadow, modify the path of shadowShapeLayer
to the shape of the shadow that matches the cube.
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
}()
Make the rounded corners smaller to make them less noticeable.
By drawing the shadow of the ground plane of the cube firmly, it is possible to express that the object is in contact with the ground surface, and the image can be tightened.
Let's realize it by setting shadow on bottomLayer
which is the bottom of the cube. .. ..
At first glance, it doesn't look strange, but if you look closely, the shadow on the left edge of the screen is strange. Another problem arises that the shadows are too dark even though the corners should be rounded.
When shadow is set to bottomLayer
by adding the surface shadowBaseLayer
that is the target of shadow to the sublayer of bottomLayer
and adding the background color, the shadow will be cast only on this 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
expresses that the circumference of the bottom surface is not grounded by laying out only the rounded corners inside the rectangle of bottomLayer
. It's the same as the area around the dice placed on the table is not exactly grounded.
Comparison before and after making
highlight/No shadow | highlight/With shadow |
---|---|
The realism of the picture has improved a little. The shape and condition have become easier to convey, but I don't know the material or texture of what it is made of, so it's an NG step as a drawing.
By drawing the material of the cube, the texture is expressed and the persuasive power is further increased.
If it is made of glass, it is necessary to express the reflection of the surrounding landscape where the cube is placed, and it seems difficult to express this with only the code. Also, I'm likely to use photos like last time. The surface of the plastic product is also smooth, so it is necessary to express the reflection and it is not tasteful. The same applies to metal. It is necessary to express the scratches and dirt on the surface of plaster, and it is harsh to express this with a code.
How about a tree? Since the annual rings have some regularity, I decided to draw the grain of the square lumber because it might be possible to express it.
First, prepare to put a texture on each side.
Change the color specification of the gradation added to the sublayer of each surface from grayscale to white alpha expression,
Allow CGImage
to be included in CALayer.contents on each side.
CubeView.swift
lazy var frontLayer: CALayer = {
...
return createGradientFaceLayer(with: transform,
colors: [UIColor(white: 0, alpha: 0.6),
UIColor(white: 0, alpha: 0.4)])
}()
Added LumberTexture
class.
An object that manages the color of trees, the spacing between annual rings, the width, the darkness, and the color, and finally spits out CGImage.
Dimension to set the spit Image on each layer.
By design
The angle and shading of the cube are managed by CubeView
, and LumberTexture
manages only the texture information, so it should be an object that generates images of all 6 sides, but due to time constraints. I made only the three visible sides.
LumberTexture.swift
final class LumberTexture {
//Annual rings
struct Ring {
let distance: CGFloat //interval
let width: CGFloat //width
let depth: CGFloat //Darkness
let colorComponents: [CGFloat] //color
}
//Length of one side
let side: CGFloat
//Wood color
let baseColorComponents: [CGFloat] = [(226 / 255), (193 / 255), (146 / 255)]
//Fine annual ring color RGB
let smoothRingColorComponents: [CGFloat] = [(199 / 255), (173 / 255), (122 / 255)]
//Rough annual ring color RGB
let roughRingColorComponents: [[CGFloat]] = [
[(176 / 255), (106 / 255), (71 / 255)],
[(194 / 255), (158 / 255), (96 / 255)],
]
//Detailed annual ring information
private var roughRings: [Ring] = []
//Rough annual ring information
private var smoothRings: [Ring] = []
}
Fine annual rings are randomly prepared for the length of the diagonal line with a narrow feeling/narrow width. The color specifies a color that is less noticeable than the color of wood. The color is RGB sampled from the cypress wood that came out by googled appropriately.
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
}
Rough annual rings have a wide spacing/wide width to specify a prominent color for the wood color.
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
}
The top and side sides have different images, but if they do not have the same annual ring width, the Tsuji 褄 will not match, so the information on the annual rings is retained. Get an image Prepare a func and draw an annual ring with a pass like this.
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()
}
Since it is randomly generated, the pattern will be different each time, but the image on the top will look like this. Baumkuchen.
The grain of wood that comes to the front is a shape in which this part of the upper surface is stretched and falls vertically.
Draw the grain of the front from the information of the annual rings that you hold.
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()
}
...
}
Draw from top to bottom with the same annual ring information as the top surface.
However, it is unnatural that all annual rings are drawn in parallel/vertical like an artificial object, so
Use CITwirlDistortion
of CIFilter
to distort a little.
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
}
Try to fit the image on the top and front.
The expression here is very difficult. .. Since it is necessary to express the cross sections of the annual rings on the upper surface and the front surface at the same time, and the distortion is also provided on the front surface, it seems that a considerably complicated calculation is required if all are expressed by paths. I didn't have any knowledge or man-hours, so I managed to make a mistake by trial and error.
First, draw the annual ring, which is the cross section of the upper surface. The pattern that continues from here must be generated.
Tiling from the right edge 1px of the top image to create an image rotated 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
}
Next, draw the annual ring, which is the cross section of the front surface. It is necessary to generate a pattern that continues from here, but it is a little complicated due to the distorted effect.
This also creates an image by tiling from the right end 1px of the image.
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
}
According to the upper cross section | According to the front cross section |
---|---|
It looks good at first glance, but it doesn't match the distorted part on the front. .. | The part that continues from the distortion on the front seems to be good here. .. |
From a 3D perspective, the front cross section is a little, and the rest should be a basic upper cross section pattern? Like this? It's a little unnatural, but is it okay?
The front section image with gradation mask is combined with the upper section image.
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
}
I'm not so confident, but I cheated like this.
Each time the wood grain is drawn randomly, it will have a different width and color thickness. If you can continue the grain you like, it will help you to compare when you make it.
Make the Ring
structure Codable.
LumberTexture.swift
final class LumberTexture {
struct Ring: Codable {
let distance: CGFloat
let width: CGFloat
let depth: CGFloat
let colorComponents: [CGFloat]
}
...
Add a save function to the part that holds the information of annual rings.
Since Ring
is set to Codable, read/write to UserDefaults as it is using JSONDecoder/JSONEncoder.
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
}
//Generation process
// Save rings to UserDefaults
UserDefaults.standard.setValue(try? JSONEncoder().encode(smoothRings), forKeyPath: "SmoothRings")
return smoothRings
}
Recreate the grain only when you tap it.
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()
}
}
With this, the grain will not be updated unless you tap it.
Adjust the brightness because the highlight of the part in contact with the upper surface is too strong.
--- 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
Check it in grayscale.
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)
+ }
+}
There seems to be no problem with brightness. I want to make the wood grain a little more realistic.
Since the width of the color that is the base of the wood grain is monochromatic, it gives a monotonous impression, and the color seems a little dark, so increase the number of colors.
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)],
]
...
Add a two-color gradation with a width of 80% to the base drawing part of the annual ring drawing part on the upper surface to make it look natural.
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])
}
...
}
The drawing on the front is also changed to follow the change on the top.
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])
}
...
}
Adjust the color because some colors of thick annual rings are too conspicuous
LumberTexture.swift
let roughRingColorComponents: [[CGFloat]] = [
[(176 / 255), (130 / 255), (71 / 255)], <-
[(194 / 255), (158 / 255), (96 / 255)],
]
Top surface | Front | right side |
---|---|---|
Before | After |
---|---|
If you look closely at the wood, you may find that there are some scratches left on it when it is processed differently from the wood fibers. It seems that the rest is left by cutting in the direction against the fibers of the tree. Draw slightly artificially at regular intervals.
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()
}
Draw so that the effect gradually disappears from the back to the front of the top surface. It looks like this when drawn in red for easy understanding.
Before | After |
---|---|
The effect is not high, but a color width and a new direction have been added to the upper surface.
Make it customizable by allowing color information to be entered from the outside.
Allows you to define and specify LumberColorSet
as a structure.
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()
}
Let's define the Christmas color.
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
Repeat tapping until you get a nice pattern. It might be a little scary. It looks like a watermelon.
Since it is troublesome to define RGB, I wrote a code to extract typical colors from an image.
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]]
])
}
Try to extract the representative color of the image using CIKMeans
of CIFilter
added from iOS14. It seems to be an algorithm that extracts representative colors by the k-means method.
Since 6 colors are required, give " inputMeans "
6 typical colors and make it an attribute so that filter.outputImage
can be obtained by 6 x 1.
For the attributes, I referred to this 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))
}
Get RGB pixel by pixel from the 6 x 1 image obtained above.
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
}
Extract the average color from a 1 x 1 image.
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)
}
Results extracted from the Christmas tree
The extracted results have the following colors.
#063902
#241410
#070707
#080808
#050505
#797979
As mentioned in the referenced site, the extracted colors are not as intended, but by the k-means method? There seems to be a correction, so let's adjust the saturation and brightness below.
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
}
}
The result of adjusting only the brightness
#0D8004
#804739
#808080
#808080
#808080
#808080
The result of adjusting both brightness and saturation
#0D8004
#804739
#804040
#804040
#804040
#804040
Hmmm ... I didn't get much expected value.
I will try it with an image that seems to be clear. Raster color.
#010101
#560200
#035500
#545400
#000000
#000000
Colors that play with lightness and saturation
#804040
#800300
#058000
#808000
#804040
#804040
Somehow I got a similar color, but after all unintended correction is applied.
I sampled colors from other wood grain images such as cedar and red pine and applied them. I didn't get very good results, so I will omit it. ..
I never thought of drawing an organic thing called wood grain with code. It was fun to make trial and error as to what kind of irregularity should be added to make it more realistic, as there were discoveries that could be incorporated into the code by finding regularity. I wanted to put the knots of the tree somewhere, but I gave up because it was difficult.
Recommended Posts