Unity - '한 우산 아래'

맵툴 제작기 6 - 랜덤 스케일과 면 고정 배치 보정

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

이전 글에서는 Scene View에서 프리팹의 크기와 높이를 직접 조절할 수 있게 만들었다.

이제 숫자를 계속 입력하지 않아도, Scene View 안에서 어느 정도 크기를 맞춰가며 배치할 수 있게 됐다.

그런데 맵을 찍다 보니 또 다른 욕심이 생겼다.

같은 블록을 계속 찍으면 너무 규칙적으로 보인다.(그리고 AI로 뽑은 게임의 레퍼런스처럼 배치해보고 싶었다.)

특히 바닥 타일이나 벽 블록을 여러 개 이어 붙이면, 전부 같은 크기라서 약간 기계적으로 보였다.

그래서 배치할 때마다 X, Y, Z 스케일에 랜덤값을 조금씩 줄 수 있게 만들고 싶었다.

예를 들면 이런 느낌이다.

X축 랜덤 0.2
Z축 랜덤 0.1

배치할 때마다
가로는 조금 길거나 짧아지고,
깊이도 살짝 달라진다.

처음에는 간단할 줄 알았다.

그냥 배치 순간에 랜덤 스케일을 더하면 되니까.

그런데 실제로 넣어보니 문제가 바로 보였다.

블록끼리 딱 붙어 있어야 하는데, 랜덤 스케일 때문에 틈이 생기거나 서로 겹쳐버렸다.

-실제 프로토타입에 쓰일 Floor

그냥 랜덤 스케일만 넣으면 생기는 문제

맵툴에서 블록을 배치할 때는 보통 면과 면을 붙여서 배치한다.

예를 들어 왼쪽에 이미 블록이 있고, 그 오른쪽에 새 블록을 붙인다고 생각해보자.

내가 원하는 건 이거였다.

기존 블록 | 새 블록

새 블록의 크기가 랜덤으로 조금 커지더라도, 기존 블록과 닿아 있는 면은 그대로 붙어 있어야 한다.

즉, 왼쪽 면은 고정되고 오른쪽으로만 늘어나야 한다.

그런데 아무 생각 없이 scale만 바꾸면 보통 pivot을 중심으로 양쪽이 같이 커진다.

그러면 이런 일이 생긴다.

1. 블록이 커진다
2. 새 블록의 왼쪽 면도 같이 밀린다
3. 기존 블록과 겹친다

반대로 블록이 작아지면:

1. 블록이 작아진다
2. 새블록의 왼쪽 면도 안쪽으로 들어간다
3. 기존 블록과 사이가 벌어진다

이게 은근히 거슬렸다.

맵툴에서 랜덤성을 넣고 싶은 이유는 자연스러운 변화를 주기 위해서지, 블록 사이에 겹침 혹은, 이상한 틈을 만들기 위해서가 아니니까.

랜덤 스케일 옵션 추가하기

먼저 툴 창에 X, Y, Z축별 랜덤 옵션을 추가했다.

private Vector3 randomScaleAmount = Vector3.zero;

private bool randomizeScaleX;
private bool randomizeScaleY;
private bool randomizeScaleZ;

각 축마다 랜덤을 켤지 말지 선택할 수 있게 했다.

예를 들어 X만 켜면 가로 길이만 랜덤으로 바뀐다.

Y만 켜면 높이만 랜덤으로 바뀐다.

private void DrawRandomScaleAxisControl(
    GUIContent toggleLabel,
    GUIContent amountLabel,
    ref bool enabled,
    ref float amount)
{
    using (new EditorGUILayout.HorizontalScope())
    {
        enabled = EditorGUILayout.ToggleLeft(toggleLabel, enabled, GUILayout.Width(42.0f));

        using (new EditorGUI.DisabledScope(!enabled))
        {
            amount = Mathf.Max(0.0f, EditorGUILayout.FloatField(amountLabel, amount));
        }

        GUILayout.Label($"±{amount:F2}", GUILayout.Width(70.0f));
    }
}

여기서 amount는 랜덤 범위다.

예를 들어 X 랜덤값을 0.2로 두면, 실제 배치할 때 X 스케일에 -0.2 ~ +0.2 사이 값이 더해진다.

