저자의 말.
Preface
이 글을 쓰면서 옛 추억을 떠올릴 수 있을 것 같습니다. 그래서... 처음부터 너무 어려운 것을 하고 싶지는 않았다는 말씀을 드리고 싶습니다. 처음부터 어려운 일을 다루면 제가 먼저 지칠 것 같았거든요. 본문으로 돌아가서... 각 효과 섹션은 최대한 간단하게 설명하려고 노력했습니다. 몇 년 동안 팀 운영에 신경을 쓰다 보니 개인적으로 기술적인 부분을 자세히 설명하는 능력이 떨어진 것 같아서 스스로 되새기는 자세를 유지하려고 합니다. 대부분의 설명은 프로그래머를 위한 것이 아니라 셰이딩에 관심이 있는 아티스트를 위한 것입니다. 렌더링 프로그래머나 테크니컬 아티스트에게 전적으로 의존하기보다는 구현 측면을 이해하면 아티스트도 생각을 정리한 후 소통할 수 있을 것입니다.
워낙 기본적인 내용입니다. 그래도 이런 간단한 유니티 셰이더에 관심이 있는 분에게는 참고가 되면 좋겠다는 생각으로 오래전에 따로 작성해 놨던 내용인데... 중국어로 써 놔서 다시 한글판으로 바꿔 올리면서 저도 복기하는 시간을 갖도록 하겠습니다.
주로 이런 글을 쓸 때 드립이나 쓸데없는 개인의견을 자주 넣는 편인데... 읽기 불편할 듯하여 최대한 사족은 제거하고 내용만 남겨보려고 해요.
원문 작성일 : 2020년 가을쯤.
원문 제목 : Stylized Toon shader implementation by URP.
이 예제에서는 셰이더를 Multi-pass(1) 형식으로 작성하지 않았습니다.
유니티 6 은 아래 방식을 지원하지 않아요.
렌더링은 기본적으로 포워드 렌더링이며 URP 환경에서 만들어졌습니다.
이 콘텐츠에서 배울 수 있는 점
- 간단한 툰 셰이더 처리 방법을 이해할 수 있습니다.
- PBR 조명 모델의 거칠기를 알 수 있습니다;
- NodtL의 사용법을 이해할 수 있습니다;
- 버텍스 속성이 무엇인지 확인할 수 있습니다.。
- 공간 변환 프로세스도 배우게 됩니다;
- 매우 일반적인 HLSL 셰이딩 구문과 구조에 대해 조금은 이해할 수 있습니다;
- Desmos를 사용할 수 있습니다.
기본 준비
예를 들기위해 인터넷에서 얻은 XRD의 캐릭터를 사용했습니다. 물론 노멀은 DCC 툴에서 편집한다는 것을 짐작할 수 있습니다. 유니티에서 직접 계산한 노멀 정보를 사용하지는 않을 것입니다. 메시의 인스펙터 정보에서 확인해야 할 사항이 몇 가지 있습니다. 가능하면 임포트를 사용하겠습니다.
멀티 패스 셰이딩으로 셰이더를 만들지 않으므로 두 가지 머티리얼이 필요합니다.
- 아우트라인 렌더링을 위한 OutlineMat.mat.
- 실제 캐릭터 셰이딩을 위한 ToonShadingMat.mat.
이 두 머티리얼을 Assets 디렉터리에 추가합니다. 준비가 되면 아래 그림과 같이 하나의 메시 안에 두 개의 머티리얼을 등록합니다.
아웃라인 매트에 적용된 셰이더는 컬 프런트로 렌더링 되므로 머티리얼의 순서는 중요하지 않습니다.
하나의 셰이더에서 멀티 패스를 구현하는 것은 이와 같이 두 개의 머티리얼을 적용하는 것과 같습니다. 개념적으로 멀티 패스라고 간단히 부를 수 있습니다. 언리얼에서는 오버레이 머티리얼을 활용할 수도 있겠네요.
아웃라인 렌더링 셰이더 제작
간단히 말해, 세 가지 주요 윤곽 처리 기술이 있습니다.
- 노멀 벡터 방향(노멀이 가리키는 방향)으로 메시의 버텍스를 오프셋 하고 색으로 채웁니다.
- 림 라이트 기법을 적용하는 방법.
- 포스트 프로세스 사용 방법(깊이 노멀 정보를 사용한 가장자리 감지 처리 + 소벨 필터 활용).
다음과 같이 분류할 수 있습니다: 위와 같은 작업을 하고 있다는 것을 알고 나면. 저는 방법 1로 구현해 보겠습니다.
위와 같은 것을 만들어 보겠습니다. 중간중간 보조 이론은 외부 링크를 추가합니다. 인터넷은 좋은 자료로 가득합니다.
구현.
URP Toon Outline.shader
Shader "LightSpaceToon2/Outline LightSpace"
{
Properties
{
[Space(8)]
[Enum(UnityEngine.Rendering.CompareFunction)] _ZTest ("ZTest", Int) = 4
[Enum(UnityEngine.Rendering.CullMode)] _Cull ("Culling", Float) = 1
[Header(Outline)]
_Color ("Color", Color) = (0,0,0,1)
_Border ("Width", Float) = 3
[Toggle(_COMPENSATESCALE)]
_CompensateScale (" Compensate Scale", Float) = 0
[Toggle(_OUTLINEINSCREENSPACE)]
_OutlineInScreenSpace (" Calculate width in Screen Space", Float) = 0
_OutlineZFallBack (" Calculate width Z offset", Range(-20 , 0)) = 0
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalPipeline"
"RenderType"="Opaque"
"Queue"= "Geometry+1"
}
Pass
{
Name "StandardUnlit"
Tags{"LightMode" = "UniversalForward"}
Blend SrcAlpha OneMinusSrcAlpha
Cull[_Cull]
ZTest [_ZTest]
// Make sure we do not get overwritten
ZWrite On
HLSLPROGRAM
// Required to compile gles 2.0 with standard srp library
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x
#pragma target 2.0
#pragma shader_feature_local _COMPENSATESCALE
#pragma shader_feature_local _OUTLINEINSCREENSPACE
// -------------------------------------
// Lightweight Pipeline keywords
// -------------------------------------
// Unity defined keywords
#pragma multi_compile_fog
//--------------------------------------
// GPU Instancing
#pragma multi_compile_instancing
// #pragma multi_compile _ DOTS_INSTANCING_ON // needs shader target 4.5
#pragma vertex vert
#pragma fragment frag
// Lighting include is needed because of GI
#include "Packages/cohttp://m.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
half4 _Color;
half _Border;
half _OutlineZFallBack;
CBUFFER_END
struct VertexInput
{
float4 vertex : POSITION;
float3 normal : NORMAL;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct VertexOutput
{
float4 position : POSITION;
half fogCoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
VertexOutput vert (VertexInput v)
{
VertexOutput o = (VertexOutput)0;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
half ndotlHalf = dot(v.normal , _MainLightPosition)*0.5+0.5;
// Extrude
#if !defined(_OUTLINEINSCREENSPACE)
#if defined(_COMPENSATESCALE)
float3 scale;
scale.x = length(float3(UNITY_MATRIX_M[0].x, UNITY_MATRIX_M[1].x, UNITY_MATRIX_M[2].x));
scale.y = length(float3(UNITY_MATRIX_M[0].y, UNITY_MATRIX_M[1].y, UNITY_MATRIX_M[2].y));
scale.z = length(float3(UNITY_MATRIX_M[0].z, UNITY_MATRIX_M[1].z, UNITY_MATRIX_M[2].z));
#endif
v.vertex.xyz += v.normal * 0.001 * (_Border * ndotlHalf);
#if defined(_COMPENSATESCALE)
/ scale
#endif
;
#endif
o.position = TransformObjectToHClip(v.vertex.xyz);
o.fogCoord = ComputeFogFactor(o.position.z);
// Extrude
#if defined(_OUTLINEINSCREENSPACE)
if (_Border > 0.0h) {
float3 normal = mul(UNITY_MATRIX_MVP, float4(v.normal, 0)).xyz; // to clip space
float2 offset = normalize(normal.xy);
float2 ndc = _ScreenParams.xy * 0.5;
o.position.xy += ((offset * (_Border * ndotlHalf)) / ndc * o.position.w);
}
#endif
o.position.z += _OutlineZFallBack * 0.0001;
return o;
}
half4 frag (VertexOutput input ) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
_Color.rgb = MixFog(_Color.rgb, input.fogCoord);
return half4(_Color);
}
ENDHLSL
}
}
FallBack "Hidden/InternalErrorShader"
}
코드가 생각보다 길어요......
윤곽 렌더링 마스크를 만듭니다.
콘셉트: 특별히 할 말은 없습니다. 일반적으로 윤곽선 두께 처리는 버텍스 색상 중 하나로 칠해 더 두껍게 만들거나 아예 렌더링 하지 않습니다.
struct Attributes//appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 color : COLOR;// Related of vertex color rgba attribute from mesh.
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings//v2f
{
float4 position : POSITION;
float4 color : COLOR;// Related of vertex color rgba attribute data delivering to vertex stage.
half fogCoord : TEXCOORD0;
half4 dubugColor : TEXCOORD1;
half3 dotBlend : TEXCOORD2;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
코드를 간단히 살펴보겠습니다.
struct Attributes//appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 color : COLOR;// Related of vertex color rgba attribute from mesh.
UNITY_VERTEX_INPUT_INSTANCE_ID
};
어트리뷰트는 모델링 된 메시에서 셰이더로 전달할 정보를 의미합니다.
이런 식입니다;
Unity3D에서는 이와 같은 구조를 생성하고 버텍스, 노멀, UV 정보 등 메시 데이터의 정보를 가져옵니다.
프로그래머와 이야기할 때 버텍스 속성에 대해 이야기할 때 업계 표준으로 사용한다는 것은 서로 통신하는 데 문제가 없도록 한다는 의미입니다. 엔진에 상관없이...
속성을 보면 색상이 있나요? 메쉬는 어떤 색일까요? 생각해 보면 우리에게 익숙한 버텍스 색을 아시나요? 바로 그 겁니다.
버텍스 컬러 어트리뷰트를 구조에 넣어야 버텍스 단계로 전달할 수 있습니다. 쉽죠? 구조를 만들면 어떤 어트리뷰트를 어트리뷰트 패키지로 만들지 정의할 수 있습니다. Attributes 패키지에 넣으면 실제로 셰이더 스테이지로 전송할 수 있는 패키지로 만들어야 하겠죠?
간단한 설명은 아래 그림을 참조하세요.
버텍스 셰이더와 픽셀 셰이더가 이미지를 화면에 그리는 과정을 살펴봅시다. 그림과 같이 직사각형 메시가 있다고 가정해 보겠습니다. 이것이 화면에 이미지로 그려진다고 가정하면 먼저 버텍스 속성 처리 장치, 즉 버텍스 - 셰이더를 통과해야 합니다. 버텍스 - 셰이더 프로세스에서 Sin()을 사용하여 버텍스에 리플 효과를 추가할 수도 있습니다. 개념적으로 더 쉽게 이해할 수 있도록 각 프로세스 단위를 공장에 있다고 생각해 보았습니다. 버텍스 어트리뷰트는 버텍스-셰이더 프로세스에서 Packet(번들, 묶음)으로 패키징 됩니다. 이때 새로운 위치 값이나 기타 정보가 패킷화 됩니다. 이때 각 버텍스의 속성 중 위치 값(Position)이 필수 속성으로 전송됩니다.
패킷화 된 데이터는 버텍스-출력 단계(스테이지)에서 래스터라이저 스테이지를 거치게 됩니다. 래스터라이저 스테이지는 쉽게 말해 픽셀화 단계라고 생각하면 됩니다. 더 정확히 말하면 이미지 정보는 2차원 배열의 픽셀로 구성되며, 이 점과 픽셀을 일정한 간격으로 조합하여 하나의 이미지 정보를 표현합니다. 즉, 한 줄로 연속된 픽셀의 집합이라고 할 수 있으며, 이를 처리하는 것을 래스터라이저라고 합니다. 위 그림과 같이 삼각형을 그리면 래스터라이저는 꼭짓점의 세(XYZ) 위치를 하나씩 모아 삼각형을 만든 다음, 그 안에 들어갈 픽셀을 찾습니다.
그런 다음 래스터라이저-출력이 프래그먼트 단계로 전송되고 마지막으로 픽셀 셰이더가 계산을 수행하여 최종 색상을 결정합니다.
이때 생성하는 것은 Varying 구조체입니다.
struct Varyings//v2f
{
float4 position : POSITION;
float4 color : COLOR;// Related of vertex color rgba attribute data delivering to vertex stage.
half fogCoord : TEXCOORD0;
half4 dubugColor : TEXCOORD1;
half3 dotBlend : TEXCOORD2;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
구조체를 만든다는 것은 이 구조체를 타입으로 사용할 수 있다는 뜻인가요? (좀 어렵죠?) 이때는 그냥 외우는 게 더 쉬워요.... 가변형 버텍스 스테이지를 만들 수 있다는 뜻입니다. 아무튼 이런 구조체를 이용해 타입을 정의할 수 있으니 아래와 같이 버텍스 셰이더 스테이지를 만들 때 Varyings 구조체 타입으로 만들어주세요. 그리고 인수의 목록으로 입력되는 Attributes 타입으로 정의하고 전달합니다. Varyings vert(속성 입력)를 해석해 보겠습니다.
속성 유형의 입력 목록이 Varyings 유형의 vert 함수에 전달되는 것으로 이해할 수 있습니다.
Varyings vert (Attributes input)
{
Varyings o = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
float ndotlLine = dot(input.normal , _MainLightPosition);
//Color mask
half vertexColorMask = input.color.a;//여기에 어트리뷰트 중에서 받아온 컬러 중에서 A체널을 넣어줍니다.
input.vertex.xyz += input.normal * 0.001 * lerp(_BorderMin , _BorderMax , 1);
input.vertex.xzy *= vertexColorMask;//버택스 컬러 마스크를 곱해줍니다.
o.position = TransformObjectToHClip(input.vertex.xyz);
o.position.z += _OutlineZSmooth * 0.0001;
return o;
}
half vertexColorMask라는 변수를 추가하고 여기에 input.color.a를 넣습니다. 이런 식으로 입력. vertex.xzy *= vertexColorMask에 이 값을 곱하면 vertexColorMask 변수에 저장된 0~1의 값에 아웃라인 두께 처리를 곱해서 0 값으로 채색된 버텍스 컬러 부분이 반환값이 됩니다. 0이니까 외곽선 두께는 0이 되겠죠?
외부 윤곽선 두께의 특성 파악하기
선 변수에 버텍스 색을 사용합니다: 외부 윤곽선은 버텍스 색의 A 채널에 기록됩니다. 흰색에 가까울수록 굵어지고 검은색에 가까울수록 얇아집니다.
input.vertex.xyz += input.normal * 0.001 * lerp(_BorderMin * vertexColorMask , _BorderMax , 1);
위 형식에서는 목적에 따라 _BorderMin에 vertexColorMask를 곱하거나 _BorderMax에 vertexColorMask를 곱할 수 있습니다.
라이트 스페이스 윤곽선 너비 구현;
개념
만화, 석고 그림, 다양한 필기구를 사용한 선화 등 선을 사용하여 그림을 그릴 때 빛을 받는 부분의 선은 얇게 그리거나 생략하고, 그 반대의 경우도 마찬가지입니다. 측면을 더 어둡게 또는 두껍게 그려서 나만의 입체감을 표현할 수 있습니다. 이는 조명 방향에 따라 표현 부분을 처리할 수 있도록 하기 위함입니다.
다음은 훨씬 더 이해하기 쉬운 만화 캐릭터 그림 참고 자료입니다! 어쨌든 이렇게 표현했습니다.
구현
위 코드 중 어느 부분이 라이트 스페이스 개요와 관련이 있는지 살펴봅시다. 구현은 다음과 같습니다.
당연히 윤곽선은 버텍스 단계에서 처리되고 있습니다. 아래 코드를 먼저 살펴봅시다.
Varyings vert (Attributes input)
{
Varyings o = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
float ndotlLine = dot(input.normal , _MainLightPosition);
//Light Space Outline mask here.
o.dotBlend = ndotlLine;
//Color mask
half vertexColorMask = input.color.a; //Put the A channel among the colors received from the attributes here.
input.vertex.xyz += input.normal * 0.001 * lerp(_BorderMin * vertexColorMask , _BorderMax , (ndotlLine ));
o.position = TransformObjectToHClip(input.vertex.xyz);
o.position.z += _OutlineZSmooth * 0.0001;
return o;
}
코드를 보면 작업에 추가된 정크 코드도 포함되어 있습니다. 생각보다 훨씬 더 많이 ndotl 연산을 사용하여 마스크 또는 가중치 값을 생성합니다.
위 이미지를 3D 렌더링이라고 생각하지 말고 포토샵의 퀵 마스크라고 생각하세요. 흰색에 가까운 가중치는 1, 검은색에 가까운 가중치는 0으로 수렴한다고 생각하면 쉽게 이해할 수 있습니다. 결국 위의 결과값을 선형 보간인 lerp 함수의 블렌딩 가중치에 넣으면 가중치에 따라 lerp(A , B , blending Weight)의 결과값이 반환되는 것입니다. 위의 그림을 해석하면 원형의 왼쪽으로 갈수록 A의 값은 1에 가까워지고, 오른쪽으로 갈수록 B의 값은 1에 가까워지는 것을 알 수 있습니다. 블렌딩 가중치에 ndotl 값을 넣었기 때문입니다. 아래 코드를 다시 살펴봅시다.
코드에서 가장 중요한 부분은 플로트 ndotl-Line 부분이라고 생각합니다.
float ndotlLine = dot(input.normal , _MainLightPosition);
윤곽선 두께는 최소값과 최대값의 두 가지 값으로 구성됩니다. 이 두 값을 혼합하겠습니다. ndotl-Line 값이 가중치로 사용됩니다.
input.vertex.xyz += input.normal * 0.001 * lerp(_BorderMin , _BorderMax , ndotlLine);
버텍스 위치는 법선 방향으로 오프셋 됩니다. 이때 _BorderMin과 _BorderMax의 두 값 사이에 가중치 ndotl-Line을 추가하여 선형 보간을 수행합니다. BorderMin과 _BorderMax를 분리한 이유는 거리에 따라 선 굵기를 변경하는 기능을 구현할 때 아티스트가 얇은 면과 두꺼운 면의 변화값을 유연하게 설정할 수 있도록 하기 위해서입니다.
이 경우 input.normal, 즉 객체 공간 노멀을 직접 사용할 수 있습니다. 이를 월드 스페이스로 변경할 필요가 없습니다.
Work in clip space
버텍스 위치를 변환하기 전에 버텍스 위치와 노멀 벡터를 클립 공간으로 변환하는 것입니다. 이렇게 하면 모델 변환을 월드 스페이스로 우회하여 오브젝트 크기 조정에 대응할 수 있습니다(변환 후 노멀을 정규화하는 경우). 순서는 노멀을 월드 스페이스로 변환해야 합니다. 뷰 투영 공간으로 변환하기 전에 월드 스페이스로 변환해야 하기 때문입니다.
Varyings vert (Attributes input)
{
Varyings o = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
float ndotlLine = dot(input.normal , _MainLightPosition) * 0.5 + 0.5;
//Generated ClipNormals
o.normalWS = TransformObjectToWorldNormal(input.normal).xyz;
half3 normalVS = TransformWorldToViewDir(float4(o.normalWS,0) , 1 ).xyz;
float2 clipNormals = TransformWorldToHClipDir(float4(normalVS,0) , 1 ).xy;
//Light Space Outline mask here.
o.dotBlend = ndotlLine;
//Color mask
half vertexColorMask = input.color.a;//Put the A channel among the colors received from the attributes here.//将A通道放在从这里的属性接收到的颜色中。
o.position = TransformObjectToHClip(input.vertex.xyz);
half2 offset = ((_BorderMax * vertexColorMask) * o.position.w) / (_ScreenParams.xy / 2.0);
offset *= o.dotBlend;
o.position.xy += clipNormals.xy * offset * 5;
o.position.z += _OutlineZSmooth * 0.0001;
return o;
}
버텍스 행렬 변환
메시의 속성이 패키징 되어 버텍스 단계에 입력되면 래스터라이저 단계 바로 전에 공간 변환을 수행해야 합니다.
아주 잘 정리된 블로그가 있어서 링크해 두었습니다.
참고로 모든 버텍스 속성이 버텍스 스테이지를 벗어나면 클립 공간에 존재하며, 이를 NDS 또는 정규화된 디바이스 공간이라고도 합니다.
인터뷰를 보면 가끔 NDS라는 단어가 나오는데, 클립 공간은 정규화된 디바이스 공간이라고 이해하시면 됩니다.
월드 스페이스 공간 변환에 대한 참고 사항입니다.
기본 제공 기능을 사용하세요:
SpaceTransforms.hlsl을 살펴보겠습니다.
float3 normalWS = TransformObjectToWorldNormal(input.normalOS);
내부적으로는 다음과 같습니다:
float3 TransformObjectToWorld(float3 positionOS)
{
#if defined(SHADER_STAGE_RAY_TRACING)
return mul(ObjectToWorld3x4(), float4(positionOS, 1.0)).xyz;
#else
return mul(GetObjectToWorldMatrix(), float4(positionOS, 1.0)).xyz;
#endif
}
TransformObjectToWorld
위 코드에서 SHADER_STAGE_RAY_TRACING 부분은 레이 트레이싱이 활성화되었을 때 사용되는 분기이며, 일반적으로 TransformObjectToWorld()은 mul(GetObjectToWorldMatrix(), float4(positionOS, 1.0)). xyz입니다.
//Generated ClipNormals
o.normalWS = TransformObjectToWorldNormal(input.normal).xyz;
half3 normalVS = TransformWorldToViewDir(float4(o.normalWS,0) , 1 ).xyz;
float2 clipNormals = TransformWorldToHClipDir(float4(normalVS,0) , 1 ).xy;
clipNormals
카메라 거리에 따라 두께 유지...
이제 셰이더의 내부 및 외부 외곽선의 두께가 카메라 거리에 따라 유지됩니다. 사실 이 문제는 개발팀 내에서 직접 게임을 다루고 수정하는 것이 좋습니다. 스크립트(컴포넌트)로 제어하는 것이 더 나은지 셰이더에서 제어하는 것이 더 나은지 테스트해야 합니다.
그런 다음 코드에서 직접 구현해 보겠습니다.
_ScreenParams.xy
Z 버퍼 값을 선형화할 때 사용합니다.
x is (1-far/near), y is (far/near), z is (x/far), w is (y/far).
half2 offset = ((_BorderMax * vertexColorMask) * o.position.w) / (_ScreenParams.xy / 2.0);
o.position.w가 여기 있나요? 더 자세한 지식을 얻고 싶으신가요? 그렇다면 동종 좌표를 이해해야 합니다.
Z-Correction.
윤곽선을 렌더링 할 때 NDS의 Z 축에 오프셋 함수를 추가하여 오브젝트 내부에서 겹치는 선 부분을 수정할 수 있습니다.
o.position = TransformObjectToHClip(input.vertex.xyz); //NDS space
o.position.z += _OutlineZSmooth * 0.0001; //Z correction 은 여기서 간단하게 ...
Diffuse Ramp shading
NdotL Ramp Shading
훈련에는 일반적으로 램버트 방정식을 사용하지만 두 개의 벡터가 필요합니다;
- 노멀 벡터. ( 노멀 벡터 : 노멀 방향 )입니다;
- 조명 벡터. (라이트 벡터: 빛의 방향)
- Dot Product ( 도트 곱 )
- Step 또는 SmoothStep 함수를 사용할 수도 있습니다;
두 벡터 사이의 도트 곱을 구합니다. 데모스에서 max(0,dot(n,l)) 식을 확인해 보겠습니다.
dot(x, y)는 점의 곱을 구하는 함수입니다. max(0 , X ) 함수는 0보다 작거나 같은 값을 버립니다.
엔닷엘 램버트 brdf에 대해 더 자세히 알고 싶으신가요?
벡터 n과 벡터 l이 모두 1로 정규화되었다고 가정합니다. 벡터 정규화란 무엇인가요? 이에 대해 궁금하다면 아래 링크에서 자세히 알아볼 수 있습니다.
이 주제의 핵심은 수학적으로나 여러 가지 면에서 포괄적인 내용이 아니므로 건너뛰셔도 됩니다.
개인적으로 저는 ndotl을 여러 곳에서 사용하는 편입니다. 도트 곱으로 얻은 값이 매우 유용하기 때문입니다. 저는 이것을 마스크 정보로 사용합니다. 눈치 빠른 아티스트라면 이미 알고 계실 겁니다. 어쨌든 위에 표시된 2D 그래프 곡선을 보면 가중치로 볼 수 있을까요? 셰이더에서 러프를 사용할 때 러프 웨이트는 매우 중요한 입력이 될 수 있습니다. 러프가 무엇인지 모르시나요? 그렇다면 이 글을 계속 읽어보세요.
NdotL을 추가하면 Step( ) 함수를 사용하여 만화 효과처럼 보이게 할 수 있습니다.
확산 램프 추가
하프 램버트 래핑 라이팅을 추가했습니다.
디퓨즈 영역과 그림자 영역을 구분합니다.
half4 LitPassFragment(Varyings input, half facing : VFACE) : SV_Target
{
...
Light mainLight = GetMainLight(shadowCoord);
float3 halfDir = normalize(viewDirWS + mainLight.direction);
float NdotL = (dot(normalDir,mainLight.direction));
float NdotH = max(0,dot(normalDir,halfDir));
float halfLambertForToon = NdotL * 0.5 + 0.5;
half atten = mainLight.shadowAttenuation * mainLight.distanceAttenuation;
half3 brightCol = mainTex.rgb * ( halfLambertForToon) * _BrightAddjustment;
...
}
PBR 스페큘러 반사 추가
전체 조명 모델이 PBR이 아닌 경우 이 방법은 큰 의미가 없을 수 있습니다. 기존의 블린-퐁 스페큘러 NDF는 광택 반사도 다루지 않기 때문에 충분합니다. 하지만 저는 학습을 위해 과감히 BeckMann-NDF를 사용했습니다.
만화 스타일의 스페큘러 표현을 원한다면 아래 링크를 통해 SmoothStep 함수에 대해 알아보세요.
PBR 백만 스페큘러 추가.
이전 포스팅을 살펴보시기 바랍니다.
PI에 대한 정의 추가
#define _PI 3.14159265359
이는 스페큘러 메서드의 에너지 절약과 관련이 있습니다.
에너지 보존에 대한 관련 주제는 아래를 참조하세요.
베크만 NDF 구현.
// Beckmann normal distribution function here for Specualr
half NDFBeckmann(float roughness, float NdotH)
{
float roughnessSqr = max(1e-4f, roughness * roughness);
float NdotHSqr = NdotH * NdotH;
return max(0.000001,(1.0 / (_PI * roughnessSqr * NdotHSqr * NdotHSqr)) * exp((NdotHSqr-1)/(roughnessSqr * NdotHSqr)));
}
우선 툰 셰이딩을 개발 중이므로 환경 반사는 잊어버리고 위의 결과를 가중치로만 생각하세요. 흰색 부분이 광택 부분이 됩니다. 툰 셰이딩의 스페큘러 화이트를 추가하는 것보다 스페큘러 컬러를 추가하는 것이 좀 더 자연스러울 수 있으므로 NDFBeckmann 함수를 적절히 사용하는 것도 한 가지 방법이 될 수 있습니다. (가중치 값으로 사용하는 것을 잊지 마세요.)
예를 들어 디퓨즈 램프 셰이딩에 직접 스페큘러를 추가하면 다음과 같은 결과가 나타납니다:
finCol += smoothstep( 0.1 , _SpecEdgeSmoothness, spec ); 형식으로 테스트합니다. (톤 매핑이 적용된 결과)
스페큘러 텍스처 생성.
사실 이건 굳이 필요 없는 작업일 수 있습니다. 다만 실험적인 차원에서 그냥 진행해 보는 것이라고 보시길 바랍니다.
이 섹션에서 구현 방법이나 결정된 내용은 주어진 상황이나 목적에 따라 매우 개인적 일 수 있습니다. 게임의 장르나 제한된 상황에 따라 변경할 수 있는 범위가 너무 다양할 수 있습니다. 이 장에서는 이해를 돕기 위한 연구 사례로 봐주시면 좋을 것 같습니다.
half SpecularWeight = smoothstep( 0.1 , _SpecEdgeSmoothness, spec );
적절한 변수 이름을 가진 변수를 추가하고 smoothstep( 0.1 , _SpecEdgeSmoothness, spec )을 포함시킵니다.
서브스턴스 디자이너를 열고 미리 시뮬레이션을 해보겠습니다. 시뮬레이션을 하려면 두 개의 렌더링 패스, 즉 디퓨즈 패스와 스페큘러웨이트 패스가 필요합니다.
실제로 렌더 버퍼의 개별 패스를 캡처할 필요는 없으며, 적절한 화면 캡처 도구를 사용하여 창 캡처 설정을 지정하고 화면 캡처를 사용하면 됩니다. 이때 셰이더 코드의 픽셀 셰이더 단계에서 적절한 줄 바꿈 뒤에 반환 값으로 half4()를 반환합니다. 이를 화면에 던지면 원하는 패스와 유사한 결과를 얻을 수 있습니다.
두 패스의 텍스처 크기는 포토샵에서 2의 거듭제곱 형식으로 캔버스 크기를 수정하여 미리 변경했습니다. 서브스턴스 디자이너는 2의 거듭제곱 형식의 그래프 캔버스만 지원하기 때문입니다.
이와 같은 두 개의 비트맵(Unity에서 캡처한 Diffuse 및 SpecularWeight)
테스트용 스페큘러 컬러 맵입니다.
색상 범위 바꾸기 노드를 사용하여 스페큘러 색상 맵을 생성 했습니다.
블렌드 노드에서 복사가 여전히 고정된 모드를 확인했습니다. 그냥 기본 Lerp를 사용할까요? 좋습니다.
두 패스가 합쳐지면 이런 느낌이 출력되어야 한다고 생각합니다. 이제 셰이더를 수정해 보겠습니다.
스페큘러 컬러 맵 샘플러를 추가했습니다.
Code.
Properties
{
[Header(Surface Inputs)]
[Space(5)]
[MainTexture]
_MainTex ("Diffuse Map", 2D) = "white" {}
_SpecColorTex ("Specular Color Map", 2D) = "white" {} // Added Specular Color map descriptor property.
_SSSTex("SSS (RGB)", 2D) = "white" {}
_ILMTex("ILM (RGB)", 2D) = "white" {}
[Space(5)]
sampler2D _MainTex;
sampler2D _SpecColorTex;
샘플러 2D를 추가했습니다.
float4 mainTex = tex2D(_MainTex,input.uv);
float4 specClorTex = tex2D(_SpecColorTex, input.uv);
픽셀 셰이더 스테이지에서 스페큘러 컬러 텍스처 샘플러 디스크립터를 연결합니다.
이제 메인텍스와 스펙컬러텍스를 렌더링 할 수 있겠죠? 해봅시다.
Code implementation. ( Pixel shader stage )
half spec = NDFBeckmann(_Roughness , NdotH);
float SpecularMask = ilmTex.b;
half SpecularWeight = smoothstep( 0.1 , _SpecEdgeSmoothness, spec );
float shadowContrast = step(shadowThreshold * _ShadowRecieveThresholdWeight,NdotL * atten);
half3 ToonDiffuse = brightCol * shadowContrast;
half3 mergedDiffuseSpecular = lerp(ToonDiffuse , specClorTex , SpecularWeight * (_SpecularPower * SpecularMask));
finCol.rgb = lerp(shadowCol,mergedDiffuseSpecular ,shadowContrast);
톤 비료의 경우 mainLight.color.rgb의 추가를 일시적으로 비활성화하고 비교했습니다. 서브스턴스 디자이너에서 예측한 것과 동일한 결과를 보여줍니다.
방향성 광도 2.0을 활성화했습니다.
스페큘러를 결합한 후 변수 값을 테스트합니다. (위의 미리 보기 테스트는 아래 톤 매핑을 추가한 후 실행한 것입니다).
Simple Tone mapping applied.
회사에서 프로젝트를 진행하다 보면 씬팀, 배경팀, 이펙트 개발부서 간의 논의가 잦은 부분이 있습니다. 보통 톤 매핑을 적용하면 후처리 단계에서 렌더링 버퍼로 처리하기 때문에 배경, 캐릭터, 효과의 원래 색이 변색(?)되는 경향이 있습니다. 자세한 내용은 첨부된 두 문서를 참고하시기 바랍니다.
하지만 카툰 스타일 렌더링을 추구하기 때문에 색 변색이 너무 심한 리얼 스타일 HDR 톤 매핑은 사용하지 않을 것입니다. 셰이더 내부에서 아주 간단하게 처리할 것입니다.
톤 매핑 구현.
//Simple Tone mapping
finCol.rgb = finCol.rgb /(finCol.rgb + 1);
톤 맵 곡선을 시각화할 수도 있습니다. 위에서 언급한 셰이더토이를 사용해 보세요.
시뮬레이터 모드에서 전체적인 느낌을 확인했습니다. 전체적인 색상과 톤이 완성되어 보입니다.
그림자 드리우기 추가.
셰이더 내부에 Shadow-Caster라는 패스가 없으면 Unity는 그림자를 렌더링 하지 않습니다. URP Toon.shder에 패스를 하나 더 추가하겠습니다.
이 구조에 패스를 추가하기만 하면 됩니다;
Unity 엔진은 태그 유형에 따라 어떤 용도로 렌더링 할지 이미 결정했습니다. "LightMode" = "ShadowCaster"를 사용하는 것입니다. 그렇기 때문에 패스 이름 "ShadowCaster"는 중요하지 않습니다.
URP Toon.shader 안에 ShadowCaster 패스를 추가하기만 하면 됩니다.
Pass//Shadow Caster Pass
{
Name "ShadowCaster"
Tags
{
"LightMode" = "ShadowCaster"
}
ZWrite On
ZTest LEqual
Cull Off
HLSLPROGRAM
#pragma exclude_renderers gles gles3 glcore
#pragma target 2.0
#pragma multi_compile_instancing
#pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
#pragma vertex ShadowPassVertex
#pragma fragment ShadowPassFragment
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
ENDHLSL
}
pass를 추가하면 셰이더는 실제로 아래 코드를 호출하여 작동합니다.
즉, #included "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hls 코드가 함께 컴파일됩니다.
#ifndef UNIVERSAL_SHADOW_CASTER_PASS_INCLUDED
#define UNIVERSAL_SHADOW_CASTER_PASS_INCLUDED
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"
float3 _LightDirection;
float3 _LightPosition;
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 texcoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float2 uv : TEXCOORD0;
float4 positionCS : SV_POSITION;
};
float4 GetShadowPositionHClip(Attributes input)
{
float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
float3 normalWS = TransformObjectToWorldNormal(input.normalOS);
#if _CASTING_PUNCTUAL_LIGHT_SHADOW
float3 lightDirectionWS = normalize(_LightPosition - positionWS);
#else
float3 lightDirectionWS = _LightDirection;
#endif
float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));
#if UNITY_REVERSED_Z
positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif
return positionCS;
}
Varyings ShadowPassVertex(Attributes input)
{
Varyings output;
UNITY_SETUP_INSTANCE_ID(input);
output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);
output.positionCS = GetShadowPositionHClip(input);
return output;
}
half4 ShadowPassFragment(Varyings input) : SV_TARGET
{
Alpha(SampleAlbedoAlpha(input.uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap)).a, _BaseColor, _Cutoff);
return 0;
}
#endif
엔진 팀과 같이 ShadowCasterPass를 수정하거나 추가 구현을 구현하려면 여기에서 구현할 수 있습니다. 아티스트의 경우 이 구조적 메커니즘만 대략적으로 이해하면 된다고 생각합니다.
섀도 캐스터가 결과를 추가했습니다.
섀도캐스터를 추가했습니다. 바닥이나 다른 물체에 그림자를 드리우는 것을 볼 수 있습니다. 그림자가 드리워지는 것이 보이지 않는다면 두 가지를 확인하고 계속 진행하세요.
조명의 그림자가 켜져 있나요?
URP 렌더링 설정에서 그림자가 켜져 있나요?
수신된 그림자 추가.
일반 그림자는 그림자를 드리우는 그림자인 캐스트 섀도와 그림자를 받는 표면인 리시드 섀도의 두 가지 유형으로 나눌 수 있습니다. 일반적으로 수신된 그림자는 셀프 섀도로 처리된다고 합니다.
Added Self shadow Receive.
코드 구현. ( 픽셀 셰이더 단계 )
#if defined(MAIN_LIGHT_CALCULATE_SHADOWS)
float3 positionWS = input.positionWS.xyz;
#endif
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
float4 shadowCoord = input.shadowCoord;
#elif defined(MAIN_LIGHT_CALCULATE_SHADOWS)
float4 shadowCoord = TransformWorldToShadowCoord(positionWS);
#else
float4 shadowCoord = float4(0, 0, 0, 0);
#endif
Light mainLight = GetMainLight(shadowCoord);
half atten = mainLight.shadowAttenuation * mainLight.distanceAttenuation;
자체 섀도 임계값 구현.
half shadowContrast = step(shadowThreshold * _ShadowRecieveThresholdWeight,NdotL * atten);
얇은 Translucent 기능이 추가되었습니다.
일반적으로 카툰 렌더링에서는 불필요한 경우가 많은 함수입니다. 반투명 산란 효과에는 실제로 이 함수를 사용하지 않겠지만, 이 함수를 사용하여 얻은 마스크를 그림자 색 등을 변환하는 데 사용할 계획입니다. 예를 들어 lerp(resultColor,resultColor * saturation,thisFunction)를 이런 식으로 사용하고 싶습니다.
빠른 반투명에 대해 자세히 알아보려면 위의 GDC 데모를 참고하세요.
이제 색의 채도를 제어하는 함수를 하나 더 만들어야 합니다.
그리고 이미 알고 있겠지만 두께 값은 아직 없습니다. 빛이 통과할 수 있는 부분의 프로퍼티에 두께 값을 만들어야 합니다. 셰이더 안에서 만들겠습니다. 아직 최적화 단계에는 신경을 쓰지 않았습니다.
LitPassVertex 함수 구현을 위한 전체 코드입니다.
//--------------------------------------
// Vertex shader
Varyings LitPassVertex(Attributes input)
{
Varyings output = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
float3 viewDirWS = GetCameraPositionWS() - positionWS;
output.uv = TRANSFORM_TEX(input.texCoord, _MainTex);
float3 normalWS = TransformObjectToWorldNormal(input.normalOS);
output.normalWS = normalWS;
output.viewDirWS = viewDirWS;
#if defined(REQUIRES_WORLD_SPACE_POS_INTERPOLATOR)
output.positionWS = float4(positionWS , 0);
#endif
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
output.shadowCoord = GetShadowCoord(vertexInput);
#endif
output.positionCS = TransformWorldToHClip(positionWS);
output.color = input.color;
return output;
}
확장 셰이더 함수 전체 코드.
//--------------------------------------
// shader and functions
#define _PI 3.14159265359
// Beckmann normal distribution function here for Specualr
half NDFBeckmann(float roughness, float NdotH)
{
float roughnessSqr = max(1e-4f, roughness * roughness);
float NdotHSqr = NdotH * NdotH;
return max(0.000001,(1.0 / (_PI * roughnessSqr * NdotHSqr * NdotHSqr)) * exp((NdotHSqr-1)/(roughnessSqr * NdotHSqr)));
}
// Fast back scatter distribution function here for virtual back lighting
half3 LightScatterFunction ( half3 surfaceColor , half3 normalWS , half3 viewDir , Light light , half distortion , half power , half scale)
{
half3 lightDir = light.direction;
half3 normal = normalWS;
half3 H = lightDir + (normal * distortion);
float VdotH = pow(saturate(dot(viewDir, -H)), power) * scale;
half3 col = light.color * VdotH;
return col;
}
LitPassFragment 함수 부분 구현 전체 코드.
half4 LitPassFragment(Varyings input, half facing : VFACE) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
// Apply lighting
float4 finCol = 1;//initializing
float4 mainTex = tex2D(_MainTex,input.uv);
float4 specClorTex = tex2D(_SpecColorTex, input.uv);
float4 sssTex = tex2D(_SSSTex,input.uv);
float4 ilmTex = tex2D(_ILMTex,input.uv);
float shadowThreshold = ilmTex.g;
shadowThreshold *= input.color.r;
shadowThreshold = 1- shadowThreshold + _ShadowShift;
float3 normalDir = normalize(input.normalWS);
float3 lightDir = _MainLightPosition.xyz;//normalize(_WorldLightDir.xyz);
float3 viewDirWS = GetWorldSpaceViewDir(input.positionWS.xyz);
float3 halfDir = normalize(viewDirWS + lightDir);
float NdotL = (dot(normalDir,lightDir));
float NdotH = max(0,dot(normalDir,halfDir));
float halfLambertForToon = NdotL * 0.5 + 0.5;
halfLambertForToon = saturate(halfLambertForToon);
#if defined(MAIN_LIGHT_CALCULATE_SHADOWS)
float3 positionWS = input.positionWS.xyz;
#endif
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
float4 shadowCoord = input.shadowCoord;
#elif defined(MAIN_LIGHT_CALCULATE_SHADOWS)
float4 shadowCoord = TransformWorldToShadowCoord(positionWS);
#else
float4 shadowCoord = float4(0, 0, 0, 0);
#endif
Light mainLight = GetMainLight(shadowCoord);
half atten = mainLight.shadowAttenuation * mainLight.distanceAttenuation;
half3 brightCol = mainTex.rgb * ( halfLambertForToon) * _BrightAddjustment;
half3 shadowCol = mainTex.rgb * sssTex.rgb;
half3 scatterOut = LightScatterFunction(shadowCol.xyz , normalDir.xyz , viewDirWS , mainLight , _Distortion , _Power ,_Scale);
half spec = NDFBeckmann(_Roughness , NdotH);
half SpecularMask = ilmTex.b;
half SpecularWeight = smoothstep( 0.1 , _SpecEdgeSmoothness, spec );
half shadowContrast = step(shadowThreshold * _ShadowRecieveThresholdWeight,NdotL * atten);
half3 ToonDiffuse = brightCol * shadowContrast;
half3 mergedDiffuseSpecular = lerp(ToonDiffuse , specClorTex , SpecularWeight * (_SpecularPower * SpecularMask));
finCol.rgb = lerp(shadowCol,mergedDiffuseSpecular ,shadowContrast);
finCol.rgb = lerp(finCol.rgb , finCol.rgb + (shadowCol.rgb * shadowCol.rgb) , scatterOut.rgb);
finCol.rgb *= mainLight.color.rgb;
float DetailLine = ilmTex.a;
DetailLine = lerp(DetailLine,_DarkenInnerLine,step(DetailLine,_DarkenInnerLine));
finCol.rgb *= DetailLine;
//Simple Tone mapping
finCol.rgb = finCol.rgb /(finCol.rgb + _Exposure);
return finCol;
}
URP Toon.shader 코드 완성.
Shader "LightSpaceToon2/ToonBase"
{
Properties
{
[Header(Surface Inputs)]
[Space(5)]
[MainTexture]
_MainTex ("Diffuse Map", 2D) = "white" {}
_SpecColorTex ("Specular Color Map", 2D) = "white" {}
_SSSTex("SSS (RGB)", 2D) = "white" {}
_ILMTex("ILM (RGB)", 2D) = "white" {}
[Space(5)]
[Header(Toon Surface Inputs)]
_ShadowShift("Shadow Shift", Range(-2,1)) = 1
_DarkenInnerLine("Darken Inner Line", Range(0, 1)) = 0.2
_BrightAddjustment("Bright Addjustment", Range(0.5,2)) = 1.0
[Space(5)]
[Header(Toon Specular Inputs)]
_Roughness ("Roughness", Range (0.2, 0.85)) = 0.5
_SpecEdgeSmoothness ("Specular Edge Smoot",Range(0.1,1)) = 0.5
_SpecularPower("Specular Power", Range(0.01,2)) = 1
[Space(5)]
[Header(Scatter Input)]
_Distortion("Distortion",Float) = 0.28
_Power("Power",Float)=1.43
_Scale("Scale",Float)=0.49
[Space(5)]
[Header(Tone Mapped)]
_Exposure ( " Tone map Exposure ", Range(0 , 1)) = 0.5
[Header(Render Queue)]
[Space(8)]
[IntRange] _QueueOffset ("Queue Offset", Range(-50, 50)) = 0
[ToggleOff(_RECEIVE_SHADOWS_OFF)] _ReceiveShadowsOff ("Receive Shadows", Float) = 1
_ShadowRecieveThresholdWeight ("SelfShadow Threshold", Range (0.001, 2)) = 0.25
// Needed by the inspector
[HideInInspector] _Culling ("Culling", Float) = 0.0
[HideInInspector] _AlphaFromMaskMap ("AlphaFromMaskMap", Float) = 1.0
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalPipeline"
"RenderType" = "Opaque"
"Queue" = "Geometry"
}
LOD 100
Pass // Toon Shading Pass
{
Name "ForwardLit"
Tags{"LightMode" = "UniversalForward"}
HLSLPROGRAM
// Required to compile gles 2.0 with standard SRP library
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x
// Shader target needs to be 3.0 due to tex2Dlod in the vertex shader or VFACE
#pragma target 3.0
// -------------------------------------
// Material Keywords
#pragma shader_feature_local _RECEIVE_SHADOWS_OFF
// -------------------------------------
// Universal Pipeline keywords
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile_fragment _ _SHADOWS_SOFT
// -------------------------------------
// Unity defined keywords
#pragma multi_compile_fog
//--------------------------------------
// GPU Instancing
#pragma multi_compile_instancing
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
sampler2D _MainTex;
sampler2D _SpecColorTex;
sampler2D _SSSTex;
sampler2D _ILMTex;
// Material Inputs
CBUFFER_START(UnityPerMaterial)
half4 _MainTex_ST;
// Toon
half _ShadowShift;
half _DarkenInnerLine;
half _SpecEdgeSmoothness;
half _Roughness;
half _BrightAddjustment;
half _SpecularPower;
half _ShadowRecieveThresholdWeight;
// Scatter
half _Distortion;
half _Power;
half _Scale;
// Tone Map
half _Exposure;
CBUFFER_END
#pragma vertex LitPassVertex
#pragma fragment LitPassFragment
struct Attributes //appdata
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float4 color : COLOR; //Vertex color attribute input.
float2 texCoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings //v2f
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
float3 normalWS : NORMAL;
float4 vertex : TEXCOORD1;
float3 viewDirWS : TEXCOORD2;
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
float4 shadowCoord : TEXCOORD3; // compute shadow coord per-vertex for the main light
#endif
float4 positionWS : TEXCOORD4;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
//--------------------------------------
// Vertex shader
Varyings LitPassVertex(Attributes input)
{
Varyings output = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
float3 viewDirWS = GetCameraPositionWS() - positionWS;
output.uv = TRANSFORM_TEX(input.texCoord, _MainTex);
float3 normalWS = TransformObjectToWorldNormal(input.normalOS);
output.normalWS = normalWS;
output.viewDirWS = viewDirWS;
#if defined(REQUIRES_WORLD_SPACE_POS_INTERPOLATOR)
output.positionWS = float4(positionWS , 0);
#endif
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
output.shadowCoord = GetShadowCoord(vertexInput);
#endif
output.positionCS = TransformWorldToHClip(positionWS);
output.color = input.color;
return output;
}
//--------------------------------------
// shader and functions
#define _PI 3.14159265359
// Beckmann normal distribution function here for Specualr
half NDFBeckmann(float roughness, float NdotH)
{
float roughnessSqr = max(1e-4f, roughness * roughness);
float NdotHSqr = NdotH * NdotH;
return max(0.000001,(1.0 / (_PI * roughnessSqr * NdotHSqr * NdotHSqr)) * exp((NdotHSqr-1)/(roughnessSqr * NdotHSqr)));
}
// Fast back scatter distribution function here for virtual back lighting
half3 LightScatterFunction ( half3 surfaceColor , half3 normalWS , half3 viewDir , Light light , half distortion , half power , half scale)
{
half3 lightDir = light.direction;
half3 normal = normalWS;
half3 H = lightDir + (normal * distortion);
float VdotH = pow(saturate(dot(viewDir, -H)), power) * scale;
half3 col = light.color * VdotH;
return col;
}
//--------------------------------------
// Fragment shader and functions
half4 LitPassFragment(Varyings input, half facing : VFACE) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
// Apply lighting
float4 finCol = 1; //initializing
float4 mainTex = tex2D(_MainTex,input.uv);
float4 specClorTex = tex2D(_SpecColorTex, input.uv);
float4 sssTex = tex2D(_SSSTex,input.uv);
float4 ilmTex = tex2D(_ILMTex,input.uv);
float shadowThreshold = ilmTex.g;
shadowThreshold *= input.color.r;
shadowThreshold = 1- shadowThreshold + _ShadowShift;
float3 normalDir = normalize(input.normalWS);
float3 viewDirWS = GetWorldSpaceViewDir(input.positionWS.xyz);
#if defined(MAIN_LIGHT_CALCULATE_SHADOWS)
float3 positionWS = input.positionWS.xyz;
#endif
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
float4 shadowCoord = input.shadowCoord;
#elif defined(MAIN_LIGHT_CALCULATE_SHADOWS)
float4 shadowCoord = TransformWorldToShadowCoord(positionWS);
#else
float4 shadowCoord = float4(0, 0, 0, 0);
#endif
Light mainLight = GetMainLight(shadowCoord);
float3 halfDir = normalize(viewDirWS + mainLight.direction);
float NdotL = (dot(normalDir,mainLight.direction));
float NdotH = max(0,dot(normalDir,halfDir));
float halfLambertForToon = NdotL * 0.5 + 0.5;
half atten = mainLight.shadowAttenuation * mainLight.distanceAttenuation;
half3 brightCol = mainTex.rgb * ( halfLambertForToon) * _BrightAddjustment;
half3 shadowCol = mainTex.rgb * sssTex.rgb;
half3 scatterOut = LightScatterFunction(shadowCol.xyz , normalDir.xyz , viewDirWS , mainLight , _Distortion , _Power ,_Scale);
halfLambertForToon = saturate(halfLambertForToon);
half spec = NDFBeckmann(_Roughness , NdotH);
half SpecularMask = ilmTex.b;
half SpecularWeight = smoothstep( 0.1 , _SpecEdgeSmoothness, spec );
half shadowContrast = step(shadowThreshold * _ShadowRecieveThresholdWeight,NdotL * atten);
half3 ToonDiffuse = brightCol * shadowContrast;
half3 mergedDiffuseSpecular = lerp(ToonDiffuse , specClorTex , SpecularWeight * (_SpecularPower * SpecularMask));
finCol.rgb = lerp(shadowCol,mergedDiffuseSpecular ,shadowContrast);
finCol.rgb = lerp(finCol.rgb , finCol.rgb + (shadowCol.rgb * shadowCol.rgb) , scatterOut.rgb);
finCol.rgb *= mainLight.color.rgb;
float DetailLine = ilmTex.a;
DetailLine = lerp(DetailLine,_DarkenInnerLine,step(DetailLine,_DarkenInnerLine));
finCol.rgb *= DetailLine;
//Simple Tone mapping
finCol.rgb = finCol.rgb /(finCol.rgb + _Exposure);
return finCol;
}
ENDHLSL
}
Pass //Shadow Caster Pass
{
Name "ShadowCaster"
Tags
{
"LightMode" = "ShadowCaster"
}
ZWrite On
ZTest LEqual
Cull Off
HLSLPROGRAM
#pragma exclude_renderers gles gles3 glcore
#pragma target 2.0
#pragma multi_compile_instancing
#pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
#pragma vertex ShadowPassVertex
#pragma fragment ShadowPassFragment
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
ENDHLSL
}
}
FallBack "Hidden/InternalErrorShader"
}
반투명 마스크를 위한 두께 맵 제작;
두께 맵 또는 곡률 맵이란 무엇인가요? 어차피 연산에 필요하고, 연산 결과와 관련된 정보 중 제가 말씀드린 가중치도 여기에 사용됩니다. 사실 수학적으로 계산할 수도 있고, 사전 계산 방법으로 레이 트레이싱을 사용하여 구운 텍스처 형태로 정보를 얻을 수도 있습니다. 어쨌든 이 모든 것이 가중치 입력으로 사용됩니다. 쉽죠? 특별한 수학적 계산 과정이 아닌 이상 무게 정보는 정말 많은 곳에서 활용되고 있고, 무게 정보를 만드는 방법만 알아도 활용할 수 있는 범위가 많으며, 자신만의 특별한 셰이딩 처리도 할 수 있습니다.
정말 중요한 문제입니다. 하지만 수학적 지식이 넓을수록 좋다는 함정이 있긴 하지만, 실제로는 광범위하게 사용하는 것이 필수적입니다.
2D LUT를 사용한 툰 램프 효과가 추가되었습니다.
위의 챕터를 완료했다면 부록으로 2D LUT를 사용한 툰 램프에 대해서도 배우게 됩니다. 생성한 셰이더를 복사하고 이름을 변경합니다. URP Toon.shader를 복사하고 이름을 URP Toon Lut.shader로 바꿉니다.
셰이더를 분리한 이유는 간단합니다. 실무에서는 범용 외부 플러그인을 사용하는 경우가 있고, Unity 내부 기능을 사용하는 경우가 있는데... 저는 사용하지 않으려고 합니다. Uber 셰이더 유형은 아티스트에게 유연성을 제공할 수 있지만, 아티스트에게 매우 복잡하고 혼란스러울 때가 분명 있습니다. 또한 브랜치 수가 많기 때문에 셰이더가 차지하는 메모리 양이 심각한 문제인 경우가 많습니다. 예를 들어, LOD0 셰이더에 포그 관련 멀티 컴파일을 할 때 포그 내부로 들어가지 않는 멀티 컴파일을 할 필요가 없습니다. 장면 가시성, 안개의 깊이 및 기타 관련 상황을 정확하게 이해한다면 다용도성보다는 고유성에 초점을 맞추고 메모리를 최적화해야 하기 때문입니다.
수행
URP Toon Lut.shader
함수 조각.
half3 ToonRamp(half halfLambertLightWrapped)
{
half3 rampmap = tex2D(_RampTex , _RampOffset + ((halfLambertLightWrapped.xx - 0.5) * _RampScale) + 0.5).rgb;
return rampmap;
}
툰 램프 셰이딩으로 경사로 텍스처를 처리하는 함수입니다.
너비 256픽셀, 높이 2픽셀로 만든 램프 툰 텍스처 맵입니다.
서브스턴스 디자이너 또는 포토샵에서 간단하게 만들 수 있습니다.
실험 결과.
반램버트 디퓨즈와 툰 램프를 함께 사용하여 셰이딩 디버깅을 시도했습니다.
half3 debugShading = rampToon * (halfLambertForToon * halfLambertForToon * 1.25) ;
return half4(debugShading,1);
노멀 맵을 사용하지 않고 버텍스 노멀을 사용하기 때문에 음영이 부드럽지 않습니다.
셰이딩을 위한 메시 분할 처리.
Appendix
색상 보정.
이 주제에서 언급하는 색 보정의 개념은 실제 기기의 색 공간 표현을 보정하는 것을 말합니다. 쉽게 말해 제가 작업하고 있는 모니터는 sRGB 색공간만 지원하지만 실제로 아이폰이나 최신 안드로이드폰은 디스플레이 p3 색공간을 사용합니다. 컴퓨터로 생성된 이미지가 다른 색으로 인쇄된다고 생각하면 이해가 더 쉬울 것 같습니다. 제가 색 보정을 중요하게 생각하는 데에는 이유가 있습니다.
2018년에 MMORPG를 개발할 때 큰 문제를 발견했습니다. 모니터 색상에 대한 대역폭 설정이 사업자마다 모두 달랐기 때문입니다. 심지어 PC 화면과 스마트폰 화면의 색 영역도 달랐습니다. 저희는 어떤 색상이 표현에 적합한지 결정하는 데 어려움을 겪었습니다.
그래서 조명 아티스트가 새 모니터를 구입한 것을 보았습니다. AdobeRGB와 DCI-P3를 지원하는 최신 Dell 모니터였고, 캐릭터 아티스트는 최대 sRGB를 지원하는 Dell UltraSharp 27인치 모니터를 사용했습니다.
이런 식으로 색 공간 지원 여부에 따라 다른 변수 값을 설정했습니다. 그래서 제가 생각해 낸 방법은 디스플레이-P3 컬러 매트릭스를 sRGB 모드로 시뮬레이션하는 것이었습니다. 컬러 비전 등에 대한 자세한 내용은 링크를 참조하시기 바랍니다.
색상 보정은 일반적으로 포스트 프로세스 영역에서 수행해야 합니다. 이 주제에서는 이에 대한 심층 학습은 다루지 않겠지만, 작업 중인 렌더링이 디스플레이 p3에서 어떻게 보이는지 간단하게 확인하는 방법을 보여드리겠습니다.
가장 간단한 방법은 포토샵의 icc 프로필을 사용하는 것입니다.
특히 병치 블렌딩의 영향을 더 많이 받는 포토리얼리즘 스타일, 즉 사진이나 더 사실적인 결과물에는 더 많은 차이가 있습니다.
https://wowseattle.com/wow-posts/specialist-column-archive/j-art-academy/151035/
이런 글을 읽어보시는 것도 좋을 것 같습니다. 디스플레이 P3와 sRGB 컬러 비전을 비교해 보겠습니다. 사용 중인 모니터가 sRGB만 지원한다고 가정해 보겠습니다. 2018년형 Dell 27인치 Ultra Sharp 기본 모델은 sRGB 모드와 사용자 모드만 지원하며 와이드 가멋은 지원하지 않습니다.
https://webkit.org/blog-files/color-gamut/
위의 웹 페이지에서 sRGB 모드로 저장한 이미지를 포토샵에서 열고 프로필 할당 ⇒ 프로필 ⇒ 이미지 P3을 선택합니다.
왼쪽은 원래 sRGB이고 오른쪽은 이미지 P3 프로파일이 적용된 이미지입니다. 컬러 비전에 대해 다시 한번 살펴봅시다.
색각의 차이를 보면 녹색 편차가 가장 크고 빨간색, 파란색 순으로 나타납니다. 색각 편차를 살펴본 다음 위의 비교 이미지를 다시 보면 어떤 색조가 차이를 만드는지 더 쉽게 이해할 수 있습니다.
글을 읽다 보면 왜 이걸 봐야 하는지 목적을 잊어버리는 경우가 있습니다. 이 때문에 최종 결과 편차가 발생한 상태에서 렌더링 결과를 보고 있는 것입니다. 또한 안드로이드 Q(운영체제 10.0 이상)부터는 안드로이드 진영에서도 디스플레이 p3를 적용하고 있습니다.
예를 들어 차분한 붉은 톤의 옷이나 머리카락은 조금 더 강한 붉은 톤으로 보일 수 있으며, 적절하다고 생각되는 피부 톤은 이상하게도 iPhone에서 더 붉게 보일 수 있습니다.
개발자는 이러한 색상 결과에 민감해야 하지 않을까요?
Monitor information for correct colour calibration.
이는 최소한 아래 제품을 사용하는 전문가용 모델의 넓은 색 영역을 지원합니다.
최종 사용자의 출력 장치의 풀 컬러 영역을 최대한 지원하는 환경에서 렌더링 워크플로우를 개발하는 것이 옳다고 생각하기 때문입니다.
카툰 렌더링을 위한 고해상도 그림자 처리 기법의 구현 및 소개.
엮어 보기
'UNITY3D' 카테고리의 다른 글
[UNITY6] For GPU Driven Rendering. BRG | GRD (0) | 2024.08.27 |
---|---|
FadeInOut Camera Volume object trace Test. (0) | 2024.08.19 |
Iridescence 효과 : 방막필름 효과 구현. (0) | 2024.05.25 |
일본 망가식 눈동자 표현 포팅. (0) | 2024.05.25 |
[버티컬 슬라이스]2021년 12월. 안구 셰이딩 테스트. (0) | 2024.05.24 |