Unity - '한 우산 아래'

맵툴 제작기 7 - 회전축과 겹침 표시를 옵션으로 분리하기

망고와플 2026. 5. 1. 07:21

맵을 찍다 보면 처음에 생각한 기능이 그대로 끝까지 맞아떨어지지 않을 때가 많다.

이번에도 그랬다.

처음에는 프리팹을 빠르게 배치하는 기능만 있으면 충분할 줄 알았다.

하지만 실제로 블록아웃을 찍다 보니, 바닥만 놓는 것이 아니라 벽, 계단, 파이프, 장식 오브젝트까지 같이 배치하게 됐다.

그러다 보니 기존 방식의 한계가 보였다.

기존 맵툴의 회전은 거의 Y축 기준으로만 생각하고 있었다.

Q: -1도 회전
E: +1도 회전
R: 90도 회전

바닥이나 일반 블록을 배치할 때는 괜찮았다.

위에서 내려다보며 Y축으로 돌리는 경우가 대부분이었기 때문이다.

하지만 벽에 붙는 오브젝트나 파이프 같은 장식물을 배치하려고 하니 이야기가 달라졌다.

(아트분께서 요청해주시기도 했고..)

어떤 오브젝트는 X축으로 눕혀야 하고, 어떤 오브젝트는 Z축으로 세워야 했다.

그때마다 프리팹 자체를 고치거나, 배치한 뒤 Scene View에서 직접 회전시키는 건 흐름이 끊겼다.

그래서 이번에는 맵툴에 회전축 선택 기능을 추가했다.

왜 회전축을 옵션으로 뺐나

기존에는 회전값이 하나만 있었다.

private float currentRotationDegrees;

그리고 이 값은 사실상 Y축 회전으로만 쓰이고 있었다.

return Quaternion.Euler(0.0f, currentRotationDegrees, 0.0f);

이 구조는 바닥 블록에는 단순해서 좋다.

하지만 맵툴이 바닥 전용 도구가 아니라, 여러 프리팹을 찍는 도구가 되면서 문제가 생겼다.

예를 들어 이런 경우가 있다.

바닥 블록
→ Y축 회전이 자연스럽다.

벽 장식
→ X축이나 Z축 회전이 필요할 수 있다.

파이프
→ 벽면 방향에 따라 눕히거나 세워야 한다.

기둥 장식
→ 한 축으로만 돌리면 원하는 방향을 만들기 어렵다.

그래서 회전값 자체는 그대로 두되, 그 회전값을 어느 축에 적용할지 선택할 수 있게 했다.

private enum PlacementRotationAxis
{
    X,
    Y,
    Z
}

그리고 현재 선택된 축을 저장하는 변수를 추가했다.

private PlacementRotationAxis rotationAxis = PlacementRotationAxis.Y;

기본값은 Y로 두었다.

기존에 쓰던 사용감이 갑자기 바뀌면 불편하기 때문이다.

Rotation Axis UI 추가

맵툴 창에는 Rotation Axis 항목을 추가했다.

rotationAxis = (PlacementRotationAxis)EditorGUILayout.EnumPopup(
    new GUIContent("Rotation Axis", "Q/E/R 키와 Rotate 버튼이 회전시킬 축입니다."),
    rotationAxis
);

이제 툴에서 X, Y, Z 중 하나를 고를 수 있다.

Q, E, R 키는 그대로 유지하기

회전축을 추가한다고 해서 단축키를 새로 만들고 싶지는 않았다.

이미 손에 익은 키가 있었기 때문이다.

Q / E: 1도씩 미세 회전
R: 90도 회전

그래서 단축키는 그대로 두고, 내부에서 적용되는 축만 바꾸기로 했다.

private void RotateRight()
{
    ChangeRotationDegrees(90.0f);
}

private void RotateFineLeft()
{
    ChangeRotationDegrees(-1.0f);
}

private void RotateFineRight()
{
    ChangeRotationDegrees(1.0f);
}

실제로 회전값을 바꾸는 처리는 하나의 함수로 모았다.

private void ChangeRotationDegrees(float deltaDegrees)
{
    // 회전값은 UI에서 고른 Rotation Axis에 적용된다.
    // Q/E는 1도씩, R/Rotate 버튼은 90도씩 같은 축의 값을 바꾼다.
    currentRotationDegrees = Mathf.Repeat(currentRotationDegrees + deltaDegrees, 360.0f);
    Repaint();
}