private Vector3 GetRandomizedPlacementScaleMultiplier()
{
    Vector3 randomizedScale = placementScale;

    if (randomizeScaleX)
    {
        randomizedScale.x += UnityEngine.Random.Range(-randomScaleAmount.x, randomScaleAmount.x);
    }

    if (randomizeScaleY)
    {
        randomizedScale.y += UnityEngine.Random.Range(-randomScaleAmount.y, randomScaleAmount.y);
    }

    if (randomizeScaleZ)
    {
        randomizedScale.z += UnityEngine.Random.Range(-randomScaleAmount.z, randomScaleAmount.z);
    }

    return SanitizePlacementScale(randomizedScale);
}

그리고 실제 프리팹 크기는 프리팹의 기본 스케일과 랜덤 배율을 곱해서 만든다.

private Vector3 GetRandomizedPlacementLocalScale()
{
    // Preview는 기준 크기를 보여주고,
    // 실제 배치 순간에만 랜덤 배율을 한 번 뽑는다.
    return Vector3.Scale(
        GetSelectedPrefabBaseScale(),
        GetRandomizedPlacementScaleMultiplier());
}

여기서 Preview에는 랜덤을 계속 적용하지 않았다.

마우스를 움직일 때마다 Preview 크기가 계속 흔들리면 오히려 배치하기 불편하기 때문이다.

그래서 Preview는 기준 크기를 보여주고, 실제 클릭해서 배치되는 순간에만 랜덤값을 뽑았다.

면이 붙어 있으면 그 면은 유지해야 한다

랜덤 스케일을 넣으면서 가장 중요했던 건 이것이었다.

이미 붙어 있는 면은 계속 붙어 있어야 한다.

예를 들어 새 블록의 왼쪽 면이 기존 블록의 오른쪽 면에 닿아 있다면, 새 블록이 커지거나 작아져도 왼쪽 면은 움직이면 안 된다.

그래서 배치 전에 어떤 면이 주변 블록과 닿아 있는지를 검사했다.

이 정보를 저장하기 위해 축마다 최소 면, 최대 면 접촉 여부를 들고 있는 구조체를 만들었다.

private struct PlacementAxisContact
{
    public bool HasMin;
    public bool HasMax;

    public float MinPlane;
    public float MaxPlane;
}

여기서 Min과 Max는 축 기준의 양쪽 면이라고 생각하면 된다.

X축 기준이라면:

Min = 왼쪽 면
Max = 오른쪽 면

Z축 기준이라면:

Min = 뒤쪽 면
Max = 앞쪽 면

이제 배치하려는 블록 주변에 기존 블록이 있는지 검사하고, 서로 면이 가까우면 접촉한 것으로 본다.

if (Mathf.Abs(previewMin[axisIndex] - neighborMax[axisIndex]) <= axisContactTolerance)
{
    SetMinContact(ref contacts[axisIndex], neighborMax[axisIndex]);
    foundContact = true;
}

if (Mathf.Abs(previewMax[axisIndex] - neighborMin[axisIndex]) <= axisContactTolerance)
{
    SetMaxContact(ref contacts[axisIndex], neighborMin[axisIndex]);
    foundContact = true;
}

이 코드는 말로 풀면 이렇다.

내 최소 면이 상대의 최대 면과 거의 같은 위치인가?
-> 그럼 내 최소 면이 붙어 있다.

내 최대 면이 상대의 최소 면과 거의 같은 위치인가?
-> 그럼 내 최대 면이 붙어 있다.

이렇게 하면 새 블록이 어느 쪽 면으로 붙어 있는지 알 수 있다.

한쪽 면만 붙어 있는 경우

가장 흔한 경우는 한쪽 면만 붙어 있는 상황이다.

예를 들어 왼쪽 면만 기존 블록에 붙어 있다고 해보자.

이때 랜덤으로 X 크기가 커지면, 오른쪽으로만 커져야 한다.

왼쪽 면은 그대로 붙어 있어야 한다.

private bool ApplyContactAnchoredScaleAxis(
    ref Vector3 targetPosition,
    Quaternion targetRotation,
    ref Vector3 targetLocalScale,
    int axisIndex,
    PlacementAxisContact contact)
{
    GetPreviewProjectionInterval(
        targetPosition,
        targetRotation,
        targetLocalScale,
        axisIndex,
        out float previewMin,
        out float previewMax);

    if (contact.HasMin)
    {
        MoveAlongPlacementAxis(
            ref targetPosition,
            targetRotation,
            axisIndex,
            contact.MinPlane - previewMin);

        return true;
    }

    if (contact.HasMax)
    {
        MoveAlongPlacementAxis(
            ref targetPosition,
            targetRotation,
            axisIndex,
            contact.MaxPlane - previewMax);

        return true;
    }

    return false;
}

