1. 왜 카메라를 움직이지 않고 벽을 투명하게 처리했는가
카메라와 플레이어 사이를 가리는 벽을 처리하는 방식은 여러 가지가 있다. 대표적으로는 카메라를 앞으로 당겨 시야를 확보하는 방식과, 가리는 벽 자체를 반투명하게 바꾸는 방식이 있다. 이번에는 후자를 선택했다.
이 프로젝트는 쿼터뷰 퍼즐 탐험 구조를 기반으로 하고 있기 때문에, 카메라 위치와 시점이 자주 흔들리는 것보다 현재 구도를 유지하면서 시야를 확보하는 쪽이 공간 인지 측면에서 더 적절하다고 판단했다. 특히 시점을 회전하며 숨겨진 길을 발견하는 구조에서는 카메라 거리까지 계속 달라지면 퍼즐 읽기감이 흐트러질 수 있다. 그래서 카메라는 현재 구도를 유지하고, 대신 카메라와 플레이어 사이를 막는 벽만 일시적으로 반투명 처리하는 방향으로 접근했다.
2. Layer를 먼저 분리한 이유
이 기능을 구현하면서 먼저 정리한 부분은 레이어였다. 처음에는 카메라와 플레이어 사이를 막는 모든 충돌체를 그대로 검사해도 될 것처럼 보였지만, 실제로는 씬 안의 모든 콜라이더를 대상으로 하면 바닥이나 일반 구조물, 장식물까지 함께 감지될 수 있다. 이렇게 되면 어떤 오브젝트를 가림 처리 대상으로 볼 것인지 모호해지고, 디버깅도 어려워진다.
그래서 실제로 캐릭터를 가릴 가능성이 있는 높은 벽과 큰 구조물만 별도의 FadeObstacle 레이어로 분리했다. 이후 오클루전 검사는 이 레이어만 대상으로 수행하도록 구성했다. 이 방식 덕분에 가림 처리 대상이 훨씬 명확해졌고, 코드와 씬 구성 모두에서 관리가 쉬워졌다. 레이어 분리는 단순한 분류가 아니라, 시스템이 어떤 오브젝트를 의미 있는 장애물로 볼 것인지 정의하는 작업에 가깝다.

3. Material을 따로 준비해야 했던 이유
벽을 반투명하게 만든다는 것은 단순히 색상값을 바꾸는 것과는 다르다. Unity에서 머티리얼은 오브젝트의 색뿐 아니라 렌더링 방식 전체를 결정한다. 즉 알파값을 낮춘다고 해서 항상 벽이 반투명하게 보이는 것은 아니고, 그 머티리얼이 알파값을 실제 투명도로 반영할 수 있는 상태여야 한다.
그래서 가림 처리 대상 벽에는 직접 만든 머티리얼을 적용하고, URP 기준으로 Surface Type을 Transparent로 바꿔두었다. 이 과정을 거쳐야 이후 코드에서 알파값을 조절했을 때 실제 화면에서도 반투명 효과가 확인된다. 결국 머티리얼 설정은 단순한 외형 작업이 아니라, 오클루전 페이드를 가능하게 만드는 사전 준비 단계였다.

