Unity Procedural Mesh Generation으로 로프·끈 물리 구현하기: Verlet 제약과 메시 렌더링

Unity Procedural Mesh Generation으로 로프·끈 물리 구현하기: Verlet 제약과 메시 렌더링

Unity C#에서 Verlet Integration과 거리 제약으로 로프·끈 물리를 구현하고 Procedural Mesh Generation으로 부드러운 튜브 메시를 생성하는 방법을 단계별로 설명합니다.

로프와 끈은 개별 메시를 여러 개 연결하는 방식보다 입자 기반 물리 시뮬레이션Procedural Mesh Generation을 분리하는 편이 안정적이고 확장하기 쉽다. Unity 2022 LTS와 C# 기준으로 Verlet Integration으로 입자 위치를 갱신하고 거리 제약으로 길이를 유지한 뒤 각 입자 구간을 따라 튜브 메시를 생성하면 된다.

로프 물리는 왜 입자와 제약으로 구현할까?

로프를 길이 L인 선분 N개로 나누면 각 입자는 위치만 가지며 인접한 입자 사이의 목표 간격은 다음과 같다.

segmentLength=LNsegmentLength = \frac{L}{N}

Rigidbody와 Hinge Joint를 다수 연결하는 방식은 빠르게 만들 수 있지만 관절 수가 증가할수록 조정할 값과 시뮬레이션 비용이 늘어난다. 반면 Verlet Integration은 이전 위치를 이용해 속도를 간접적으로 표현하므로 가벼운 로프·갈고리·전선·장식 끈에 적합하다.

방식장점주의점적합한 용도
Rigidbody + Joint충돌과 힘 적용이 직관적조인트 튜닝과 비용 증가상호작용이 강한 물체
Verlet + 거리 제약구현 단순, 길이 유지가 안정적충돌 처리를 별도로 설계로프, 끈, 전선, 덩굴
단순 Spline비용이 낮고 형태 제어가 쉬움물리 반응이 없음배경 장식, UI 선

Verlet 입자와 거리 제약으로 구성한 로프 세그먼트 구조

Unity에서 Verlet Integration 로프 물리를 어떻게 구현할까?

Step 1. 입자 상태를 정의한다

각 입자는 현재 위치와 이전 위치를 보관한다. 현재 위치에서 이전 위치를 빼면 별도 velocity 필드 없이도 이전 프레임의 이동량을 얻을 수 있다.

using UnityEngine;

public struct RopeParticle
{
    public Vector3 Position;
    public Vector3 PreviousPosition;
    public bool IsPinned;

    public RopeParticle(Vector3 position, bool isPinned = false)
    {
        Position = position;
        PreviousPosition = position;
        IsPinned = isPinned;
    }
}

Step 2. 중력과 감쇠를 적용해 위치를 적분한다

Verlet Integration의 기본 형태는 다음과 같다. a는 가속도, dt는 고정 시간 간격이다.

xt+dt=xt+(xtxtdt)+adt2x_{t+dt} = x_t + (x_t - x_{t-dt}) + a \cdot dt^2

실제 게임에서는 감쇠 계수를 적용해 진동이 끝없이 이어지지 않게 한다.

[SerializeField] private float damping = 0.995f;
[SerializeField] private Vector3 gravity = new(0f, -9.81f, 0f);

private void Integrate(float deltaTime)
{
    Vector3 acceleration = gravity * (deltaTime * deltaTime);

    for (int i = 0; i < particles.Length; i++)
    {
        if (particles[i].IsPinned)
            continue;

        Vector3 current = particles[i].Position;
        Vector3 velocity = (current - particles[i].PreviousPosition) * damping;

        particles[i].Position += velocity + acceleration;
        particles[i].PreviousPosition = current;
    }
}

Update() 대신 FixedUpdate()에서 실행하고 Time.fixedDeltaTime을 전달해야 프레임레이트 변화에 덜 민감하다. 렌더링만 LateUpdate()에서 처리하면 캐릭터나 앵커 Transform이 갱신된 뒤의 위치를 사용할 수 있다.

Step 3. 거리 제약으로 로프 길이를 복원한다

물리 적분만 하면 입자 간격이 늘어나므로 인접 입자를 반복 보정한다. 두 입자 p1, p2의 오차는 다음과 같다.

error=p2p1segmentLengtherror = \lVert p2 - p1 \rVert - segmentLength

