NPR Project Demo

06. 2 Pass Outline(Inverted Hull)_[NPR Project]

NyumMa 2026. 2. 27. 23:36

NPR Stylized Shading에서 Outline의 중요성

NPR(Non-Photorealistic Rendering) Stylized Shading을 구현하는데 있어서

Iconic한 느낌을 내게 하는 요소 중에는

cell shading 과 같이 여러가지가 있지만

그중 하나를 선별하자면 당연 Outline일 것이다. 

 

NPR은 물리적으로 정확한 광학의 재현보다는,
시각적 인상을 명확하고 의도적으로 전달하는 것을 목표로 한다.(한마디로 Beauty)

 

Outline은 오브젝트의 실루엣을 따라 선을 추가함으로써
현실 세계에서는 존재하지 않는 만화적 표현을 만들어낸다.

 

Outline은 오브젝트의 외곽에 윤곽선을 추가하여 현실 세계에서 보여지지 않을 법한 만화적인 스타일의 효과를 만든다. 

필자의 그림을 예시로 일본 캐릭터 일러스트, 애니메이션 풍의 2D 그림에서

동일한 캐릭터에 대해 윤곽선이 옅게 적용된 경우와 강하게 적용된 경우의 차이를 보여준다.

Outline이 만들어내는 시각적 효과 분석

두 차이점을 통해 윤곽선의 npr 스타일의 효과를 극적으로 만드는  효과에 대하여

감각적 분석해 볼 필요가 있는데, 필자가 생각하는 효과는 다음과 같다. 

 

1. 실루엣 강조
오브젝트의 외곽이 명확해지며, 시선이 자연스럽게 피사체로 집중된다.

 

2. 형태의 깊이 보완
셀 쉐이딩은 명암 단계를 제한하기 때문에 깊이 정보가 단순화된다.
이때 실루엣 영역(뷰 방향과 법선이 수직에 가까운 영역)에
윤곽선이 존재함으로써 깊이 단서를 보완한다.

 

3. 정보 결핍 보완

사실적인 묘사와 다르기 때문에 발생하는 충분하지 않은 정보의 인지적 결핍에 대해 보완하는 역할을 한다.   

Photorealistic shading은 물리 기반의 미묘한 광량 변화로 형태를 인지하게 만든다.
반면 NPR은 정보가 의도적으로 단순화되기 때문에,

실루엣과 윤곽선 같은 명확한 형태 단서가 더 중요해진다.

 

4. 카툰 인지 친화성

윤곽선은 오브젝트의 형태를 한눈에 파악하기 쉽게하는 단서이다.

전통적인 카툰 일러스트 애니메이션 제작 방식은 윤곽선 실루엣을 통해 먼저 형태감을 잡은 다음

내부의 채색을 하는 방식을 사용하여 카툰이라고 인식하는데 있어서 친화적이다.

 

결과적으로 Outline은 단순한 카툰 스타일 NPR을 표현하기 위한 장식 요소에 한정되는 것 뿐만 아니라
NPR에서 형태 전달을 책임지는 핵심적인 요소라고 할 수 있다.

 

본 글에서는 이러한 Outline생성을 위한 방법과 핵심요 요소에 대해서 다루어 볼 것이고 

그중 Outline을 제어하기 쉽고 범용적으로 사용되는 2 pass outline 방식에 대해서 중점적으로 다루어본다. 


Outline 생성 방식

3D에서 Outline을 생성하는 방식은 크게 3가지가 있다.

1. Geometry based

2. screen space kernel based

3. deep learning based

 

각 방식은 구현 위치, 제어 가능성, 퍼포먼스 특성, 그리고 스타일 안정성 측면에서 뚜렷한 차이를 가진다.

 

1. geometry based

(2pass normal extrusion , inverted hull)

 

본 글에서 사용할 2pass outline 방식이 기하 형상의 확장을 통해 outline을 만드는 방식이다.

 

가장 전통적인 NPR Outline 구현 방식으로,

간단하게 설명하자면
메쉬 정보를 두번 그려서(2pass)

오브젝트의 색상과 빛의 결과물에 대한 pass와 

오브젝트의 형상을 후면에서

각 정점의 normal 방향으로 확장하여 표현하는 pass를 합치는 방식이다. 

 

normal 확장의 기하학적인 방식이므로 선의 두께 제어가 쉽고 직관적이며

쉐이더에서 추가 패스를 통해 구현하기 때문에 머터리얼 단위로 선 두께를 제어가 가능하다는 장점이 있다.

하지만, 추가 패스가 생성되는 만큼 DrawCall이 증가한다는 단점이 있고 geometry topology에 의존하기 때문에

기하학적 형상에 따라 outline이 원하는 위치에 제대로 표현이 안되게 보일 수 있다는 단점이 존재한다. 

 

2. screen space kernel based

(sobel filter / canny edge detection)

 

영상처리에서의 edge detection 알고리즘을 사용하여 outline을 만드는 방식이다. 

최종 화면 영상에 대하여 Kernel Function을 통해

Kenny detection, 혹은 Sobel Filter를 사용해 edge정보를 추출한 후

이를 최종 영상과 결합한다. 

각 인접 픽셀들의 이산적인 변화량을 gradient로 계산하여 커널을 통해 엣지를 추출한다. 

최종 화면 또는 Depth, Normal 버퍼에 대하여
인접 픽셀 간의 변화량(gradient)을 계산하여 엣지를 추출한다.

 

예를 들어 위의 그림과 같이 x, y에 대한 sobel 필터를 통해 각 방향의 미분 변화량을 계산하고

해당 값이 일정 임계값(threshold)을 넘는 영역을 엣지로 판단한다.

즉 ,화면의 픽셀들의 변화율을 기반으로 한 outline 방식이다.

Kernel Function은 기본적으로 Threshold 기반이기 때문에 불필요한 노이즈들이 표시되고

선 두께들을 제어하기 어렵다는 단점이 있다. 또한 ScreenSpace 기반이기 때문에

오브젝트와 경계의 구분을 할 수 없어 제어하기 어렵다는 명확한 단점이 존재한다. 

 

3. DL based

CNN, GAN, Diffusion 등으로 여러 데이터들의 정보들을 기반으로 학습하여 

오브젝트의 강조할 경계들을 표현한다.

해당방식과 위의 edge detection 방식의 근본적 차이점은

학습된 네트워크가 edge가 형성될 영역을 추론하여 결과를 보여준다는 것이다.

 

edge detection 방식이 아니기 때문에

학습 가중치 파라메터에 따라서 보다 정교하게 제어할 수 있도록 조정할 수 있으며 

특히나 GAN, Diffusion과 같은 Generative Model은 stylized한 연출에 특화되어 있다. 

하지만 RTR 환경에서는 사용하기에 아직까지 명확한 한계가 존재하고 GPU 성능에 의존적인 문제가 있다. 

또한 연속적인 프레임을 보여주어야 하는 영상에 있어서 프레임 일관성이 깨지기 쉽고 제어하기 어렵다는 단점이 있다.


2 pass Outline 선정 이유

캐릭터 중심 NPR 스타일에서 Outline은 단순한 보조 효과가 아니라
형태 인지를 담당하는 핵심 요소이다.

 

따라서 Outline 구현 방식은 다음 조건을 만족해야 한다.

  1. 선 두께를 아티스트가 직관적으로 제어할 수 있어야 한다.
  2. 캐릭터별, 오브젝트별 개별 설정이 가능해야 한다.
  3. 모바일 환경에서도 안정적으로 동작해야 한다.
  4. 퍼포먼스가 예측 가능해야 한다.
  5. 스타일 일관성이 유지되어야 한다.

이 기준을 바탕으로 각 방식의 한계를 다시 살펴보면 다음과 같다.


Kernel 기반 방식의 한계

Screen-space Kernel 기반 방식은
영상 전체에 대해 엣지를 자동으로 추출할 수 있다는 장점이 있다.

그러나 해당 방식은 근본적으로 Threshold 기반의 미분 연산에 의존한다.
따라서 다음과 같은 제약이 존재한다.

 

1. 선 두께를 정밀하게 제어하기 어렵다.

2. 원하는 오브젝트에만 선택적으로 적용하기 어렵다.

3.노이즈에 민감하다.

