일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |
- 곰곰이
- ui 그래픽스
- 프로그래밍
- PS #문제출제 #알고리즘 #곰곰이
- C언어
- 타이젠
- SUAPC #낙서장 #대회후기
- Dali
- Tizen
- 히노히에
- UCPC
- rounded corner
- Problem Solving
- 대회 후기
- 뿌요뿌요
- 이산로그
- 알고리즘 #자료구조 #퀵소트 #정렬 #시간복잡도
- 콘솔게임
- 낙서장
- Hinohie
- 뿌요뿌요2
- Today
- Total
히농의 잡합다식
Tizen OS / DALi 에서 구현한 Corner Radius 설계도 (1/N) 본문
정신을 차리고보니 어느 순간 세상의 많은 UI들이 둥글둥글해졌다. iOS/안드로이드 같은 모바일 OS 는 물론 윈도우즈, 리눅스 같은 OS, 웹페이지, 심지어 키오스크에서 조차 많은 UI 요소들이 둥글둥글한 사각형이 된 것을 볼 수 있다. 모서리가 둥근 사각형(Rounded Rectangle) 은 어느 순간 우리 세계를 지배하기 시작했다.
Tizen 에서도 이러한 Rounded Rectangle 을 표현하기 위한 기능들을 제공하고 있다. 본인이 군대를 전역했을 때에도 이미 관련 기능이 들어가 있었으나, 이후 외곽선 효과, 그림자 표현을 위한 Blur 효과, 그리고 기타 요구사항들이 짬뽕되면서 최종적으로 현재의 소스코드는 꽤나 거대한 모습이 되었다.
아래는 현재 최신 Tizen 에서 "단색"을 출력하기 위해 사용하는 shader 코드의 모습이다. 가장 단순하게 색상 하나를 출력하는 Shader 코드임에도 2만글자정도가 된다.
(※ Tizen 은 오픈소스이기 때문에 이렇게 외부에서 자유롭게 코드를 확인할 수 있다. 그래서 집에서도 코딩이 가능하다. 장점이자 단점...)
Vertex Shader 코드 링크 : https://github.com/dalihub/dali-toolkit/blob/master/dali-toolkit/internal/graphics/shaders/color-visual-shader.vert
dali-toolkit/dali-toolkit/internal/graphics/shaders/color-visual-shader.vert at master · dalihub/dali-toolkit
Provides reusable UI Controls and building blocks for applications. - dalihub/dali-toolkit
github.com
Fragment Shader 코드 링크 : https://github.com/dalihub/dali-toolkit/blob/master/dali-toolkit/internal/graphics/shaders/color-visual-shader.frag
dali-toolkit/dali-toolkit/internal/graphics/shaders/color-visual-shader.frag at master · dalihub/dali-toolkit
Provides reusable UI Controls and building blocks for applications. - dalihub/dali-toolkit
github.com
이렇게 현재는 Rounded Corner 를 표현하기 위한 수식이 더이상 단순하지 않고, 레퍼런스로 사용한 게시들도 따로 없이 본인이 혼자서 펜으로 끄적거린 코드들이기 때문에 다른 사람이 이 코드를 보고 분석을 하는데 시간이 오래 걸린다.
그 증거로, 위 2개의 파일의 commit history 를 보면 본인(Eunki Hong)의 commit 이 압도적으로 많다.
만약 Shader 코드에서 무언가 수정사항이 필요하게 되었는데, 본인이 일을 할 수 없는 상황이 된 경우 회사에서는 큰 혼란을 겪을 것이다. 이를 방지할 겸, 위 코드가 어떠한 시행착오를 통해서 완성되었는지를 세상사람들에게 공유하고자 한다.
우선 가장 간단한 형태로 표현해본 RoundedCorner 의 Shader 코드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31 |
precision highp float;
varying highp vec2 vPosition;
uniform vec2 rectSize;
uniform float radius;
uniform vec4 uColor; // from https://iquilezles.org/articles/distfunctions
float roundedBoxSDF(vec2 PixelPositionFromCenter, vec2 RectangleEdgePositionFromCenter, float Radius)
{
vec2 positiveDiff = max(PixelPositionFromCenter
- RectangleEdgePositionFromCenter
+ Radius
, 0.0);
return length(positiveDiff) - Radius;
}
mediump float calculateCornerOpacity()
{
float distance = roundedBoxSDF(abs(vPosition), rectSize * 0.5, radius);
float edgeSoftness = min(1.0, radius);
float smoothedAlpha = 1.0 - smoothstep(-edgeSoftness, edgeSoftness, distance);
return smoothedAlpha;
}
int main()
{
gl_FragColor = uColor;
gl_FragColor.a *= calculateCornerOpacity();
}
|
cs |
그리고 위 shader 코드를 이용해 렌더링한 결과이다.
큰 흐름을 보자. 우선 Rounded Rectangle 에 대한 SDF (Signed Distance Field) 값을 계산한 뒤,
이 값이 -edgeSoftness 이하면 alpha 값을 1 로, edgeSoftness 이상이면 alpha 값을 0 으로, 그 사이의 값이면 적당히 보간해준다. (smoothstep 은 glsl 내장함수이다.)
여기서 vPosition 이나 rectSize, radius 같은 값은 개발자가 구현하기에 따라서 취향껏 알아서 정의하면 된다. 본 문서에서는 vPositoin 은 사각형의 중점이 (0,0) 이며, rectSize 는 실제 눈에 보이는 사각형의 크기의 절반으로 정의했다. vPosition, rectSize 값과 radius 값은 픽셀단위라고 가정했다.
SDF 란, 외곽선이 주어졌을 때, Field 내의 특정 지점으로부터 외곽선까지의 최단거리값을 의미한다. 이 때 특정 지점이 Field 의 내부에 속해있으면 음수, 외부에 속해있으면 양수가 된다. 당연하지만 이 값이 0 이면 외곽선 그 자체가 된다.
우선 SDF 함수 계산식을 보자. 이 식을 이해하기 위해선 고등학교시간에 배웠던 약간의 벡터지식이 필요하다.
편의상 모든 점이 1사분면에 위치해있다고 가정하자. Rounded Corner 를 표현할 사분원의 중심이 되는 지점을 C 라고 표현해보면, 이 점의 위치는 사각형의 꼭지점 B 로부터 (-radius, -radius) 만큼 떨어진 위치가 될 것이다.
이 지점 C 와, 현재 픽셀의 위치 A, 즉, vPosition 까지의 거리를 계산하고, 그 값이 radius 값인 지점이 Rounded Corner 사분원 지점이 될 것이다.
여기서 vPosition 이 C 사분원 밖에 있을 경우에는 Rect 의 직선영역에 해당하므로 C 와의 거리를 계산하지 않아야한다. 이 때는 약간의 재밌는 트릭을 사용하는데, max(A-C, 0) 를 사용하게 되면 사분원 밖의 점들의 x,y 좌표값이 전부 양수가 되면서, 강제로 C 사분원 내부의 점인 것 처럼 표현되게 된다.
C = B - radius 이고, B 는 입력으로 들어온 rectSize 와 동일하므로, 수식을 풀어쓰면 다음과 같이 된다.
P = max(A - (B - radius), 0) = max(A - B + radius, 0) = max(vPosition - rectSize + radius, 0)
SDF = length(P) - radius
이렇게 하면 SDF 값이 0 인 지점이 Rounded Rect 의 외곽선이 된다.
(※ 위 수식의 경우 C 와 원점 사이의 직사각형 영역이 전부 -radius 로 계산되게 되는데, radius 값이 매우 작거나 Borderline 기능을 쓸 때에만 내부 직사각형의 엄밀한 SDF 값이 필요하므로 지금은 생략하었다.)
위 Shader 코드에서 몇 가지 의문점들이 보일 수 있다. 예를 들어서 edgeSoftness 라는 값을 통해서 SDF 사이를 보간하지 않으면 무슨 일아 생기는가? 같은 의문이 들 수 있다.
위 이미지는 edgeSoftness 보간을 하지 않고, 냅다 SDF 값을 0 이상 / 0 미만으로 구분해서 렌더링한 결과이다. 보통 렌더링은 픽셀단위로 이루어지며, 픽셀간의 보간은 MultiSampling 이라는 조금 무거운 연산을 통해서만 가능하다. 따라서 보통은 위 그림처럼 픽셀간의 경계선이 뚜렷하게 보여지게 된다.
모바일이나 조금 작은 모니터같이 픽셀 화소가 작은 기기에서는 저 계단현상이 크게 눈에 띄지 않을 수 있지만, TV나 냉장고처럼 픽셀 하나당 크기가 큰 장치에서는 계단현상이 두드러지게 보인다.
따라서 저런 계단현상을 완화하기 위해서 완전 검은색 / 완전 흰색이 아닌 회색을 칠하도록 간단하게 보간을 수행할 필요가 있다. (당연하지만, 계단현상을 없애는 것은 픽셀단위 렌더링을 하는 이상, 물리적으로 불가능하다.)
그래픽 세계에서 계단현상을 영어로 하면 Alias 라고 부르며, 완화하기 위해서 적용하는 여러 기법을 Anti-Alias 라고 부른다. Vertex 단위의 Anti-Alias 로 가면 진짜 MultiSampling 말고는 답이 없긴한데, 지금같은 경우는 Fragment, 즉 픽셀단위의 Anti-Alias 기법을 적용하는 케이스이고, + 색상을 결정하는 수식이 간단한 편이기 때문에 최종 색상에 Alpha 값을 추가하는 것 만으로도 계단현상이 완화가 된다. 지금처럼 외곽선이 x/y축과 평행하지 않을 경우에 많이 쓰이는 Anti-Alias 방식이다.
그래서 edgeSoftness 값을 통한 보간을 -1 부터 1 로 하는 것이 가장 좋은가? 라고 하면 이것도 좀 애매하다.
위 수식에 따르면 SDF 값이 정확히 0이 되는 지점은 색상이 0.5, 즉, 회색이 된다. 만약 직선부분의 픽셀의 SDF 값이 0 이 되면, 가장 외곽선 픽셀 하나는 0.5 만큼의 alpha 값을 가지게 될 것이다.
Rounded Corner 기능을 구현하지 않았다먼 저 픽셀부분은 GPU 의 적절한 판단에 의해서 항상 0 또는 1 로 그려야만 하는 부분이었지만, 지금은 0.5 가 될 수 있는 가능성이 생겼다. 예를 들어 radius 값이 0 에서 1 로 변하는 애니메이션 같은 것이 걸려있었다면, 애니메이션을 재생하는 순간 네 모서리의 색상이 0.5 가 되면서, 크기가 조금 작아진 듯한 착각을 일으키게 된다.
위 문제를 해결하기 위해 보간을 -1 부터 1 로 하지 않고 0 부터 2 로 했을 경우에는 위와 같은 결과가 나온다. 화소가 작은 PC 나 모바일에서는 티가 나지 않을 수 있지만, 원과 직선이 만나는 부분에서 묘한 이질감이 느껴진다. 소위 말하는 표현으로, 예쁘지 않다. 비슷한 이유로 -0.5~0.5 나 0~1 로 보간해도 예쁘지 않은 결과를 보여주게 된다.
따라서 화면을 가장 예쁘게 보여줄 수 있는 -1~1 사이의 보간법이 가장 적절하다고 여러 사람들의 의사결정을 통해서 결정된 사항이고, 일개 개발자인 나는 이 문제를 풀어야한다.
여담으로... 위에서 언급하지 않은 수식으로 edgeSoftness = min(1.0, radius); 가 있는데, radius 가 0 값인 경우에는 roundedBoxSDF 함수 리턴값이 모든 픽셀에 대해서 항상 0이 되고, 따라서 radius 가 0 이 되는 순간 모든 사각형의 색상이 0.5 가 되는 현상이 발생하기 때문에 이러한 조치를 취하게 되었다.
물론 roundedBoxSDF 함수가 음수 거리도 고려하도록 구현하면 되겠지만, 위의 직선구간에서 색상이 0.5가 되는 이슈가 해결되지 않으면 결국 같은 문제가 발생한다.
여기서부터는 노약자, 임산부, 삼성 임직원은 관람시 주의를 바랍니다.
알 사람은 알고 모르는 사람은 모르는 사실이지만, 과거의 pichulia 는 휴리스틱의 장인으로 불렸다. 내부에 어떠한 로직이 들어가있는지는 중요하지 않고, 아무튼 최종적으로 사람 눈에 봤을 때 괜찮기만 하면 장땡! 내가 그래픽스를 전공하면서 배운 경험 중 하나이다.
이런 아무래도 상관없는 떡밥을 풀고있는 이유는, 위 문제를 휴리스틱으로 해결했기 때문이다. 실제로 지난 4년간 이 휴리스틱에 의해 생긴 Visual Defect 이나 문제를 리포트받지 않았고, 따라서 아마도 괜찮을 것이다. 아마도.
직선 영역에서 opacity 가 1 이 아니게 되는 문제의 본질은, 원래는 Anti-Alias 를 적용하지 않아도 되는 부분에도 Anti-Alias 를 적용했기 때문이다. 원래라면 항상 1 의 색상이 칠해져야 했던 곳이다.
만약 곡선인 부분에 대해서만 위 edgeSoftness 값을 적용하고 그 이외의 부분은 edgeSoftness 값이 항상 0 이 되도록 했을 때의 모습을 상상해보자. 직선과 곡선의 경계선을 생각해보면, 방금전까지 곡선 부분의 색상은 0.5 에 가까운 색이었는데, 갑자기 1.0으로 색이 확 튀어버린다.
따라서 저 직선과 곡선의 경계선에 "적절한" Opacity를 주도록 해서 색상 변화 폭이 적어지도록 만들면 문제가 해결된다.
여러 가지 실험을 통해 본인이 채택한 방법은, 기울기가 1 / Radius 인 직선만큼 외곽선의 범위를 확장시키는 방식이었다. 원과 직선의 경계지점에서 시작해, 직선영역으로 갈수록 기울기가 1 / Radius 가 되도록 SDF 경계선의 범위를 확장시켜서 계산하였다. 이렇게 하면 아무리 못해도 경계 지점으로부터 Radius 픽셀을 넘어서는 직선은 항상 내부인 것 처럼 판정이 되고, 따라서 1 의 색상을 띄게 될 것이다.
최종 결과물은 위와 같이 나온다.
여기서, 실제 최종 외곽선이 사각형이 아니게 되지 않나? 라는 의문이 들 수 있다. 실제로 외곽선 수식을 그대로 따라서 SDF 를 그려보면 위에처럼 악간 팔각형 비스무리한 모양이 나온다.
하지만 직선 영역 너머로 색상이 0 이 된 부분들은 GPU가 판단하기로, 이 너머 영역은 그리지 않기로 결정을 내린 픽셀들이다. 이 판단은 fragment shader 로 오기 전에 이미 판단을 끝마쳤다. 따라서 원래 사각형이 되어야할 지점을 넘어서는 픽셀들에는 렌더링이 이루어지지 않고, 따라서 위에처럼 팔각형 모양으로 렌더링이 되지 않는다.
실제 Tizen 코드에서 위 휴리스틱을 사용했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
void setupMinMaxPotential(highp float currentBorderlineWidth)
{
gPotentialRange = vAliasMargin;
gMaxOutlinePotential = gRadius + gPotentialRange;
gMinOutlinePotential = gRadius - gPotentialRange;
#ifdef IS_REQUIRED_BLUR
#elif defined(IS_REQUIRED_BORDERLINE)
gMaxInlinePotential = gMaxOutlinePotential - currentBorderlineWidth;
gMinInlinePotential = gMinOutlinePotential - currentBorderlineWidth;
#else
#endif
// reduce defect near edge of rounded corner.
highp float heuristicEdgeCasePotential = clamp(-min(gDiff.x, gDiff.y) / max(1.0, gRadius), 0.0, gPotentialRange);
gMaxOutlinePotential += heuristicEdgeCasePotential;
gMinOutlinePotential += heuristicEdgeCasePotential;
}
|
cs |
(※ 여기서는, 그리고 앞으로도 설명의 편의를 위해 1픽셀이라고 설명하겠지만, Scale 애니메이션 등을 통해, rectSize 나 radius, 심지어 vPosition 도 pixel 단위가 아니게 될 수 있다. Anti-Alias 계산시 사용하게 될 오차의 범위를 vertex shader 에서 미리 계산해두어, vAliasMargin 이라는 변수로 전달하고 있다.)
TODO : 앞으로 outter shadow 등, fragment shader 영역에 사각형 너머의 범위에도 렌더링을 요구할 때가 올텐데, 그 때는 위에서 적용된 휴리스틱이 문제가 될 수 있다. 추후 outter shader 를 fragment shader 내에서 구현하게 될 때 참고하자.
실패 경험 노트 : 하드웨어에 따라서는 smoothstep(0, 0, 0) 의 결과값으로 0.5 가 나올 수 있다. 본인이 min(1.0, radius) 처럼, edgeSoftness 값을 줄이는 방식을 최종적으로 사용하지 않은 이유이다. edgeSoftness 방식은 alpha 값이 0.5가 되어도 크게 티가 나지 않는 BacgkroundBlurEffect 에서만 사용되고, alpha 값에 민감한 일반 shader 에서는 edgeSoftness 방식을 사용해서는 안된다.
실패 경험 노트 2 : 각 픽셀이 차지하는 사각형 영역에 대해서, SDF 내부를 '적분'해서 영역의 넓이를 계산하는 방식도 고려해보았으나, 결과적으로 직선 영역에 opacity 가 항상 1.0 이 될 것이라고 보장할 수 없으므로 폐기되었다.
'프로그래밍' 카테고리의 다른 글
제 2회 곰곰컵 출제 후기 (GGANALi) (1) | 2022.11.27 |
---|---|
Tkinter 를 이용한 예금/적금 이자 비교 프로그램 코드 (2) | 2022.08.14 |
정수 자료형 퀵소트(quick sort) 의 상한선은 O(N log A) 이다. (0) | 2022.05.21 |
SUAPC 2021 : 기지국 업그레이드 이미지 제작 후기 (1) | 2021.08.29 |
UCPC 2021 이미지 제작 후기 (0) | 2021.07.30 |