여기서 하는 일은 단순하다.

랜덤 스케일을 적용한 뒤에 면 위치를 다시 계산한다.

그리고 붙어 있어야 하는 면이 원래 접촉면에서 벗어났다면, 그만큼 위치를 밀어서 다시 붙인다.

예를 들어 왼쪽 면이 0.2만큼 안쪽으로 들어갔다면, 블록 전체를 왼쪽으로 0.2만큼 이동시킨다.

그러면 왼쪽 면은 다시 붙고, 크기 변화는 반대쪽으로만 나타난다.

양쪽 면이 모두 붙어 있는 경우

조금 더 까다로운 경우도 있다.

블록이 양쪽 사이에 끼어 들어가는 상황이다.

예를 들면 이런 배치다.

기존 블록 | 새 블록 | 기존 블록

이 경우에는 새 블록의 왼쪽 면과 오른쪽 면이 모두 붙어야 한다.

랜덤 스케일을 그대로 적용하면 한쪽은 붙어도 다른 한쪽이 겹치거나 벌어진다.

그래서 양쪽 면이 모두 접촉 중이면, 랜덤 스케일을 그대로 쓰는 것이 아니라 두 면 사이 길이에 맞춰 크기를 조정한다.

if (contact.HasMin && contact.HasMax)
{
    float targetLength = Mathf.Max(
        placementSizeEpsilon,
        contact.MaxPlane - contact.MinPlane);

    float currentLength = Mathf.Max(
        placementSizeEpsilon,
        previewMax - previewMin);

    float scaleRatio = targetLength / currentLength;
    targetLocalScale[axisIndex] *= scaleRatio;

    Vector3 previewCenter = GetPreviewProjectionCenter(
        targetPosition,
        targetRotation,
        targetLocalScale,
        axisIndex);

    float targetCenter = (contact.MinPlane + contact.MaxPlane) * 0.5f;

    MoveAlongPlacementAxis(
        ref targetPosition,
        targetRotation,
        axisIndex,
        targetCenter - previewCenter);

    return true;
}

이 경우에는 랜덤값을 무조건 반영한다보다 면이 붙는 것을 더 우선했다.

맵툴에서 중요한 건 자연스러운 크기 변화지만, 블록이 서로 겹치거나 벌어지면 그게 더 큰 문제라고 봤다.

그래서 양쪽이 막혀 있는 축은 두 면 사이에 맞게 크기를 다시 맞춘다.

Y축 랜덤이 들어가면 바닥이 뜬다

X, Z 랜덤은 주로 옆면 문제였다.

그런데 Y축 랜덤은 다른 문제가 있었다.

높이가 랜덤으로 바뀌면 블록의 바닥면이 살짝 뜨거나 바닥 안으로 파고들 수 있었다.

특히 pivot이 중앙에 있는 프리팹이면 더 쉽게 보인다.

Y scale 증가
- 위아래로 같이 커짐
- 바닥이 아래로 파고듦

Y scale 감소
- 위아래로 같이 줄어듦
- 바닥에서 살짝 뜸

그래서 최종 배치 직전에 실제 scale 기준으로 다시 표면 위에 올려놓았다.

private void KeepPlacementOnSurface(
    ref Vector3 targetPosition,
    Vector3 surfacePosition,
    Vector3 surfaceNormal,
    Quaternion placementRotation,
    Vector3 localScale,
    float fallbackY)
{
    // 위쪽 표면에 올려놓는 배치라면,
    // 실제 scale 기준으로 다시 바닥면을 맞춘다.
    if (surfaceNormal.y < 0.5f)
    {
        targetPosition.y = fallbackY;
        return;
    }

    Vector3 surfaceAlignedPosition = GetAlignedPreviewPosition(
        surfacePosition,
        placementRotation,
        surfaceNormal,
        localScale);

    targetPosition.y = surfaceAlignedPosition.y;
}

여기서 surfaceNormal.y < 0.5f 조건은 옆면에 붙이는 배치까지 억지로 Y 보정을 하지 않기 위해 넣었다.

바닥 위에 올리는 배치라면 Y 위치를 다시 맞추고, 벽면이나 옆면 배치라면 기존 위치를 유지한다.