4.해상도 및 Depth 변화에 따라 결과가 달라질 수 있다.

 

무엇보다 중요한 점은,
3D 공간에서는 2D 영상과 달리 정점 정보와 기하학적 구조를 직접 활용할 수 있다는 것이다.

이미 기하 정보를 알고 있는 상황에서
굳이 화면 결과를 다시 분석하여 엣지를 추출할 필요는 없다.

즉, 3D 환경에서는 Kernel 기반 방식이 최적 선택은 아니다.


DL 기반 방식의 가능성과 한계

CNN, GAN, Diffusion 기반 Edge Detection 또는 Stylization 방식은

노이즈 억제 능력이 뛰어나고 스타일 학습이 가능하며 복잡한 경계도 자연스럽게 표현할 수 있기에
결과물과 표현력 측면에서는 매우 강력한 방식이라고 할 수 있다.

 

그러나 현재 실시간 렌더링(RTR) 환경에서는 다음과 같은 제약이 존재한다.

 

1. 높은 연산 비용

2. 프레임 간 일관성 문제

3. 아티스트가 수치적으로 직접 제어하기 어려움

(학습 기반 방식은 내부 동작이 명시적이지 않기 때문에, 아티스트가 직접 제어하기 어렵다는 한계가 있다.)

 

실시간 렌더링의 철학은
예측 가능성과 안정성에 있다.

현 시점의 실무 환경에서는
Outline 구현 방식으로 채택하기에는 아직 명확한 한계가 존재한다.


한편으로 분명한 것은, DL 기반 방식은 분명히 향후 렌더링 패러다임을 바꿀 잠재력이 있는 분야
이다.
특히 모델 경량화와 하드웨어 가속이 발전한다면
Stylized Rendering 전반에서 큰 변화를 가져올 가능성이 높다.

(지금도 현재진행형이다. Stylized GAN, Neural Shading, Diffusion etc)

 


Geometry 기반 2Pass 방식의 선택

2Pass 방식 역시 단점이 아얘 없는 것은 아니다.

2pass outline 방식은 추가 패스로 인해 DrawCall이 증가하여 성능제약이 일부 존재하고

Geometry topology에 의존하여 Normal 확장 방식 특성상 특정 형상에서 왜곡이 발생할 수 있다.

 

그러나 이러한 단점은 다음과 같은 방식으로 보완 가능하다.

 

1. Vertex Color, 별도 채널 등을 활용한 마스킹

2. View space 보정

3. z offset 보정

4. 스무스 노멀 사용 (심한 요철에 대비)

 

2Pass로 인한 DrawCall 증가는 존재하지만

NPR에서의 조명 계산이나 연산량등을

사실 기반의 고 연산 랜더링과 비교했을때  어느정도 관리 가능한 수준이다.

 

 

오히려 실제로는 텍스처 해상도 최적화나 압축 포맷 선택이
퍼포먼스에 더 큰 영향을 미치는 경우가 많다.

 

중요한 것은 2pass normal 방식은 3d 정보를 기반으로 한 제어 방식이기 때문에

수치적이고 아티스트 친화적인 방식이라는 점이다. 

따라서, 캐릭터 중심 NPR에서는 Geometry 기반 2Pass 방식이
가장 통제 가능하고, 안정적이며, 실무 친화적인 선택 방향이 된다.


실제로 대부분의 NPR 게임들은 normal 확장 기반 outline에 여러 비주얼적으로 보이는 문제점들을 다른 방식으로 

보완해 문제를 해결하며 실무에서 오랫동안 많이 쓰인 방식이므로 

본 프로젝트에서는 2pass normal 확장 기반 outline shader를 사용할 것이다.


Rendering Pipline review

2Pass Outline을 구현하기 전에,

단순히 셰이더 내부의 기법만 이해하는 것에는 한계가 있다.


2Pass 방식은 추가 패스를 통해 결과를 합성하는 구조이며,

이 과정은 CPU가 GPU에 드로우 순서와 렌더 타깃 구성을 어떻게 지시하느냐,

렌더링 파이프라인(Rendering Pipeline) 구조와 밀접하게 연결된다.

 

특히 Geometry 기반 Outline은 같은 메시를 두 번 렌더링하기 때문에,
해당 패스가 렌더링 흐름의 어느 단계에서 실행되는지에 따라 결과가 달라질 수 있다.

 

따라서 본 섹션에서는 렌더링 파이프라인의 개념을 정리하고,

대표적인 라이팅 구조인 Forward RenderingDeferred Rendering의 차이를 리뷰함으로써,

본 프로젝트에서 어떤 방식을 선택하는 것이 자연스러운지 점검한다.

 

이를 위해 먼저 자주 혼동되는 두 용어, Graphics PipelineRendering Pipeline을 구분할 필요가 있다.


Graphics Pipleine vs Rendering Pipline 

Graphics Pipline

일반적으로 알고 있는 그래픽스 파이프라인의 구조이다. (위의 도식화된 그림은 DX12의 구조이다)

 

그래픽스 파이프라인은 OBJ 파일이나 FBX 와 같은 3D 기하정보를 갖고 있는 파일이 랜더러에 업로드 되었을때

정점 정보들(위치, 노말, 탄젠트, 스무딩 옵션, etc)과 텍스쳐 정보들이 

GPU 내부에서 어떤 순서로 데이터가 변환되고 전달되어

최종 출력 책상(frame buffer)으로 출력되는지에 대하여

다루는 전체적인 과정을 의미한다.

 

이러한 일련의 과정을 드로우 콜(draw call)이라고 하며,

쉽게 말해서, GPU 내부에서 드로우콜이 처리될 때

정점 데이터가 어떻게 변환되고 픽셀로 래스터라이즈되며,

최종적으로 프레임버퍼에 기록되는지에 대한 처리 흐름을 의미한다.

 

이 과정에서 그래픽스 API는

vertex shader, pixel(fragment) shader와 같은 programmable stage를 제공하고

우리는 셰이더 언어(HLSL, GLSL, CG 등)를 통해 해당 단계의 동작을 제어할 수 있다.

 

정점 정보를 입력 받아 적절히 변형하고, fragment에서

원하는 동작 처리를 통해(uv 좌표에 맞게 샘플링 후 텍스쳐 출력, 빛계산, 색상 계산 등)

최종 픽셀의 RGBA 4차원 벡터의 값들을 하나 하나씩 출력을 할 수 있게 되는 것이다. 


 

반면, 랜더링 파이프라인은 그래픽스 파이프라인과 서로 연관성이 밀접하고 강하게 연결되어 있지만

그렇다고 서로 같은 용어를 의미하는 것은 아니다. 

 

Rendering Pipeline은

CPU가 어떤 순서로 GPU에 어떤 드로우콜을 제출하고,

어떤 렌더 타깃(Render Target)에 무엇을 기록하며,

어떤 패스를 언제 수행할지 등을 정의하는

Rendering Flow(스케줄러) 이다.

 

흔히 랜더러(Renderer)라고 부르는 것은 

이러한 렌더링 흐름을 구조화한 구현체이며,

여러 엔진(Game Engine: 유니티, 언리얼, Godot / DCC: 마야, 블렌더, Renderman etc)의 설계에 따라

상이하고 이에 따른 세부 단계와 품질, 기능이 달라진다.

 

중요한 핵심은 서로 다른 랜더러를 사용하더라도 위의

그래픽스 파이프라인의 흐름에서 모델의 정점 데이터들이 전달되는 과정 자체에는 큰 변화가 없다는 것이다.

(정점 변환 -> 기하 조립 -> 래스터화 -> 픽셀 처리 -> 출력)

 

따라서, 두 용어는 연관서이 높고 밀접하지만 지칭하는 Field가 다르므로

두 용어에 대해 혼선하지 말고 서로 분리하여 생각할 필요가 있다.

(대부분의 정리글이나 개념 설명에서 두 용어를 혼선하여 사용하는 경우를 많이 보았다.)

 

그래픽스 파이프라인은 하드웨어단에서의 처리 과정에 좀더 치우쳐져 있고 

랜더링 파이프라인은 cpu -> gpu 로 넘어가는 데이터의 순서를 제어하는 방식에 좀더 치우쳐져 있다.

