본문으로 건너뛰기
HY Devlog
뒤로 가기

1 << -1은 에러가 아니라 31번 레이어다

맵에 배치한 함정 오브젝트를 플레이어가 가까워지면 활성화하려고 방법을 찾던 중 스크랩한 글이다. 함정마다 트리거 콜라이더를 하나 더 달지 않고 반경으로 판정하고 싶었다. 그래서 OverlapSphere의 기본값을 다룬 글을 쓰면서 모아둔 자료에 이 2022년 글을 더 얹었다.

앞 글은 이 함수의 숨은 인자 둘을 짚고 **“하드코딩한 1 << 10 대신 이름을 쓰라”**로 끝났다. 거기서 남겨둔 게 마스크를 직접 만드는 쪽이다. 함정이 플레이어만 보려면 결국 마스크를 써야 하므로, 그 자리를 이어서 봤다.

이 2022년 글은 그 조언을 따른다. 레이어 번호를 숫자로 적지 않고 LayerMask.NameToLayer("Cube")로 받아온다. 이름으로 쓰는 쪽이다. 그런데 받아온 값을 손으로 시프트한다.

LayerMask cubeLayer;

void Start()
{
    cubeLayer = LayerMask.NameToLayer("Cube");
}

void Update()
{
    int layerMask = (1 << cubeLayer);
    // ...
}

이름을 쓰면서 시프트를 남겨둔 이 조합에 그 자리만의 실패 방식이 있다. NameToLayer가 무엇을 돌려주는지가 문서에 적혀 있다.

Given a layer name, returns the layer index as defined by either a Builtin or a User Layer in the Tags and Layers manager. Returns -1 if not found.

찾지 못하면 -1이다. 그러면 1 << -1이 되는데, 이 식은 터지지 않는다.

목차

목차

1 << -1은 에러가 아니라 31번 레이어다

C# 문서가 시프트 횟수를 어떻게 계산하는지 적어둔다.

If the type of x is int or uint, the low-order five bits of the right-hand operand define the shift count. That is, the shift count is computed from count & 0x1F (or count & 0b_1_1111).

오른쪽 피연산자의 하위 5비트만 쓴다. 그래서 음수를 넣어도 예외가 아니다. 문서가 예제까지 붙여둔다.

int count = -31;
int c = 0b_0001;
Console.WriteLine($"{c} << {count} is {c << count}");
// Output:
// 1 << -31 is 2

-1 & 0x1F은 31이다. 따라서 1 << -1은 1 << 31이고, int에서 그 값은 부호 비트가 켜진 int.MinValue다. 레이어 마스크로 읽으면 31번 레이어 하나를 가리킨다.

식계산마스크가 가리키는 것
1 << 6 (Cube가 6번일 때)0x000000406번 레이어만
1 << -1-1 & 0x1F = 31 → 1 << 3131번 레이어만
~(1 << -1)~int.MinValue = int.MaxValue31번만 뺀 전부

31번이 왜 조용한 자리인지도 문서에 있다. LayerMask 클래스 설명이 레이어 구성을 적는다 — 32개 중 앞의 8개가 내장이고 24개가 사용자용이다. 즉 31번은 마지막 사용자 레이어 칸이고, 새 프로젝트에서는 비어 있다.

포함과 제외에서 증상이 반대다

클리핑은 마스크를 두 형태로 쓴다. 레이어 이름이 틀렸을 때 두 형태의 증상이 갈린다.

// 포함 — "Cube만 검출"
int layerMask = (1 << cubeLayer);

// 제외 — "Cube만 빼고 검출"
int layerMask = ~(1 << cubeLayer);

이름이 맞으면 둘 다 의도대로 돈다. 이름이 틀리면(철자가 다르거나 레이어를 아직 안 만들었거나) 이렇게 된다.

이름이 맞을 때이름이 틀렸을 때
1 << cubeLayerCube만 잡힌다아무것도 안 잡힌다 (31번은 비어 있다)
~(1 << cubeLayer)Cube만 빠진다전부 잡힌다 — 31번만 빠지는데 거기는 비어 있다

아래쪽이 위험하다. 포함 형태는 “왜 안 잡히지”로 바로 드러나는데, 제외 형태는 의도한 결과와 거의 같아 보인다. Cube가 빠지지 않는 것만 다르고, 그 하나를 못 알아채면 마스크가 틀린 채로 계속 간다.

클리핑의 최종 코드가 제외 형태다. 레이어 이름 하나만 어긋나도 증상이 가려지는 쪽으로 끝난다.

GetMask는 그 한 단계를 없앤다

-1이 문제가 되는 건 받아온 값을 내가 시프트하기 때문이다. 시프트를 안 하면 그 자리가 사라진다. LayerMask.GetMask의 선언과 설명이다.

public static int GetMask(params string[] layerNames);