실제 배치 흐름

최종 배치 흐름은 대략 이렇게 된다.

Vector3 baseLocalScale = GetPlacementLocalScale();

Vector3 snappedPosition = GetAlignedPreviewPosition(
    surfacePosition,
    placementRotation,
    surfaceNormal,
    baseLocalScale);

Vector3 placementLocalScale = GetRandomizedPlacementLocalScale();

float lockedPlacementY = snappedPosition.y;

ApplyContactAnchoredScale(
    ref snappedPosition,
    placementRotation,
    baseLocalScale,
    ref placementLocalScale,
    HasScaleRandomEnabled());

KeepPlacementOnSurface(
    ref snappedPosition,
    surfacePosition,
    surfaceNormal,
    placementRotation,
    placementLocalScale,
    lockedPlacementY);

순서가 중요했다.

먼저 기준 크기로 배치 위치를 잡는다.

그 다음 랜덤 스케일을 뽑는다.

그리고 붙어 있던 면이 떨어지지 않도록 위치를 보정한다.

마지막으로 바닥면이 뜨거나 파고들지 않게 Y 위치를 다시 맞춘다.

기준 배치 위치 계산
- 랜덤 스케일 적용
- 닿아 있던 면 보정
- 바닥면 보정
- 최종 배치

이 순서로 하니까 랜덤성을 넣어도 블록이 훨씬 안정적으로 붙었다.

겹치는 배치는 막기

랜덤 스케일을 넣으면 또 하나 조심해야 할 게 있다.

기존에는 중심 위치만 보고 이미 배치된 셀인가?를 판단해도 어느 정도 괜찮았다.

하지만 스케일이 랜덤으로 바뀌면 중심은 달라도 실제 부피가 겹칠 수 있다.

그래서 Preview bounds와 기존 배치물 bounds를 비교해서 실제로 부피가 겹치는지 검사했다.

private bool HasMeaningfulBoundsOverlap(Bounds lhs, Bounds rhs, float tolerance)
{
    float overlapX = Mathf.Min(lhs.max.x, rhs.max.x) - Mathf.Max(lhs.min.x, rhs.min.x);
    float overlapY = Mathf.Min(lhs.max.y, rhs.max.y) - Mathf.Max(lhs.min.y, rhs.min.y);
    float overlapZ = Mathf.Min(lhs.max.z, rhs.max.z) - Mathf.Max(lhs.min.z, rhs.min.z);

    // 면끼리 딱 닿는 상태는 허용하고,
    // 실제로 부피가 겹칠 때만 점유 상태로 본다.
    return overlapX > tolerance &&
           overlapY > tolerance &&
           overlapZ > tolerance;
}

여기서 중요한 건 딱 붙은 상태는 허용한다는 점이다.

블록 맵에서는 면과 면이 닿는 게 정상이다.

그래서 overlap > tolerance일 때만 실제 겹침으로 봤다.

면만 닿음
→ 허용

부피가 겹침
→ 배치 막기

배치가 막히면 기존처럼 경고를 띄운다.

Debug.LogWarning("Map Tool: A placement already exists at this snapped cell.");

써보면서 느낀 점

이번 기능은 처음에는 랜덤 스케일만 넣으면 되겠지라고 생각했다.

그런데 막상 넣어보니 랜덤보다 더 중요한 건 기준점이었다.

그냥 크기만 랜덤으로 바꾸면 블록이 자연스러워지는 게 아니라, 오히려 배치가 지저분해진다.

특히 맵툴처럼 면과 면을 붙여가며 배치하는 도구에서는 더 그렇다.

크기를 바꾼다

에서 끝나는 게 아니라,

어느 면을 고정한 채로 크기를 바꿀 것인가

까지 생각해야 했다.

이번에 만든 방식은 완벽한 모델링 툴은 아니지만, 지금 필요한 블록아웃 작업에는 꽤 잘 맞는다.

바닥 타일이나 벽 블록을 찍을 때 조금씩 크기가 달라지고, 그래도 붙어야 할 면은 계속 붙어 있다.

덕분에 반복 배치의 딱딱한 느낌을 줄이면서도, 맵 구조가 무너지지 않게 만들 수 있었다.

다음에는 이 툴로 실제 프로토타입 맵을 더 찍어보면서, 어떤 기능이 진짜 자주 쓰이고 어떤 기능은 과한지 다시 걸러봐야겠다.