집합 관계로 따지자면 랜더링 파이프라인이 더 넓은 범주에 속해 있는 관계라고 말할 수 있겠다.

 

이제 Rendering Pipeline이 구체적으로 어떤 항목들을 순서로 정의하는지 살펴보자.


Rendering Pipeline

Rendering Pipeline은 단순히 그린다는 개념이 아니라,
GPU의 Graphics Pipeline을 여러 번 호출하여 한 프레임을 구성하는

그래픽스 파이프라인의 상위 스케줄링 구조이다.

 

Unity, Unreal과 같은 상용 게임 엔진들은
상기 서술하였듯이 엔진마다 제공하는 기능이 다르고(해당 부분에서 엔진의 특성이 나타남)

명칭과 세부 구현은 다르지만, 공통적으로 다음과 같은 단계적 구조를 가진다.

 

1.Shadow pass

2. Depth Prepass

3. Opaque Pass

4. Skybox

5. Transparent Pass

6. Post Processing

 

여기서 각 Stage는

독립적인 Graphics Pipeline의 실행 단위이며,
GPU 내부의 vertex shader-> rasterization  -> fragment shader -> outputmerge 과정이 반복적으로 수행된다.

 

유니티의 Rendering Pipline에 대한 구조는

기본적으로 유니티 프로젝트를 생성할 시

PC_Renderer와 Mobile_Renderer 두가지로 나뉘어 자동적으로 생성되며 

이는 PC 환경과 모바일 환경의 하드웨어 제약에 따라 랜더링 파이프라인을 타기 위한

솔루션과 제약이 서로 다르기 때문에 발생하는 구조적 차이다. 

 

Unity는 Rendering Pipeline을 직접 다이어그램으로 보여주지는 않지만, 유니티에서 제공하는
Frame Debugger를 통해서 실제 실행 순서를 간접적으로 확인할 수 있다.

Frame Debugger는 한 프레임 동안 GPU에 제출된 모든 렌더 패스를 순차적으로 확인할 수 있으며,

이를 통해 Rendering Pipeline의 구조를 실증적으로 분석할 수 있다.

 

상단 우측 Window -> Analysis -> Frame Debugger탭에서 확인 가능하다.

 

Forward Lighting기준(프로젝트에서 사용할)

현재 Scene에서

FrameDebugger를 통해 확인한 유니티 URP 기준

한 프레임을 생성하기 위한 랜더 파이프라인의 기본 구조는 다음과 같다.

 

DepthPrepass
DrawOpaqueObjects
FinalBlit

Depth Prepass

depth prepass 단계에서는 color 출력 없이 depth만 기록된다.
이는 ealry z 및 ssao 계산을 위한 기반 정보로 사용된다.

SSAO

SSAO는 depth 및 normal 기반의 screenspace 효과이며,
lighting 이전 단계에서 적용된다.

Opaque(forward lighting 에서는 조명이 해당 단계에 같이 계산됨)

 

해당 단계에서 Shadow Map, Post Process와 같은 기능들을 사용하거나

카메라를 여러개 배치할 때마다 랜더링 파이프라인의 과정이 추가되거나 달라진다.


Render Path

엔진에서 렌더링 파이프라인의 기본적인 Stage 구조는 유사하지만,

각 Stage 내부에서 조명을 어떻게 처리하느냐에 따라 전체 구조는 크게 달라질 수 있다.

 

특히 Lighting Stage의 위치와 처리 방식에 따라
동일한 엔진에서도 서로 다른 Render Path가 존재한다.

대표적으로 위에서 설명하였듯이 크게 다음 두 가지 방식이 있다.

  1. Forward Rendering
  2. Deferred Rendering

이 두 방식은 단순히 조명 계산을 다르게 한다는 개념 뿐 만 아니라
Lighting이 어느 시점에서 수행되는가에 따라

Rendering Pipeline의 흐름 자체가 달라진다.

 

해당 방식의 차이로 2pass Outline을 사용하기 적합한 랜더러가 있고 사용하기 부적합한 랜더러가 나뉘어져 지기 때문에 

두 방식에 대한 처리의 차이에 대해서 알아볼 필요가 있는 것이다. 

 


Forward Rendering

 

1. DepthPrepass
2. DrawOpaqueObjects (Lighting 포함)
3. DrawTransparentObjects
4. PostProcessing
5. FinalBlit

 

Forward Rendering에서는
Opaque Pass 내부의 Fragment Shader에서 직접 조명 계산이 수행된다.

 

이전까지 작성했던 diffuse, specular, rim light, emission과 같은 픽셀 색상 연산들이 opaque pass의

그래픽스 파이프라인에서 fragment 단계에 한번에 계산된다.

 

즉, 정리하면 Forward Pass는 오브젝트를 그리는 순간 하나의 픽셀에서 조명 정보까지

함께 한번에 계산한다고 말할 수 있다. 

 

Forward renderer는 전통적인 랜더링 파이프라인 구조로 오랫동안 사용되어 왔고 직관적이며 Opaque pass 단계에 픽셀에서

라이팅 계산까지 한번에 처리되므로 이를 통한 stylized된 연출을 하기 쉽다. 

또한, 그리는 오브젝트에 대해 여러 pass를 두어 pass마다 개별적으로 계산을 처리하기 유리한 구조이다.

 

반면 Forward 방식은 라이트 경로가 많아질수록 픽셀에 누적 계산 되어야 하는 광원 정보가 추가되므로 계산이 비싸고 

따라서 라이트가 많은 대형 씬에서는 성능적 제약이 발생하기도 한다. 

 

한편, 최근 엔진들은 전통적인 Forward Lighting 방식에서 개선한 Forward+ 방식을 지원하는데,

간단히 설명하자면 조명이 계산되어야 하는 부분들을 tile, cluster 기반으로 분할하여

lighting이 계산되어야 하는 부분과 아닌 부분들을 구분하여 처리하는 방식이다,


Deferred Rendering

 

1. GBuffer Pass
2. Deferred Shading
3. DrawForwardOnly
4. PostProcessing
5. FinalBlit

 

Defered Rendering은 Forward Rendering에서 광원의 부하( 라이트 비용) 문제를 해결하기 위해 도입된 방식이다. 

문자 그대로 Defered(지연된, 연기된) Rendering이라는 의미로써 라이트 계산을

과정 뒤로 미뤄서 계산하는 방식이다. 

 

Defered Rendering의 설계 철학은 

1. 먼저 기하 정보, 물성 및 재질 정보들을 화면의 임시 텍스쳐(GBufffer, Geometry Buffer)에 저장을 해두고 

2. 저장된 텍스쳐 정보를 통해 별도의 lighting pass 단계에서 조명을 전체 화면을 기준으로 한번에 계산

이라는 방식이다.

 

GBuffer에는 

Albedo, Normal, Specular, Metallic, Roughness, Depth texture 와 같은 정보들이 포함된다.

유니티 URP의 Defered renderer의 GBuffer각 텍스쳐의 채널에 대해서 다음과 같은 구조를 가지고 있다. 

https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@12.0/manual/rendering/deferred-rendering-path.html

Defered Rendering 은 조명 정보가 많아져도 추후 화면 전체에 대해서 조명 계산을

한꺼번에 처리하기 때문에 대량의 라이트 환경에서 성능 향상을 끌어 올리기에 최적화 되어 있다. 

따라서 여러 lighting pass를 두어 구현하는 수준 높은 AAA 그래픽 환경을 구성하는데 있어서

대부분의 게임들은 Defered Lighting을 선호한다.

 

하지만 GBuffer에 기하, 재질 정보들을 임시로 저장해야 되므로

그만큼 텍스쳐의 메모리 및 대역폭에 대한 비용이 커지며

이는 특히 하드웨어적 한계가 있는 모바일 환경에서 큰 제약이 있는 방식이다. 

 

또한 Outline과 같이 geometry 기반의 multi pass 확장과 라이팅 연산 흐름이 분리되어 있기 때문에

순서를 의도하여 제어하기가 까다롭다.

Defered 방식은 앞서 설명하였듯이 기하 정보들은 사전에 먼저 texture로 저장한 다음

조명 계산을 나중에 GBuffer를 조합하여 한번에 처리한다고 하였다. 

 