고정되지 않은 두 입자가 오차를 절반씩 나눠 보정하면 된다.

[SerializeField, Range(1, 16)] private int constraintIterations = 8;
private float segmentLength;

private void SolveConstraints()
{
    for (int iteration = 0; iteration < constraintIterations; iteration++)
    {
        for (int i = 0; i < particles.Length - 1; i++)
        {
            RopeParticle first = particles[i];
            RopeParticle second = particles[i + 1];

            Vector3 delta = second.Position - first.Position;
            float distance = delta.magnitude;
            if (distance < 0.0001f)
                continue;

            Vector3 correction = delta * ((distance - segmentLength) / distance);

            if (!first.IsPinned && !second.IsPinned)
            {
                first.Position += correction * 0.5f;
                second.Position -= correction * 0.5f;
            }
            else if (!first.IsPinned)
            {
                first.Position += correction;
            }
            else if (!second.IsPinned)
            {
                second.Position -= correction;
            }

            particles[i] = first;
            particles[i + 1] = second;
        }
    }
}

반복 횟수는 로프 강성과 비용을 맞바꾸는 값이다. 짧은 로프는 4~8회, 빠른 이동이나 긴 로프는 8~16회부터 측정하며 정하는 편이 좋다.

Procedural Mesh Generation으로 로프를 튜브 형태로 렌더링하기

LineRenderer는 빠르게 확인하기 좋지만 단면, UV, 노멀, 꼬임을 세밀하게 제어하기 어렵다. 로프 입자마다 원형 링을 만들고 인접 링을 삼각형으로 연결하면 재질과 굵기를 제어할 수 있는 튜브 메시가 된다.

각 입자 i의 진행 방향은 다음처럼 구한다.

tangenti=normalize(pi+1pi1)tangent_i = normalize(p_{i+1} - p_{i-1})

Vector3.up이 진행 방향과 거의 평행하면 외적 결과가 0에 가까워진다. 이 경우 Vector3.right를 대체 기준축으로 사용해야 메시가 찌그러지지 않는다.

private void BuildRopeMesh(Mesh mesh, float radius, int radialSegments)
{
    int ringCount = particles.Length;
    int vertexCount = ringCount * radialSegments;
    Vector3[] vertices = new Vector3[vertexCount];
    Vector3[] normals = new Vector3[vertexCount];
    Vector2[] uvs = new Vector2[vertexCount];
    int[] triangles = new int[(ringCount - 1) * radialSegments * 6];

    for (int i = 0; i < ringCount; i++)
    {
        Vector3 previous = particles[Mathf.Max(0, i - 1)].Position;
        Vector3 next = particles[Mathf.Min(ringCount - 1, i + 1)].Position;
        Vector3 tangent = (next - previous).normalized;
        Vector3 reference = Mathf.Abs(Vector3.Dot(tangent, Vector3.up)) > 0.95f
            ? Vector3.right
            : Vector3.up;
        Vector3 normal = Vector3.Cross(tangent, reference).normalized;
        Vector3 binormal = Vector3.Cross(tangent, normal).normalized;

        for (int j = 0; j < radialSegments; j++)
        {
            float angle = j * Mathf.PI * 2f / radialSegments;
            Vector3 radial = normal * Mathf.Cos(angle) + binormal * Mathf.Sin(angle);
            int index = i * radialSegments + j;

            vertices[index] = particles[i].Position + radial * radius;
            normals[index] = radial;
            uvs[index] = new Vector2((float)j / radialSegments, (float)i / (ringCount - 1));
        }
    }

    int triangleIndex = 0;
    for (int i = 0; i < ringCount - 1; i++)
    {
        for (int j = 0; j < radialSegments; j++)
        {
            int nextJ = (j + 1) % radialSegments;
            int a = i * radialSegments + j;
            int b = i * radialSegments + nextJ;
            int c = (i + 1) * radialSegments + j;
            int d = (i + 1) * radialSegments + nextJ;

            triangles[triangleIndex++] = a;
            triangles[triangleIndex++] = c;
            triangles[triangleIndex++] = b;
            triangles[triangleIndex++] = b;
            triangles[triangleIndex++] = c;
            triangles[triangleIndex++] = d;
        }
    }

    mesh.Clear();
    mesh.vertices = vertices;
    mesh.normals = normals;
    mesh.uv = uvs;
    mesh.triangles = triangles;
    mesh.RecalculateBounds();
}