Given a set of layer names as defined by either a Builtin or a User Layer in the Tags and Layers manager, returns the equivalent layer mask for all of them.

이름을 넣으면 마스크가 나온다. 인덱스를 거치지 않는다. params라서 여러 개를 한 번에 넘길 수 있고, 제외는 그 결과에 ~를 붙인다.

// 포함 — 이름에서 마스크까지 한 단계
int includeMask = LayerMask.GetMask("Cube");

// 여러 개도 된다
int enemiesMask = LayerMask.GetMask("Enemy", "Boss");

// 제외 — 마스크를 뒤집는다
int excludeMask = ~LayerMask.GetMask("Cube");

세 가지 쓰는 법을 나란히 두면 이렇게 갈린다.

쓰는 법깨지는 조건드러나는가
1 << 6레이어 순서가 바뀌면 다른 레이어를 가리킨다조용하다
1 << NameToLayer("Cube")이름이 틀리면 31번을 가리킨다조용하다 (제외 형태에서 특히)
LayerMask.GetMask("Cube")—시프트 단계가 없다

첫 줄은 앞 글에서 다룬 자리다. 둘째 줄이 이 글의 자리이고, 이름을 쓰는 것만으로는 안전해지지 않는다는 게 요지다.

정직하게 덧붙이면, GetMask가 없는 이름을 받으면 어떻게 되는지는 문서에 없다. 문서가 보증하는 건 “주어진 이름들에 해당하는 마스크를 돌려준다”까지다. 확실한 차이는 -1이 시프트로 흘러드는 경로가 없다는 것이고, 그게 이 자리를 고르는 이유다.

LayerMask 타입에 레이어 번호가 들어 있다

같은 코드에 한 겹 더 있다. 변수의 타입이다.

LayerMask cubeLayer;

void Start()
{
    cubeLayer = LayerMask.NameToLayer("Cube");
}

NameToLayer가 돌려주는 건 인덱스다. 그걸 LayerMask 타입 변수에 담았다. 그런데 LayerMask가 담는 것은 인덱스가 아니다. value 프로퍼티 설명이다.

Converts a layer mask value to an integer value.

마스크다. 컴파일이 되는 이유는 암시적 변환 때문이다.

Implicitly converts an integer to a LayerMask.

int를 넣으면 그대로 LayerMask가 되고, 다시 int로 꺼내면 같은 값이 나온다. 그래서 (1 << cubeLayer)는 숫자상 의도대로 계산된다. 동작은 하고 타입만 거짓말을 한다.

문제는 그 변수를 다른 곳에 넘길 때다. LayerMask 타입이니 마스크를 받는 자리에 그대로 들어간다.

// Cube가 6번 레이어일 때, cubeLayer의 내용은 6이다.

// 의도대로 — 6번 비트를 세운다
Physics.OverlapSphere(pos, radius, 1 << cubeLayer);

// 컴파일된다. 그런데 마스크 6은 1번과 2번 레이어다.
Physics.OverlapSphere(pos, radius, cubeLayer);

마스크 6은 이진수로 110이고, 1번(TransparentFX)과 2번(Ignore Raycast) 레이어를 가리킨다. Cube와 아무 상관이 없다. 두 호출이 똑같이 컴파일되고 똑같이 조용하다.

인덱스를 들고 있을 거면 타입도 int여야 한다. 그게 읽는 사람에게 “여기엔 시프트가 필요하다”를 알려준다.

// 인덱스를 들고 있다는 게 타입에 드러난다
private int _cubeLayerIndex;

// 마스크를 들고 있다 — 시프트 없이 바로 넘긴다
private LayerMask _cubeMask;

GetMask를 쓰면 애초에 아래쪽만 남는다.

이름으로 자기를 빼면 남의 것도 빠진다

클리핑이 자기 자신을 제외하는 방법이다.

foreach (Collider col in colliders)
{
    if (col.name == "Sphere" /* 자기 자신은 제외 */) continue;

    changeMaterial(col.gameObject, detectedMat);
}

이 글의 테스트 장면 구성을 같이 보면 문제가 보인다. 글은 “보라색 Sphere”에 스크립트를 붙이고, 주변에 Cube·Capsule·Sphere를 배치한다고 적는다. 즉 장면에 Sphere가 둘이다. 검출 중심인 보라색 Sphere와, 검출 대상인 Sphere다.

col.name == "Sphere"는 이름이 같은 전부를 건너뛴다. 검출 대상 Sphere의 이름이 그냥 Sphere라면 그쪽도 같이 빠진다. 자기 자신을 뺀 게 아니라 같은 이름을 전부 뺀 것이다.

자기 자신인지는 이름이 아니라 참조로 본다.