라이팅이 geometry와 분리되어 수행되기 때문에 object 단위로 쉐이더를 커스터마이즈하는 데 구조적 제약이 발생한다.

원하는 효과를 위한 Pass(Outline, Depth Only Pass)에 대해서

Defered는 이러한 정보들까지 합쳐 조명계산을 한번에 처리하기 때문에

별도의 pass를 두게 되면 의도치 않은 결과가 발생한다. 


2 Pass Outline(Inverted Hull) 방식에서 Forward Path 의 사용 이유

지금까지의 forward, defered renderer의 차이점을 정리해보면 다음과 같이 정리해 볼 수 있다.

  Forward Lighting Deffered Lighting
Lighting 시점 Geometry(Opaque) Draw 시점 Geometry 이후 화면 전체에 대해서 처리
계산 단위 per object per pixel
GBuffer 사용 X O
Multi-pass 확장 용이 구조적 제약
대량의 Light 비용 높음 효율적(최적화)
Stylized 제어 직관적임 구조적인 고려 필요
메모리 사용 비교적 적음 GBuffer의 MRT(multip render target)
메모리, 대역폭 한계

 

Forward Rendering은

Lighting이 Geometry와 함께 처리되므로
object 단위로 여러 pass를 두어 효과를 처리하거나 이를 통한 stylized shading에 유리하다고 할 수 있다. 

 

Deferred Rendering
Light 수가 많은 AAA그래픽이나 고품질 효과 등의 최적화 처리에는 적합하지만

Outline과 같은 Geometry 기반 커스터마이징 및 multi pass 구조에 제약이 존재한다.

 

deferred rendering은 기본적으로 MRT 기반 GBuffer 구조를 사용하므로,
Color만 별도로 제어하는 multipass 확장이 forward rendering보다 복잡하다.


이러한 항목 비교에 근거하여

deferred 에서 2 pass outline이 까다로운 이유는

다음과 같이 정리해 볼 수 있다. 

 

1. Geometry가 GBuffer에 기록되는 시점과 Lighting이 분리되어 있다.

Geometry 기반 Outline은 Geometry단계에서 실루엣을 완성하는 구조(per geometry lighting)이지만
Deferred는 기하정보를 조명과 분리하여 후처리하는 구조이므로
설계 관점에서 흐름이 충돌한다.

 

2. Outline 확장 Geometry가 GBuffer에 포함될 경우,

해당 픽셀은 Lighting Pass에서 일반 오브젝트와 동일한 조명 계산을 받게 된다. 이로 인해 Outline이 단순한 색 실루엣이 아닌 조명 영향을 받는 표면처럼 처리될 수 있다.

 

따라서, 캐릭터 중심 NPR 스타일에서
2 Pass 기반 Outline을 구현하기에는
Forward 구조가 보다 직관적이고 통제 가능하다.

 

그리고 NPR 환경에서는 forward의 단점인 여러 조명에 따른 성능 제약에 있어서 

대부분의 셀 셰이딩 기반 NPR은 단일 Directional Light 기반으로 구성되며,
수십 개의 동적 광원을 사용하는 경우가 드물다.

따라서 Forward shading의 단점에 대해서 어느정도 환경적으로 걱정을 덜 수 있다. 

 

실제로 defered 환경에서 invert hull(2 pass outline)을 사용하려고 하면 다음과 같은 문제가 발생한다. 

Deferred 환경에서 Inverted Hull 방식의 Outline을 적용하면,
확장된 Geometry가 GBuffer에 기록되면서
Depth 테스트 및 Lighting Pass에서 일반 표면과 동일하게 처리된다.

 

그 결과, Outline이 단순한 외곽선이 아니라
조명 계산의 대상이 되어 Opaque 표면을 덮어버리는 현상이 발생할 수 있다.

 

해당 문제는 무조건 발생하는 문제라기 보다는

각 pass의 쉐이더 태그(랜더링 순서)를 어떻게 정의했는지에 따라서 달라지지만 

defered 환경에서 2pass Outline을 사용하는데에는 제어하기 까다롭다는 것이며

 

따라서 캐릭터 중심 NPR 환경에서
Geometry 기반 2 Pass Outline을 보다 직관적으로 제어하기 위해서는
Forward Rendering 구조가 더 적합하다.


2 Pass Outline이 작동하기 위한 전제 조건

지금까지 NPR 스타일에서 Outline의 필요성과 구현 방식,
그리고 Rendering Pipeline의 구조 review와

2Pass Outline구현에서 Forward Render Path를 선택한 이유를 정리하였다.

이제 실제 2Pass Outline(Inverted Hull) 구현 구조를 살펴본다.

 

그전에 먼저, 2Pass Outline을 구현하기 위해서는
단순히 Shader에 두 개의 Pass를 작성하는 것만으로는 충분하지 않다.

 

URP 환경에서는 Built-in Pipeline과 달리
Shader에 작성된 Pass가 단순히 위에서부터 순차적으로 실행되는 구조가 아니기 때문이다.

URP는 렌더링을 여러 개의 Render Stage로 나누어 관리하며,
각 Stage는 특정 조건을 만족하는 Pass만 선택하여 실행한다.

 

따라서 각 Pass가 랜더링 파이프라인의 어느 단계에에 속하는지,
그리고 GPU에서 Geometry가 어떻게 처리되어

어떻게 동작하는지를 명확히 정의해야 한다.

 

이를 실현하기 위해 반드시 구분해야 할 두 가지 요소가 있다.

 

1. Render Pass State

2. Render Pass Tag

 

이 중에서 먼저 Render State부터 살펴본다.


Render Pass State

2Pass Outline의 핵심은
기하 확장 + 가시성 제어이다.

 

Outline Pass는 확장된 Geometry가 원본 뒤에서만 보이도록 만들어야 하며,
이 역할을 수행하는 것이 바로 Render State이다.

 

이를 위해 반드시 다음과 같은 Render State가 명시되어야 다.

Cull Front
ZWrite On
ZTest LEqual

 

각 항목의 의미는 다음과 같다.


Cull

cull의 뜻은 사전적으로 '골라서 제거하다', '쓸모없는 것을 죽이다' 라는 의미이며 

GPU에서는 그리지 않아도 되는 면을 제거하여 성능을 최적화하는 기능이다.

 

일반적으로 Culling의 종류에는 크게 3가지가 있는데 

Frustrum  Culling

Backface Culling

Occlusion Culling

이 있다. 

 

1. View Frustrum Culling

카메라의 View Frustum 바깥에 존재하는 오브젝트를
CPU 단계에서 미리 제거하는 방식이다.
렌더링 파이프라인에 진입하기 전에 걸러진다.

2. Backface Culling

primtive에 대하여 카메라를 향하지 않는 면(법선이 반대 방향인 면)

에 대해서 그리지 않도록 처리하는 방식이다.

triangle정점 순서(그래픽스 api 마다 다름)를 기준으로
카메라를 향하지 않는 면을 제거하여 불필요한 픽셀 처리를 줄인다.

3. Occlusion Culling

다른 오브젝트에 완전히 가려져 화면에 보이지 않는 오브젝트를
렌더링하지 않는 방식이다.

 

여기서, 해당 쉐이더 단계에서의 Cull의 의미는

Backface Culling의 옵션 단계에 대한 설정이다. 

 

유니티에서 Cull 옵션은 다음과 같이 세가지가 있다.

 

1. Cull Back (기본값)

일반적인 3D 모델 렌더링에서 사용되는 기본 옵션이다.

후면에 대한 cull 옵션이며 명시하지 않더라도 해당 pass에서

default 옵션으로 처리된다.(Backface Culling)

2. Cull Front

전면을 제거하고 후면만 남긴다.

Inverted Hull 방식에서는
메시를 노말 방향으로 확장한 뒤
전면을 제거하고 확장된 후면만 보이도록 만들어야 한다.

즉, Cull Front는 Inverted Hull의 핵심 구성 요소이다.

확장된 메시의 외곽선만 남기기 위해
원본과 겹치는 내부 면을 제거하는 역할을 한다.

3. Cull Off

전면과 후면을 모두 렌더링한다.
Plane과 같이 두께가 없는 Geometry에서
양면을 모두 보여야 할 때 사용한다.

 plane과 같이 manifold를 이루고 있지 않은 오브젝트에 대해