입자 경로를 따라 원형 단면 링을 연결해 생성한 로프 튜브 메시

로프 메시가 비틀리거나 흔들리는 이유는 무엇인가?

매 프레임 독립적으로 기준축을 계산하면 탄젠트 방향이 바뀌는 구간에서 단면의 회전 방향도 급격히 바뀔 수 있다. 특히 S자 곡선과 수직에 가까운 로프에서 UV가 뒤집히거나 튜브가 비틀려 보인다.

증상주된 원인해결 방법
단면이 갑자기 회전함기준축이 프레임마다 바뀜이전 링의 normal을 전달해 평행 이동 프레임 구성
로프가 늘어남제약 반복 횟수가 부족함constraintIterations 증가 또는 고정 시간 간격 감소
빠르게 움직일 때 튐앵커 위치를 늦게 반영함FixedUpdate() 시작 전에 고정 입자 위치 갱신
화면 밖에서 잘림Bounds가 오래되었거나 작음메시 갱신 후 RecalculateBounds() 호출

시각적 품질이 중요한 경우에는 이전 링의 법선을 다음 링의 기준으로 활용하는 Parallel Transport Frame을 적용한다. 단순 외적 방식보다 구현은 늘어나지만 꼬임이 눈에 띄는 케이블과 밧줄에서 효과가 크다.

충돌과 성능은 어떻게 관리할까?

Step 1. 충돌은 입자 수보다 호출 빈도를 먼저 제한한다

각 입자에 Physics.CheckSphere 또는 Physics.OverlapSphereNonAlloc을 적용할 수 있다. 다만 입자 수와 FixedUpdate 빈도가 곱해지므로 원거리 로프에는 충돌을 끄거나 2~3개 입자마다 검사하는 방식이 효율적이다.

Step 2. 메시 배열을 매 프레임 새로 만들지 않는다

위 예제는 원리 설명을 위해 배열을 메서드 안에서 생성했다. 실제 구현에서는 vertices, normals, uvs, triangles를 초기화 시점에 한 번 할당하고 재사용한다. Mesh.MarkDynamic()도 호출해 빈번한 정점 갱신 의도를 Unity에 알려준다.

Step 3. 거리 기반 품질 단계를 둔다

카메라 거리별로 입자 수와 단면 분할 수를 낮춘다. 예를 들어 근거리에는 32개 입자 × 8 radialSegments, 원거리에는 12개 입자 × 4 radialSegments를 사용하면 삼각형 수를 크게 줄일 수 있다. 로프가 화면에서 매우 작다면 튜브 메시 대신 LineRenderer 또는 빌보드 리본으로 전환하는 것도 효과적이다.

자주 묻는 질문 (FAQ)

Verlet 로프에 Rigidbody를 함께 써도 될까?

가능하지만 같은 자유도를 두 시스템이 동시에 제어하면 충돌한다. Rigidbody는 앵커나 로프 끝의 상호작용 물체에 사용하고 로프 입자는 Verlet 방식으로 유지하는 구성이 관리하기 쉽다.

로프 끝을 캐릭터 손에 고정하려면 어떻게 해야 할까?

첫 번째 또는 마지막 입자를 IsPinned = true로 설정하고 매 물리 프레임에 해당 입자의 PositionPreviousPosition을 손 Transform 위치로 함께 갱신한다. 이전 위치도 갱신해야 손이 순간이동할 때 비정상적인 속도가 생기지 않는다.

로프가 장애물을 통과하지 않게 하려면 어떻게 해야 할까?

각 입자에 작은 반지름의 구 충돌 검사를 수행한 뒤 Collider.ClosestPoint() 또는 캐스트 결과의 법선 방향으로 입자를 표면 밖으로 밀어낸다. 이후 거리 제약을 다시 수행하면 충돌 보정으로 흐트러진 로프 길이를 복원할 수 있다.

정리

Procedural Mesh Generation 기반 로프 구현은 물리와 렌더링을 분리하는 것이 핵심이다. Verlet Integration으로 위치를 갱신하고 거리 제약으로 길이를 유지한 뒤 입자 경로에 단면 링을 연결해 메시를 생성한다. 이후 충돌 빈도, 제약 반복 횟수, 입자·단면 해상도를 상황별로 조절하면 게임플레이 요구사항에 맞는 로프와 끈을 만들 수 있다.

#Unity#Procedural Mesh#Verlet Integration#로프 물리#C##게임 물리#메시 생성

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs