상세 컨텐츠

본문 제목

Unreal Engine Point Cloud 실시간 렌더링하기

언리얼엔진

by zmo 2025. 10. 25. 21:26

본문

오늘 공부해볼 내용은 Point Cloud를 어떻게 실시간 렌더링 할 것인지, 이 점들을 어떻게 3D물체, 즉 메쉬로 재구성을 할 것인지에 대해 알아볼 것이다. 우리의 AI가 2D 이미지를 통해 충분히 맵에 대해 학습했으니 이제는 2D에서 3D로 직접 맵을 구성하고 파악할 수 있게끔 초능력을 부여할 차례이다.

하지만 학습한 내용을 그대로 렌더링하게 되면 중대한 문제가 발생하는데 그 문제가 바로 매 시각마다 달라지는 결과로 인한 노이즈 문제이다. 이 노이즈 문제는 구현한 3D 메쉬를 흔들리게 만들고 제대로된 결과를 만들지 못하게 한다. 고로 이 문제 또한 해결할 방법을 찾아보자.

 

 

 

[시각화] Point Cloud 실시간 렌더링

[재구성] Point Cloud → 3D Mesh

1. 방법 A: 실시간 메시 생성 (Procedural Mesh Component)

이대로 진행하면 나올 문제: 지터링(Jittering) 현상

2. 방법 B: TSDF / Voxel Fusion (고급 기법)

TSDF를 위해 선행으로 알아야 하는 변환좌표 사용법: Visual Odometry (VO)

 

 

 


[시각화] Point Cloud 실시간 렌더링

수만~수십만 개의 3D 포인트를 매 프레임 효율적으로 렌더링하는 것이 목표다. CPU 기반의 AActor 스폰은 프레임 드랍을 유발하므로 반드시 GPU 가속을 활용해야 한다. 왜 매 프레임을 렌더링해야할까?

AI의 환경 인지 (시뮬레이션)

‘소프트웨어 중심의 언리얼 프로젝트' 관점으로

  • 개념: 복잡한 지형이 있지만, AI 캐릭터는 그 맵 정보를 전혀 모른다고 가정한다. (마치 처음 탐험하는 것처럼)
  • AI: AI 캐릭터의 '눈'(SceneCaptureComponent)으로만 세상을 본다.
  • 결과: AI가 뎁스 추론으로 주변 지형을 실시간으로 '스캔'해서 자신만의 3D 지도를 만든다. (이것이 바로 재구성된 메시이다.)
  • 활용: AI는 이 재구성된 지도를 바탕으로, "아, 저긴 벽이 있으니 돌아가야겠다" 또는 "저긴 절벽이니 떨어지면 안 되겠다"라고 스스로 판단하고 경로를 계획(Navigation)한다. 엔진의 정답(길찾기 정보)을 컨닝하는 것이 아니라, 오직 '보는 것'만으로 판단하는 고등 AI를 만드는 것이 목표이기 때문이다.

요약하자면, 이 프로젝트에서 3D 포인트를 매 프레임 렌더링 하는 이유는 '맵을 생성(Generate)'하는 것이라기보다는, 'AI의 시각(2D 이미지)만으로 3D 공간을 실시간으로 이해하고 복제(Reconstruct)'하는 능력 그 자체를 구현하는 것이다.

1. 왜 Niagara인가?

  • 대규모 파티클 처리: Niagara는 수백만 개의 파티클을 GPU에서 직접 시뮬레이션하고 렌더링하도록 설계되었다. 포인트 클라우드 시각화에 완벽하다.
  • 유연성: C++에서 파티클의 위치, 색상, 크기 등을 데이터로 직접 전달하고 제어하기 용이하다.

2. C++ ↔ Niagara 데이터 파이프라인

Niagara에 포인트 클라우드 데이터를 전달하는 가장 효율적인 방법은 Niagara Data Interface를 활용하는 것이다.

A. C++ (데이터 전송) Python으로부터 TArray<FVector> (월드 좌표계로 변환된 포인트 클라우드) 데이터를 수신했다고 가정한다.

C++

// YourActor.h
#include "NiagaraComponent.h"#include "NiagaraDataInterfaceArrayFunctionLibrary.h"#include "NiagaraSystem.h"

UPROPERTY(EditAnywhere)
UNiagaraComponent* NiagaraComponent;

// ...

// YourActor.cpp

// Python에서 데이터를 수신했을 때 호출되는 함수 (예시)
void AMyActor::OnPointCloudReceived(const TArray<FVector>& PointCloudData)
{
    if (NiagaraComponent)
    {
        // 1. Niagara 시스템에 "PointCloud"라는 이름의 FVector 배열 변수가 있다고 가정한다.
        // 2. 이 함수를 호출하여 Niagara 시스템의 GPU 메모리에 데이터를 직접 복사한다.
        UNiagaraDataInterfaceArrayFunctionLibrary::SetNiagaraArrayVector(
            NiagaraComponent,
            FName("PointCloud"), // Niagara 시스템 내부의 변수 이름
            PointCloudData
        );

        // (선택적) 파티클 수를 포인트 수에 맞게 동적으로 설정
        NiagaraComponent->SetIntParameter(FName("NumParticles"), PointCloudData.Num());
    }
}