내부가 뚤려 보이지 않고 surface 전부 다 보여지게끔 처리된다.

하지만 면을 두 번 그리게 되므로 비용이 증가한다.


ZWrite

ZWrite는 해당 Pass가 GPU 하드웨어의

Depth Buffer(ZBuffer)에 깊이 값을 기록할 것인지 여부를 결정한다.

 

ZWrite 옵션에는 다음과 같이 두가지가 있다.

 

1. ZWrite On = 깊이 기록

2. ZWrite Off = 깊이 기록하지 않음

 

일반적으로 ZWrite Off 옵션은 transparent물체를 다루기 위한

Alpha Blending의 문제를 해결하기 위해서 처리되며

일반적으로는 ZWrite를 켜둔다.

 

Outline Pass 에 대해 ZWrite On으로 두어
Outline이 화면에 남는 영역을 Depth Buffer에 기록하고

후술할 ZTest에서 원본메쉬와의 처리를 통해

올바르게 화면에 나타나도록 한다.


ZTest

ZTest는 현재 픽셀의 깊이 값과
이미 기록된 Depth Buffer의 값을 비교하는 연산이다.

 

ZWrite와 ZTest는 서로 다른 역할을 가진다.

ZWrite는 그 결과를 Depth Buffer에 기록할지 말지를 결정하는 과정이고
ZTest는 Depth Buffer와 비교하여 그릴지 말지를 판정한다.

 

ZTest의 옵션에는 여러 가지 옵션을 지원하는데 다음과 같다.

옵션 설명
LEqual 기존 지오메트리 앞에 있거나 기존 지오메트리와 같은 거리에 있는 지오메트리를 드로우합니다. 기존 지오메트리 뒤에 있는 지오메트리는 드로우하지 않습니다.

기본값에 해당합니다.
Equal 기존 지오메트리와 같은 거리에 있는 지오메트리를 드로우합니다. 기존 지오메트리 앞에 있거나 기존 지오메트리 뒤에 있는 지오메트리는 드로우하지 않습니다.
GEqual 기존 지오메트리 뒤에 있거나 기존 지오메트리와 같은 거리에 있는 지오메트리를 드로우합니다. 기존 지오메트리 앞에 있는 지오메트리는 드로우하지 않습니다.
Greater 기존 지오메트리 뒤에 있는 지오메트리를 드로우합니다. 기존 지오메트리와 같은 거리에 있거나 기존 지오메트리 앞에 있는 지오메트리는 드로우하지 않습니다.
NotEqual 기존 지오메트리와 같은 거리에 있지 않은 지오메트리를 드로우합니다. 기존 지오메트리와 같은 거리에 있는 지오메트리는 드로우하지 않습니다.
Always 뎁스 테스트가 실행되지 않습니다. 거리와 관계없이 모든 지오메트리를 드로우합니다.

https://docs.unity3d.com/kr/2021.3/Manual/SL-ZTest.html

 

ZTest LEqual은

현재 픽셀이 기존 Depth 값보다
같거나 더 가까울 때 통과한다.

 

Inverted Hull Outline에서는 확장된 Geometry가 원본보다 살짝 뒤에 위치하도록 설계되며,
Opaque Pass가 먼저 Depth를 기록한 상태에서 Outline Pass가 실행된다.

 

이때, Depth 비교를 통해

원본 메쉬 뒤쪽에 위치한 확장 Geometry는 대부분 Depth Test에서 탈락하고

실루엣 경계에서만 일부 픽셀이 통과하여 Outline이 남게 된다.

 

이 설정이 잘못되면 Outline이 본체 위를 덮어버리거나
실루엣이 아닌 내부까지 보이는 문제가 발생한다.


Inverted Hull Outline의 본질

정리하면 Inverted Hull Outline의 구조는 다음과 같다.

 

1. 원본 메시를 정상적으로 렌더링한다. (Opaque Pass)

2.동일 메시를 노말 방향으로 확장한다.

3. 확장된 메시에서 전면을 제거한다. (Cull Front)

4. Depth 비교를 통해 원본 뒤에서만 보이게 한다. (ZTest)

5.색상을 출력한다.(fragment)

 

즉,

메시를 두 번 그리며

두 번째 메시를 확장하고

가시성은 Depth 기반으로 제어한다

 

이것이 2Pass Outline의 기본이 되는 원리이다.


여기까지 어떻게 보이게 할 것인가에 대한

Render State를 다루어 보았다.

 

하지만 아무리 Render State를 올바르게 설정해도,
URP가 해당 Pass를 실행하지 않으면 아무 의미가 없다.

URP에서는 Pass가 단순히 작성되었다고 자동 실행되지 않는다.

 

이제 다음으로,

Pass가 렌더링 파이프라인의 어느 Stage에서 실행되는가

를 결정하는 구조,
Render Pass Tag (LightMode) 를 살펴본다.


Render Pass Tag

URP에서 Pass는 단순 실행 단위가 아니라
렌더링 파이프라인 내에서 어느 Stage에 소속될 것인지 명시하는 객체이다.

이 역할을 수행하는 것이 바로 LightMode 태그이다.

 

URP는 렌더링을 여러 Render Stage(DrawRenderers를 통해 호출)로 나누고,
각 Stage에서 ShaderTag(LightMode)를 기준으로 사용할 Pass를 선택하여 그린다.

 

즉, LightMode는

Pass가 어느 Render Stage에 포함되어 실행될 것인가를 결정하는 역할을 한다.


이 과정에서 오브젝트당 보통 해당 Stage에서 매칭되는 Pass 하나가 선택되며,
같은 LightMode가 여러 Pass에 중복되면 첫 번째 Pass가 선택되어 나머지가 실행되지 않을 수 있다.

 

따라서 2Pass Outline을 안정적으로 동작시키려면
Opaque pass와 Outline Pass를 서로 다른 별도의 Stage와 별도의 Draw 호출에서 선택되도록

LightMode와 호출 지점을 설계해야 한다


URP의 주요 Render Stage 구조

 

URP 랜더링 파이프라인은 다음과 같은 계층 구조로 이루어 져 있다고 사전에 얘기하였다.

 

1.Shadow pass(ShadowCaster)

2. Depth Prepass(DepthOnly, DepthNormals)

3. Opaque Pass

4. Skybox

5. Transparent Pass

6. Post Processing

 

쉐이더의 LightMode 태그는 DrawRenderers 기반 Stage에서만 의미를 가진다.

해당 URP의 랜더링 파이프라인의 Render Stage는
DrawRenderers를 사용하는 Stage와, 그렇지 않은 Stage로 나뉜다.

 

DrawRenderers 기반 Stage

해당 Stage는 씬의 Renderer를 순회하며
LightMode Tag를 기준으로 Pass를 필터링하여 실행한다.

stage LightMode
Shadow pass  ShadowCaster
Depth Prepass  DepthOnly, DepthNormals
Opaque Pass UniversalForward(Only), SRPDefaultUnlit
Transparent Pass  UniversalForward(Only), SRPDefaultUnlit

 

해당 Render Stage에서만 LightMode Tag가 스케줄링 키로 작동한다.

 

별도 호출 Stage

Shader의 LightMode와 무관하게
Pipeline 내부 코드에서 직접 호출된다.

 

Skybox / ScriptableRenderContext.DrawSkybox

Post Processing  / Graphics.Blit, Custom Render Pass

 

이 단계들은
LightMode로 제어되지 않는다.(scriptable render feature 등으로 관리)


2 Pass Outline에서 Tag의 사용

Pass
{
    Name "Forward"
    Tags { "LightMode"="UniversalForward" }
 	
    Cull [_Cull]
    ZWrite On
    
    HLSLPROGRAM
    ...
    ENDHLSL
}

Pass
{
    Name "Outline"
    Tags { "LightMode"="SRPDefaultUnlit" }

    Cull Front
    ZWrite On
    ZTest LEqual
    
    HLSLPROGRAM
    ...
    ENDHLSL
}
 

위의 구조는 opaque pass와 outline pass가 제대로 동작되기 위한 최소 조건을 작성한 것이다.

 

여기서 착각하지 말아야 할 점은,

