https://developer.apple.com/documentation/coregraphics/cgmutablepath/
프로젝트 작업 중 UI 구현하기를 제일 재미없어 하는 내가, 코드로 그림을 그리는 걸 공부하게 될 줄이야. 살다보면 이런 피하고 싶은 영역도 맞닥뜨린다. 오늘은 화면에 띄울 도형(shape
)에 입힐 경로(path
)를 정의하는 방법을 정리하려 한다. CGMutablePath
를 이용해 경로를 만들고, SKShapeNode
에 입혀 길을 그린다. 노드의 개념과 화면에 띄우는 방법은 SpriteKit을 공부하면 알 수 있다.
CGMutablePath
는 Core Graphics
프레임워크에서 제공하는 클래스로, 2D 그래픽 경로(path)를 생성하고 수정할 수 있는 객체다. 벡터 기반의 그래픽 경로를 동적으로 구성할 수 있게 해주며 직선, 곡선, 호(부채꼴) 등 다양한 형태의 경로 요소들을 조합하여 복잡한 도형을 만들 수 있다. 이름에서도 말하듯 CGPath
의 가변(mutable) 버전 하위 클래스로, 생성 후에도 경로를 추가하거나 수정할 수 있다.
경로를 생성하기 위해서는 인스턴스를 생성하고 시작점을 설정해준 뒤 경로를 추가하면 된다. 우선 가장 이해하기 쉽게 직선 경로부터 보겠다.
let path = CGMutablePath() // 인스턴스 생성
path.move(to: CGPoint(x: 100, y: 100)) // 시작점 설정
path.addLine(to: CGPoint(x: 200, y: 100)) // 경로 추가 1
path.addLine(to: CGPoint(x: 150, y: 200)) // 경로 추가 2
CGPoint
로 되어있는 경유 지점을 따라 선을 그리며 경로가 만들어지는 것이다. 예시 코드를 SKShapeNode
에 입혀 결과를 확인해보겠다.
let shape = SKShapeNode(path: path)
shape.lineWidth = 10 // 선 굵기
shape.strokeColor = .blue // 선 색상
addChild(shape)
경로를 닫아주는 메소드로, 호출할 경우 시작점과 마지막 점이 이어진다.
let path = CGMutablePath() // 인스턴스 생성
path.move(to: CGPoint(x: 100, y: 100)) // 시작점 설정
path.addLine(to: CGPoint(x: 200, y: 100)) // 경로 추가 1
path.addLine(to: CGPoint(x: 150, y: 200)) // 경로 추가 2
path.closeSubpath() // 경로 닫기
곡선 경로에는 두가지가 있는데 아치 모양
의 2차 베지어 곡선(Quadratic Curve
)와 S자 모양
의 3차 베지어 곡선(Cubic Curve
)이 있다.
우선 2차 베지어 곡선을 만들기 위해서는 시작점과 끝점, 그리고 그 사이에 제어점이 필요하다.
let path = CGMutablePath()
path.move(to: CGPoint(x: 50, y: 200)) // 시작점
path.addQuadCurve(
to: CGPoint(x: 250, y: 200), // 끝점
control: CGPoint(x: 150, y: 100) // 제어점
)
3차 베지어 곡선은 제어점이 하나 더 필요하다.
let path = CGMutablePath()
path.move(to: CGPoint(x: 50, y: 150)) // 시작점
path.addCurve(
to: CGPoint(x: 250, y: 150), // 끝점
control1: CGPoint(x: 100, y: 50), // 제어점 1
control2: CGPoint(x: 200, y: 250) // 제어점 2
)
이 외에도 여러가지 도형도 그릴 수 있다. 지금은 필요 없으니 정리는 생략하겠다.
addPath
라는 메소드를 사용해 2개의 경로를 결합한 combinedPath
를 생성해보았다.
let path1 = CGMutablePath()
path1.move(to: CGPoint(x: 10, y: 150))
path1.addCurve(
to: CGPoint(x: 160, y: 150),
control1: CGPoint(x: 40, y: 50),
control2: CGPoint(x: 110, y: 250)
)
let path2 = CGMutablePath()
path2.move(to: CGPoint(x: 160, y: 150))
path2.addCurve(
to: CGPoint(x: 320, y: 150),
control1: CGPoint(x: 190, y: 50),
control2: CGPoint(x: 260, y: 250)
)
let combinedPath = CGMutablePath()
combinedPath.addPath(path1)
combinedPath.addPath(path2)
path1
이 끝남과 동시에 path2
가 새로 생성된 지점 (160, 150)
을 보면 부드럽게 이어지지 않고 살짝 떨어진 게 보인다. 이 이유는 move(to: )
로 새로운 시작점을 생성하는 것은 새로운 subpath
를 시작하는 것으로, 그림을 그릴 때 펜을 뗐다가 대고 그리는 것과 같다고 한다.
따라서, 여러 개의 커브를 가진 선을 그리고 싶다면 경로를 결합하기 보다는 path
자체에 addCurve
를 통해 경로를 이어주는 것이 더 좋다.
let path = CGMutablePath()
path.move(to: CGPoint(x: 10, y: 150))
path.addCurve(
to: CGPoint(x: 160, y: 150),
control1: CGPoint(x: 40, y: 50),
control2: CGPoint(x: 110, y: 250)
)
path.addCurve(
to: CGPoint(x: 320, y: 150),
control1: CGPoint(x: 190, y: 50),
control2: CGPoint(x: 260, y: 250)
)
나는 처음에 제어점(control
, control2
)의 좌표가 화면에서 보이는 가장 볼록한 부분의 좌표와 일치하는 줄 알았다. 저 좌표들로부터 직선을 당겨서 구불거리게 만들어주는 줄 알았다. 하지만 touchesBegan
메소드를 활용하여 시뮬레이터 상에서 좌표를 눌러 디버깅해보니 그렇지 않았다.
위에서부터 아래로 시작점, 끝점, 제어점1, 제어점2 순이다.
let path = CGMutablePath()
path.move(to: CGPoint(x: 50, y: 150)) // 시작점
path.addCurve(
to: CGPoint(x: 250, y: 150), // 끝점
control1: CGPoint(x: 100, y: 50), // 제어점 1
control2: CGPoint(x: 200, y: 250) // 제어점 2
)
시작점과 끝점은 할당한 CGPoint
값과 일치하지만 제어점 2개는 대략적으로도 비슷하지 않은 걸 확인할 수 있었다. 특히 y값이 너무 달라서 당황했다. 혼자서는 이유를 도저히 추론할 수 없어 지피티에게 물어봤다.
우선 시작점/끝점은 절대 위치로 설정된다고 한다. 하지만 제어점은 시작점과 끝점 간의 곡률을 정의하는 역할을 하는데, 실제 경로에 직접 그려지는 게 아니라 베지어 곡선 계산 과정에서 내부적으로 비율 조정 또는 좌표 변환이 일어난다고 한다.
그래서 그 계산을 어떻게 하면 내가 입력한 좌표에서 곡선이 꺾일까? 궁금해서 물어봤는데
이렇다고 한다.