B. Niagara System (데이터 수신 및 스폰)

  1. Niagara Emitter를 생성한다.
  2. User Exposed 파라미터(사용자 변수)를 추가한다.
    • PointCloud (Type: Array<Vector>)
    • NumParticles (Type: Int)
  3. Emitter Spawn
    • Spawn Burst Instantaneous: Spawn Count를 User.NumParticles로 설정한다. (매 프레임 새로 스폰하는 방식)
  4. Particle Spawn
    • Initialize Particle: Mesh Scale이나 Sprite Size를 설정해 점의 크기를 조절한다.
  5. Particle Update (핵심)
    • Array Get: User.PointCloud 배열에서 파티클의 인덱스(Particles.UniqueID)에 해당하는 FVector 값을 읽어온다.
    • Set Particle Position: 읽어온 FVector 값으로 Particles.Position을 설정한다.
    • Location: Particle Position 타입으로 설정한다.

이 파이프라인이 완성되면, C++에서 SetNiagaraArrayVector를 호출할 때마다 Niagara 시스템이 즉시 GPU에서 해당 위치에 파티클을 렌더링한다.


[재구성] Point Cloud → 3D Mesh

점을 넘어 '면'을 만드는 단계. 이것이 진정한 3D Scene Reconstruction이다.

 

 

1. 방법 A: 실시간 메시 생성 (Procedural Mesh Component)

 

Procedural Mesh Component (PMC)는 런타임에 정점(Vertices)과 삼각형(Triangles) 인덱스 데이터를 받아 메시를 동적으로 생성하는 컴포넌트다.

  • 장점: 즉각적으로 충돌(Collision)이 가능한 메시를 만들 수 있다.
  • 단점: 점이 많아질수록 삼각형을 계산하는 알고리즘(예: Delaunay Triangulation, Marching Cubes)의 CPU 부하가 극심해진다.

구현해보자:

  1. 그리드 기반 접근: 뎁스 맵은 본질적으로 2D 그리드다. Step 2에서 생성한 3D 포인트들은 이미 그리드 형태로 정렬되어 있다.
  2. 삼각형 인덱싱: 이 3D 포인트 그리드를 순회하며 인접한 4개의 점 (i, j), (i+1, j), (i, j+1), (i+1, j+1)을 묶어 2개의 삼각형(Triangle)을 만든다.
  3. Procedural Mesh Component의 CreateMeshSection 함수에 계산된 Vertices 배열과 Triangles 인덱스 배열을 전달한다.
// Vertices: 2D 그리드를 순회하며 생성한 TArray<FVector> (Step 2의 결과)
// Triangles: TArray<int32>

for (int y = 0; y < Height - 1; y++)
{
    for (int x = 0; x < Width - 1; x++)
    {
        int v0 = (y * Width) + x;       // (x, y)
        int v1 = (y * Width) + (x + 1);   // (x+1, y)
        int v2 = ((y + 1) * Width) + x; // (x, y+1)
        int v3 = ((y + 1) * Width) + (x + 1); // (x+1, y+1)
        
        // Triangle 1
        Triangles.Add(v0);
        Triangles.Add(v1);
        Triangles.Add(v2);

        // Triangle 2
        Triangles.Add(v2);
        Triangles.Add(v1);
        Triangles.Add(v3);
    }
}

// PMC에 메시 생성 요청
ProceduralMeshComponent->CreateMeshSection(0, Vertices, Triangles, Normals, UVs, Colors, Tangents, true);

이대로 진행하면 나올 문제: 지터링(Jittering) 현상

 

Monocular Depth Estimation 모델은 완벽하지 않다. 특히 비디오를 실시간으로 처리할 때, 매 프레임 추정되는 깊이 값이 미세하게(혹은 심하게) 떨리게 된다.

  • 현상: 시각화 단계에서 포인트 클라우드를 렌더링하면, 가만히 있는 벽이나 물체조차도 3D 공간에서 매우 시끄럽게 떨리는(Jittering) 현상이 발생.
  • 원인: 2D 뎁스 맵의 노이즈가 3D 공간에서 증폭되어 나타나는 것이다.
  • 결과 (방법 A의 한계): 만약 이 떨리는 포인트 클라우드를 그대로 Procedural Mesh Component로 매 프레임 재구성(방법 A)하면, 지형 자체가 꿀렁거리며 울렁이는 매우 불안정한 결과물이 나올 수 있다.

이 결과물의 문제를 해결하기 위해 다음과 같은 방법을 사용해보자

 

2. 방법 B: TSDF / Voxel Fusion (고급 기법)

 

TSDF란 "세상을 안정적으로 쌓아 올리는 방법" 으로 실시간 재구성을 위한 업계 표준 방식이다. (예: Kinect Fusion)

 

