일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 프로그래밍 기초
- 3d
- Rim Light
- VR
- URP
- Toon Shader
- ColorGradingLutPass
- Three(Two) Tone Shading
- C언어
- ASW(Application SpaceWarp)
- working set
- Windows Build
- 가상 바이트
- URP로 변경
- Cartoon Rendering
- Cell Shader
- 벡터
- AppSW
- Virtual Byte
- 게임 수학
- Private Bytes
- OculusMotionVectorPass
- 개인 바이트
- Specular
- Cell Look
- 메모리 누수
- 작업 집합
- Today
- Total
WinCNT
카메라와 오브젝트가 가까울 때 Dithering해서 반투명처럼 보이게 하는 셰이더 구현해보기! 본문
카메라와 오브젝트가 가까울 때 Dithering해서 반투명처럼 보이게 하는 셰이더 구현해보기!
WinCNT_SSS 2024. 2. 1. 11:44서론
이번에는 카메라와 오브젝트가 가까워졌을 때, 디더링을 통해 오브젝트를 반투명처럼 보이게 만드는 셰이더에 대해서 정리하고자 한다
특허에 대한 얘기를 듣기도 했고, 왜 이러한 이런 기법을 쓰느냐에 대해서도 제법 정리할 보람이 있었다
여기서 말하는 Dithering 셰이더란?
사실 디더링은 여러 분야에서 여러 의미로 사용된다
음향에서도 사용되고 컴퓨터 그래픽 분야에서만 봐도 8비트/16비트 컴퓨터용 게임에 자주 사용된 기법이다
하지만 여기서 다룰 디더링이란 픽셀을 일정 패턴으로 폐기해서 그물망처럼 만들어서 반투명의 효과를 내는 기법을 말한다
참고로 해당 기법은 일본에서는 보통 디더링, 디더 빼기 등으로 불리지만, 디더링을 통한 반투명 표현을 콕 찝어서 말할 때는 Screen-Door Transparency이란 명칭도 사용하는 모양이다
그런데 한국에서는 뭐라고 불리는지 모르겠다
검색해도 다른 의미의 디더링에 대한 것밖에 안 나왔다
Why is it necessary
여기서 “왜 디더링을 쓰는 거야! 반투명이 더 예쁘지 않아?”라고 할 수 있는데 필자도 정말 그렇다고 생각한다
하지만 일반적으로 반투명 오브젝트를 그리는 것은 무거우며, 디퍼드 렌더링 같이 상성이 잘 안 맞는 경우도 많다
그에 비해 Dithering으로 인한 의사 반투명 표현은 말 그대로 ‘의사’ 반투명일 뿐, 실제로는 불투명 오브젝트인 건 변함이 없기 때문에 더 가볍다고 할 수 있다
하지만 결국 Alpha Clpping하기 때문에 Early-Z Test가 작동하지 않을테니 진짜 불투명 오브젝트보단 무거울 듯
실제 사용 사례로는 캐릭터와 카메라 사이에 지형이나 건물 오브젝트가 있을 때 캐릭터를 가리지 않기 위해 사용(이번에 구현할 셰이더)하거나, 폭발 등의 이펙트를 반투명 대신 Dithering으로 표현(니어 오토마타의 사례) 등등이 있다
그리고 원신이나 붕괴: 스타레일처럼 지형이 아니라 캐릭터를 반투명 처리하는 경우도 있다
https://www.youtube.com/watch?v=rVeS7oh3oug
그런데 특허랑 무슨 관련?
다른 회사에게 고소를 자주 때리는 걸로 유명한 일본의 모 게임 업계 대기업이 “게임 공간을 조감으로 표시하는 경우에, 플레이어 캐릭터의 모습이나 시야의 범위를, 벽이나 바닥의 존재에 관계없이 표시할 수 있는 특허”를 가지고 있었다고 한다
과거형인 이유는 해당 특허는 이미 만료된 걸로 보이기 때문이다(아마?)
실제로 고소한 사례가 있나 잠깐 찾아봤는데 최근에 화제가 된 우마무스메에 대한 소송 정도가 나올 뿐 디더링 벽 투과 기법에 대한 소송 사례는 찾아볼 수 없었다
좀 더 찾아보니 한 때 일본에서 게임 개발자 사이에 퍼진 일종의 도시전설이라는 느낌을 받았다
하지만 정리 글이나 정리 유튜브 영상도 있는 것을 보면 유명하긴 했던 모양이다
Dithering Pattern에 대해서
조금 찾아보니 Dithering Pattern에 대한 구현은 이미 계산된 텍스처를 이용하는 경우가 많았다
아래의 매우 작은 텍스처는 참고 사이트(https://knasa.hateblo.jp/entry/2020/01/14/233005)에서 가져왔다
4x4사이즈라 매우 매우 작기 때문에 텍스처의 필터 모드나 퀄리티 설정 등을 주의할 필요는 있지만, 텍스처를 사용한 쪽이 코드가 깔끔해진다는 느낌을 받았다
당연히 텍스처 말고 이미지 디더링 알고리즘을 통해서 디더링을 하는 방법도 있으며, 이를 Ordered dithering(일본어로는 配列ディザリング, 한국어로는 몰?루)이라고 한다
Ordered dithering에서 주로 사용되는 것은 Bayer matrix를 이용한 디더링 기법이다
(그 외에도 다양한 기법들이 발표는 되었지만 Bayer matrix가 가장 빠르고 품질이 좋다고 함)
이번에는 텍스처 파일을 따로 임포트하는 것을 피하고 싶었기에 Bayer matrix를 사용하는 방법으로 구현하기로 했다
일단 Dithering부터 해보자
우선 디더링을 먼저 구현해봤다(중간중간 생략함)
Shader "Universal Render Pipeline/Test/DitheringTranparent"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_DitherLevel("DitherLevel", Range(0, 10)) = 5
}
SubShader
{
Tags { "RenderType"="Opaque" }
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float4 screenPos : TEXCOORD1;
float3 worldPos : TEXCOORD3;
};
sampler2D _MainTex;
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
float _DitherLevel;
CBUFFER_END
// 디더링 패턴 행렬
static const float pattern[16] = {
0 / 16.0, 8 / 16.0, 2 / 16.0, 10 / 16.0,
12 / 16.0, 4 / 16.0, 14 / 16.0, 6 / 16.0,
3 / 16.0, 11 / 16.0, 1 / 16.0, 9 / 16.0,
15 / 16.0, 7 / 16.0, 13 / 16.0, 5 / 16.0
};
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
// 클립 공간의 좌표로 스크린 좌표를 계산
o.screenPos = ComputeScreenPos(o.vertex);
return o;
}
half4 frag (v2f i) : SV_Target
{
// w로 나눠서 스크린 스페이스에서의 위치를 구함
float2 viewPortPos = i.screenPos.xy / i.screenPos.w;
// 0~1의 위치 값에서 실제 픽셀의 위치로 변환
float2 screenPosInPixel = viewPortPos.xy * _ScreenParams.xy;
// 디더링 패턴은 4x4행렬이므로, 스크린의 픽셀 위치를 4로 나눈 나머지로 대응시킨다
// (단 실제 디더링 패턴 데이터는 1차원 행렬이므로 X좌표의 나머지에는 4를 곱해서 2차원 배열처럼 값을 취득한다)
uint index = (uint(screenPosInPixel.x) % 4) * 4 + uint(screenPosInPixel.y) % 4;
float ditherOut = 1.0f - pattern[index];
clip(_DitherLevel - ditherOut);
float4 color = tex2D(_MainTex, i.uv);
return color;
}
ENDHLSL
}
}
}
간단히 설명하자면, 우선 오브젝트의 픽셀 좌표 값을 구한다
그리고 픽셀 좌표 값으로 디더링 패턴의 행렬에서 값을 취득한 뒤, 그 값이 특정 수치(여기서는 _DitherLevel)를 넘는지 안 넘는지로 클리핑한다
의사 반투명 표현 자체가 목적이라면 이 정도로도 충분할 것 같다
다만 주의해야 할 점은 같은 디더링 반투명 셰이더를 적용한 물체가 겹쳐진다면, 뒤쪽의 오브젝트의 가려진 부분이 그려지지 않는다는 점이다
다른 불투명 오브젝트는 그려져서 제대로 반투명처럼 보이므로 사용에 주의가 필요하다
애초에 겹처진 디더링 반투명 셰이더 물체들이 제대로 그려져도 지저분해질 뿐이긴 하다
구현 방법 1 - 오브젝트와의 거리에 따라 Dithering를 적용하기
처음에 구현한 방식은 오브젝트의 월드 포지션과 카메라의 월드 포지션의 거리에 따라 Dithering를 하는 방식이었다(원신에서 사용되는 방식)
Shader "Universal Render Pipeline/Test/DitheringTranparent"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
// _DitherLevel("DitherLevel", Range(0, 10)) = 5
_minDist("Min Dist", Range(0.001, 100)) = 0.5
_maxDist("Max Dist", Range(0.001, 100)) = 2
}
SubShader
{
Tags { "RenderType"="Opaque" }
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float4 screenPos : TEXCOORD1;
float3 worldPos : TEXCOORD3;
};
sampler2D _MainTex;
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
// float _DitherLevel;
float _minDist;
float _maxDist;
CBUFFER_END
// 디더링 패턴 행렬
static const float pattern[16] = {
0 / 16.0, 8 / 16.0, 2 / 16.0, 10 / 16.0,
12 / 16.0, 4 / 16.0, 14 / 16.0, 6 / 16.0,
3 / 16.0, 11 / 16.0, 1 / 16.0, 9 / 16.0,
15 / 16.0, 7 / 16.0, 13 / 16.0, 5 / 16.0
};
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
// 클립 공간의 좌표로 스크린 좌표를 계산
o.screenPos = ComputeScreenPos(o.vertex);
return o;
}
half4 frag (v2f i) : SV_Target
{
// w로 나눠서 스크린 스페이스에서의 위치를 구함
float2 viewPortPos = i.screenPos.xy / i.screenPos.w;
// 0~1의 위치 값에서 실제 픽셀의 위치로 변환
float2 screenPosInPixel = viewPortPos.xy * _ScreenParams.xy;
// 오브젝트의 월드 포지션을 구한다
float4 worldPosition = mul(GetObjectToWorldMatrix(), float4(0, 0, 0, 1));
// 카메라와의 거리를 구한다
float distCamToObj = distance(worldPosition, _WorldSpaceCameraPos);
// 오브젝트와 카메라의 거리를 [0,1]로 매핑한다
float remapDist = saturate((distCamToObj - _minDist) / (_maxDist - _minDist));
// 디더링 패턴은 4x4행렬이므로, 스크린의 픽셀 위치를 4로 나눈 나머지로 대응시킨다
// (단 실제 디더링 패턴 데이터는 1차원 행렬이므로 X좌표의 나머지에는 4를 곱해서 2차원 배열처럼 값을 취득한다)
uint index = (uint(screenPosInPixel.x) % 4) * 4 + uint(screenPosInPixel.y) % 4;
float ditherOut = 1.0f - pattern[index];
// clip(_DitherLevel - ditherOut);
clip(remapDist - ditherOut);
float4 color = tex2D(_MainTex, i.uv);
return color;
}
ENDHLSL
}
}
}
_DitherLevel를 없애고, _minDist, _maxDist라는 프로퍼티를 추가했다
이 프로퍼티는 오브젝트와 카메라의 거리를 [0,1]로 매핑하기 위해 사용할 프로퍼티이다
오브젝트와 카메라의 거리가 _minDist보다 작으면 0으로, _maxDist보다 크면 1로 제한한다
오브젝트의 월드 포지션은 월드 행렬에서 Translation 값을 취하는 방식으로 구했다
유니티는 종벡터 방식이니 mul(행렬, 벡터)의 순서로 해야한다(매번 헷갈리는 부분)
결과
그럼 다음과 같이 거리에 따라서 오브젝트 자체가 균일하게 디더링이 적용된다
(화질이 좋지 않아서 반투명처럼 보일 순 있지만 디더링이 맞다)
구현 방법 2 - 버텍스와의 거리에 따라 Dithering를 적용하기
원래는 구현 방법 1로 진행하려고 했으나 문제가 있다는 것을 깨달았다
캐릭터와 같이 오브젝트가 작으면 상관 없지만 큰 건물일 경우에는 카메라와 오브젝트와의 거리가 계속 멀리 있게 될 수도 있다
머테리얼의 프로퍼티를 건물마다 조정하면 해결할 수도 있겠지만…역시 수고가 많이 든다
그래서 오브젝트와의 거리가 아니라 오브젝트의 버텍스와의 거리에 따라서 Dithering되는 방법으로 수정해서 대응하기로 하였다
Shader "Universal Render Pipeline/Test/DitheringTranparent"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
// _DitherLevel("DitherLevel", Range(0, 10)) = 5
_minDist("Min Dist", Range(0.001, 100)) = 0.5
_maxDist("Max Dist", Range(0.001, 100)) = 2
}
SubShader
{
Tags { "RenderType"="Opaque" }
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float4 screenPos : TEXCOORD1;
float3 worldPos : TEXCOORD3;
};
sampler2D _MainTex;
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
// float _DitherLevel;
float _minDist;
float _maxDist;
CBUFFER_END
// 디더링 패턴 행렬
static const float pattern[16] = {
0 / 16.0, 8 / 16.0, 2 / 16.0, 10 / 16.0,
12 / 16.0, 4 / 16.0, 14 / 16.0, 6 / 16.0,
3 / 16.0, 11 / 16.0, 1 / 16.0, 9 / 16.0,
15 / 16.0, 7 / 16.0, 13 / 16.0, 5 / 16.0
};
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.worldPos = TransformObjectToWorld(v.vertex);
// 클립 공간의 좌표로 스크린 좌표를 계산
o.screenPos = ComputeScreenPos(o.vertex);
return o;
}
half4 frag (v2f i) : SV_Target
{
// w로 나눠서 스크린 스페이스에서의 위치를 구함
float2 viewPortPos = i.screenPos.xy / i.screenPos.w;
// 0~1의 위치 값에서 실제 픽셀의 위치로 변환
float2 screenPosInPixel = viewPortPos.xy * _ScreenParams.xy;
// 버텍스의 월드 포지션을 구한다
// float4 worldPosition = mul(GetObjectToWorldMatrix(), float4(0, 0, 0, 1));
float4 worldPosition = float4(i.worldPos, 1.0f);
// 카메라와의 거리를 구한다
float distCamToObj = distance(worldPosition, _WorldSpaceCameraPos);
// 오브젝트와 카메라의 거리를 [0,1]로 매핑한다
float remapDist = saturate((distCamToObj - _minDist) / (_maxDist - _minDist));
// 디더링 패턴은 4x4행렬이므로, 스크린의 픽셀 위치를 4로 나눈 나머지로 대응시킨다
// (단 실제 디더링 패턴 데이터는 1차원 행렬이므로 X좌표의 나머지에는 4를 곱해서 2차원 배열처럼 값을 취득한다)
uint index = (uint(screenPosInPixel.x) % 4) * 4 + uint(screenPosInPixel.y) % 4;
float ditherOut = 1.0f - pattern[index];
// clip(_DitherLevel - ditherOut);
clip(remapDist - ditherOut);
float4 color = tex2D(_MainTex, i.uv);
return color;
}
ENDHLSL
}
}
}
버텍스 셰이더에서 버텍스의 월드 포지션을 계산하는 처리를 추가하고, 프래그먼트 셰이더에서는 오브젝트의 월드 포지션 대신에 버텍스의 월드 포지션을 취하도록 하면 변경 가능하다
결과
버텍스와의 거리로 디더링을 적용하기 때문에 오브젝트의 크기와 상관 없이 디더링을 적용할 수 있다는 장점이 있다
하지만 아래와 같이 디더링이 나이테처럼 단계적으로 적용된다는 단점도 존재한다
이 부분은 포기하고 받아들이거나 새로운 로직을 추가하는 수 밖에 없을 것 같다
(필자는 새로운 로직을 찾지 못 해서 받아들일 수 밖에 없었다…)
다만 큐브 외에는 그다지 위화감이 느껴지지 않기도 하니 선택의 문제라고도 할 수 있지 않을까?
주의점 - 버텍스의 월드 포지션이 제대로 구하기!
이건 도중에 발생한 이슈에 대한 간단한 메모이다(캡쳐나 상세한 원인 분석은 없음)
위에서는 버텍스 셰이더에서 버텍스의 월드 포지션을 다음과 같이 구했다(중간중간 생략)
struct appdata
{
float4 vertex : POSITION;
};
v2f vert (appdata v)
{
o.worldPos = TransformObjectToWorld(v.vertex);
}
하지만 원래는 다음과 같이 구했었다(마찬가지로 중간중간 생략)
struct appdata
{
float3 vertex : POSITION;
};
v2f vert (appdata v)
{
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
}
즉 버텍스의 위치를 float3로 받으면서 월드 행렬을 직접 곱하는 방식이었다
그런데 그 방식으로는 일부 오브젝트의 버텍스의 월드 포지션이 제대로 구해지지 않는 이슈가 발생했다
아마도 float3인 버텍스의 로컬 포시션은 float3(x, y, z, 0.0)의 값, 즉 버텍스의 좌표가 아닌 방향 벡터였고 그걸 월드 행렬과 곱해도 위치 값은 반영이 안 되고, 단순히 회전과 스케일만 적용된 값이 계산된 것이 문제이지 않았을까 ‘추측’해본다
TransformObjectToWorld함수는 내부에서 float3(x, y, z, 1.0)과 같이 조정을 하기 때문에, 이 함수를 사용하면 해당 이슈가 사라진 것이 근거이지 않을까 싶다
필자에게 시간과 예산이 좀 더 있었더라면 자세한 원인을 조사해보고 싶지만 아쉽게도 둘 다 없으므로 생략하기로 했다
추가 - Dithering 패턴의 사이즈 조절하기
Dithering 패턴의 사이즈는 간단히 조절할 수 있다
아래와 같이 스크린 스페이스에서의 픽셀 위치를 특정 값으로 나눠주면 된다
half4 frag (v2f i) : SV_Target
{
// w로 나눠서 스크린 스페이스에서의 위치를 구함
float2 viewPortPos = i.screenPos.xy / i.screenPos.w;
// 0~1의 위치 값에서 실제 픽셀의 위치로 변환
float2 screenPosInPixel = viewPortPos.xy * _ScreenParams.xy;
screenPosInPixel /= 25;
// 생략...
}
예를 들어 25로 나눠주면 다음과 같이 디더링이 된다
혹은 다음과 같이 특정 값으로 나누고 더하는 방식도 있다
// screenPosInPixel = screenPosInPixel * 0.5 + 0.5;
screenPosInPixel = screenPosInPixel * 0.1 + 0.1;
다음은 0.1을 곱하고 0.1을 더한 결과이다
마무리
작성하고 보니 분량 조절에 실패했다는 느낌이 든다
디더링 부분과 거리에 따른 조절은 나눌 수 있지 않았을까?
아무튼 디더링을 이용한 의사 반투명은 퀄리티를 유지하면서 퍼포먼스도 나쁘지 않다는 것을 알게 되었다
정확한 반투명을 고집할 필요가 없는 경우에는 훌륭한 대체재가 되리라 생각한다
참고 사이트
ディザリング透過や壁を考慮したカメラワーク [ゲーム開発ログ 2020-09-21] | Alto-tascal
【Unity】壁に隠れた箇所をディザリング透過で表示する - 原カバンは鞄のお店ではありません。
【Unity】【シェーダ】ディザ抜きで半透明描画を実現する - LIGHT11
Unityのシェーダーでディザリングを実装するには | 3DCG school
なんだか雲行きの怪しい雑記帖 週アレ(16) GLSLでディザパターン(BayerMatrix)
ポケモソの近づくと透明になるシェーダーを再現してみた - "そこ"から這い上がるブログ
https://www.ronja-tutorials.com/post/042-dithering/
'Unity > URP or Shader 관련' 카테고리의 다른 글
URP에서 팔레트 스왑(Palette Swap) 기능 구현하기 (0) | 2024.02.19 |
---|---|
텍스처 샘플링의 부하 측정해보기(Oculus Quest2) (0) | 2024.02.07 |
URP에서 깊이를 기록하는 Depth Map 셰이더 만들어보기! + After Effect의 이미지의 레벨 조정(Levels adjustment) 흉내기기! (0) | 2024.01.23 |
Unity의 Depth Texture에는 실제로 어떤 오브젝트가 기록되는 걸까?(feat. SSAO) (0) | 2024.01.22 |
MatCap 셰이더를 만들어보자 (0) | 2024.01.15 |