Pass의 실행순서와 Stage 소속은 다르다는 것이고

Pass가 먼저 작성되었다고 해서 반드시 먼저 실행되는 것이 아니다.

 

실행 여부는 다음에 의해 결정된다

 

1. 해당 Stage에서 LightMode와 일치하는가

2. 동일 LightMode 중 우선순위가 어떻게 되는가

3. Render State가 어떻게 설정되었는가

 

따라서, 2Pass Outline은 단순히 먼저 그리고 나중에 덮는다 의 개념이 아닌

서로 다른 Render Stage에 각각 배치되는 두 Pass의 조합

이라는 구조적 개념으로 이해해야 한다.

 

첫번째 UniversalForward 태그는 Opaque Rendering Stage에 포함되어 있으며

여러 라이팅 계산들이 처리된 패스 부분이다.

두번째 SRPDefaultUnlit 태그는 조명 계산이 없는 Unlit 성격의 Pass임을 나타내며,

태그를 작성하지 않아 만약 pass 내부에 명시되어 있지 않다면

유니티는 해당 pass를 기본적으로 SRPDefaultUnlit으로 간주한다.

 

위와 같이 각 패스에 대해서 Tag를 명시해야 렌더링 파이프라인이 두 Pass를 올바른 Stage에 배치한다.

 

https://docs.unity3d.com/kr/Packages/com.unity.render-pipelines.universal%4014.0/manual/urp-shaders/urp-shaderlab-pass-tags.html

 

URP ShaderLab 패스 태그 | Universal RP | 14.0.9

URP ShaderLab 패스 태그 이 섹션에는 URP 전용 ShaderLab 패스 태그에 대한 설명이 포함되어 있습니다. 참고: URP는 다음의 LightMode 태그를 지원하지 않습니다. Always, ForwardAdd, PrepassBase, PrepassFinal, Vertex, Ve

docs.unity3d.com

 

 즉, Forward, Outline 패스에서 태그를 별도로 명시하지 않으면

해당 pass는 SRPDefaultUnlit 가 되어 

두 패스 모드 SRPDefaultUnlit lightmode가 되기 때문에

 

같은 태그에서는 우선순위로 인해 Forward Pass만 작동하고

Outline이 그려지지 않는다. 


2 Pass Outline 구현 

이제 본격적으로 Outline 기능 구현에 대해서 설명한다.  

해당 섹션에서는 vertex shader에서 object space에 대해 전통적인 노말 확장 방식으로 

Outline을 구현하는 방법을 소개하고 

해당 방식의 문제점과 보완 및 개선 코드 순서로 내용을 전개한다. 


간단한 노말 확장 방법

Pass
{
    Name "Forward"
    Tags { "LightMode"="UniversalForward" }
 	
    Cull [_Cull]
    ZWrite On
    
    HLSLPROGRAM
    ...
    ENDHLSL
}

Pass
 {
     Name "Outline"
     Tags {"LightMode" = "SRPDefaultUnlit"}

     Cull Front
     ZWrite On
     ZTest LEqual

     HLSLPROGRAM
     
     #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
     #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

     #pragma vertex vert
     #pragma fragment frag

     CBUFFER_START(UnityPerMaterial)
         half _OutlineWidth;
     CBUFFER_END

     struct Attributes
     {
         float4 positionOS : POSITION;
         float4 color : COLOR;
         float3 normalOS : NORMAL;
     };

     struct Varyings
     {
         float4 positionCS : SV_POSITION;
         float2 uv : TEXCOORD0;
         float3 normal : TEXCOORD1;
     };

     Varyings vert(Attributes input)
     {
        Varyings o = (Varyings)0;

        VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS);
        VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS);

        float3 positionOS = input.positionOS + input.normalOS * _OutlineWidth;
        vertexInput = GetVertexPositionInputs(positionOS);
        o.positionCS = vertexInput.positionCS;
        
         return o;
     }

     half4 frag(Varyings input) : SV_Target
     { 
         return float4(0,0,0,1);
     }
     ENDHLSL
 }

지금까지 코드를 구현하기 위한 세부 내용과 과정을 거창하게 설명했지만 

결국은 해당 패스를 추가하면 정상적으로 Outline이 표시된다.

 

vertex shader에서 object space에 대해서 오브젝트의 각 vertex의 normal 방향으로

원하는 offset만큼 확장하여(_OutlineWidth)

해당 확장된 vertex position을 통해 positionHCS를 구하고

이를 단색 색상으로 출력하면 원하는 윤곽선 효과를 구현해 낼 수 있다. 

 

vertexshader 단계에서 positoin의 확장으로 처리되고 특별한 사영 변환이나 연산이 없기 때문에

직관적이고 간단하게 구현할 수 있는 방법으로써 보통 단순한 형태를 가진 오브젝트 등에서 

유용하게 사용될 수 있는 방식이다. 


전통적인 노말 확장 방식의 문제점

한편, 단순히 Object Space에서 노말 방향으로 확장하는 방식은 구현은 간단하지만
다음과 같은 문제를 가진다.

 

1. 오브젝트 공간 기반 확장이기 때문에 시야각이나 카메라 거리 변화에 따라 윤곽선 두께가 일정하게 유지되지 않는다.

2. 확장 길이가 고정값이므로 오브젝트가 멀어질수록 윤곽선이 상대적으로 얇아 보인다.

3. 카메라 FOV 변화(광각,망원)에 따라 동일한 확장 길이라도 화면에서의 두께가 달라진다.

4. 요철이 심한 형상의 경우 윤곽선이 자연스럽게 형성되지 않을 수 있다. 

-> 특히 캐릭터에서 눈이나 입과 같은 부위에서 이러한 점이 부각되어 윤곽선이 형성되면 인상과 이미지에 큰 영향을 줄 수 있다. 

 

이러한 세부 디테일들을 개선하기 위해서

카메라 기준에서 일관성을 유지할 수 있는 확장 방식이 필요하다.


개선된 2 Pass Outline(1)

CBUFFER_START(UnityPerMaterial)
    half _UseSmoothNormal;
    half _OutlineWidth;
    half _nearDistance;
    half _farDistance;
    half _nearOutline;
    half _farOutline;
CBUFFER_END

struct Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS : NORMAL;
    float4 tangentOS : TANGENT;
    float4 color : COLOR;
    float2 uv : TEXCOORD0;
    float3 smoothNormal : TEXCOORD7;
};

struct Varyings
{
    float2 uv : TEXCOORD0;
    float4 positionCS : SV_POSITION;
    float3 smoothNormal : TEXCOORD1;
    float3 normal : TEXCOORD2;
};

Varyings vert(Attributes input)
{
    Varyings o = (Varyings)0;

    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS);
    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS);

    // 스무스 노말 변해서 기존 오브젝트의 하드 노말 이랑 블렌딩
    half3x3 TBN = half3x3(normalInput.tangentWS, normalInput.bitangentWS, normalInput.normalWS);
    //스무드 노말 범위 0~1 에서 -1~1로 매핑
    half3 normalTS = 2 * (input.smoothNormal - 0.5); 
    half3 normalWS = TransformTangentToWorld(normalTS, TBN, true);
    normalInput.normalWS = lerp(normalInput.normalWS, normalWS, _UseSmoothNormal);

    //뷰스페이스의 z 값 받아오기 -> 뷰 공간 기반 깊이에 따른 outline 조절하기 위해
    float z = vertexInput.positionVS.z; 
   
    //시야각 45도를 기준으로 하여 fov 커지면 outline 키우기(광각이 되므로)
    //fov 작아지면 outlie 줄이기(망원이 되므로)
    half ReferenceFov = 45.0;
    float cotRef = 1.0 / tan(radians(ReferenceFov * 0.5));
    float fovFactor = cotRef / UNITY_MATRIX_P[1].y;
    float fovz = abs(z * fovFactor);

    //해당 픽셀의 z 범위 매핑 0~1로
    float outlineratio = saturate((fovz-_nearDistance)/(_farDistance - _nearDistance));
    float width = lerp(_farOutline,_nearOutline, outlineratio) * _OutlineWidth * 0.01;

   //두께 vertexcolor로 마스킹
    half outlinemask = input.color.a;
    float maskedwidth = width * outlinemask;

    //vertexnormalInput에는 WS 정보만 있음 
    half3 normalVS = TransformWorldToViewNormal(normalInput.normalWS, true);
    normalVS = SafeNormalize(half3(normalVS.xy, 0.0));

    float3 positionVS = vertexInput.positionVS;
    //VS 기반 확장
    positionVS += maskedwidth * normalVS;

    float4 positionCS = TransformWViewToHClip(positionVS);
    o.positionCS = positionCS;

    return o;
}