이렇게 해두면 Q, E, R 키의 의미는 그대로 유지된다.

다만 현재 선택된 축이 X면 X축 회전, Y면 Y축 회전, Z면 Z축 회전으로 적용된다.

선택된 축으로 Quaternion 만들기

회전값을 실제 배치 회전으로 바꾸는 쪽도 수정했다.

먼저 선택된 축을 Vector3로 바꿔주는 함수를 만들었다.

private Vector3 GetRotationAxisVector()
{
    switch (rotationAxis)
    {
        case PlacementRotationAxis.X:
            return Vector3.right;

        case PlacementRotationAxis.Z:
            return Vector3.forward;

        default:
            return Vector3.up;
    }
}

그리고 배치 회전을 만들 때 이 축을 사용한다.

private Quaternion GetPlacementRotation(Vector3 surfaceNormal)
{
    Vector3 rotationAxisVector = GetRotationAxisVector();

    if (!alignToSurfaceNormal)
    {
        return Quaternion.AngleAxis(currentRotationDegrees, rotationAxisVector);
    }

    Quaternion surfaceRotation = Quaternion.FromToRotation(Vector3.up, surfaceNormal);
    Quaternion localSpin = Quaternion.AngleAxis(currentRotationDegrees, rotationAxisVector);

    return surfaceRotation * localSpin;
}

여기서 중요한 점은 Quaternion.Euler() 대신 Quaternion.AngleAxis()를 사용했다는 것이다.

Quaternion.Euler(0, 값, 0)은 Y축 회전에 고정된 형태다.

반면 Quaternion.AngleAxis(각도, 축)은 원하는 축을 직접 넣을 수 있다.

Quaternion.AngleAxis(90.0f, Vector3.right);   // X축 기준 90도
Quaternion.AngleAxis(90.0f, Vector3.up);      // Y축 기준 90도
Quaternion.AngleAxis(90.0f, Vector3.forward); // Z축 기준 90도

이번 기능에는 AngleAxis 쪽이 더 잘 맞았다.

두 번째 문제: 겹침 표시가 배치를 막고 있었다

이번에 같이 고친 문제도 있다.

(이것도 이전 글에서 영상으로 올려두었습니다)

맵을 찍다 보면 일부러 오브젝트를 살짝 겹치게 배치할 때가 있다.

예를 들어 블록아웃 단계에서는 이런 식으로 배치할 수 있다.

바닥 블록을 조금 겹쳐서 울퉁불퉁하게 만들기
벽과 바닥이 살짝 맞물리게 두기
파이프가 벽 안쪽으로 조금 파고들게 배치하기

처음에는 겹침을 막는 기능이 있으면 좋을 줄 알았다.

하지만 실제로 맵을 찍다 보니, 겹침은 항상 나쁜 것이 아니었다.

오히려 블록아웃 단계에서는 겹침을 허용해야 자연스러운 구조를 빠르게 만들 수 있었다.

그런데 기존에는 겹침이 감지되면 프리뷰가 빨간색이 되고, 배치 자체가 막혔다.

문제는 여기서 끝이 아니었다.

회전된 오브젝트끼리는 실제 메쉬 사이에 틈이 있어도 겹친 것으로 판단되는 경우가 있었다.

왜 틈이 있는데도 겹친다고 판단했나

원인은 Bounds였다.

Unity의 renderer.bounds나 collider.bounds는 월드 축 기준의 박스다.

즉 오브젝트가 회전되어 있어도, 그 오브젝트를 월드 X/Y/Z 방향의 박스로 크게 감싼다.

그래서 이런 일이 생긴다.

실제 메쉬
-> 틈이 있음

월드 bounds
-> 회전된 오브젝트를 감싼 큰 네모라서 겹쳐 보임

맵툴
-> 겹쳤다고 판단

이건 예전에 디버그 선 문제를 겪었을 때랑 비슷했다.

(이전에 Snap관련 코드 수정 때, 회전한 후 그냥 배치되는 부분은 따로 안고친 문제였다.)

눈에 보이는 것과 실제 계산 기준이 다르면, 툴이 사람 입장에서는 이상하게 느껴진다.

배치 금지와 겹침 경고를 분리하기

그래서 이번에는 역할을 나눴다.

배치 금지
- 같은 snapped 위치에 이미 오브젝트가 있는 경우만 막는다.

겹침 검사
- 배치는 막지 않고, 경고로만 보여준다.

기존 IsCellOccupied()는 겹침까지 같이 검사하고 있었다.