foreach (Collider col in colliders)
{
    // 같은 Transform이면 내 콜라이더다.
    if (col.transform == transform)
    {
        continue;
    }

    // 자식에 콜라이더가 있는 구조라면 이쪽.
    if (col.transform.IsChildOf(transform))
    {
        continue;
    }

    // ...
}

그리고 name은 이 자리에 쓰기에 비싸다. 오브젝트 풀링을 다룬 글에서 GameObject.name이 접근할 때마다 네이티브 문자열을 관리 측 문자열로 새로 만든다는 것을 확인했는데, 이 코드는 그 접근이 Update의 루프 안에 있다. 검출된 콜라이더 수만큼 매 프레임 문자열이 생긴다. 참조 비교는 할당이 없다.

검출은 있고 해제가 없다

클리핑의 Update는 검출된 콜라이더의 머티리얼을 바꾼다.

void changeMaterial(GameObject go, Material changeMat)
{
    Renderer rd = go.GetComponent<MeshRenderer>();
    Material[] mat = rd.sharedMaterials;
    mat[0] = changeMat;
    rd.materials = mat;
}

되돌리는 코드가 없다. 반경 안에 들어오면 빨개지고, 나가도 그대로다. 글이 “Radius가 커질수록 멀리 있는 오브젝트가 DetectedMat(Red)로 변경되는 것을 알 수 있다”고 적은 그대로이고, 반경을 줄이면 색이 돌아오지 않는다.

반경을 조절해보는 데모로는 충분하다. 다만 “주변 탐색”을 실제로 쓰는 자리는 들어옴과 나감이 다 필요하다. 함정이라면 가까워지면 켜고 멀어지면 꺼야 하는데, 이 코드로는 한 번 켜진 함정이 꺼지지 않는다. 트리거의 OnTriggerEnter/OnTriggerExit에 대응하는 것을 직접 만들어야 한다는 뜻이고, 방법은 프레임 사이의 차이를 보는 것이다.

using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Collider))]
public class ProximityTracker : MonoBehaviour
{
    private const int BUFFER_SIZE = 32;

    [Header("Detection")]
    [SerializeField, Range(0.1f, 20f), Tooltip("탐색 반지름")]
    private float _radius = 3f;

    [SerializeField, Tooltip("탐색할 레이어. 인스펙터에서 고른다")]
    private LayerMask _targetMask;

    private readonly Collider[] _buffer = new Collider[BUFFER_SIZE];
    private readonly HashSet<Collider> _current = new HashSet<Collider>();
    private readonly HashSet<Collider> _previous = new HashSet<Collider>();

    private void FixedUpdate()
    {
        _current.Clear();

        // NonAlloc으로 버퍼를 재사용한다. 반환값은 채워진 개수다.
        int count = Physics.OverlapSphereNonAlloc(
            transform.position, _radius, _buffer, _targetMask);

        for (int i = 0; i < count; i++)
        {
            Collider col = _buffer[i];

            // 이름이 아니라 참조로 자기 자신을 건너뛴다.
            if (col.transform == transform || col.transform.IsChildOf(transform))
            {
                continue;
            }

            _current.Add(col);

            // 이전 프레임에 없었으면 '들어옴'이다.
            if (!_previous.Contains(col))
            {
                OnEntered(col);
            }
        }

        foreach (Collider col in _previous)
        {
            // 파괴된 콜라이더가 섞여 있을 수 있다. Unity 오브젝트라 == null로 본다.
            if (col == null)
            {
                continue;
            }

            // 이번 프레임에 없으면 '나감'이다.
            if (!_current.Contains(col))
            {
                OnExited(col);
            }
        }

        // 다음 프레임의 기준으로 current를 옮긴다.
        _previous.Clear();
        _previous.UnionWith(_current);
    }

    private void OnEntered(Collider col)
    {
        Debug.Log($"entered {col.name}", col);
    }

    private void OnExited(Collider col)
    {
        Debug.Log($"exited {col.name}", col);
    }

    private void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.green;

        // 솔리드가 아니라 와이어다. 안쪽이 보여야 쓸모가 있다.
        Gizmos.DrawWireSphere(transform.position, _radius);
    }
}

_previous를 돌면서 col == null을 먼저 보는 이유가 있다. 집합에 담아둔 콜라이더가 그 사이에 파괴될 수 있고, Unity 오브젝트는 파괴된 뒤에도 관리 측 참조가 남는다. ?.가 아니라 == null인 것도 그래서다.

Update가 아니라 FixedUpdate에 둔 것도 의도다. 물리 쿼리는 물리 상태를 읽으니 물리 스텝에 맞추는 편이 결과가 안정적이다.

머티리얼을 바꾸는 쪽은 따로 둘 값이 있다. 셰이더가 무엇인지 본 글에서 Renderer.material을 읽는 순간 머티리얼이 복제되고 치우는 건 내 책임이라는 문서 문장을 봤다. 매 프레임 머티리얼 배열을 다시 대입하는 클리핑의 형태는 그 경계에 가깝다. 들어옴·나감 한 번씩만 바꾸면 그 호출 자체가 프레임당 0회가 된다.

대조하고 넘어간 것들

걸리지 않은 것들이 더 많다. 확인한 것을 남겨둔다.

클리핑의 주장확인
OnDrawGizmosSelected는 선택된 경우만 Gizmos를 그린다맞음. 문서가 “Gizmos are drawn only when the object is selected”
자기 자신도 검출되므로 예외 처리가 필요하다맞음
매번 검출하는 것은 성능에 영향을 미친다맞음
layerMask는 레이어를 비트로 판단한다맞음
~ 비트 연산으로 특정 레이어를 제외할 수 있다맞음

OnDrawGizmosSelected 쪽은 문서가 예시까지 같은 걸 든다.

For example an explosion script could draw a sphere showing the explosion radius.

폭발 반경을 구로 그리는 것이 이 콜백의 교과서 용례다. 클리핑이 탐색 반경에 쓴 것과 같은 자리다.

“매번 검출하는 것은 성능에 영향을 미치기 때문에 실제로는 이벤트나 충돌이 발생할 경우에 사용한다”는 문장도 정확한 지적이다. 다만 Update에서 꼭 돌려야 하는 경우라면 호출 횟수보다 할당 쪽이 먼저 걸린다. OverlapSphere는 호출마다 배열을 새로 만들고, OverlapSphereNonAlloc은 내가 준 버퍼를 채운다. 그 비교는 앞 글에서 코드까지 나란히 두고 다뤘고, Unity 코드 최적화 문서에도 같은 함수가 예제로 나온다.

캡슐 모양으로 같은 일을 하는 함수는 OverlapCapsule을 다룬 글에 있다. 마스크를 만드는 이야기는 그쪽에도 그대로 해당한다.

어디에 왜 쓰나

마스크는 인스펙터에서 고른다

코드에서 이름이나 번호로 마스크를 만드는 것보다 나은 선택지가 하나 더 있다. LayerMask 타입을 [SerializeField]로 두면 인스펙터가 레이어 드롭다운을 그려준다.

[SerializeField, Tooltip("탐색할 레이어")]
private LayerMask _targetMask;

이쪽이 좋은 이유는 문자열이 코드에서 사라지는 것이다. 레이어 이름을 바꿔도 코드를 고칠 일이 없고, 철자를 틀릴 자리도 없다. 앞 절의 -1 문제가 생길 경로가 아예 없어진다.

세 가지를 정리하면 이렇다.

방법레이어 이름이 코드에틀렸을 때
[SerializeField] LayerMask없다고를 수 없으니 틀릴 자리가 없다
LayerMask.GetMask("Cube")있다시프트 단계는 없다
1 << NameToLayer("Cube")있다31번을 가리킨다

코드에서 마스크를 조합해야 하는 경우에만 둘째 줄을 쓰고, 기본은 첫째 줄이다.

탐색 결과를 이벤트로 넘긴다

위의 ProximityTracker는 들어옴·나감을 Debug.Log로 끝냈다. 실제로는 그 시점에 다른 쪽이 반응해야 한다. 탐색하는 쪽과 반응하는 쪽을 직접 참조로 묶지 않으려면 이벤트로 넘긴다.

using System;
using UnityEngine;

public class ProximityEvents : MonoBehaviour
{
    // event를 붙여 외부의 호출과 = null 대입을 막는다.
    public event Action<Collider> Entered;
    public event Action<Collider> Exited;

    public void RaiseEntered(Collider col)
    {
        // 구독자가 없으면 null이다. 델리게이트이므로 ?. 를 쓴다.
        Entered?.Invoke(col);
    }

    public void RaiseExited(Collider col)
    {
        Exited?.Invoke(col);
    }
}

?.Invoke()와 event를 쓰는 이유는 Action으로 이벤트를 등록한 글에 있다. 구독하는 쪽은 OnEnable/OnDisable에서 등록과 해제를 짝으로 둔다 — 탐색 대상이 풀에서 나왔다 들어가는 오브젝트라면 특히 그렇다.

쓰지 말아야 할 자리

정리


참고

이 글의 출발점이 된 자료는 피로물든딸기 — 유니티 - OverlapSphere로 주변 콜라이더 탐색하기 (2022-07-21)이다. 같은 함수를 다룬 앞 글에서는 기본 인자를 봤고, 이 글에서는 마스크를 직접 만드는 쪽을 Unity 스크립팅 레퍼런스와 C# 연산자 문서에 대조했다.


이 글 공유하기:

이전 글
익명 함수를 못 지우는 건 익명이라서가 아니다
다음 글
await Awaitable은 변수에 담을 값이 없다