half4 frag(Varyings input) : SV_Target
{
    return float4(0,0,0,1);
}

개선의 부분의 핵심은 크게 네 가지로 분류해 볼 수 있다.

해당 방식에서 개선된 항목들을 간단하게 리뷰해 본다. 

 

먼저 방식의 차이에 있어서 크게 달라진 점은

노말 확장의 방식을 기존 Object Space 방식에서 View Space 기반 확장으로 변경한 부분이다. 

 

View Space 기반으로 확장을 함으로써 특정 각도 등에 상관없이 Outline 두께에 대한 일관성을 유지할 수 있고  

랜더링된 화면을 보는 것은 결국은 카메라 기준이므로

View Space 기준 깊이에 따른 윤곽선 두께의 변화를 조절하여 처리한다. 

(멀면 윤곽선 상대적으로 두껍게, 가까우면 윤곽선 상대적으로 얇게)

 

또한 카메라의 Fov각에 따른 광각 망원 효과에 따라 줄어들고 확대된 비율에 대비하여

윤곽선의 두께가 적절히 조절되도록 처리하였다. 

 

그리고, 모델 내부에 존재하는 UV7에 스무스 노말이 저장되어 있는 정보를 이용하여 기존 모델의 노말 정보와

적절히 혼합해 요철을 제어할 수 있도록 한다. 

 

마지막으로, 모델의 vertexcolor의 alpha채널에 존재하는 

마스킹 데이터를 사용하여 눈과 입과 같은 주요 부위에 outline이 형성되지 않도록 방지한다. 


1. object space -> view space 기반 노말 확장

half3 normalVS = TransformWorldToViewNormal(normalInput.normalWS, true);
normalVS = SafeNormalize(half3(normalVS.xy, 0.0));

float3 positionVS = vertexInput.positionVS;
positionVS += maskedwidth * normalVS;

float4 positionCS = TransformWViewToHClip(positionVS);
o.positionCS = positionCS;

 

기존 방식의 문제점은 확장이 object space 기준이라는 점이다.

Object Space의 확장으로 이를 선형 변환하게 되면 위치나 각도에 따라서 윤곽선의 크기가 달라져 보이게 된다.

 

실제로 화면에 보이는 것은
카메라 기준(View Space) 결과이기 때문에

따라서 확장을 View Space 기준으로 변경하는 것이 정확하다.

(추구하고자 하는 스타일마다 다를 수는 있다.)

 

GetVertexNormalInputs함수에는 world space에 대한 변환까지 밖에 없기 때문에

이를 view space로 변환하도록 한다.

 

이때, 변환된 normal 에 대해서 z성분을 제외하고 정규화 해

화면 평면의 xy방향의 확장으로만 처리되도록 하여 outline 두께의 일관성을 보장한다.


2. 깊이에 따른 윤곽선 크기의 조정

float outlineratio = saturate((fovz-_nearDistance)/(_farDistance - _nearDistance));
float width = lerp(_farOutline,_nearOutline, outlineratio) * _OutlineWidth * 0.01;

view space에서 positionVS.z값은 카메라와 해당 vertex의 지점까지의 거리이다.

랜더링되는 공간의 basis는 camera space(View Space) 이기 때문에

깊이에 대한 파라메터를 view space 기준으로 해야만 깊이에 따른 윤곽선 조절을 올바르게 표현할 수 있다.

 

여기서 _nearDistacne, _farDistance _nearOutline, _farOutline은 카메라 z 값의

하한과 상한에 대한 outline의 하한값과 상한값의 범위를 제약한 비율에 대한 부분이다. 

 

카메라가 대상으로부터 멀어진다고 Outline을 무한정 키워서도 안되고,

가까워진다고 무한정 작게 처리하는 것은 목적과 맞지 않기 때문에

이를 수동적으로 조절할 수 있도록 처리한 부분이다. 

 

fovz(fov 보정된 z)가 _neardistance 가 되면 0값이 되고, _farDistance가 되면 1이 되는데 

이에 따라서 0일때 하한선인 _nearOutline을

1일때 상한선인 _farOutline을

중간은 ratio에 따라 outline 두께가 선형적으로 보간되도록 처리한 로직이다. 

 


3. Fov에 따른 윤곽선 크기 조절

float z = vertexInput.positionVS.z; 

half ReferenceFov = 45.0;
float cotRef = 1.0 / tan(radians(ReferenceFov * 0.5));
float fovFactor = cotRef / UNITY_MATRIX_P[1].y;
float fovz = abs(z * fovFactor);

 

이 부분은 카메라의 FOV(Field of View)에 따른 화면 투영 변화가
Outline 두께에 영향을 주는 현상을 보정하기 위한 코드이다.

유니티에서는 카메라 파라메터의 fov를 조절하여 카메라의 사이각을 조절할 수 있다. 

 

fov가 커질수록 시야각이 커지므로 광각렌즈의 효과가 나타나고

화면에 넓은 영역이 투영되므로 상대적으로 피사체의 크기가 축소된다.

한편, fov가 작아질수록 시야각이 좁아지므로 망원렌즈의 효과가 나타나며

화면에 좁은 영역에 대해서 투영되므로 상대적으로 피사체의 크기가 확대된다

 

따라서 이러한 축소 확대에 따라 윤곽선의 두께를 제어하는 것이 

위의 코드의 모습이다.

 

일반적으로 fov는 y축의 각도를 기준으로 설명하며 fov는 전체 화면의 y축에 대한 카메라의 화각이기 때문에

이에 대하여 일반적으로 view -> perspective 투영행렬을 구현할 때에는(MVP중 P matrix)

카메라의 중심선을 기준으로 한 각도인 fov/2를 통해

perspective 행렬을 구성한다. 

 

 

위의 그림은 DirectX API 기준에 대한 도식화된 그림과 P 행렬로서

전반적인 흐름을 해석하면 다음과 같다.

두번째 그림에서  D tan⁡(Fov/2)에 대하여

여기서 D는 View Space의 Z값(카메라로부터의 거리)이며,

width = D tan (Fov/2)

 

즉,

D가 커질수록 (멀어질수록) 화면에 투영되는 폭은 증가하고

FOV가 커질수록 (광각이 될수록) 동일한 거리에서도 더 넓게 퍼진다.

 

실제 카메라에서 화면 해상도는 고정되어 있기 때문에,
FOV가 변화하면 D값이 변하는 것이 아니라

P 행렬의 스케일이 변하여

화면에 투영되는 크기가 상대적으로 축소되거나 확대된다.

 

Perspective 행렬이 적용된 이후,

NDC 공간으로 변환하기 위해 동차 나누기(x/w, y/w, z/w)를 수행하게 되는데,

FOV가 변하면 Projection Matrix 내부의

 

값이 변하게 된다.

 

이 값이 곧 화면 스케일을 결정하는 항이며,
동차 나누기 이후 최종 화면 좌표에 영향을 준다.

Unity에서는 이 값이 

엔진에서 제공하는 P행렬에 대한(매 프레임마다 업데이트 되는 CBuffer)

UNITY_MATRIX_P[1].y

에 해당한다.

 

즉,

UNITY_MATRIX_P[1].y = cot(FOV/2)

이다.


이러한 해석을 바탕으로 위의 코드를 다시 봐 본다면,

해당 로직은 기준이 되는 FOV와 현재 P행렬의 FOV의  상대 비율

을 계산하는 공식이다.

 

fov가 커지게 되면 UNITY_MATRIX_P[1].y 값이 작아지고

fov가 작아지게 되면 UNITY_MATRIX_P[1].y 값이 커지게 된다.

 

최종적으로,

float fovFactor = cotRef / UNITY_MATRIX_P[1].y

에서

 

fov가 커지면 fovfactor가 커지고

fov가 작아지면 fovfactor가 작아지며