이번에는 이 함수를 같은 위치에 이미 찍힌 오브젝트가 있는지만 확인하도록 바꿨다.

private bool IsCellOccupied(Vector3 targetPosition, Quaternion targetRotation, Vector3 targetLocalScale)
{
    // 배치를 막는 조건은 같은 snapped 위치를 다시 찍는 경우로만 둔다.
    // 실제 부피 겹침은 rough blockout이나 높이 차이 표현에 필요할 수 있으므로
    // 아래 UpdatePreviewOverlapState에서 경고/디버그 용도로만 따로 표시한다.
    Transform parent = placementParent != null ? placementParent : GameObject.Find(RootObjectName)?.transform;

    if (parent == null)
    {
        return false;
    }

    const float epsilon = 0.01f;

    foreach (Transform child in parent)
    {
        if (Vector3.Distance(child.position, targetPosition) <= epsilon)
        {
            return true;
        }
    }

    return false;
}

이제 같은 위치에 다시 찍으려고 하면 막는다.

하지만 실제 부피가 겹치는 것은 별도 경고로만 보여준다.

Show Overlap Warning 옵션

겹침 경고도 항상 켜져 있으면 시야가 복잡해질 수 있다.

그래서 체크박스로 뺐다.

private bool showOverlapWarning = true;

UI에는 이렇게 추가했다.

showOverlapWarning = EditorGUILayout.Toggle(
    new GUIContent(
        "Show Overlap Warning",
        "켜면 배치 미리보기가 기존 오브젝트와 겹칠 때 노란색 경고와 겹친 collider 이름을 보여줍니다. 배치를 막지는 않습니다."
    ),
    showOverlapWarning
);

이제 겹침 경고는 선택 사항이다.

Show Overlap Warning ON
- 겹치면 노란색으로 표시
- 어떤 collider와 겹쳤는지 이름 표시
- 그래도 배치는 가능

Show Overlap Warning OFF
- 겹침 검사를 표시하지 않음
- 빠르게 블록을 찍을 때 화면이 덜 복잡함

회전된 물체의 겹침은 어떻게 검사했나 - 3D SAT 기반의 간이 OBB overlap 검사

겹침 경고를 표시할 때도 기존 Bounds만 쓰면 같은 문제가 반복된다.

그래서 이번에는 프리뷰와 주변 collider의 실제 world point를 모아서, 각 축에 투영하는 방식으로 바꿨다.

//3D SAT 기반의 간이 OBB overlap 검사
//2D OBB 만들어본 경험을 살려 만들어 볼까 했지만.. 굳이? 라서 간이로..
private bool HasProjectedPointCloudOverlap(
    List<Vector3> lhs,
    List<Vector3> rhs,
    Quaternion lhsRotation,
    Quaternion rhsRotation,
    float tolerance)
{
    Vector3[] axes =
    {
        lhsRotation * Vector3.right,
        lhsRotation * Vector3.up,
        lhsRotation * Vector3.forward,
        rhsRotation * Vector3.right,
        rhsRotation * Vector3.up,
        rhsRotation * Vector3.forward,
    };

    foreach (Vector3 rawAxis in axes)
    {
        if (rawAxis.sqrMagnitude <= 0.000001f)
        {
            continue;
        }

        Vector3 axis = rawAxis.normalized;

        GetProjectionInterval(lhs, axis, out float lhsMin, out float lhsMax);
        GetProjectionInterval(rhs, axis, out float rhsMin, out float rhsMax);

        // 한 축이라도 분리되어 있으면 실제 부피는 겹치지 않는다.
        if (Mathf.Min(lhsMax, rhsMax) - Mathf.Max(lhsMin, rhsMin) <= tolerance)
        {
            return false;
        }
    }

    return true;
}

완전한 물리 충돌 계산을 직접 구현한 것은 아니다.

하지만 회전된 블록끼리 bounds만 보고 판단하는 것보다는 훨씬 자연스럽다.

여기서 중요한 생각은 이것이다.

월드 기준 큰 박스끼리 비교하지 말고,
오브젝트가 실제로 바라보는 축 기준으로도 한 번 비교하자.

이렇게 하니 분명 틈이 있는데도 빨간색으로 막히는 문제가 해결됐다

Fit Between Faces 옵션

이번에 크기 맞춤 기능도 옵션으로 분리했다.

이 기능은 주변 면 사이에 프리팹을 넣을 때, X/Z 크기를 자동으로 맞춰주는 기능이다.

