개요
본 글에서는 Top View 시점 게임들에서 볼 수 있는 오클루전 마스킹(Occlusion Masking)을 유니티에서 쉐이더 그래프로 구현하는 방법에 대해서 살펴본다. 디비니티 오리지널 씬 2 플레이 영상을 보면 이런 기법이 나오는 것을 볼 수 있다.
자료조사
게임하다가 발견해서 구현을 시작한건 아니고, 유튜브에서 다른 사람이 구현해놓은 것을 보고 '오 나도 할 수 있지 않을까?' 싶어서 시작했다. 그런데 찾아보니 언리얼 엔진으로 되게 그럴싸하게 구현한 영상이 있어서, 그것을 유니티로 포팅하는 정도로 작업이 간단해졌다... 참고한 영상은 아래와 같다.
Unreal Engine 5 - Occlusion Masking (See Through) Part 1
대충 정리하자면
Sphere Mask
노드를 사용한다. 이 노드는 특정 위치를 중심으로 하는 구체의 반경 내 값을 반환한다. 이 값을 알파값으로 사용하여 원 모양으로 구멍을 뻥 뚫어줄 것이다.- 너무 둥글둥글한 원으로 뚫려있으면 멋이 없으니까 노이즈 텍스처같은 걸로 패턴을 추가해준다.
구현
Sphere Mask
- 픽셀의
Position
(Absolute World)를View
공간으로 변환시켜Coords
에 연결. Camera Position
이나 대상의Position
(예를들면 플레이어 캐릭터)의Position
도 마찬가지로 변환 후Center
에 연결.Radius
,Hardness
는 프로퍼티로 만들어서 인스펙터의 Surface Inputs에서 조절할 수 있게 만들어줌.
여기서 Camera Position
을 안쓰고 World Position
을 프로퍼티로 새로 만들어서 사용했는데, 위 참고 영상의 Part 2에 나오는 것처럼 캐릭터 포지션을 기준으로 마스킹해주고 싶었기 때문이다. 쉐이더 그래프 내에서 특정 오브젝트의 위치값을 얻어오는건 불가능하기 때문에, 외부에서 설정해줘야 한다. 따라서 새로 작성한 코드의 Update
루프에서 이 값을 갱신해준다. 아래와 같이 작성했다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class OcclusionMask : MonoBehaviour
{
private MaterialPropertyBlock materialPropertyBlock;
private Renderer meshRenderer;
private Transform targetTransform;
// Property이름을 올바르게 넣어준다.
private readonly int positionPropertyID = Shader.PropertyToID("_WorldPosition");
private void Awake()
{
materialPropertyBlock = new MaterialPropertyBlock();
meshRenderer = GetComponent<Renderer>();
// 타겟이 될 대상의 transform을 매개변수로 넘겨준다.
// 본 글에서는 Character라는 컴포넌트를 갖고 있는 오브젝트를 찾아서 사용했다.
// 인스펙터에서 직접 넣어줘도 되고, 각자 입맛에 맞게 Transform을 찾아서 넘겨주자.
SetTarget(FindFirstObjectByType<Character>().transform);
}
public void SetTarget(Transform transform)
{
targetTransform = transform;
}
private void Update()
{
if(targetTransform != null)
{
materialPropertyBlock.SetVector(positionPropertyID, targetTransform.position);
meshRenderer.SetPropertyBlock(materialPropertyBlock);
}
}
}
노이즈 텍스처
- Noise 노드를 만들어서
UV
에Tiling And Offset
노드를 연결. - (선택) 노이즈 노드는 Voronoi도 되고 그냥
Texture2D
를 프로퍼티로 만들어서 인스펙터에서 넣어줘도 무방함. 이런 경우 Texture2D를Sample Texture 2D(Texture(T2))
에 연결하고,Tiling And Offset(Out(2))
를Sample Texture 2D(UV)
에 연결한다. - (선택)
UV
에 특정 값을 곱해서 패턴이 더 많이 반복되게 할 수 있음. 이것도 따로 PatternStrength라는 프로퍼티로 만듦. 네이밍이 맘에 안들지만 아직 어떻게 지을지 잘 모르겠다... - UV 스크롤링 효과를 주기 위해서
Time
노드와PatternSpeed
프로퍼티를 만들어서 곱해주고,Tiling And Offset
노드의Offset
에 연결. - 미리보기에서 움직이는 노이즈 텍스처를 확인할 수 있음.
합치기
Sphere Mask
의 결과값 자체를 알파값으로 사용(0은 투명하고 1은 불투명)할 것이기 때문에,One Minus
를 해줌. 이렇게 하면 원 내부로 갈수록 0이 될거고 원 바깥은 1이 될거임. 이 0 ~ 1의 값은Sphere Mask
의Hardness
값에 따라 결정됨. (Soften falloff of the sphere)- 1의 결과값에 노이즈를 더하면 다시 원 내부 값은 노이즈로 채워짐.
- 1과 2를 곱함. 이렇게 하면 1에서 투명인 부분은 노이즈를 더해도 투명일 것임. (0은 어떤 수와 곱해도 0이기 때문에) 불투명한 부분은 노이즈 값이 되어 원 외곽 부분은 우리가 원하는 노이즈가 될 것임.
여기까지하고 Fragment
의 Alpha
에 연결하면 원하는 결과가 나온다. 하지만 투명한 곳이 뻥 뚫려서 Directional light에 의한 그림자도 사라지게 되는 문제가 있다.
그림자 문제 해결하기
이를 해결하기 위해서 Custom Function
노드를 하나 새로 만들고, 아래와 같이 작성해준다.
state = false;
#if SHADERPASS == SHADERPASS_SHADOWCASTER
state = true;
#endif
유니티의 SHADERPASS_SHADOWCASTER 는 그림자 캐스팅을 위한 셰이더 패스를 정의하는 데 사용됩니다. 이 패스는 주로 그림자를 렌더링하기 위해 사용되며, 씬의 조명과 그림자 효과를 생성하는 데 중요한 역할을 합니다. 그림자를 생성하는 동안 오브젝트의 깊이 값을 렌더링하고, 이 정보를 바탕으로 다른 오브젝트에 그림자를 드리우게 됩니다. -ChatGPT
그리고 이 함수가 반환하는 결과값에 따라서 true면 알파값 1을, false면 위에서 연산한 결과값을 연결해준다.
그림자 렌더링 시에는 알파값 1을 사용하고, 아닐때는 우리가 연산한 값을 사용하겠다는 뜻이다.
거리 비교
참고 영상에서는 거리에 따라서도 투명 or 불투명 분기 해주고 있긴 하지만, 오히려 거리에 따라 처리 다르게 하면 어색해보이기도 하고 어차피 탑뷰 게임에선 카메라 시점이 크게 변하질 않아서 생략했다.
결론
출력 결과는 이렇게 나온다.
에셋에다가 입혀서 하면 좀 더 이쁘게 나올 것 같은데, 여러 에셋에 실험해보다가 그냥 대충 큐브로 만들어봤다.
은근 이런거 가정하고 만들어진 인테리어 + 건물 에셋이 별로 없어서 애매했다.
인스펙터에서 값을 조절하면 이렇게 된다.
근데 막상 다 하고나서 디아블로4나 다른 게임 해보니까 메쉬 자체가 통째로 사라지는 식으로 한 게 대부분이었다.
이것도 추후에 구현해봐야겠다.