TSDF의 작동 원리

 

TSDF는 "한 프레임만 믿지 말고, 수백 프레임의 정보를 누적 평균내자"는 아이디어다.

  1. 복셀 그리드(Voxel Grid): 3D 월드 공간을 작은 정육면체 '복셀(Voxel)' 그리드로 잘게 나눈다.
  2. Signed Distance (부호화된 거리): 각 복셀은 '가장 가까운 표면(Surface)까지의 거리' 값을 저장한다. ◦ + (양수): 내가 표면의 바깥쪽에 있다. (빈 공간) ◦ - (음수): 내가 표면의 안쪽에 있다. (물체 내부) ◦ 0: 내가 정확히 표면 위에 있다.
  3. Truncated (절단): 표면에서 너무 멀리 떨어진 복셀(예: +0.5m 이상, -0.5m 이하)은 관심 없으므로, 값을 특정 범위(예: +1 ~ -1)로 제한(Truncate)한다.

융합 (Fusion) 과정 (핵심!)

새로운 뎁스 맵(포인트 클라우드)이 들어올 때마다 (VO를 통해 월드에 정렬된 상태로),

  1. 이 뎁스 맵에 해당하는 복셀들을 찾는다.
  2. 각 복셀의 기존 TSDF 값새롭게 계산된 TSDF 값'가중 평균(Weighted Average)' 낸다. ◦ 예: Voxel(x,y,z).Value = (OldValue * OldWeight + NewValue * NewWeight) / (OldWeight + NewWeight)
  3. 이 과정을 수백 프레임 반복한다. 결과: 한 프레임에서 노이즈가 튀어 값이 잘못 계산되어도, 이미 누적된 수백 프레임의 '평균' 값에 묻혀버린다. 따라서 노이즈가 제거된 매우 안정적이고 매끄러운 3D 표면(TSDF \approx 0 = TSDF값이 0에 가깝다)만이 남게 된다.

 

TSDF를 위해 선행으로 알아야 하는 변환좌표 사용법

Visual Odometry (VO): "나는 지금 어디에 있는가?"

  • VO(시각 주행 거리 측정)는 오직 카메라 이미지(와 IMU 센서)만을 사용해, 카메라가 3D 공간에서 어떻게 움직였는지를 실시간으로 추적하는 기술이다.

왜 필요할까? (문제점)

  • 우리가 가진 뎁스 맵은 카메라 중심의 로컬 좌표계에서 생성된다.
  • 카메라가 1cm만 움직여도, 다음 프레임의 뎁스 맵은 완전히 새로운 로컬 좌표계에서 생성된다.
  • 이전 프레임의 포인트 클라우드와 현재 프레임의 포인트 클라우드를 하나의 월드(맵)에 쌓으려면, 두 프레임 사이의 카메라 움직임(변환 행렬)을 정확히 알아야 한다.

해결책 (VO의 역할)

VO는 연속된 이미지 프레임(Frame 1, Frame 2) 사이의 공통 특징점(Feature)들을 추적한다.

  1. Frame 1에서 특징점(코너 등)을 찾는다.
  2. Frame 2에서 Frame 1의 특징점들이 어디로 이동했는지 찾는다.
  3. 이 2D 이동(Optical Flow)을 기반으로 "Frame 1에서 Frame 2로 이동할 때 카메라가 얼마큼 회전(R)하고 이동(t)했는지"를 역으로 계산한다.
  4. 이 (R, t) 값이 바로 이전 프레임의 FTransform과 현재 프레임의 FTransform 사이의 변화량이다.

VO가 이 변화량을 매 프레임 정확하게 제공해야만, 모든 뎁스 맵을 동일한 월드 좌표계에 정렬(Align)시킬 수 있다.

 

// 최종 통합 파이프라인

  1. [Unreal] SceneCapture로 이미지와 현재 카메라의 FTransform (Pose_N)을 Python에 전송한다.
  2. [Python]VO: (필요시) 이미지로 Pose_N을 추정/보정한다. ◦ Depth: 이미지로 Depth_N을 추론한다. ◦ Unprojection: Depth_N을 카메라 로컬 좌표계의 포인트 클라우드로 변환한다.
  3. [TSDF Fusion (Python or C++)] ◦ Pose_N을 사용해 로컬 포인트 클라우드를 월드 좌표계로 변환한다. ◦ 이 월드 좌표 기준으로 글로벌 TSDF 복셀 그리드의 값을 갱신(가중 평균)한다.
  4. [Mesh Extraction (Step 4)] ◦ 'Marching Cubes' 같은 알고리즘을 사용해, TSDF 값이 0인 지점들을 연결하는 메시를 추출한다.
  5. [Unreal (Step 3)] ◦ 추출된 메시를 Procedural Mesh Component로 렌더링한다. VO와 TSDF는 이렇게 복잡하지만, 이 두 가지를 구현해야 비로소 '불안정한 점'에서 '안정적인 3D 맵'으로 넘어갈 수 있다.

관련글 더보기