들어가며
오랜만에 글을 적는다. 오늘은 셀 셰이딩(툰 셰이딩)에 대한 글로, 포스트 프로세싱을 통해 카툰 스타일을 구현하는 방법에 대해서 이야기해보고자 한다.
사실 기획에 대해 이야기하고 싶은 것도 많지만, 아직은 조금 더 고민할 시간이 필요하다고 생각했다. 최근에 단기 팀 프로젝트를 하고 있는데, 그 과정에서 나의 현실적인 기획을 돌아보게 됐다.
이 과정에서 작년 9월에 정의한 나의 이상적인 기획과 현실적인 기획 사이의 괴리, 그리고 그 괴리를 매우기 위한 방법에 대해 고민하고 있는데, 이것들에 대해서는 작년과 마찬가지로 조금 더 깊게 고민한 후 정리할 필요가 있어 보였다.
그래서 오늘은 기획이 아닌 기술, 그중에서도 셀 셰이딩을 구현해 본 과정에 대해 글을 쓰겠다.
셀 셰이딩을 구현한 이유는 무엇인가?
나는 개인적으로 셰이딩에 관심이 많은데, 관심만 많고 하는 것은 없어 이대로라면 그저 꿈만 꾸게 될 것 같았다. 이에 소소하게나마 한두 가지씩 해보자는 마음가짐에서 셀 셰이딩을 구현하게 됐다.
어떤 방법으로 학습하고 구현했는가?
올해 초 'Cat DarkGame'이라는 블로그를 알게 됐다. 프로그래밍부터 TA까지 다양한 분야를 다루는 블로그인데 그중에서도 카툰 렌더링 글이 눈에 띄어서 따라 해보고자 했다.
UE4 Cel Shading : 카툰렌더링
Git : https://github.com/CatDarkGame/PostProcess-CelShading Cel Shading을 구현하는 방법은 렌더링 방식, 구현 방식에 따라 여러 가지 있습니다. 이번 포스팅에서는 언리얼4 엔진에서 Deferrd(지연) 렌더링 기반 Post
darkcatgame.tistory.com
글을 보고 따라 하는 과정에서 단순히 베끼기만 하면 의미가 없기에 개인적인 학습과 이해를 병행했다. 이에 본 글에서는 참고 글에서 아래와 같은 부분이 추가되었다.
- 언리얼 엔진 5에서의 주의점 (5.3 버전으로 진행)
- Metalic 소재에 대한 셀 셰이딩 적용 방법
- Edge filtering 원리와 활용
그럼 같이 한번 확인해 보도록 하자. 목차는 다음과 같다.
목차
- Cel shading이란 무엇인가?
- Cel shading 구현을 위한 환경 구성
- 단계별 음영 (Banded Lighting)
- 환경광과 하이라이트 (Ambient Light & Specular)
- 외곽선 (Outline)
- 림라이트 (Rim Light)
- 결과
Cel Shading 구현
Cel shading이란 무엇인가?
Cel shading(이하 카툰 렌더링)이란 기본적인 3D 모델에 단일 색상과 단계별 음영, 외곽선 등의 요소를 추가하여 만화처럼 느낄 수 있도록 렌더링 하는 기술을 말한다.
Cel shading - Wikipedia
From Wikipedia, the free encyclopedia Computer graphics rendering technique used to mimic the look of 2D animation A representation of a spacesuit from The Adventures of Tintin comic Explorers on the Moon with a basic cel shader (also known as a toon shade
en.wikipedia.org
이런 카툰 렌더링을 잘 사용한 게임으로는 'Arc System Works'에서 개발한 <Guilty Gear -STRIVE->가 있다.
아래의 자료를 보면 캐릭터 모델링이 평면적인 색상으로 칠해져 있으며, 단계별 음영과 하이라이트가 들어가 있고, 외곽선을 통해 만화처럼 보이게 한 것을 확인할 수 있다.

이 외에도 'Colin Leung'이라는 중국의 한 개발자의 'NiloToon Shader'가 다양한 분야에서 활용되는 것처럼 카툰 렌더링은 게임, 애니메이션, 뮤비 등 분야를 가리지 않고 활용된다.



정리
- Cel shading은 평면적인 색상, 단계별 음영, 외곽선, 하이라이트, 림 라이트 등의 요소를 통해 기존 3D 모델을 만화처럼 렌더링하는 것을 말한다.
- Cel shading은 게임, 애니메이션, 뮤비 등 분야를 가리지 않고 활용되는 기술이다.
Cel shading 구현을 위한 환경 구성
본 글에서는 언리얼 엔진의 deferred rendering 방식에 기반하여 포스트 프로세싱을 통해 cel shading을 구현한다.
Deferred rendering이란 무엇인가?
렌더링 방식에는 forward rendering과 deferred rendering이 있는데, 언리얼 엔진은 기본적으로 deferred rendering(연기된 렌더링) 방식을 통해 화면을 구성한다. 그렇다면, 이 둘의 차이는 무엇일까?
간단하게 정리하면 forward rendering은 화면을 구성할 때 렌더링 파이프라인을 따라 선형적으로 화면을 구성하고, deferred rendering은 두 단계에 걸쳐 화면을 구성한다고 보면 된다. 그렇다면, deferred rendering 방식은 효율적이지 않은가?
아니, Deferred rendering 방식은 가변적인 빛을 사용하는 환경에서 효과적이다. Deferred rendering 방식에서는 렌더링 파이프라인을 따라 계산한 결과를 G-buffer에 기록한 뒤, 빛만 따로 계산하여 화면을 구성한다.
이 덕분에 빛이 바뀔 때마다 G-buffer 데이터를 재사용하여 화면을 구성할 수 있고, 이로 인해 전체 파이프라인을 거쳐야 하는 forward rendering 방식에 비해 효과적으로 화면을 출력할 수 있게 된다.
아래의 이미지를 확인하면 언리얼 엔진의 deferred rendering 방식을 이해할 수 있다.

위와 같이 언리얼 엔진에서는 특정 화면을 구성할 때 base color, specular, world normal, depth map 등의 여러 가지 연산을 진행하고 그 결과를 G-buffer에 기록한다. 그리고, 빛이 변하는 경우에 G-buffer에 기록된 값을 재활용하여 효과적으로 렌더링을 하게 된다.


이렇게 부족한 지식으로 어떻게 설명을 해봤는데 사실 cel shading 구현을 위해 위의 모든 내용을 완벽하게 이해할 필요는 없다. 나도 전부 제대로 이해했다고 볼 수 없고 말이다.
요점은 언리얼 엔진이 deferred rendering 방식을 사용하며, 그 과정에서 화면 요소에 대한 연산 결과를 G-buffer에 기록해 놓는다는 것, 그리고 이 G-buffer의 값을 이용하여 cel shading을 구현할 것이라는 것만 이해하고 있으면 된다.
어떻게 구현 환경을 구성해야 하는가?
우리는 포스트 프로세싱을 통해 cel shading을 구현할 것이기에 레벨에 post process volume을 추가한다. 그리고, cel shading을 위한 material을 하나 만들어서 post process material에 할당하고, 전체 레벨에 적용할 것이기에 infinite extent를 true로 설정한다.


그리고, 다음으로는 post process material의 domain을 post process로 설정하고, blendable location을 before tonemapping으로 설정한다.


이때 주의할 점은 언리얼 엔진 5.3 이하 버전으로 진행해야 한다는 것이다. 나도 가능하면 최신인 5.4 버전으로 해보려고 했는데, 5.4 버전에서는 blendable location의 옵션이 달라져서 어려웠다. 해결책을 찾아보려고 포럼을 뒤져봐도 지금 당장 적용할 수 있는 마땅한 해결책이 없는 것 같아서 버전 다운 후 5.3 버전에서 진행했다.
5.4 Removed Post Process Material settings (Before Tonemapping)
Not sure if it’s too late to help you with the sky, but in your post process material, you can implement a mask for your skybox using a distance that’s set high enough that it won’t interact with your environment, but will still mask out your sky (mi
forums.unrealengine.com
엔진 5.3 버전에서의 blendable location 옵션을 설명하기 전에 렌더링 파이프라인을 조금만 더 살펴보자.
간단하게 기본적인 렌더링 파이프라인은 아래와 같이 구분된다.
- Geometry Pass : 3D 모델의 기하학적 데이터(vertex, uv 등)와 위치 등을 처리한다.
- Base Pass : 조명 및 머티리얼 정보를 포함한 기본 색상을 계산한다.
- Lighting Pass : 조명 데이터를 집중적으로 처리한다.
- Post Processing Pass : 화면 전체에 걸쳐 다양한 post processing 효과를 처리한다.
여기에서 아까 말했던 렌더링 방식을 다시 한번 이야기하면, forward rendering 방식은 화면을 렌더링 할 때마다 이 과정을 순차적으로 진행하며, deferred rendering 방식에서는 geometry pass와 base pass(조명 제외)에서 계산한 결과를 G-buffer에 기록해 둔 뒤 조명이 바뀔 때마다 lighting pass와 post processing pass를 거치는 방식으로 화면을 구성한다고 이해할 수 있다.
이를 통해 우리가 위에서 설정한 blendable location은 post processing pass 내의 Tonemapping, Screen Space Reflections(SSR), Anti-Aliasing, Depth of Field 등 다양한 효과를 처리하는 절차 중 어떤 절차에서 작업을 진행할 것인가를 선택하는 부분이라는 걸 알 수 있다.
그리고, 이름에서 알 수 있듯이 우리가 이 옵션을 before tonemapping으로 설정한 이후는 tonemapping 단계에서 색상이나 밝기가 왜곡되기 전에 효과를 적용하기 위함이다. (정확히는 HDR 이미지를 LDR 이미지로 압축하기 전에 효과를 적용하기 위함.)
...복잡하면 그냥 색상 왜곡하기 전에 구현한 cel shading을 적용하기 위해서 이렇게 설정한다고 생각하자. 나도 처음 이해할 때는 머리가 복잡해서 그냥 대충 이해하고 완성한 뒤에야 제대로 살펴봤다.
이렇게 하면 cel shading을 구현하기 위한 환경 구성이 끝난다.
정리
- 언리얼 엔진은 기본적으로 deferred rendering(연기된 렌더링) 방식을 통해 화면을 구성한다.
- Deferred rendering 방식에서는 G-buffer에 화면 요소에 대한 연산 결과를 기록해 놓는다.
- G-buffer에 기록된 값을 이용하여 cel shading을 구현할 수 있다.
- Post process volume을 배치하고, material을 할당한 뒤, infinite extent를 설정한다.
- Post process material의 domain과 blendable location을 설정한다.
- Blendable location을 before tonemapping으로 설정하는 이유는 tonemapping 과정에서 색상이 왜곡되기 전에 효과를 적용하기 위함이다.
단계별 음영 (Banded Lighting)
Cel shading 구현을 위해 가장 먼저 할 일은 단계별 음영을 구현하는 것이다. 단계별 음영이란 아래의 이미지와 같이 캐릭터의 기본적인 색상 위에 음영이 뚜렷하게 구분되어 표현되는 것을 말한다.

단계별 음영을 구현하기 위해 무엇을 해야 하는가?
단계별 음영을 구현하기 위해서는 대상의 기본 색상을 구하고, directional light buffer(대상이 빛을 어느 방향에서 얼마나 받는지에 대한 데이터)를 구한 뒤, 이 데이터를 통해 음영을 확실히 구분해줘야 한다.
가장 먼저 기본 색상은 SceneTexture 노드의 diffuse color를 통해 구할 수 있다. SceneTexture 노드는 앞서 말한 G-buffer 데이터를 활용할 수 있는 노드인데 자세한 설명은 공식 문서를 살펴보도록 하자.

Diffuse color란 난반사되는 색상을 말하는데 물체의 기본적인 색상이라고 이해하면 좋다.

그럼 한번 diffuse color를 구해서 확인해 보자. 먼저 SceneTexture 노드를 생성하고, diffuse color 값을 가져온 뒤 output의 emissive color에 연결하면 다음과 같은 결과를 얻을 수 있다.


위의 이미지를 확인해 보면 우측의 오브젝트는 난반사 색이 잘 보이는데, 좌측의 오브젝트는 무언가 이상하게 검은색으로 출력되는 걸 확인할 수 있다. 이에 이것저것 테스트해 보니 metalic material들만 이런 현상이 발생하는 걸 확인할 수 있었다.


이렇게 metalic material들에 대해서만 위와 같은 현상이 발생하는 건 알겠는데, 그 이유에 대해서는 정확하게 알기 어려웠다. 한번 알아보기는 했는데 당시에 그래픽스에 대한 지식이 거의 없어서 그 내용을 못 알아듣고 TA 레딧에다가 질문 글을 올렸다.
From the TechnicalArtist community on Reddit
Explore this post and more from the TechnicalArtist community
www.reddit.com
이에 몇 가지 답변을 얻을 수 있었는데 정리하면 metalic material들은 diffuse color 값을 갖지 않기에 검은색(0의 값을 가짐.)으로 출력된다는 것, metalic material들은 기본 색상을 specular color에서 받아온다는 것을 알 수 있었다.


그럼 이 값들을 활용하여 directional light buffer를 구할 차례다.
가장 먼저 diffuse color에서 metalic 소재인 부분에 specular color를 더해주어 화면의 기본 색상을 구했고, 이렇게 구한 색상 데이터와 기본 화면 데이터(PostProcessInput0)를 desaturation 노드를 통해 채도를 제거한 뒤 나눠주어 directional lighting buffer를 구했다.


원본(최종 결과) 데이터에서 색상 데이터를 나눠서 directional lighting buffer를 구할 수 있는 이유는 화면을 렌더링 할 때 색상 데이터에 조명 정보(lighting data)를 곱해 최종 결과를 구하기 때문이다.
예를 들어, 위의 directional lighting buffer에서 검은 부분인 0부터 하얀 부분인 1까지의 데이터가 곧 조명의 강도이고, 이를 색상 데이터와 곱해서 최종 화면을 만들게 된다.
이렇게 directional lighting buffer를 구했으면 일정 강도를 기준으로 조명을 구분하는 방식으로 단계별 음영을 구현할 수 있다.



..이렇게 써놨긴 한데 다 끝나고 알게 된 내용이지만, diffuse color에 복잡하게 specular color를 더해줄 필요 없이 base color로 가져오면 깔끔하게 구현할 수 있다. 😅😅

어쨌든 이런 식으로 단계별 음영을 구할 수 있는데, 'Cat DarkGame' 블로그에서는 한 걸음 더 나아가 LUT(Look Up Table) texture라는 것을 통해 단계별 음영을 더 구분한 것을 확인할 수 있었다.
LUT의 정의를 살펴보면 성능 최적화를 위해 사용하는 계산 결과 값을 미리 저장한 표라고 하는데, 그냥 위의 if 노드를 대체하여 directional lighting buffer의 값으로 음영을 구분해 주기 위해 사용한다고 이해하면 될 것 같다.
아래와 같이 texture를 간단하게 만들어 노드를 구성했고, 다음과 같은 결과를 얻을 수 있었다.




간단하게 원리를 설명하면 directional lighting buffer의 결과 값을 UV 형태로 가져와서 LUT texture에 매핑했다고 생각하면 된다.
그리고, 그림자 색상의 경우는 어두운 부분에만 적용돼야 하니 one minus 노드로 반전해 주고, 색상 또한 마지막에 다시 반전할 것을 생각하여 마찬가지로 one minus 노드로 반전하여 곱해준다. 이를 LUT texture와 더한 뒤 반전해 주어 그림자 색상 texture를 만들고, 최종적으로 LUT texture와 곱해주어 그림자 색상을 반영한 LUT texture를 만든다.
일단 참고 블로그에 이렇게 나와 있어서 위와 같이 구성하기는 했는데, 사실 multiply 이후에 바로 반전해도 결과는 똑같다. LUT texture를 더하는 또다시 LUT texture에 더하는 절차가 왜 있는지는 아직도 잘 모르겠다..
아무튼 이렇게 단계별 음영을 구현해 봤다. 제대로 된 서브 컬처 툰 셰이더에서는 face mask 같은 것도 따로 만들어서 음영을 적용하던데 오늘은 간단한 카툰 렌더링만 다루니 이번 항목은 이쯤에서 마무리 짓겠다.
정리
- Directional light buffer(대상이 빛을 어느 방향에서 얼마나 받는지에 대한 데이터)를 활용하여 단계별 음영을 구현할 수 있다.
- SceneTexture 노드는 G-buffer 값을 가져올 수 있는 노드다.
- Metalic material은 specular color로 색을 가져온다.
- LUT texture를 통해 음영의 단계를 더 깊게 구분할 수 있다.
환경광과 하이라이트 (Ambient Light & Specular)
다음은 환경광이라고 제목을 붙였지만, 정확히는 sky light와 반사에 의한 색상 표현이다.
이건 단순하게 최종 화면에서 light가 반영된 base color를 나눠주어 환경광 색상과 directional lighting buffer의 곱을 구하고, desaturation 노드로 directional lighting buffer만을 분리하여 다시 한번 나누어서 환경광 색상을 구하면 된다. 그 뒤에 단계별 음영 결과 값에 곱해주어 환경광을 적용해 준다.





그리고, 다음으로는 하이라이트를 적용하는데 이번에는 최종 결과를 desaturate 한 뒤, light가 반영된 specular(정반사) 데이터 마찬가지로 desaturate한 뒤 나눠서 specular가 적용된 lighting buffer를 구한다.
정확히는 위에서 언급한 directional lighting buffer와 같다고 봐도 좋다. 사실 위에서 언급한 directional lighting buffer는 metalic material에 대한 부분도 반영하느라 임의로 specular에 대한 데이터를 추가시킨 거라 엄밀히 말해서는 참고 블로그의 directional lighting buffer와 다르다.





이때 0과 1 사이의 값으로 clamp 하기 전에 specular intensity라는 이름으로 강도를 나타내는 parameter를 추가하여 계산에 활용했다. Specular intensity의 값이 커지면 1 이상인 영역이 많아져 clamp 이후의 specular 영역 또한 커진다.
그리고, 마지막으로 specular 데이터에 색상을 곱해서 더해주는 방식으로 specular color 또한 설정할 수 있게 구성했다.
이후에는 alpha mask를 통해 효과를 적용하지 않은 부분, 즉 하늘이 최종 화면과 같이 보이도록 lerp 노드를 통해 합쳐줬다.


정리
- light가 반영된 데이터를 활용하여 환경광 색상과 specular(정반사) 영역을 구해 적용한다.
- 효과를 적용하지 않은 부분은 alpha mask와 lerp 노드를 통해 기존 최종 화면을 렌더링 한다.
외곽선 (Outline)
다음은 카툰 렌더링의 꽃, 외곽선이다. 이 부분은 custom depth를 이용해서 edge detection 방식으로 외곽선을 그리기 때문에 조금 복잡하다. 차근차근 같이 한번 살펴보도록 하자.
Custom Depth란 무엇인가?
언리얼 엔진에서는 기본적으로 현재 카메라의 위치를 기준으로 레벨 내 오브젝트들을 그리는데, 이때 카메라와 오브젝트 내 각 픽셀 사이의 거리를 scene depth라고 하며, 이러한 깊이 정보를 Z-buffer(Depth buffer)에 저장하여 렌더링 과정에서 활용한다.
이때 개발자가 특정 오브젝트를 선택하여 별도의 깊이 정보로 관리할 수도 있는데 이를 custom depth라고 한다. 우리는 이 custom depth를 활용하여 오브젝트의 깊이를 확인하고 이를 활용해 외곽선을 그릴 것이다.


Edge detection이란 무엇인가?
우리가 custom depth로 카툰 렌더링할 오브젝트의 실루엣을 가져왔다면 edge detection을 통해 외곽선을 검출한다.
Edge detection이란 말 그대로 경계를 검출하는 알고리즘으로 특정 좌표를 기준으로 주위 좌표와의 밝기 차이를 통해 경계선을 검출한다.
이때 검출 과정에서 좌표 전후의 변화율을 계산하기 위해 미분 마스크(differential mask)라는 것을 활용하는데, 이는 미적분을 공부할 때 특정 방정식의 기울기를 구하기 위해 미분을 했던 걸 생각하면 이해하기 쉽다.

위 이미지의 좌측 상단 그래프를 x축에서의 밝기 값으로, 좌측 하단 그래프를 밝기 변화량으로 생각하면 변화율을 통해 edge를 검출한다는 말을 이해할 수 있을 것이다.
위 이미지 내 우측의 그래프는 detection 할 대상이 결국 디지털 이미지이기에 이산적인 값으로 처리해야 함을 나타낸다.
우리는 이 중에서 1차 미분 마스크를 사용하는 sobel operator 알고리즘을 통해 외곽선을 검출할 것이다.
이 과정에서 1차 미분에 가장 근사하도록 중앙 차분으로 계산하기에 아래의 이미지와 같은 1차 미분 마스크를 사용하게 된다. 그리고, 이를 가우시안 필터와 convolution 하면 수평과 수직에 대한 sobel operator가 만들어진다.
Sobel operator 알고리즘에서 가우시안 필터와 convolution 하는 이유는 가우시안 필터가 중심에 가까운 픽셀에 더 큰 가중치를 부여하기에 노이즈의 영향이 줄어들기 때문이다.


.. 나도 복잡하다는 걸 잘 안다. 나 또한 이해한 지 얼마 되지 않았기에 나름대로 최선의 설명을 했다는 점 양해 바란다.
아무튼 정리한 원리를 바탕으로 이를 구현해 보면 아래와 같은 결과를 얻을 수 있다.

그럼 edge detection은 어떻게 구현하는가?
Sobel operator는 마찬가지로 블루프린트로 작업했는데, 구조가 복잡해서 material function을 사용해서 정리했다. 가장 먼저 전체적인 구조를 살펴보자.

많이 복잡해 보이는데 별 거 없다. 입력으로 받은 LineWidth에 따라 GetNeighbourUVs로 주변 8 방향의 uv를 구하고, ExtractDepthMap을 통해 해당 uv의 custom depth 정보를 가져온다.
그 뒤, CombineMap을 통해 수평과 수직 sobel operator와 각 uv의 custom depth 데이터를 convolution 한 뒤 각 값들을 더한다. 그리고 이렇게 더한 값이 곧 edge detection의 결과가 된다.

이렇게만 설명하면 이해가 안 될 수 있으니 아래의 이미지를 예시로 같이 확인해 보자.

위 이미지의 픽셀 값은 행렬로 $\begin{pmatrix} 255 & 255 & 255 \\ 255 & 255 & 255 \\ 0 & 0 & 0 \end{pmatrix}$와 같이 표현된다.
여기에서 아래와 같이 수직 sobel operator를 곱한 뒤 모든 요소를 더해주면 -1020의 값을 얻을 수 있다.
\begin{pmatrix} 255 * -1 & 255 * -2 & 255 * -1 \\ 255 * 0 & 255 * 0 & 255 * 0 \\ 0 * 1 & 0 * 2 & 0 * 1 \end{pmatrix}
다음으로 아래와 같이 수평 sobel operator를 곱한 뒤 모든 요소를 더해주면 0이라는 값을 얻을 수 있다.
\begin{pmatrix} 255 * -1 & 255 * 0 & 255 * 1 \\ 255 * -2 & 255 * 0 & 255 * 2 \\ 0 * -1 & 0 * 0 & 0 * 1 \end{pmatrix}
계산 결과를 볼 때 수직 검출의 경우 -1020의 값이, 수평 검출의 값이 0이 나온 걸 확인할 수 있다. 이때 우리는 vector length 노드를 통해 길이, 즉 절댓값을 계산했기에 이 경우 해당 좌표에 1020의 값이 들어가게 되어 외곽선이 있음을 알게 되는 것이다. (예시 출처 : DIY 메카솔루션 오픈랩)
[영상 처리 강좌] 소벨 검출이란? 소벨 엣지 검출기 알아보기 (sobel edge detection)
안녕하세요 이번에는 소벨 검출기를 이용한 가장자리 검출에 대해서 알아보도록 하겠습니다. s...
blog.naver.com
전체 과정을 다시 한번 살펴보면 다음과 같다.
주변 UV 좌표(texture 좌표)에 대해 계산할 때는 방향 offset을 GetOffsetPixel로 넘겨주어 선 굵기만큼 곱한 뒤 화면의 inverse size를 곱해주어 UV 좌표계로 변환한다. 그리고 이렇게 변환한 방향 offset UV 좌표를 현재 UV에 더해주는 방식으로 주변 UV를 구한다.


다음으로는 이렇게 구한 주변 UV 좌표를 G-buffer의 custom depth 데이터에 매핑하여 해당 값을 가져온다. 그 뒤 해당 값을 DepthDistance 값 기준으로 정규화(0에서 1 사이 값으로 변환)하여 MaxWidth 값을 넘지 않도록 clamp 한 뒤 깊이 정보를 반환한다.

이 작업이 완료됐다면 CombineMap을 통해 sobel operator를 곱해준 뒤 모두 더한 값을 반환한다.

그리고 최종적으로 vector length 노드로 절댓값으로 변환하여 해당 값을 반환해 외곽선을 검출한다.

이게 끝이다.
Sobel operator 말고 다른 방법은 없나?
Sobel operator 말고도 prewitt, scharr 등의 다양한 방법이 있다. Prewitt는 가우시안이 아닌 평균값(1, 1, 1) 필터로 만든 operator고, scharr는 3:10:3의 가중치를 갖게 하여 조금 더 가우시안 분포에 가깝게 구성한 operator다.

그리고, 나는 GradientComponent와 CentralWeight라는 변수를 만들어 위의 operator에 더불어 여러 가지 operator를 테스트해 봤는데 아래는 그에 대한 결과다. (림라이트와 노멀 외곽선이 적용된 상태)



내부의 line은 어떻게 구성하는가?
외곽선을 구성했다면 다음은 내부의 선을 구성할 차례다.
내부는 world normal 데이터를 활용하여 간단하게 구현할 수 있는데 다음과 같이 현재 UV 기준 상하좌우에 대한 노멀 데이터 값을 구한 뒤 그 차이를 모두 더해 반환하는 방식으로 내부의 선들을 검출할 수 있었다.



정리
- 외곽선은 custom depth와 edge detection 알고리즘을 통해 구한다.
- Custom depth는 scene depth와 달리 개발자가 선택한 오브젝트를 별도의 깊이 버퍼에서 관리하는 것을 말한다.
- 변화량을 나타내는 중앙 차분 미분 마스크로 edge detection operator를 만들 수 있다.
- 수평과 수직에 대한 edge detection operator를 현재 UV와 주변 UV에 대한 custom depth buffer 값과 convolution 하고, 그 값을 모두 더하여 외곽선을 검출한다.
- World normal 데이터를 활용하여 현재 UV와 주변 UV의 normal 값 차이로 내부 선을 검출한다.
림라이트 (Rim Light)
마지막은 림라이트다. 림라이트(Rim Light)란 피사체의 외곽선을 따라 형성되는 빛의 테두리를 말한다. 이러한 설명에서 알 수 있듯이 림라이트는 lighting buffer에 outline과 color를 곱해서 만들 수 있다.




이 부분에서 특이 사항이라고 하면 metalic material에 대한 림라이트가 제대로 구현되지 않아서, 림라이트를 위한 metalic material에 대한 림라이트 강도 parameter를 추가했다는 것이다. 이에 대한 조정 예시는 아래와 같다.


이를 통해, 특정 수치를 기준으로 림라이트를 구성했을 때 metalic material의 림라이트가 만들어지지 않는 문제를 임시로나마 해결했다.
정리
- 림라이트는 lighting buffer에 outline과 color를 곱해서 만들 수 있다.
결과

결과는 위와 같이 성공적으로 구현할 수 있었다. 다만, 플레이해보니 약간씩 끊기는 느낌이 들어서 Gpu visualizer를 확인해 봤다.


Post process volume의 material 적용 전후를 측정해 봤는데 보이는 것과 같이 1ms 밖에 차이 나지 않았다. 이게 큰 값인지, 이것 때문에 생기는 문제인지는 아직 잘 모르겠다.
뭐.. 이번에 그동안 관심만 많던 셰이더 제작 쪽으로 한 걸음 나아갔으니 앞으로 차근차근 알아보면 되지 않을까.. 이쯤 하면 첫 시도로는 만족한다.
마치며
오랜만에 작업이 아닌 학습을 하고, 또 그 과정과 결과를 기록하니 기분이 좋다. 확실히 작업과 학습을 병행해서 하는 게 피폐해지지 않고 나아갈 수 있는 방법인 것 같다.
그러나, 이런 소감과는 다르게 이번 기록을 작성하는 데는 조금 힘들었다. 늘 그랬지만 실질적으로 이해하고 구현하는 데 걸린 시간은 반나절 정도인 것에 반해, 글을 쓰는 데는 꼬박 하루가 걸렸다.. 인생 😓😓
뭐 어쩌겠나. 빠르게 정리하지 못했다는 것은 제대로 이해하지 못했다는 것이며, 이는 곧 내가 무엇을 정리하고 기록하는지 몰랐다는 것이니 고생을 하는 것도 당연지사다.
그래도 이번 기록을 위해 다시금 원리를 알아보고, 이것저것 시도하는 과정에서 여러 지식을 더할 수 있었고, 글을 정리하고 다듬는 과정에서 생각 또한 함께 구체화하고 연결할 수 있었다. 이를 통해 한 순간의 휘발적인 지식이 아닌 오래 남을 지식으로 체화했다는 점에서 내 스스로를 칭찬하고 싶다.
다시금 말하지만 내가 셰이더를 공부하는 이유는 기획자로서의 취업에 도움이 되기 위해서가 아니라 개인적인 흥미이자 취미로 학습을 하는 것이다.
언젠가 1인으로 팬 게임을 만들어보고 싶은데 그때를 위해서라면 카툰 렌더링을 잘 다룰 필요가 있다. 그때가 되면 <명일방주 : 엔드필드>나 <Guilty Gear -STRIVE-> 수준의 셰이더를 구현할 수는 없어도 이해할 수는 있는 수준이 되기를 바랄 뿐이다. (근데 언뜻 보니까 수학 되게 잘해야 되더라.. 열심히 공부해야지..)


아무튼 혹시라도 기획자를 지망하는데 삽질한다고 오해하지는 말아줬으면 한다. 아무리 내가 추구하는 기획이 모든 영역에 대한 이해를 바탕으로 방향성을 엮어내는 것이라고 해도 지금 파고드는 게 필요 이상으로 너무 깊다는 것 잘 안다. 그냥 이건 취미다.
어떻게 됐든 또 이렇게 글 하나를 마무리 지었다. 요즘 작은 팀 프로젝트를 하면서 나의 지난 기획들을 돌아보고 있는데, 작년에는 이상적인 기획에 대한 고민을 해 성취를 얻었다면, 이번에는 현실적인 기획에 대한 실마리를 잡을 수 있을 것 같은 느낌이다. 이것도 정리가 되면 기획에 관련된 글로 찾아오도록 하겠다.
다들 길고 어려운 글을 끝까지 읽어줘서 고맙고, 지금 하는 일 모두 잘 되기를 기원하면서 글을 마무리 짓겠다.
아디오스! 🙂 (아! 혹시라도 글에 잘못된 부분이 있다면 언제든 피드백 부탁드리겠습니다. 그럼 진짜 안녕!)
'개발 > 언리얼' 카테고리의 다른 글
[Game Design Lab] 구조 기록 (0) | 2025.06.11 |
---|---|
언리얼 엔진으로의 원신 모델링 적용 기록 (실패) (2) | 2024.06.08 |
[Blueprint] VR 환경에서 HUD를 구성하는 방법 (1) | 2024.01.16 |
[Blueprint] 나이아가라 시스템에서 외부 데이터를 변수로 입력 받는 방법 (0) | 2024.01.10 |
[Blueprint] Linetrace (0) | 2024.01.09 |
들어가며
오랜만에 글을 적는다. 오늘은 셀 셰이딩(툰 셰이딩)에 대한 글로, 포스트 프로세싱을 통해 카툰 스타일을 구현하는 방법에 대해서 이야기해보고자 한다.
사실 기획에 대해 이야기하고 싶은 것도 많지만, 아직은 조금 더 고민할 시간이 필요하다고 생각했다. 최근에 단기 팀 프로젝트를 하고 있는데, 그 과정에서 나의 현실적인 기획을 돌아보게 됐다.
이 과정에서 작년 9월에 정의한 나의 이상적인 기획과 현실적인 기획 사이의 괴리, 그리고 그 괴리를 매우기 위한 방법에 대해 고민하고 있는데, 이것들에 대해서는 작년과 마찬가지로 조금 더 깊게 고민한 후 정리할 필요가 있어 보였다.
그래서 오늘은 기획이 아닌 기술, 그중에서도 셀 셰이딩을 구현해 본 과정에 대해 글을 쓰겠다.
셀 셰이딩을 구현한 이유는 무엇인가?
나는 개인적으로 셰이딩에 관심이 많은데, 관심만 많고 하는 것은 없어 이대로라면 그저 꿈만 꾸게 될 것 같았다. 이에 소소하게나마 한두 가지씩 해보자는 마음가짐에서 셀 셰이딩을 구현하게 됐다.
어떤 방법으로 학습하고 구현했는가?
올해 초 'Cat DarkGame'이라는 블로그를 알게 됐다. 프로그래밍부터 TA까지 다양한 분야를 다루는 블로그인데 그중에서도 카툰 렌더링 글이 눈에 띄어서 따라 해보고자 했다.
UE4 Cel Shading : 카툰렌더링
Git : https://github.com/CatDarkGame/PostProcess-CelShading Cel Shading을 구현하는 방법은 렌더링 방식, 구현 방식에 따라 여러 가지 있습니다. 이번 포스팅에서는 언리얼4 엔진에서 Deferrd(지연) 렌더링 기반 Post
darkcatgame.tistory.com
글을 보고 따라 하는 과정에서 단순히 베끼기만 하면 의미가 없기에 개인적인 학습과 이해를 병행했다. 이에 본 글에서는 참고 글에서 아래와 같은 부분이 추가되었다.
- 언리얼 엔진 5에서의 주의점 (5.3 버전으로 진행)
- Metalic 소재에 대한 셀 셰이딩 적용 방법
- Edge filtering 원리와 활용
그럼 같이 한번 확인해 보도록 하자. 목차는 다음과 같다.
목차
- Cel shading이란 무엇인가?
- Cel shading 구현을 위한 환경 구성
- 단계별 음영 (Banded Lighting)
- 환경광과 하이라이트 (Ambient Light & Specular)
- 외곽선 (Outline)
- 림라이트 (Rim Light)
- 결과
Cel Shading 구현
Cel shading이란 무엇인가?
Cel shading(이하 카툰 렌더링)이란 기본적인 3D 모델에 단일 색상과 단계별 음영, 외곽선 등의 요소를 추가하여 만화처럼 느낄 수 있도록 렌더링 하는 기술을 말한다.
Cel shading - Wikipedia
From Wikipedia, the free encyclopedia Computer graphics rendering technique used to mimic the look of 2D animation A representation of a spacesuit from The Adventures of Tintin comic Explorers on the Moon with a basic cel shader (also known as a toon shade
en.wikipedia.org
이런 카툰 렌더링을 잘 사용한 게임으로는 'Arc System Works'에서 개발한 <Guilty Gear -STRIVE->가 있다.
아래의 자료를 보면 캐릭터 모델링이 평면적인 색상으로 칠해져 있으며, 단계별 음영과 하이라이트가 들어가 있고, 외곽선을 통해 만화처럼 보이게 한 것을 확인할 수 있다.

이 외에도 'Colin Leung'이라는 중국의 한 개발자의 'NiloToon Shader'가 다양한 분야에서 활용되는 것처럼 카툰 렌더링은 게임, 애니메이션, 뮤비 등 분야를 가리지 않고 활용된다.



정리
- Cel shading은 평면적인 색상, 단계별 음영, 외곽선, 하이라이트, 림 라이트 등의 요소를 통해 기존 3D 모델을 만화처럼 렌더링하는 것을 말한다.
- Cel shading은 게임, 애니메이션, 뮤비 등 분야를 가리지 않고 활용되는 기술이다.
Cel shading 구현을 위한 환경 구성
본 글에서는 언리얼 엔진의 deferred rendering 방식에 기반하여 포스트 프로세싱을 통해 cel shading을 구현한다.
Deferred rendering이란 무엇인가?
렌더링 방식에는 forward rendering과 deferred rendering이 있는데, 언리얼 엔진은 기본적으로 deferred rendering(연기된 렌더링) 방식을 통해 화면을 구성한다. 그렇다면, 이 둘의 차이는 무엇일까?
간단하게 정리하면 forward rendering은 화면을 구성할 때 렌더링 파이프라인을 따라 선형적으로 화면을 구성하고, deferred rendering은 두 단계에 걸쳐 화면을 구성한다고 보면 된다. 그렇다면, deferred rendering 방식은 효율적이지 않은가?
아니, Deferred rendering 방식은 가변적인 빛을 사용하는 환경에서 효과적이다. Deferred rendering 방식에서는 렌더링 파이프라인을 따라 계산한 결과를 G-buffer에 기록한 뒤, 빛만 따로 계산하여 화면을 구성한다.
이 덕분에 빛이 바뀔 때마다 G-buffer 데이터를 재사용하여 화면을 구성할 수 있고, 이로 인해 전체 파이프라인을 거쳐야 하는 forward rendering 방식에 비해 효과적으로 화면을 출력할 수 있게 된다.
아래의 이미지를 확인하면 언리얼 엔진의 deferred rendering 방식을 이해할 수 있다.

위와 같이 언리얼 엔진에서는 특정 화면을 구성할 때 base color, specular, world normal, depth map 등의 여러 가지 연산을 진행하고 그 결과를 G-buffer에 기록한다. 그리고, 빛이 변하는 경우에 G-buffer에 기록된 값을 재활용하여 효과적으로 렌더링을 하게 된다.


이렇게 부족한 지식으로 어떻게 설명을 해봤는데 사실 cel shading 구현을 위해 위의 모든 내용을 완벽하게 이해할 필요는 없다. 나도 전부 제대로 이해했다고 볼 수 없고 말이다.
요점은 언리얼 엔진이 deferred rendering 방식을 사용하며, 그 과정에서 화면 요소에 대한 연산 결과를 G-buffer에 기록해 놓는다는 것, 그리고 이 G-buffer의 값을 이용하여 cel shading을 구현할 것이라는 것만 이해하고 있으면 된다.
어떻게 구현 환경을 구성해야 하는가?
우리는 포스트 프로세싱을 통해 cel shading을 구현할 것이기에 레벨에 post process volume을 추가한다. 그리고, cel shading을 위한 material을 하나 만들어서 post process material에 할당하고, 전체 레벨에 적용할 것이기에 infinite extent를 true로 설정한다.


그리고, 다음으로는 post process material의 domain을 post process로 설정하고, blendable location을 before tonemapping으로 설정한다.


이때 주의할 점은 언리얼 엔진 5.3 이하 버전으로 진행해야 한다는 것이다. 나도 가능하면 최신인 5.4 버전으로 해보려고 했는데, 5.4 버전에서는 blendable location의 옵션이 달라져서 어려웠다. 해결책을 찾아보려고 포럼을 뒤져봐도 지금 당장 적용할 수 있는 마땅한 해결책이 없는 것 같아서 버전 다운 후 5.3 버전에서 진행했다.
5.4 Removed Post Process Material settings (Before Tonemapping)
Not sure if it’s too late to help you with the sky, but in your post process material, you can implement a mask for your skybox using a distance that’s set high enough that it won’t interact with your environment, but will still mask out your sky (mi
forums.unrealengine.com
엔진 5.3 버전에서의 blendable location 옵션을 설명하기 전에 렌더링 파이프라인을 조금만 더 살펴보자.
간단하게 기본적인 렌더링 파이프라인은 아래와 같이 구분된다.
- Geometry Pass : 3D 모델의 기하학적 데이터(vertex, uv 등)와 위치 등을 처리한다.
- Base Pass : 조명 및 머티리얼 정보를 포함한 기본 색상을 계산한다.
- Lighting Pass : 조명 데이터를 집중적으로 처리한다.
- Post Processing Pass : 화면 전체에 걸쳐 다양한 post processing 효과를 처리한다.
여기에서 아까 말했던 렌더링 방식을 다시 한번 이야기하면, forward rendering 방식은 화면을 렌더링 할 때마다 이 과정을 순차적으로 진행하며, deferred rendering 방식에서는 geometry pass와 base pass(조명 제외)에서 계산한 결과를 G-buffer에 기록해 둔 뒤 조명이 바뀔 때마다 lighting pass와 post processing pass를 거치는 방식으로 화면을 구성한다고 이해할 수 있다.
이를 통해 우리가 위에서 설정한 blendable location은 post processing pass 내의 Tonemapping, Screen Space Reflections(SSR), Anti-Aliasing, Depth of Field 등 다양한 효과를 처리하는 절차 중 어떤 절차에서 작업을 진행할 것인가를 선택하는 부분이라는 걸 알 수 있다.
그리고, 이름에서 알 수 있듯이 우리가 이 옵션을 before tonemapping으로 설정한 이후는 tonemapping 단계에서 색상이나 밝기가 왜곡되기 전에 효과를 적용하기 위함이다. (정확히는 HDR 이미지를 LDR 이미지로 압축하기 전에 효과를 적용하기 위함.)
...복잡하면 그냥 색상 왜곡하기 전에 구현한 cel shading을 적용하기 위해서 이렇게 설정한다고 생각하자. 나도 처음 이해할 때는 머리가 복잡해서 그냥 대충 이해하고 완성한 뒤에야 제대로 살펴봤다.
이렇게 하면 cel shading을 구현하기 위한 환경 구성이 끝난다.
정리
- 언리얼 엔진은 기본적으로 deferred rendering(연기된 렌더링) 방식을 통해 화면을 구성한다.
- Deferred rendering 방식에서는 G-buffer에 화면 요소에 대한 연산 결과를 기록해 놓는다.
- G-buffer에 기록된 값을 이용하여 cel shading을 구현할 수 있다.
- Post process volume을 배치하고, material을 할당한 뒤, infinite extent를 설정한다.
- Post process material의 domain과 blendable location을 설정한다.
- Blendable location을 before tonemapping으로 설정하는 이유는 tonemapping 과정에서 색상이 왜곡되기 전에 효과를 적용하기 위함이다.
단계별 음영 (Banded Lighting)
Cel shading 구현을 위해 가장 먼저 할 일은 단계별 음영을 구현하는 것이다. 단계별 음영이란 아래의 이미지와 같이 캐릭터의 기본적인 색상 위에 음영이 뚜렷하게 구분되어 표현되는 것을 말한다.

단계별 음영을 구현하기 위해 무엇을 해야 하는가?
단계별 음영을 구현하기 위해서는 대상의 기본 색상을 구하고, directional light buffer(대상이 빛을 어느 방향에서 얼마나 받는지에 대한 데이터)를 구한 뒤, 이 데이터를 통해 음영을 확실히 구분해줘야 한다.
가장 먼저 기본 색상은 SceneTexture 노드의 diffuse color를 통해 구할 수 있다. SceneTexture 노드는 앞서 말한 G-buffer 데이터를 활용할 수 있는 노드인데 자세한 설명은 공식 문서를 살펴보도록 하자.

Diffuse color란 난반사되는 색상을 말하는데 물체의 기본적인 색상이라고 이해하면 좋다.

그럼 한번 diffuse color를 구해서 확인해 보자. 먼저 SceneTexture 노드를 생성하고, diffuse color 값을 가져온 뒤 output의 emissive color에 연결하면 다음과 같은 결과를 얻을 수 있다.


위의 이미지를 확인해 보면 우측의 오브젝트는 난반사 색이 잘 보이는데, 좌측의 오브젝트는 무언가 이상하게 검은색으로 출력되는 걸 확인할 수 있다. 이에 이것저것 테스트해 보니 metalic material들만 이런 현상이 발생하는 걸 확인할 수 있었다.


이렇게 metalic material들에 대해서만 위와 같은 현상이 발생하는 건 알겠는데, 그 이유에 대해서는 정확하게 알기 어려웠다. 한번 알아보기는 했는데 당시에 그래픽스에 대한 지식이 거의 없어서 그 내용을 못 알아듣고 TA 레딧에다가 질문 글을 올렸다.
From the TechnicalArtist community on Reddit
Explore this post and more from the TechnicalArtist community
www.reddit.com
이에 몇 가지 답변을 얻을 수 있었는데 정리하면 metalic material들은 diffuse color 값을 갖지 않기에 검은색(0의 값을 가짐.)으로 출력된다는 것, metalic material들은 기본 색상을 specular color에서 받아온다는 것을 알 수 있었다.


그럼 이 값들을 활용하여 directional light buffer를 구할 차례다.
가장 먼저 diffuse color에서 metalic 소재인 부분에 specular color를 더해주어 화면의 기본 색상을 구했고, 이렇게 구한 색상 데이터와 기본 화면 데이터(PostProcessInput0)를 desaturation 노드를 통해 채도를 제거한 뒤 나눠주어 directional lighting buffer를 구했다.


원본(최종 결과) 데이터에서 색상 데이터를 나눠서 directional lighting buffer를 구할 수 있는 이유는 화면을 렌더링 할 때 색상 데이터에 조명 정보(lighting data)를 곱해 최종 결과를 구하기 때문이다.
예를 들어, 위의 directional lighting buffer에서 검은 부분인 0부터 하얀 부분인 1까지의 데이터가 곧 조명의 강도이고, 이를 색상 데이터와 곱해서 최종 화면을 만들게 된다.
이렇게 directional lighting buffer를 구했으면 일정 강도를 기준으로 조명을 구분하는 방식으로 단계별 음영을 구현할 수 있다.



..이렇게 써놨긴 한데 다 끝나고 알게 된 내용이지만, diffuse color에 복잡하게 specular color를 더해줄 필요 없이 base color로 가져오면 깔끔하게 구현할 수 있다. 😅😅

어쨌든 이런 식으로 단계별 음영을 구할 수 있는데, 'Cat DarkGame' 블로그에서는 한 걸음 더 나아가 LUT(Look Up Table) texture라는 것을 통해 단계별 음영을 더 구분한 것을 확인할 수 있었다.
LUT의 정의를 살펴보면 성능 최적화를 위해 사용하는 계산 결과 값을 미리 저장한 표라고 하는데, 그냥 위의 if 노드를 대체하여 directional lighting buffer의 값으로 음영을 구분해 주기 위해 사용한다고 이해하면 될 것 같다.
아래와 같이 texture를 간단하게 만들어 노드를 구성했고, 다음과 같은 결과를 얻을 수 있었다.




간단하게 원리를 설명하면 directional lighting buffer의 결과 값을 UV 형태로 가져와서 LUT texture에 매핑했다고 생각하면 된다.
그리고, 그림자 색상의 경우는 어두운 부분에만 적용돼야 하니 one minus 노드로 반전해 주고, 색상 또한 마지막에 다시 반전할 것을 생각하여 마찬가지로 one minus 노드로 반전하여 곱해준다. 이를 LUT texture와 더한 뒤 반전해 주어 그림자 색상 texture를 만들고, 최종적으로 LUT texture와 곱해주어 그림자 색상을 반영한 LUT texture를 만든다.
일단 참고 블로그에 이렇게 나와 있어서 위와 같이 구성하기는 했는데, 사실 multiply 이후에 바로 반전해도 결과는 똑같다. LUT texture를 더하는 또다시 LUT texture에 더하는 절차가 왜 있는지는 아직도 잘 모르겠다..
아무튼 이렇게 단계별 음영을 구현해 봤다. 제대로 된 서브 컬처 툰 셰이더에서는 face mask 같은 것도 따로 만들어서 음영을 적용하던데 오늘은 간단한 카툰 렌더링만 다루니 이번 항목은 이쯤에서 마무리 짓겠다.
정리
- Directional light buffer(대상이 빛을 어느 방향에서 얼마나 받는지에 대한 데이터)를 활용하여 단계별 음영을 구현할 수 있다.
- SceneTexture 노드는 G-buffer 값을 가져올 수 있는 노드다.
- Metalic material은 specular color로 색을 가져온다.
- LUT texture를 통해 음영의 단계를 더 깊게 구분할 수 있다.
환경광과 하이라이트 (Ambient Light & Specular)
다음은 환경광이라고 제목을 붙였지만, 정확히는 sky light와 반사에 의한 색상 표현이다.
이건 단순하게 최종 화면에서 light가 반영된 base color를 나눠주어 환경광 색상과 directional lighting buffer의 곱을 구하고, desaturation 노드로 directional lighting buffer만을 분리하여 다시 한번 나누어서 환경광 색상을 구하면 된다. 그 뒤에 단계별 음영 결과 값에 곱해주어 환경광을 적용해 준다.





그리고, 다음으로는 하이라이트를 적용하는데 이번에는 최종 결과를 desaturate 한 뒤, light가 반영된 specular(정반사) 데이터 마찬가지로 desaturate한 뒤 나눠서 specular가 적용된 lighting buffer를 구한다.
정확히는 위에서 언급한 directional lighting buffer와 같다고 봐도 좋다. 사실 위에서 언급한 directional lighting buffer는 metalic material에 대한 부분도 반영하느라 임의로 specular에 대한 데이터를 추가시킨 거라 엄밀히 말해서는 참고 블로그의 directional lighting buffer와 다르다.





이때 0과 1 사이의 값으로 clamp 하기 전에 specular intensity라는 이름으로 강도를 나타내는 parameter를 추가하여 계산에 활용했다. Specular intensity의 값이 커지면 1 이상인 영역이 많아져 clamp 이후의 specular 영역 또한 커진다.
그리고, 마지막으로 specular 데이터에 색상을 곱해서 더해주는 방식으로 specular color 또한 설정할 수 있게 구성했다.
이후에는 alpha mask를 통해 효과를 적용하지 않은 부분, 즉 하늘이 최종 화면과 같이 보이도록 lerp 노드를 통해 합쳐줬다.


정리
- light가 반영된 데이터를 활용하여 환경광 색상과 specular(정반사) 영역을 구해 적용한다.
- 효과를 적용하지 않은 부분은 alpha mask와 lerp 노드를 통해 기존 최종 화면을 렌더링 한다.
외곽선 (Outline)
다음은 카툰 렌더링의 꽃, 외곽선이다. 이 부분은 custom depth를 이용해서 edge detection 방식으로 외곽선을 그리기 때문에 조금 복잡하다. 차근차근 같이 한번 살펴보도록 하자.
Custom Depth란 무엇인가?
언리얼 엔진에서는 기본적으로 현재 카메라의 위치를 기준으로 레벨 내 오브젝트들을 그리는데, 이때 카메라와 오브젝트 내 각 픽셀 사이의 거리를 scene depth라고 하며, 이러한 깊이 정보를 Z-buffer(Depth buffer)에 저장하여 렌더링 과정에서 활용한다.
이때 개발자가 특정 오브젝트를 선택하여 별도의 깊이 정보로 관리할 수도 있는데 이를 custom depth라고 한다. 우리는 이 custom depth를 활용하여 오브젝트의 깊이를 확인하고 이를 활용해 외곽선을 그릴 것이다.


Edge detection이란 무엇인가?
우리가 custom depth로 카툰 렌더링할 오브젝트의 실루엣을 가져왔다면 edge detection을 통해 외곽선을 검출한다.
Edge detection이란 말 그대로 경계를 검출하는 알고리즘으로 특정 좌표를 기준으로 주위 좌표와의 밝기 차이를 통해 경계선을 검출한다.
이때 검출 과정에서 좌표 전후의 변화율을 계산하기 위해 미분 마스크(differential mask)라는 것을 활용하는데, 이는 미적분을 공부할 때 특정 방정식의 기울기를 구하기 위해 미분을 했던 걸 생각하면 이해하기 쉽다.

위 이미지의 좌측 상단 그래프를 x축에서의 밝기 값으로, 좌측 하단 그래프를 밝기 변화량으로 생각하면 변화율을 통해 edge를 검출한다는 말을 이해할 수 있을 것이다.
위 이미지 내 우측의 그래프는 detection 할 대상이 결국 디지털 이미지이기에 이산적인 값으로 처리해야 함을 나타낸다.
우리는 이 중에서 1차 미분 마스크를 사용하는 sobel operator 알고리즘을 통해 외곽선을 검출할 것이다.
이 과정에서 1차 미분에 가장 근사하도록 중앙 차분으로 계산하기에 아래의 이미지와 같은 1차 미분 마스크를 사용하게 된다. 그리고, 이를 가우시안 필터와 convolution 하면 수평과 수직에 대한 sobel operator가 만들어진다.
Sobel operator 알고리즘에서 가우시안 필터와 convolution 하는 이유는 가우시안 필터가 중심에 가까운 픽셀에 더 큰 가중치를 부여하기에 노이즈의 영향이 줄어들기 때문이다.


.. 나도 복잡하다는 걸 잘 안다. 나 또한 이해한 지 얼마 되지 않았기에 나름대로 최선의 설명을 했다는 점 양해 바란다.
아무튼 정리한 원리를 바탕으로 이를 구현해 보면 아래와 같은 결과를 얻을 수 있다.

그럼 edge detection은 어떻게 구현하는가?
Sobel operator는 마찬가지로 블루프린트로 작업했는데, 구조가 복잡해서 material function을 사용해서 정리했다. 가장 먼저 전체적인 구조를 살펴보자.

많이 복잡해 보이는데 별 거 없다. 입력으로 받은 LineWidth에 따라 GetNeighbourUVs로 주변 8 방향의 uv를 구하고, ExtractDepthMap을 통해 해당 uv의 custom depth 정보를 가져온다.
그 뒤, CombineMap을 통해 수평과 수직 sobel operator와 각 uv의 custom depth 데이터를 convolution 한 뒤 각 값들을 더한다. 그리고 이렇게 더한 값이 곧 edge detection의 결과가 된다.

이렇게만 설명하면 이해가 안 될 수 있으니 아래의 이미지를 예시로 같이 확인해 보자.

위 이미지의 픽셀 값은 행렬로
여기에서 아래와 같이 수직 sobel operator를 곱한 뒤 모든 요소를 더해주면 -1020의 값을 얻을 수 있다.
다음으로 아래와 같이 수평 sobel operator를 곱한 뒤 모든 요소를 더해주면 0이라는 값을 얻을 수 있다.
계산 결과를 볼 때 수직 검출의 경우 -1020의 값이, 수평 검출의 값이 0이 나온 걸 확인할 수 있다. 이때 우리는 vector length 노드를 통해 길이, 즉 절댓값을 계산했기에 이 경우 해당 좌표에 1020의 값이 들어가게 되어 외곽선이 있음을 알게 되는 것이다. (예시 출처 : DIY 메카솔루션 오픈랩)
[영상 처리 강좌] 소벨 검출이란? 소벨 엣지 검출기 알아보기 (sobel edge detection)
안녕하세요 이번에는 소벨 검출기를 이용한 가장자리 검출에 대해서 알아보도록 하겠습니다. s...
blog.naver.com
전체 과정을 다시 한번 살펴보면 다음과 같다.
주변 UV 좌표(texture 좌표)에 대해 계산할 때는 방향 offset을 GetOffsetPixel로 넘겨주어 선 굵기만큼 곱한 뒤 화면의 inverse size를 곱해주어 UV 좌표계로 변환한다. 그리고 이렇게 변환한 방향 offset UV 좌표를 현재 UV에 더해주는 방식으로 주변 UV를 구한다.


다음으로는 이렇게 구한 주변 UV 좌표를 G-buffer의 custom depth 데이터에 매핑하여 해당 값을 가져온다. 그 뒤 해당 값을 DepthDistance 값 기준으로 정규화(0에서 1 사이 값으로 변환)하여 MaxWidth 값을 넘지 않도록 clamp 한 뒤 깊이 정보를 반환한다.

이 작업이 완료됐다면 CombineMap을 통해 sobel operator를 곱해준 뒤 모두 더한 값을 반환한다.

그리고 최종적으로 vector length 노드로 절댓값으로 변환하여 해당 값을 반환해 외곽선을 검출한다.

이게 끝이다.
Sobel operator 말고 다른 방법은 없나?
Sobel operator 말고도 prewitt, scharr 등의 다양한 방법이 있다. Prewitt는 가우시안이 아닌 평균값(1, 1, 1) 필터로 만든 operator고, scharr는 3:10:3의 가중치를 갖게 하여 조금 더 가우시안 분포에 가깝게 구성한 operator다.

그리고, 나는 GradientComponent와 CentralWeight라는 변수를 만들어 위의 operator에 더불어 여러 가지 operator를 테스트해 봤는데 아래는 그에 대한 결과다. (림라이트와 노멀 외곽선이 적용된 상태)



내부의 line은 어떻게 구성하는가?
외곽선을 구성했다면 다음은 내부의 선을 구성할 차례다.
내부는 world normal 데이터를 활용하여 간단하게 구현할 수 있는데 다음과 같이 현재 UV 기준 상하좌우에 대한 노멀 데이터 값을 구한 뒤 그 차이를 모두 더해 반환하는 방식으로 내부의 선들을 검출할 수 있었다.



정리
- 외곽선은 custom depth와 edge detection 알고리즘을 통해 구한다.
- Custom depth는 scene depth와 달리 개발자가 선택한 오브젝트를 별도의 깊이 버퍼에서 관리하는 것을 말한다.
- 변화량을 나타내는 중앙 차분 미분 마스크로 edge detection operator를 만들 수 있다.
- 수평과 수직에 대한 edge detection operator를 현재 UV와 주변 UV에 대한 custom depth buffer 값과 convolution 하고, 그 값을 모두 더하여 외곽선을 검출한다.
- World normal 데이터를 활용하여 현재 UV와 주변 UV의 normal 값 차이로 내부 선을 검출한다.
림라이트 (Rim Light)
마지막은 림라이트다. 림라이트(Rim Light)란 피사체의 외곽선을 따라 형성되는 빛의 테두리를 말한다. 이러한 설명에서 알 수 있듯이 림라이트는 lighting buffer에 outline과 color를 곱해서 만들 수 있다.




이 부분에서 특이 사항이라고 하면 metalic material에 대한 림라이트가 제대로 구현되지 않아서, 림라이트를 위한 metalic material에 대한 림라이트 강도 parameter를 추가했다는 것이다. 이에 대한 조정 예시는 아래와 같다.


이를 통해, 특정 수치를 기준으로 림라이트를 구성했을 때 metalic material의 림라이트가 만들어지지 않는 문제를 임시로나마 해결했다.
정리
- 림라이트는 lighting buffer에 outline과 color를 곱해서 만들 수 있다.
결과

결과는 위와 같이 성공적으로 구현할 수 있었다. 다만, 플레이해보니 약간씩 끊기는 느낌이 들어서 Gpu visualizer를 확인해 봤다.


Post process volume의 material 적용 전후를 측정해 봤는데 보이는 것과 같이 1ms 밖에 차이 나지 않았다. 이게 큰 값인지, 이것 때문에 생기는 문제인지는 아직 잘 모르겠다.
뭐.. 이번에 그동안 관심만 많던 셰이더 제작 쪽으로 한 걸음 나아갔으니 앞으로 차근차근 알아보면 되지 않을까.. 이쯤 하면 첫 시도로는 만족한다.
마치며
오랜만에 작업이 아닌 학습을 하고, 또 그 과정과 결과를 기록하니 기분이 좋다. 확실히 작업과 학습을 병행해서 하는 게 피폐해지지 않고 나아갈 수 있는 방법인 것 같다.
그러나, 이런 소감과는 다르게 이번 기록을 작성하는 데는 조금 힘들었다. 늘 그랬지만 실질적으로 이해하고 구현하는 데 걸린 시간은 반나절 정도인 것에 반해, 글을 쓰는 데는 꼬박 하루가 걸렸다.. 인생 😓😓
뭐 어쩌겠나. 빠르게 정리하지 못했다는 것은 제대로 이해하지 못했다는 것이며, 이는 곧 내가 무엇을 정리하고 기록하는지 몰랐다는 것이니 고생을 하는 것도 당연지사다.
그래도 이번 기록을 위해 다시금 원리를 알아보고, 이것저것 시도하는 과정에서 여러 지식을 더할 수 있었고, 글을 정리하고 다듬는 과정에서 생각 또한 함께 구체화하고 연결할 수 있었다. 이를 통해 한 순간의 휘발적인 지식이 아닌 오래 남을 지식으로 체화했다는 점에서 내 스스로를 칭찬하고 싶다.
다시금 말하지만 내가 셰이더를 공부하는 이유는 기획자로서의 취업에 도움이 되기 위해서가 아니라 개인적인 흥미이자 취미로 학습을 하는 것이다.
언젠가 1인으로 팬 게임을 만들어보고 싶은데 그때를 위해서라면 카툰 렌더링을 잘 다룰 필요가 있다. 그때가 되면 <명일방주 : 엔드필드>나 <Guilty Gear -STRIVE-> 수준의 셰이더를 구현할 수는 없어도 이해할 수는 있는 수준이 되기를 바랄 뿐이다. (근데 언뜻 보니까 수학 되게 잘해야 되더라.. 열심히 공부해야지..)


아무튼 혹시라도 기획자를 지망하는데 삽질한다고 오해하지는 말아줬으면 한다. 아무리 내가 추구하는 기획이 모든 영역에 대한 이해를 바탕으로 방향성을 엮어내는 것이라고 해도 지금 파고드는 게 필요 이상으로 너무 깊다는 것 잘 안다. 그냥 이건 취미다.
어떻게 됐든 또 이렇게 글 하나를 마무리 지었다. 요즘 작은 팀 프로젝트를 하면서 나의 지난 기획들을 돌아보고 있는데, 작년에는 이상적인 기획에 대한 고민을 해 성취를 얻었다면, 이번에는 현실적인 기획에 대한 실마리를 잡을 수 있을 것 같은 느낌이다. 이것도 정리가 되면 기획에 관련된 글로 찾아오도록 하겠다.
다들 길고 어려운 글을 끝까지 읽어줘서 고맙고, 지금 하는 일 모두 잘 되기를 기원하면서 글을 마무리 짓겠다.
아디오스! 🙂 (아! 혹시라도 글에 잘못된 부분이 있다면 언제든 피드백 부탁드리겠습니다. 그럼 진짜 안녕!)
'개발 > 언리얼' 카테고리의 다른 글
[Game Design Lab] 구조 기록 (0) | 2025.06.11 |
---|---|
언리얼 엔진으로의 원신 모델링 적용 기록 (실패) (2) | 2024.06.08 |
[Blueprint] VR 환경에서 HUD를 구성하는 방법 (1) | 2024.01.16 |
[Blueprint] 나이아가라 시스템에서 외부 데이터를 변수로 입력 받는 방법 (0) | 2024.01.10 |
[Blueprint] Linetrace (0) | 2024.01.09 |