예를 들어 이런 상황이다.

왼쪽 블록과 오른쪽 블록 사이에 빈 공간이 있음
그 사이에 새 블록을 넣고 싶음
새 블록이 원래 크기 그대로면 살짝 겹치거나 넘침
- 주변 면 사이에 맞게 크기를 줄여야 함

처음에는 이 기능이 랜덤 스케일이 켜져 있을 때만 동작했다.

하지만 실제로 써보니 랜덤 여부와 상관없이 필요했다.

그래서 옵션을 따로 만들었다.

private bool fitBetweenNearbyFaces = true;

UI에는 이렇게 추가했다.

fitBetweenNearbyFaces = EditorGUILayout.Toggle(
    new GUIContent(
        "Fit Between Faces",
        "켜면 주변 면 사이에 들어갈 때 X/Z 크기를 자동으로 맞춥니다. 랜덤 스케일 여부와 상관없이 동작합니다."
    ),
    fitBetweenNearbyFaces
);

이제 동작은 이렇게 나뉜다.

Fit Between Faces ON
- 주변 면 사이에 들어갈 때 크기 자동 보정

Fit Between Faces OFF
- 원래 크기 그대로 배치

디버그 표시

겹침 경고를 켰을 때는 어떤 collider와 겹쳤는지도 볼 수 있게 했다.

if (showOverlapWarning && lastPreviewOverlapCollider != null)
{
    DrawColliderWire(lastPreviewOverlapCollider, new Color(1.0f, 0.25f, 0.2f, 1.0f));

    Handles.Label(
        lastPreviewOverlapCollider.bounds.center + Vector3.up * 0.25f,
        $"Overlap Check: {lastPreviewOverlapCollider.name} [{lastPreviewOverlapCollider.GetType().Name}]"
    );
}

이건 단순히 보기 좋은 기능이라기보다, 툴을 고칠 때 꽤 중요하다.

왜 노란색으로 표시되지? 뭐랑 겹침? 이라는 상황에서 바로 확인할 수 있기 때문이다.

현재 마우스가 맞고 있는 collider
겹침 경고를 낸 collider
Face Anchor로 잡힌 면

이런 정보가 Scene View에 보이면, 툴이 어떤 기준으로 판단하는지 추적하기 쉬워진다.

색상 의미 정리

이번 수정 이후 프리뷰 색 의미는 이렇게 정리했다.

파란색
→ 배치 가능

노란색
→ 기존 오브젝트와 겹침 경고
→ 그래도 배치 가능

빨간색
→ 같은 snapped 위치에 이미 오브젝트가 있음
→ 배치 차단

빨간색과 노란색을 나눈 게 중요하다.

이전에는 겹침과 배치 불가가 같은 의미처럼 처리됐다.

하지만 실제 맵 제작에서는 겹치더라도 배치해야 하는 경우가 있다.

그래서 이제는

  1. 정말 막아야 하는 경우(빨간색)
  2. 그냥 알려주기만 하면 되는 경우(노란색)
  3. 정상 배치(파란색)

로 분리했다.

정리

이번 작업은 엄청 화려한 기능은 아니다.

하지만 맵툴을 실제로 쓰면서 생긴 불편함을 줄이는 작업이었다.

추가한 것은 크게 세 가지다.

1. Rotation Axis
   - Q/E/R 키가 X/Y/Z 중 선택한 축 기준으로 회전한다.

2. Fit Between Faces
   - 주변 면 사이에 들어갈 때 크기를 자동으로 맞춘다.
   - 랜덤 스케일 여부와 상관없이 사용할 수 있다.

3. Show Overlap Warning
   - 겹침을 배치 금지로 보지 않고 경고로만 표시한다.
   - 필요 없으면 꺼둘 수 있다.

이번에 중점을 둔 건, 딴사람이 쓸 때 기능의 선택권을 제공하자! 였다.

처음에는 자동으로 막아주는 게 좋을 것 같았다.

하지만 실제로 맵을 찍다 보니, 어떤 경우에는 막으면 안 됐다.

처음에는 Y축 회전이면 충분할 줄 알았다.

하지만 파이프나 벽 장식을 놓기 시작하니 X/Z축 회전도 필요했다.

결국 툴은 사용자의 흐름을 끊지 않아야 한다.

그래서 이번 수정은 기능을 더 복잡하게 만든 것이 아니라,

맵을 찍는 사람이 상황에 따라 선택할 수 있게 만든 작업에 가깝다.