4. 카메라와 플레이어 사이를 어떻게 검사했는가
오클루전 검사는 Main Camera 위치에서 플레이어 기준점까지 수행했다. 이때 단순한 Raycast 대신 SphereCastAll을 사용했다. 얇은 선 하나로 검사하는 것보다 약간 두께를 가진 구 형태로 검사하는 쪽이 벽을 더 안정적으로 감지할 수 있기 때문이다.
플레이어 쪽 기준점은 몸통 높이 정도에 별도의 CameraTarget 오브젝트를 두는 방식으로 잡았다. 이렇게 하면 바닥 쪽이 아니라 실제 캐릭터가 위치한 높이를 중심으로 가림 여부를 판단할 수 있어, 시각적으로도 더 자연스럽다. 검사 결과로 감지된 오브젝트들은 렌더러 단위로 처리하고, 현재 플레이어를 가리고 있는 벽만 반투명 대상으로 넘기도록 구성했다.
5. 반투명 처리 방식
감지된 벽은 머티리얼의 알파값을 낮춰 일시적으로 반투명하게 만들었다. 반대로 더 이상 플레이어를 가리지 않게 된 벽은 다시 원래 알파값으로 복구하도록 구성했다. 이 과정을 즉시 바꾸는 대신 Lerp 기반으로 보간해, 벽이 갑자기 튀듯이 사라지는 느낌이 아니라 자연스럽게 서서히 흐려지고 복구되도록 처리했다.
이 방식의 장점은 카메라 거리와 시점을 유지할 수 있다는 점이다. 카메라를 직접 움직이는 방식보다 현재 구도를 안정적으로 유지할 수 있고, 퍼즐 구조를 읽는 과정에서도 화면이 덜 흔들린다. 특히 쿼터뷰 기반 탐험 구조에서는 이 점이 꽤 중요했다.
6. 이번 구현에서 정리된 핵심
이번 작업을 통해 가장 크게 정리된 부분은 세 가지였다. 첫째, 카메라 오클루전 처리는 단순히 물리 검사만의 문제가 아니라, 어떤 오브젝트를 가림 처리 대상으로 볼 것인지 레이어 단계에서 먼저 정리해야 한다는 점이다. 둘째, 반투명 처리는 단순 색상 변경이 아니라 머티리얼의 렌더링 상태와 연결된 문제라는 점이다. 셋째, 카메라를 움직이기보다 가리는 벽 자체를 조절하는 방식이 이번 프로젝트의 쿼터뷰 퍼즐 구조에는 더 잘 맞을 수 있다는 점이다.
이번 구현은 단순한 시야 확보 기능을 붙이는 것 이상의 의미가 있었다. Layer, Material, 카메라 검사 방식이 어떻게 연결되는지 함께 확인할 수 있었고, 이후에는 특정 벽만 선택적으로 처리하거나, 상단 구조물과 측면 벽을 다르게 구분하거나, 더 자연스러운 페이드 규칙을 추가하는 방향으로 확장할 수 있는 베이스를 마련했다고 볼 수 있다.
7. 관련 코드
관련 코드
using System.Collections.Generic;
using UnityEngine;
public class CameraOcclusionFade : MonoBehaviour
{
// 카메라가 바라볼 플레이어 기준점 (Player의 몸통 높이에 세팅하면 좋음)
public Transform target;
// 가림 처리 대상 레이어 - 예: FadeObstacle 레이어를 지정하면 그 레이어만 검사
public LayerMask obstacleMask;
// 카메라와 플레이어 사이를 검사할 때 사용할 구의 반지름, 단순 Raycast보다 약간 두께 있는 검사라서 더 안정적으로 장애물을 찾을 수 있음
public float sphereRadius = 0.3f;
// 가려질 때 알파값
public float hiddenAlpha = 0.2f;
// 원래 알파값
public float visibleAlpha = 1.0f;
// 알파가 변하는 속도
public float fadeSpeed = 10.0f;
// 이번 프레임에서 실제로 플레이어를 가리고 있는 Renderer 목록
// HashSet을 쓰는 이유는 중복 추가를 막기 위해서
private readonly HashSet<Renderer> currentOccluders = new();
// 한 번이라도 감지한 Renderer의 머티리얼을 캐시해두는 딕셔너리
// Renderer를 key로 쓰고, 그 Renderer가 가진 Material 배열을 value로 저장
// 나중에 가리지 않을 때 원래 알파값으로 복구할 때도 사용
private readonly Dictionary<Renderer, Material[]> materialCache = new();
void LateUpdate()
{
// 목표 지점이 연결되지 않았다면 더 이상 처리하지 않음
if (target == null)
{
return;
}
// 이번 프레임에 새로 검사할 것이므로 목록을 먼저 비움
currentOccluders.Clear();
Vector3 start = transform.position; // 카메라 위치에서 시작
Vector3 end = target.position; // 플레이어의 CameraTarget 위치를 끝점으로 사용
Vector3 direction = end - start; // 카메라에서 플레이어 쪽으로 향하는 방향 벡터
float distance = direction.magnitude; // 카메라와 목표 지점 사이의 거리
// 카메라와 플레이어 사이를 구 형태로 검사, 너무 가까운 경우를 제외하고 검사를 수행할 수 있도록
if (distance > 0.001f)
{
// SphereCastAll:
// 카메라에서 플레이어까지 구 형태로 훑으면서 obstacleMask에 해당하는 충돌체를 모두 찾음
RaycastHit[] hits = Physics.SphereCastAll(
start,
sphereRadius,
direction.normalized,
distance,
obstacleMask,
QueryTriggerInteraction.Ignore
);
foreach (RaycastHit hit in hits)
{
// 충돌한 오브젝트에서 Renderer를 직접 찾음
Renderer renderer = hit.collider.GetComponent<Renderer>();
// Collider는 자식에 있고 Renderer는 부모에 있는 경우가 많아서
// 직접 못 찾으면 부모 쪽에서도 다시 찾음
if (renderer == null)
{
renderer = hit.collider.GetComponentInParent<Renderer>();
}
// Renderer를 끝내 찾지 못하면 처리 대상이 아니므로 넘어감
if (renderer == null)
{
continue;
}
// 아직 캐시에 등록되지 않은 Renderer라면 머티리얼 인스턴스를 저장
CacheMaterials(renderer);
// 이번 프레임에 플레이어를 가리고 있는 오브젝트로 등록
currentOccluders.Add(renderer);
}
}
// 지금까지 캐시된 모든 Renderer를 순회
// 현재 가리고 있으면 hiddenAlpha,
// 아니면 visibleAlpha를 목표값으로 삼아 보간
foreach (var pair in materialCache)
{
Renderer renderer = pair.Key;
Material[] materials = pair.Value;
float targetAlpha = currentOccluders.Contains(renderer) ? hiddenAlpha : visibleAlpha;
FadeMaterials(materials, targetAlpha);
}
}
void CacheMaterials(Renderer renderer)
{
// 이미 캐시된 Renderer라면 다시 저장할 필요 없음
if (materialCache.ContainsKey(renderer))
{
return;
}
// renderer.materials를 사용하면 이 Renderer 전용 머티리얼 인스턴스가 생성됨
// 공유 머티리얼 전체를 바꾸지 않고, 감지된 오브젝트만 개별적으로 알파 조절하기 위함
materialCache[renderer] = renderer.materials;
}
void FadeMaterials(Material[] materials, float targetAlpha)
{
foreach (Material material in materials)
{
if (material == null)
{
continue;
}
// URP Lit 머티리얼은 보통 _BaseColor를 사용
if (material.HasProperty("_BaseColor"))
{
Color color = material.GetColor("_BaseColor");
color.a = Mathf.Lerp(color.a, targetAlpha, fadeSpeed * Time.deltaTime); // 현재 알파값을 목표 알파값으로 부드럽게 보간
material.SetColor("_BaseColor", color); // 변경된 색을 다시 머티리얼에 적용
}
// 일부 셰이더는 _Color를 사용할 수 있으므로 예외 처리
else if (material.HasProperty("_Color"))
{
Color color = material.color;
color.a = Mathf.Lerp(color.a, targetAlpha, fadeSpeed * Time.deltaTime); // 현재 알파값을 목표 알파값으로 부드럽게 보간
material.color = color; // 변경된 색을 다시 머티리얼에 적용
}
}
}
}
'Unity - '한 우산 아래'' 카테고리의 다른 글
| 맵툴 제작기 2 - AABB는 왜 안 맞았는가 : Surface Snap에서 Face Anchor까지 (0) | 2026.04.08 |
|---|---|
| 맵툴 제작기 1 - Unity Editor에서 쿼터뷰 퍼즐 게임용 Map Tool 만들기 (0) | 2026.04.08 |
| C#의 foreach와 var를 C++과 비교해보기 (0) | 2026.04.01 |
| Unity - Dictionary (0) | 2026.04.01 |
| Unity-HashSet (0) | 2026.04.01 |