fov가 45일때는 cotRef와 같아지며 fovFactor가 1이 된다.

 

즉, 위의 코드는 fov의 변화와 기준이 되는 값(45)에 대하여

광각일때(fov가 클때)는 outline을 키우기 위해 fovfactor를 증가시켜야하고

망원일때(fov가 작을때)는 outline을 줄이기 위해 fov factor를 감소시켜야 하며

45일때를 기준이되도록 설계한 매핑이다. 


4. Smooth Normal blending

half3x3 TBN = half3x3(normalInput.tangentWS, normalInput.bitangentWS, normalInput.normalWS);
half3 normalTS = 2 * (input.smoothNormal - 0.5); 
half3 normalWS = TransformTangentToWorld(normalTS, TBN, true);
normalInput.normalWS = lerp(normalInput.normalWS, normalWS, _UseSmoothNormal);

해당 모델에는 vertex에 본래 가지고 있는 고유의 vertex normal 값 뿐만 아니라 

보다 노말의 강도가 약한smoothnormal 값이 저장되어 있다.

Object Normal  / SmoothNormal(Custom Normal) 

위 이미지를 보면 두 노말의 차이를 직관적으로 확인할 수 있다.

 

DCC 툴에서 확인시 attributes에 커스텀 노말 옵션이 있으며

노말이 각지지 않고 부드럽게 형성되어 있는 모습을 볼 수 있다. 

반면, 해당 attributes를 삭제하면 다음과 같이 오브젝트의 원래 노말이 적용되어

표면에 명확한 굴곡이 생기는 모습을 볼 수 있다. 

 

기존 노말은 모델의 토폴로지 형태를 그대로 반영하여

라이팅 등의 모델에서 사용하는 목적으로  볼 수 있다.

반면, 커스텀 노말(스무스 노말)은 내부 요철에 대해서 실루엣을 부드럽게 만들어

Outline의 요철들을 제어하기 쉽게 따로 저장한 값이라고 생각해 볼 수 있을 것이다.

 

유니티에서는 해당 smoothnormal에 대한 정보가 vertex의 texcoord7에 저장되어 있는 것을 확인해 볼 수 있다. 

Object Normal / Smooth Normal

모습은 다음과 같은데, 푸른색감으로 형성되어 있는 모습을 보아

smoothnormal은 tangent space 기준으로의

노말 데이터가 버텍스에 저장되 있는 것을 확인해 볼 수 있다. 

 

여기서 커스텀 노말에 대하여, 왜 오브젝트 공간임에도 tangent space 기준의 노말 데이터를 

저장해 놓는지에 대한 의문점을 가지게 되었다.

 

모델을 만든 원 제작사의 의도와 데이터 저장 방식에 대해서 역추적 해 본다면,

먼저 tangent space의 장점은

모델의 스키닝 이후에도 안정적인 방향성을 유지하게 하고 

각 공간의 사영(월드, 뷰 변환)시 일관성을 확보할 수 있다는 장점이 있을 것이다. 

 

단지, normal map과 같이 2D UV 좌표를 사용하는 방식이 아닌

모델의 vertex 자체에 normal 벡터를 저장하는 방식이라면, 직접적으로 노말 벡터를

사용하는 방식으로 처리해도 무방하지 않나 라는 생각이 들어

추가적으로 탐구해야 할 부분이기도 하다.

 

해당 tangent space의 smoothnormal은 TBN Matrix를 통해 

WorldSpace로 변환하여 기존 WorldSpace로 변환된

원본 노말값과 적절한 블랜딩을 통해 윤곽선 형태를 조정한다.


5. vertex color masking 윤곽선 제어

half outlinemask = input.color.a;
float maskedwidth = width * outlinemask;

입가나 눈 등의 위치에 요철로 인한 깊이감이 생기면

원하지 않는 위치에 윤곽선이 형성될 수 있는데

특정 부위에는 Outline이 아예 형성되지 않도록 조절 해야 한다.

 

이러한 항목을 해결하기 위한 아이디어로

DCC에서 모델 프로덕션 단계에 사전에 해당 부위에 대해 Outline이 형성되지 않도록 마스킹하여 

처리할 수 있는 방안을 생각해 볼 수 있을 것이다.

 

이를 실현하기 위해서 대부분 프로덕션에서는

VertexColor의 채널을 이용한 여러 마스킹을 수행한다. 

 

일례로, 해당 모델의 VertexColor Alpha 채널에는

눈과 입, 그리고 각 코너 부위 등에 급격한 노말의 변화로 생기는

윤곽선 형태를 제어하기 위한 마스킹 처리가 되어 있다. 

vertexcolor mask 사용 x / vertexcolor alpha 채널(마스킹용) / vertexcolor mask 사용 o


개선된 2 Pass Outline(2)

기존 방식은 Outline에 대하여 단색상의(주로 검은색) 윤곽선 처리를 하였다.

이러한 방식 또한 충분히 매력적이고 투톤의 흑백 만화 등에서 표현되는 만화적인 연출을 하거나

색상을 달리 처리하여 그 자체로 독특한 매력을 표현하도록 설정해 보는 것도 가능하다.

흑백톤의 만화적 연출의 툰 쉐이더 - by Ke Medley / 게임 '미사이드'에서 주요 포인트인 자홍색 윤곽선이 돋보인다.

한편, 일반적인 일러스트나 애니메이션 기법 등에서는 

윤곽선에 대하여 빛의 영향, 혹은 해당 재질의 고유 색상을 고려해 

윤곽선 자체에 색상을 나타냄으로써 보다 자연스럽고 부드러우며 인상적인 연출을 시도해 볼 수도 있다.

 

이러한 작업을 은어로 '선을 녹인다' 라고 말하며

예를 들어 피부 윤곽선은 보다 붉은 기가 도는 윤곽선 색상을,

머리카락이나 옷, 금속 등에는 물체 고유의 색상에 가까운 어두운 윤곽선 색 등을

아티스트의 색감에 따라 선택해 볼 수 있을 것이다. 

 

이를 구현하기 위해서

모델에 사용되는 커스텀 LightMap 텍스쳐의 마스킹 채널에서 

alpha 값이 Material ID 를 나타낸다는 정보를 통해

다음과 같이 부위별로 원하는 색상의 Outline이 형성되도록 처리해 볼 수 있다. 

    half4 lightMap = SAMPLE_TEXTURE2D(_LightMap, sampler_LightMap, input.uv);
    half material = lightMap.a;

    half4 color = _OutlineColor5;
    //0, 78, 128, 177, 255
    color = lerp(color, _OutlineColor4, step(0.2, material));
    color = lerp(color, _OutlineColor3, step(0.4, material));
    color = lerp(color, _OutlineColor2, step(0.6, material));
    color = lerp(color, _OutlineColor, step(0.8, material));

    return float4(color,1)

 

LightMap의 Alpha 채널에는

0~1 범위로 정규화된 Material ID 값이 저장되어 있고 채널 안에 포함되어 있는 색상 값은

0, 78, 128, 177, 255로

총 5개로 나뉘어져 있다고 Diffuse Ramp 단계에서 분석해 보았었다. 

https://nyumma02.tistory.com/41

 

해당식에서 분기문 if를 사용하지 않고 lerp를 사용하여 

GPU 하드웨어에서 불필요한 연산을 수행후 마스킹으로 처리하는 연산에 대해 회피할 수 있고 

따라서 warp의 branch divergence를 최소화 할 수 있다.

 

조건문에 대해서 GPU는 사전에 분기되는 것 없이

모든 분기 경로를 한번씩 실행한 다음 

warp의 32개의(AMD는 WaveFront, 64개) 스레드가 한번씩은 실행이 된 다음

(SIMT 구조이므로 병렬처리에 최적화 되어 있기 때문)

연산 결과가 개별적으로 마스킹 되어 결과가 반영된다.

 

Diffuse Ramp를 구현할때도 사용하였지만

이와 같이 조건 분기가 많은 NPR 쉐이더에서는
조건문 대신 수학적 연산 기반의 step과 lerp를 활용한 연산 기반 분기가 효과적이다. 

Outline pass Only / Outline Color 여러색상 적용 / Outline Color 단색


결과

Outline x / Outline 단색 / Material 별 Outline 분기