{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[코딩의탑] 5층: 포탈(Portal), 더 나아가기

2층에서 많은 부족함이 있었죠..

  2층에서 우리가 구현한 포탈은 다음과 같은 부족함이 있습니다:

  • 포탈을 단순히 월드에 배치하기만 했으며, 포탈 건의 요구사항을 고려하지 않았습니다. 가령 포탈은 벽에 생성될 수 있어야 합니다.
  • 플레이어의 포탈 이동만을 고려했습니다. 큐브 등 다른 오브젝트 역시 통과할 수 있어야 합니다.
  • 포탈에 걸쳐있는 오브젝트는 포탈 양면에서 보일 수 있어야 합니다. 즉, 오브젝트의 복사가 이루어져야 합니다.
  • Near plane clippng으로 인해 포탈에 들어가기 직전 카메라가 평면과 겹치는 순간, 포탈 평면의 뒷면 모습이 비춰집니다.
  • 포탈에 비추는 플레이어의 모습이 3인칭으로 보여야 합니다.
  • 효율적인 포탈 재귀 렌더링 방법이 필요합니다.


복습해봅시다.

벡터와 점의 변환

  포탈 건너편으로 이동하는 효과를 주기 위해서, 우리는 적절히 오브젝트를 이동 및 회전시켜야 합니다. 속도 역시 적절한 방향으로 보존되어야 하죠.

포탈


  먼저 위치와 속도는 3차원 벡터로 표현될 수 있습니다. 그리고 이것이 포탈과 포탈 사이에서 자연스럽게 이어져야 하죠. 마치 포탈이 붙어있는 것처럼요! 다음 그림을 참고해보세요. 블루 포탈과 오렌지 포탈은 서로가 위 방향 축을 기준으로 180도 회전한 상태에서 맞붙어있는 것처럼 느껴야 합니다.

블루 포탈과 오렌지 포탈


  이는 각 포탈을 일종의 지역 좌표계로 생각해볼 수 있습니다. 포탈의 [앞 방향 벡터, 우측 방향 벡터, 위 방향 벡터]를 각각 [X, Y, Z]축으로 삼는 셈이죠. 우리는 마치 "이어져 있는" 환상을 선사해야 하므로, 한쪽 지역 좌표계의 좌표 값을 그대로 다른쪽 지역 좌표계의 좌표 값으로 자연스럽게 넘겨주면 됩니다. 다양한 방법이 있겠지만 성능을 위해, 각 축에 대한 내적의 값을 이용하도록 합시다. 각 축은 정규화되어 있고 수직이므로, 내적을 이용할 수 있습니다.


좌표계의 변환


소스 코드: APortal::TransformVectorToDestSpace(), TransformPointToDestSpace()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
FVector APortal::TransformVectorToDestSpace(
    const FVector& Target, 
    const FVector& SrcPortalForward,
    const FVector& SrcPortalRight, 
    const FVector& SrcPortalUp,
    const FVector& DestPortalForward,
    const FVector& DestPortalRight, 
    const FVector& DestPortalUp)
{
    FVector Coordinate;
    Coordinate.X = FVector::DotProduct(Target, SrcPortalForward);
    Coordinate.Y = FVector::DotProduct(Target, SrcPortalRight);
    Coordinate.Z = FVector::DotProduct(Target, SrcPortalUp);
    
    return Coordinate.X * DestPortalForward +
        Coordinate.Y * DestPortalRight +
        Coordinate.Z * DestPortalUp;
}

FVector APortal::TransformPointToDestSpace(
    const FVector& Target,
    const FVector& SrcPortalPos,
    const FVector& SrcPortalForward,
    const FVector& SrcPortalRight, 
    const FVector& SrcPortalUp,
    const FVector& DestPortalPos, 
    const FVector& DestPortalForward, 
    const FVector& DestPortalRight,
    const FVector& DestPortalUp)
{
    const FVector SrcToTarget = Target - SrcPortalPos;

    const FVector DestToTarget = TransformVectorToDestSpace(
        SrcToTarget,
        SrcPortalForward,
        SrcPortalRight,
        SrcPortalUp,
        DestPortalForward,
        DestPortalRight,
        DestPortalUp
    );
    
    return DestPortalPos + DestToTarget;
}
cs

  포탈이 180도 회전해 있으므로 앞 방향 축과 우측 방향 축이 반대로 변해야 한다는 사실에 유의하세요! 이를 잘 적용해주어야 할 것입니다. 벡터가 아닌 점(Position)의 경우, 월드 좌표계의 포탈과 점의 상대 위치를 이용하게 됩니다.


회전의 변환

  회전의 변환은 사원수를 이용해서 해결할 수 있습니다. 사원수에 대한 세부사항은 여백이 부족하여 적지 않습니다. 쉽게 말하면 사원수는 "회전"을 결정하는 4차원의 벡터입니다. 특정한 오브젝트의 회전은 3차원의 벡터로 표현하기 쉬운 일이 아닙니다. 반면 사원수는 "내 머리의 방향"을 명확하게 정의하며, 그에 대한 연산 역시 수월하게 해주죠.

  각 오브젝트는 자신의 회전에 대한 사원수를 가지고 있습니다. 이 경우, 두 오브젝트의 회전의 차이 역시 정의할 수 있습니다. 역을 곱한 결과를 얻는 것이죠. 다음 수식을 참고해보세요:

  따라서 우리는 두 포탈의 회전의 차이를 얻을 수 있습니다. 그리고 포탈을 넘어가는 순간, 이 회전의 차이를 반영해주면 되죠. 포탈은 서로를 맞대고 있기 때문에 포탈의 위 방향으로 추가적인 180도 회전이 필요함에 유의하세요.


소스 코드: APorta::TransformQuatToDestSpace()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FQuat APortal::TransformQuatToDestSpace(
    const FQuat& Target,
    const FQuat& SrcPortalQuat, 
    const FQuat& DestPortalQuat,
    const FVector DestPortalUp)
{
    const FQuat Diff = DestPortalQuat * SrcPortalQuat.Inverse();

    FQuat Result = Diff * Target;
    // Turn 180 degrees.
    // Multiply the up axis pure quaternion by sin(90) (real part is cos(90))
    FQuat Rotator = FQuat(DestPortalUp, PI);

    return Rotator * Result;
}
cs


  언리얼 엔진에서는 사원수를 직관적으로 얻을 수 있는 생성자들을 제공합니다. 직접 사원수의 값을 정의할 수도 있습니다. 사원수 q(x, y, z, w)에서 특정 축 (x0, y0, z0)로 2θ만큼 회전하고 싶다면, q(x0 cosθ, y0 cosθ, z0 cosθ, sinθ)의 값을 대입하면 됩니다. 사원수의 곱은 2θ를 회전하게 되는데, 이는 회전 시 실수부 값을 0으로 만들기 위해 역을 한 번 더 곱하는 과정이 있기 때문입니다. 결론적으로 두 번 회전하게 되며, 2θ를 회전하게 되는 이유입니다.

  회전에서 사원수 대신 오일러 각을 활용하는 방법도 생각해볼 수 있습니다. 하지만 이는 정확하지 않습니다. 오일러 각을 이용한 회전은 X, Y, Z 축에 대해서 특정 순서를 갖고 회전이 적용되게 되며, 따라서 각 축에 대한 회전의 차이를 이용하게 되면 예상치 못한 결과를 얻을 수 있습니다. 가령 (20º, 20º, 20º)에서 Y축에 대해서 10º를 더 회전하는 것과, (0º, 0º, 0º)에서 Y축에 대해서 10º를 더 회전하는 것은 직관적으로 같은 회전을 하는 것처럼 보이지 않습니다. 이는 각 축에 의한 회전이 순서를 갖고 적용되며, 앞의 회전이 뒤의 회전에 영향을 미칠 수 있기 때문입니다.


포탈 건너편 렌더링

  포탈을 통해 건너편을 보는 효과를 내기 위해서, 카메라와 포탈 사이의 시야 관계를 그대로 맞은편 포탈에서 재현할 수 있어야 합니다. 즉, 위치와 회전이 건너편 포탈로 변환된 플레이어의 카메라가 찍은 모습을 포탈의 평면에 렌더링해주면 되죠. 다음 그림을 참고해보세요:

오렌지 포탈을 기준으로 똑같은 위치에 카메라를 놓은 뒤 캡처하여 얻은 모습을
블루 포탈에 렌더링합니다.

  이는 우리가 생각하는 일반적인 렌더링 절차가 아니므로, 가장 좋은 방법은 언리얼 엔진의 렌더링 패스를 수정하는 것입니다. 하지만 언리얼 엔진의 렌더링 패스를 적절히 수정하는 것은 쉬운 일이 아니며, 예상치 못한 부작용 또한 생길 수 있습니다. 따라서 우리는 언리얼 엔진에서 제공하는 기능들을 응용해서 이를 구현하도록 합니다.

  언리얼 엔진은 렌더 타겟과 씬 캡처 컴포넌트를 제공합니다. 씬 캡처 컴포넌트는 플레이어의 카메라가 아니어도 우리가 원하는 장면을 렌더 타겟에 캡처할 수 있게 해줍니다. 가령 CCTV의 화면을 그리기 위해 활용할 수 있죠. 우리는 이를 이용해서 포탈의 건너편을 렌더링할 것입니다. 조금 보기 어려울 수도 있지만, 다음의 그림들을 유심히 살펴보고 어떻게 포탈이 렌더링되는지 생각해보세요:


플레이어 카메라


블루 포탈 렌더 타겟


오렌지 포탈 렌더 타겟


최종 렌더링


  이와 관련해 세부사항은 다루지 않겠습니다. 고려해야 할 중요한 사항들은 다음과 같습니다:

  • 카메라의 위치와 회전을 정확히 반영해야 합니다.
  • 씬 캡처 컴포넌트가 캡처하는 화면은 포탈 평면 뒤에 있는 오브젝트들로만 구성되어야 합니다. Near plane clipping이라는 옵션이 있습니다. 다음 그림을 참고해보세요:

Near plane clipping


포탈 건의 요구사항을 해결해 봅시다

  포탈 건을 벽면으로 쏘면 포탈을 설치할 수 있습니다. 사실 포탈 건의 요구사항은 그리 단순하지는 않습니다. 다음을 고려해야 하죠:

  • 포탈을 설치할 수 있는 벽과 그렇지 않은 벽이 있습니다.
  • 수평한 지면 및 천장의 경우 플레이어가 바라보는 방향에 정렬되어 포탈이 설치되며, 그 외의 경우 반드시 위쪽 방향에 정렬되어 포탈이 설치됩니다. 다음 그림을 참고하세요:
지면의 경우 내가 바라보는 방향이 포탈의 위 방향이 됩니다.

벽면의 경우 포탈의 위 방향이 하늘을 바라봅니다.
  • 기본적으로 포탈 건의 조준점을 중심으로 포탈이 생성되지만, 포탈의 공간이 충분치 않은 경우 적절히 보정되어 설치됩니다.
  • 통과할 수 없었던 벽에 포탈을 설치하면 통과할 수 있습니다.
  • 물건을 집을 수 있습니다.


월석에만 포탈이 설치될 수 있도록 하기

  먼저 포탈을 설치할 수 있는 벽과 그렇지 않은 벽을 구분하는 일은 단순하며, 다양한 구현 방법이 있을 것입니다. 언리얼 엔진에서 적절한 구현 방법 중 하나는, Physics Material을 이용하는 것입니다. Physics Material은 오브젝트에 적용될 수 있는 물리적 재질이며, 이를 기반으로 다양한 상호작용을 실현할 수 있습니다. 예를 들어 포탈이 설치될 수 있는 벽면은 "월석"에 해당하는 Physics Material을 적용한 뒤, 포탈 건이 쏜 벽면의 Physics Material이 "월석"에 해당하는지를 확인하면 될 것입니다. 이에 대한 세부사항은 다루지 않을 것입니다.


포탈을 적절히 회전하기

  우리는 두 가지 단계를 거쳐서 포탈의 회전값을 결정할 것입니다.

  1. 포탈의 법선은 벽면의 법선과 동일해야 합니다.
  2. 지면에 설치된 경우 포탈의 위 방향은 플레이어의 시선 방향과 동일하며, 그렇지 않은 경우 우측 방향이 하늘과 평행합니다.

  먼저, 포탈과 벽면의 법선을 일치시켜야 합니다. 이는 사원수를 이용해서 계산할 수 있습니다. 다음 코드를 참고해보세요:


소스 코드: UPortalGun::CalculatePortalRotation() - 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
..

56
FQuat UPortalGun::CalculatePortalRotation(
const FVector& ImpactNormal, const APortal& TargetPortal) const
{
    FQuat Result;

    {
        const auto PortalForward =
            TargetPortal.GetPortalForwardVector();
        const auto OldQuat = PortalForward.ToOrientationQuat();
        const auto NewQuat = ImpactNormal.ToOrientationQuat();

        const auto DiffQuat = NewQuat * OldQuat.Inverse();
        Result =
            DiffQuat * TargetPortal.GetTransform().GetRotation();
    }

...
}
cs


벽면의 법선과 포탈의 법선을 일치시킨 예.
여전히 벽면 위에서 임의의 회전을 가질 수 있습니다.


  이렇게 회전시키면 포탈은 반드시 벽면에 붙게 되지만 이것이 끝은 아닙니다. 법선을 축으로 하는 회전은 여전히 임의의 값을 가지게 되기 때문입니다. 포탈에서는 이 회전을 결정하는 규칙이 있으며, 두 가지로 나뉘게 됩니다.

  1.   지면(혹은 천장)의 경우 포탈의 위 방향은 플레이어의 시선 방향과 동일합니다. 
  2.   (기울어진) 벽면의 경우, 우측 방향은 반드시 하늘과 평행합니다.

  따라서, 회전된 포탈의 법선을 이용해서 지면에 설치된 포탈인지 아닌지를 결정해야 합니다. 이는 단순히 법선 벡터를 Z축 벡터와 비교해서 확인할 수 있습니다. Z축 벡터가 (0, 0, 1)이거나 (0, 0, -1)이면 지면인 것이죠. 이후에 분기문을 이용해서 각 상황에 맞게 포탈의 회전을 결정해주면 됩니다.

  지면의 경우, 플레이어의 시선 벡터와 포탈의 법선 벡터를 외적하면 포탈의 우측 방향 벡터를 결정할 수 있습니다. 다시 포탈의 법선과 우측 방향 벡터를 외적해 포탈의 위 방향 벡터를 얻을 수 있습니다.

  벽면의 경우, Z축 벡터와 포탈의 법선 벡터를 외적하면 포탈의 우측 방향 벡터를 결정할 수 있습니다. 마찬가지로 다시 포탈의 법선과 우측 방향 벡터를 외적해 포탈의 위 방향 벡터를 얻을 수 있습니다. 다음 코드를 참고해보세요:


소스 코드: UPortalGun::CalculatePortalRotation() - 2

1
2
3
4

..

17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
FQuat UPortalGun::CalculatePortalRotation(
const FVector& ImpactNormal, const APortal& TargetPortal) const
{
    FQuat Result;

...

    // The result calculated before is only correctly for
    // forward vector, but to rotate correctly for up vector
    // or right vector, correct with WorldZ.
    const auto WorldZ = FVector(0.0f, 0.0f, 1.0f);
    if (ImpactNormal.Equals(WorldZ) || ImpactNormal.Equals(-WorldZ))
    {
        const auto PortalUp = TargetPortal.GetPortalUpVector(Result);

        const auto TargetPortalUp =
            Character->GetActorForwardVector();

        const auto RotateAxis =
            PortalUp.Cross(TargetPortalUp).GetSafeNormal();
        const auto CosTheta = PortalUp.Dot(TargetPortalUp);

        const auto ThetaRadian = FMath::Acos(CosTheta);

        Result = FQuat(RotateAxis, ThetaRadian) * Result;
    }
    else
    {
        const auto PortalRight = TargetPortal.GetPortalRightVector(Result);

        const FVector TargetPortalRight =
            FVector::CrossProduct(WorldZ, ImpactNormal).GetSafeNormal();

        FVector RotateAxis;
        if (!PortalRight.Equals(TargetPortalRight))
        {
            RotateAxis =
                PortalRight.Cross(TargetPortalRight).GetSafeNormal();
            const auto CosTheta = PortalRight.Dot(TargetPortalRight);
            const auto ThetaRadian = FMath::Acos(CosTheta);

            Result = FQuat(RotateAxis, ThetaRadian) * Result;
        }
    }

    return Result;
}
cs


포탈의 위치를 보정하기

   포탈은 내가 조준한 점에 설치되어야겠지만, 벽의 범위를 넘을 수는 없습니다. 벽의 경계선에 가깝게 조준해 포탈을 설치하면, 포탈은 적절히 자신의 크기만큼 밀려나 설치되죠. 포탈의 위치를 적절히 바로잡는 것은 단순한 일은 아닙니다.

  먼저, 포탈을 "어느 방향"으로 밀어내야 할까요? 포탈의 위 방향과 우측 방향의 벡터에 적절한 스칼라 값을 곱해서 포탈의 위치를 조정해야 할 것입니다. 정해진 단 하나의 방향만으로 이동하는 정책은 자연스럽지 않습니다. 다음 그림을 참고해보세요:

포탈의 위, 우측 방향으로 이동한 것(좌)과 경계선의 방향으로 이동한 것(우)


  정리해보면 우리는 u 방향으로만 포탈이 이동하기를 바라지, v 방향으로 포탈이 이동하는 것을 원하지 않습니다. 따라서 v 방향 성분을 전혀 건드리지 않고 포탈을 움직이는 방법을 고려해야 합니다.


  둘째, 포탈을 "얼마나" 밀어내야 할까요? 이는 포탈의 크기와 관련이 있습니다. 포탈이 넘을 수 없는 경계가 정해져 있고, 포탈의 크기와 회전을 알 수 있다면 포탈의 중심이 최소한 어디여야 하는지 알 수 있습니다.

  포탈의 가로 세로 크기를 width, height라고 해보죠. 포탈을 u 방향으로 밀어내야 한다면, u 축에 대한 포탈의 크기는 다음 수식과 같을 것입니다:


  따라서 우리는 경계선으로부터 포탈의 중심이 u 방향 기준의 포탈 크기의 절반보다는 위에 있어야 한다는 사실을 알 수 있습니다.

  또한 문제를 조금 더 단순화하기 위해, 바운딩 박스를 활용하기로 했습니다. 바운딩 박스는 물체의 영역을 정의하며, 축에 정렬(axis-aligned)되어 있습니다. 따라서 Center 벡터와 Extent 벡터만 정의되면 바운딩 박스를 정의할 수 있습니다. 다음 그림을 참고해보세요:


  정리해보면, 바운딩 박스가 주어졌을 때, 바운딩 박스 안에 포탈이 포함되도록 포탈을 u 방향으로 밀어내면 되는 것입니다. 다음 소스 코드를 참고해보세요:


소스 코드: UPortalGun::MovePortalUAxisAligned()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
UPortalGun::PortalOffset UPortalGun::MovePortalUAxisAligned(
    const FVector& BoundCenter,
    const FVector& BoundExtent,
    const FVector& PortalRight,
    const FVector& PortalUp,
    const FVector& PortalCenter,
    const FVector& U) const
{
    const auto BoundCenterU = BoundCenter.Dot(U);
    const auto BoundExtentU = BoundExtent.Dot(U);

    // Calculate boundary of U coordinate.
    const auto UMax = FMath::Max(
        BoundCenterU + BoundExtentU,
        BoundCenterU - BoundExtentU);
    const auto UMin = FMath::Min(
        BoundCenterU + BoundExtentU,
        BoundCenterU - BoundExtentU);

    const auto PortalUpDotU = PortalUp.Dot(U);
    const auto PortalRightDotU = PortalRight.Dot(U);

    const auto PortalUSizeHalf =
        PORTAL_UP_SIZE_HALF * FMath::Abs(PortalUpDotU) +
        PORTAL_RIGHT_SIZE_HALF * FMath::Abs(PortalRightDotU);

    // Portal.U should be in the boundary:
    // PortalUMin <= Portal.U < PortalUMax
    const auto PortalUMax = UMax - PortalUSizeHalf;
    const auto PortalUMin = UMin + PortalUSizeHalf;

    if (PortalUMax < PortalUMin)
    {
        // Impossible.
        return std::nullopt;
    }

    const auto CenterDotU = PortalCenter.Dot(U);
    double Delta = 0.0;

    // Should move portal to +U?
    if (CenterDotU < PortalUMin)
    {
        Delta = PortalUMin - CenterDotU;
    }
    else if (CenterDotU > PortalUMax)
    {
        Delta = PortalUMax - CenterDotU;
    }

    return Delta * U;
}
cs


  위의 함수를 이용해서 X축, Y축, Z축에 대해 바운딩 박스 안쪽으로 포탈을 밀어넣을 수 있게 됩니다. 다음 소스 코드를 참고해보세요:


소스 코드: UPortalGun::CalculateCorrectPortalCenter()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
UPortalGun::PortalPointAndRotation UPortalGun::CalculateCorrectPortalCenter(
    const FHitResult& HitResult,
    const APortal& TargetPortal) const
{
    constexpr auto PortalForwardOffset = 3.0f;
    FVector ResultPoint = HitResult.ImpactPoint -
        PortalForwardOffset * HitResult.ImpactNormal;

    const auto PortalRotation =
        CalculatePortalRotation(HitResult.ImpactNormal, TargetPortal);

    const auto PortalUp = TargetPortal.GetPortalUpVector(PortalRotation);
    const auto PortalRight = TargetPortal.GetPortalRightVector(PortalRotation);

    const auto OtherActorBounds =
        HitResult.GetActor()->GetComponentsBoundingBox();

    const auto Center = OtherActorBounds.GetCenter();
    const auto Extent = OtherActorBounds.GetExtent();

    {
        // Calculate offset to X axis.
        const auto PortalOffsetX = MovePortalUAxisAligned(
            Center,
            Extent,
            PortalRight,
            PortalUp,
            ResultPoint,
            FVector(1.0, 0.0, 0.0)
        );

        if (!PortalOffsetX)
        {
            return std::nullopt;
        }

        ResultPoint += PortalOffsetX.value();
    }

    {
        // Calculate offset to Y axis.
        const auto PortalOffsetY = MovePortalUAxisAligned(
            Center,
            Extent,
            PortalRight,
            PortalUp,
            ResultPoint,
            FVector(0.0, 1.0, 0.0)
        );

        if (!PortalOffsetY)
        {
            return std::nullopt;
        }

        ResultPoint += *PortalOffsetY;
    }

    {
        // Calculate offset to Z axis.
        const auto PortalOffsetZ = MovePortalUAxisAligned(
            Center,
            Extent,
            PortalRight,
            PortalUp,
            ResultPoint,
            FVector(0.0, 0.0, 1.0)
        );

        if (!PortalOffsetZ)
        {
            return std::nullopt;
        }

        ResultPoint += *PortalOffsetZ;
    }

    return std::make_pair(ResultPoint, PortalRotation);
}
cs


벽에 설치된 포탈을 통과할 수 있게 만들기

  플레이어는 벽을 통과할 수 없습니다! 당연한 사실이죠. 포탈이 설치되더라도, 여전히 벽은 존재합니다. 단지 포탈에 가려서 보이지 않게 되었을 뿐이죠. 이를 어떻게 해결할 수 있을까요? 계속 생각하다보면 결국 포탈과 겹치는 부분만을 삭제한 메쉬를 새롭게 생성해야 한다는 결론에 이를 수 있습니다. 하지만 이는 어려운 일입니다. 별로 효율적인 방법도 아닙니다. 따라서 조금 더 단순화된 방법으로 이를 구현할 것입니다.

  우선 플레이어가 벽을 통과하기 위해서 기존의 벽을 무시해야 합니다. 이는 기존의 벽들과의 상호작용을 완전히 배제해야 한다는 의미입니다. 포탈이 설치되었을 때 기존의 벽을 변경하는 방법은 고려하지 않습니다. 벽이란 보통 성능을 위해 "절대 변경되지 않을 것으로 전제"되는 스태틱 메시들이기 때문이죠. 

  따라서 액터는 포탈의 주변에 다다르면 모든 스태틱 메시와의 충돌을 무시하도록 만듭시다. 이렇게 될 경우, 바닥과도 충돌을 무시하게 되므로 떨어지게 됩니다. 따라서 바닥에 해당하는 메시를 별도로 생성해주고 액터는 생성된 바닥과 충돌할 수 있도록 해야합니다. 또한, 포탈의 경계와 똑같이 생긴 메시를 생성하여 마치 구멍이 뚫린 것처럼 느끼도록 합시다. 다음 그림처럼 생긴 메시이죠.

얇은 링 형태의 스태틱 메시를 포탈에 둘러줍니다.
포탈의 경계에 진입할 때, 이 링이 벽면을 무시하는 충돌 판정의 괴리를 없애줄 것입니다.


  충돌 채널을 변경하는 것에 대해서는 세부사항을 다루지 않겠습니다. 중요한 점은 포탈과 겹치는 영역에 있는 스태틱 메시들이 (충돌이 비활성화되었음에도 불구하고) 여전히 플레이어와 충돌하고 있는 환상을 만들어내야 한다는 것입니다. 예를 들어 이러한 고려가 없다면, 다음처럼 플레이어는 포탈과 겹치자 마자 떨어져버릴 것입니다:

플레이어는 포탈에 겹침과 동시에 충돌 채널이 변경되며,
기존의 스태틱 메시들을 무시하게 됩니다.

  이를 극복하기 위해서는, 변경된 충돌 채널에서 여전히 충돌할 수 있으면서 포탈 주위의 스태틱 메시들을 모방하는 메시들을 별도로 생성해주어야 합니다. 다양한 모양의 스태틱 메시를 고려해야 겠지만, 제 경우 구현의 편이를 위해 단순한 평면 형태의 바닥만이 존재한다고 가정하겠습니다.

  평면 형태의 바닥을 생성하기 위해서는 바닥의 U, V축과 법선을 알아야 합니다. 

  1. 포탈의 법선과 바닥의 법선을 외적하면 바닥의 V축을 얻을 수 있습니다. 
  2. V축과 바닥의 법선을 다시 외적하면 U축을 얻을 수 있습니다. 

  이 방법에는 중요한 전제가 있는데, 바닥은 평평하거나, 한쪽 축으로만 기울어져 있어야 합니다. 만약 양쪽 축으로 기울어져 있다면 제대로 동작하지 않을 것입니다. 다음 그림을 참고해보세요:

다양한 바닥의 각도 유형


소스 코드: UPortalGun::SpawnPlanesAroundPortal()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void UPortalGun::SpawnPlanesAroundPortal(TObjectPtr<APortal> TargetPortal)
{
    ...

    auto& CollisionPlanes = GetCollisionPlanes(TargetPortal);
    DestroyAllPlanesSpawnedBefore(CollisionPlanes);

    ...

    for (auto HitResult : HitResults)
    {
        // If the actor is movable, it is may a cube, so continue.
        if (HitResult.GetActor()->IsRootComponentMovable())
        {
            continue;
        }

        const auto SpawnLocation = HitResult.ImpactPoint;
        const auto PlaneNormal = HitResult.ImpactNormal;

        const auto PlaneV =
            PortalForward.Cross(PlaneNormal);
        const auto PlaneU = 
            PlaneNormal.Cross(PlaneV).GetSafeNormal();

        const auto SpawnRotation =
            UKismetMathLibrary::MakeRotationFromAxes(
                PlaneU,
                PlaneV,
                PlaneNormal);

        FActorSpawnParameters ActorSpawnParams;
        ActorSpawnParams.SpawnCollisionHandlingOverride =
            ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
        auto SpawnedPlane = World->SpawnActor<AStaticMeshActor>(
            SpawnLocation,
            SpawnRotation,
            ActorSpawnParams);

        ...

        CollisionPlanes.Add(SpawnedPlane);
    }
}
cs


생성된 바닥들. 플레이어는 포탈과 겹치더라도 바닥을 딛고 설 수 있습니다.

  참고로, 이렇게 생성되는 바닥은 다른 포탈과 겹치면 안 됩니다. 따라서 바닥에 포탈이 있는 경우 바닥을 생성하지 않도록 합시다. 더 나아가서, 바닥에 포탈이 있어서 바닥을 생성하지 않았는데 해당 포탈이 다른 자리로 옮겨졌을 수도 있습니다. 따라서 각 포탈은 서로의 위치가 갱신될 때마다 바닥 메시들 역시 갱신해주어야 함에 유의하세요!


물체 집기

  포탈 건은 큐브와 같은 물체를 집을 수 있습니다. 조준한 물체를 집는 것은 단순하며 다양한 방법이 존재합니다. 간단하게, 플레이어의 카메라 전방 방향으로 광선을 쏴 최초로 맞는 물체를 집게 할 수 있습니다. 이 세부사항에 대해서는 다루지 않을 것입니다. 대신 포탈이 존재할 때 어떤 것들에 대해서 고려해야 하는지 알아봅시다.

  먼저 물체를 집는다는 것은 플레이어 카메라의 전방 임의의 한 점에 물체가 계속 머무르게 하는 것입니다. 하지만 물체를 집은 상태로, 플레이어는 포탈을 넘어가지 않았는데,물체만 포탈을 넘어간다면 어떻게 될까요? 물체는 포탈을 넘어가자마자 건너편 세계에서 다시 플레이어의 앞으로 돌아가려고 할 것입니다. 다음 영상을 참고해보세요:


물체가 포탈의 경계선을 넘어 반대편 포탈로 이동되자 마자
다시 플레이어의 앞쪽으로 되돌아갑니다.


  따라서 물체가 포탈을 넘어가게 되면, 물체는 플레이어의 앞쪽이 아닌 건너편 포탈의 한 점에 고정되도록 해야 합니다. 다음 코드를 참고해보세요:


소스 코드: UPortalGun::ForceGrabbedObject()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void UPortalGun::ForceGrabbedObject()
{
    if (!bIsGrabbing)
    {
        return;
    }

    ...

    if (bIsGrabbedObjectAcrossedPortal)
    {
        if (auto PortalOpt = GetPortalInFrontOfCharacter())
        {
            const auto& PortalInFront = *PortalOpt;
            TargetLocation =
                PortalInFront->TransformPointToDestSpace(TargetLocation);
        }
...
    }

    ...
}
cs


  또한 물체를 집을 때도 마찬가지로, 건너편 포탈의 물체 역시 집을 수 있어야 합니다. 즉, 플레이어 카메라의 광선이 포탈에 부딪히면, 여전히 맞은편 포탈에서 그 광선을 쏴주어야 합니다. 다음 코드를 참고해보세요:


소스 코드: UPortalGun::Interact()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
void UPortalGun::Interact()
{
    ...

    bIsGrabbedObjectAcrossedPortal = false;

    // If hit a portal:
    if (auto PortalOpt = APortal::CastPortal(HitResult.GetActor()))
    {
        UE_LOG(Portal, Log, TEXT("Hit Portal, try grab beyond the opposite space"));

        auto PortalActor = *PortalOpt;

        const auto ImpactPoint = HitResult.ImpactPoint;
        const auto RemainRange =
            (End - ImpactPoint).Length();

        const auto OppositeDirection =
            PortalActor->TransformVectorToDestSpace(Direction);
        const auto OppositePortalForward =
            PortalActor->GetLink()->GetPortalForwardVector();

        // We should move start point little because
        // prevent to hit the wall mesh.
        constexpr auto ForwardOffset = 10.f;
        const auto DirectionDotForward =
            OppositeDirection.Dot(OppositePortalForward);

        const auto StartOffset =
            ForwardOffset / DirectionDotForward;
        
        const auto OppositeStart =
            PortalActor->TransformPointToDestSpace(ImpactPoint) +
            OppositeDirection * StartOffset;
        const auto OppositeEnd =
            OppositeStart + OppositeDirection * RemainRange;
        
        CollisionParams.AddIgnoredActor(BluePortal);
        CollisionParams.AddIgnoredActor(OrangePortal);

        bIsBlocked = GetWorld()->LineTraceMultiByChannel(
            HitResults,
            OppositeStart,
            OppositeEnd,
            PORTAL_QUERY,
            CollisionParams);

        bNothingToInteract = HitResults.IsEmpty();
        if (bNothingToInteract)
        {
            UE_LOG(Portal, Log, TEXT("Nothing to interact."));
            return;
        }

        bIsGrabbedObjectAcrossedPortal = true;
        HitResult = HitResults[0];
    }

    auto NewGrabbedActor = HitResult.GetActor();

    ...

    StartGrabbing(NewGrabbedActor);
}
cs


클론

  분명 세계에 오브젝트는 하나뿐입니다. 하지만 오브젝트가 포탈의 경계선에 위치할 때, 양쪽의 포탈에서 오브젝트가 둘다 보여야 합니다. 이를 해결하기 위해서 우리는 포탈을 건너고 있는 오브젝트들에 대해서 클론을 만들어야 합니다. 클론이 없다면, 오브젝트는 하나뿐이므로 포탈을 넘어가는 과정에서 반쪽만 보이게 될 것입니다. 다음 그림을 참고해보세요:

양쪽 포탈에서 물체를 볼 수 있어야 하지만 한쪽에만 보입니다.

  물체가 포탈과 오버랩되기 시작되면, 맞은편 포탈에서 클론을 생성해주어야 할 것입니다. 이렇게 생성된 클론은 맞은편 포탈과 상호작용하지 않아야 합니다. 만약 상호작용한다면, 재귀적으로 무한하게 클론이 또 생성될 것이니까요. 그러므로 이에 대한 처리가 필요합니다.

  또한 클론의 위치와 속도, 회전 등을 계속해서 원본과 동기화시켜 주어야 합니다. 특히, 언리얼 엔진에서 일반적인 액터와 캐릭터의 속도를 변경하는 방법이 다름에 유의하세요. 다음 코드를 참고해보세요:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void APortal::UpdateClones()
{
    for (auto [Original, Clone] : CloneMap)
    {
        if (!Clone)
            continue;

        auto CloneLocation =
            TransformPointToDestSpace(Original->GetActorLocation());
 
       Clone->SetActorLocation(CloneLocation);
        // If the clone is the player, set rotation and velocity
        // by different way.

        if (const auto OriginalPlayer =
            Cast<APortalRevisitedCharacter>(Original))
        {
            auto ClonePlayer =
                Cast<APortalRevisitedCharacter>(Clone);
            auto OriginalController = OriginalPlayer->GetController();

            const auto CloneRotation =
                TransformQuatToDestSpace(
                    OriginalPlayer->GetActorRotation().Quaternion());
            ClonePlayer->GetMovementComponent()->Velocity = 
                OriginalPlayer->GetMovementComponent()->Velocity;
            
            const auto CameraRotator =
                OriginalPlayer->GetFirstPersonCameraComponent()->GetRelativeRotation();
            ClonePlayer->SetActorRotation(CloneRotation.Rotator());
            ClonePlayer->GetFirstPersonCameraComponent()->
                SetRelativeRotation(CameraRotator);
            continue;
        }

        auto CloneRotation =
            TransformQuatToDestSpace(Original->GetActorQuat());
        Clone->SetActorRotation(CloneRotation);

        auto PrimitiveCompOpt = GetPrimitiveComponent(Original);
        if (!PrimitiveCompOpt)
        {
            UE_LOG(Portal, Warning, TEXT("Cannot set velocity of the clone."))
            continue;
        }
        
        auto CloneVelocity = Original->GetVelocity();
        auto PrimitiveComp = *PrimitiveCompOpt;
        PrimitiveComp->SetPhysicsLinearVelocity(CloneVelocity);
    }
}
cs


  클론에 대한 오브젝트 그랩 역시 처리해주어야 함에 유의하세요. 만약 클론을 집게 된다면, 대신 그의 원본 오브젝트가 맞은편 포탈 너머에서 집어지도록 구성하면 될 것입니다. 세부사항은 다루지 않겠습니다.


포탈이 단순히 평면 메시일 때의 문제

  씬 캡처 컴포넌트는 플레이어의 카메라 위치를 고려하여 건너편 포탈에서 적절히 화면을 캡처합니다. 그리고 그렇게 캡처된 렌더 타겟은 플레이어 앞 포탈 평면의 머티리얼에 적용되어 플레이어의 카메라가 포탈의 건너편을 볼 수 있도록 합니다. 하지만 이 경우 포탈은 단순히 평면이므로, 투영 창의 near plane과 관련한 문제가 발생할 수 있습니다. 포탈의 평면이 카메라의 near plane보다 더 앞에 있다면, 해당 픽셀들은 기각되고 더 뒤에 있는 오브젝트들이 보이게 됩니다. 다음 그림을 참고해보세요:

Near, far clipping

너무 가까이에 물체가 있어 물체 뒤의 바닥이 보이게 됩니다. (우측 하단)

  우리가 단순히 "포탈의 건너편을 보여주는 평면"을 렌더링한다면, 플레이어와 포탈 평면이 너무 가까워졌을 때 순간적으로 포탈의 건너편이 보이지 않게 될 것입니다. 아마, 대신 벽이 보이게 되겠죠. 

  따라서 포탈의 평면 뒤에도 여전히 포탈을 렌더링할 수 있도록 간단한 편법을 추가해야 합니다. 제 경우, 단순히 포탈 모양으로 오목한 메시를 하나 더 추가해서 역시 이 메시 또한 렌더 타겟을 샘플링하도록 했습니다. 이렇게 하면 포탈 평면의 픽셀이 기각되더라도, 여전히 안쪽에 같은 머티리얼을 지닌 또 다른 메시가 보일 것이기 때문에 플레이어는 이를 인지하지 못합니다. 

앞쪽의 포탈 평면 뒤에 오목한 메시가 하나 더 있습니다. 


  그럼에도 불구하고 문제가 여전히 있습니다. 포탈은 벽면 위에 설치되고, 벽면의 메시는 사라지지 않는다는 것이죠. 다음 그림을 보면 회색 선이 보이는 것을 알 수 있습니다. 이 그림은 포탈 평면, 벽면, 포탈 내부 메시 세 가지가 전부 보이는 상태입니다. 회색 선을 기준으로 왼쪽은 포탈의 평면이 보이는 것이고, 회색 선은 벽면이 보이는 것이며, 회색 선의 우측은 포탈 내부 메시가 보이고 있는 것입니다. 포탈이 아주 미세하게 벽면보다 앞쪽에 위치하고 있기 때문에, 포탈의 평면은 기각되지만 벽면 메시는 기각되지 않고 플레이어의 화면에 보이는 경우가 생긴 것이죠.


이해하기 어렵다면, 다음 그림 역시 참고해보세요.

이전 그림에 대한 개략적 설명


  따라서 포탈이 설치된 벽면은 잠시 렌더링이 되지 않게 만들어야 합니다. 언리얼 엔진의 머티리얼에서는 Masked 블렌드 모드를 지원합니다. 이는 특정한 픽셀들에 대해서 마스킹, 즉 렌더링하지 않게 만들 수 있습니다. 우리는 포탈이 설치된 벽면을 마스킹하고 싶으며, 이를 위해서 포탈 평면의 바운딩 박스를 정의해 볼 수 있습니다. 그리고 벽의 픽셀이 포탈 평면의 바운딩 박스 안에 있다면 해당 픽셀을 기각하도록 머티리얼을 구성해볼 수 있습니다. 다음과 같은 형태가 될 것입니다.


BoxMask-Portal 머티리얼 함수는 포탈의 회전, 크기, 위치를 인자로 받아
현재 렌더링할 픽셀이 기각되어야 할지 여부를 결정합니다.


  바운딩 박스 안의 픽셀인지 아닌지를 구별하는 것은 단순하지 않습니다. 왜냐하면 포탈은 임의의 회전을 할 수 있으며, 즉 바운딩 박스가 축에 정렬되어 있지 않기 때문입니다. 따라서 포탈의 회전을 반영해줄 필요가 있습니다. 바운딩 박스를 회전하는 대신, 포탈의 중심을 원점으로 주어진 벽면의 픽셀을 X, Y, Z 축에 대해서 회전해주기로 합시다. 이때 주의할 사항이 두 가지 있습니다. 1) 머티리얼 함수인 RotateAboutAxis는 회전 오프셋을 반환하므로, 픽셀의 위치에 더해줘야 합니다. 2) 오일러 각을 이용한 회전은 Z축, Y축, X축 순서로 회전해야 올바른 값이 나오게 됩니다.

BoxMask-Portal의 내부 로직.
주어진 포탈의 회전을 기반으로 픽셀의 위치를 변경합니다.


  이후 해당 픽셀이 바운딩 박스에 포함되는지 아닌지를 결정하는 방법은 단순합니다. 각 축에 대해서 Center-Extent≤Pixel≤Center+Extent 가 참인지 거짓인지 확인하면 되죠. 머티리얼 에디터 상에서 노드들의 모습은 복잡하고 보기 어려우므로, 생략하겠습니다.


3인칭의 모습이 보이기

  포탈을 적절히 배치하면, 포탈 너머로 내 모습을 볼 수 있습니다. 이는 하나의 걸림돌인데, 일반적으로 1인칭 게임에서는 전체의 모습이 있는 캐릭터 메시를 사용하지 않습니다. 팔과 총만 덩그러니 있는 메시를 활용하죠. 즉, 포탈 너머로 내 모습을 본다면 팔과 포탈 건만 둥둥 떠다니는 모습을 볼 수 있을 것입니다:

포탈에 비친 내 모습은 팔과 총밖에 없습니다.

  3인칭 메시를 따로 준비하여, 내가 포탈 너머로 내 모습을 볼 수 있다면 1인칭 메시 대신 3인칭 메시가 보이도록 만들어봅시다. 이는 그리 단순한 문제는 아닙니다. 혹시 "포탈 너머에 있는 캐릭터들은 죄다 3인칭 메시로 렌더링하면 되는 것 아닌가?"라고 생각하셨다면, 아쉽게도 아닙니다. 다음 그림을 참고해보세요:

포탈 중간에 내 몸이 걸쳐있는 경우, 포탈의 건너편에서 내 모습이 겹쳐보입니다.


  여러분의 피곤함을 덜어드리기 위해서, 제가 간단하게 각 카메라가 볼 수 있는 메시를 정리했습니다:




무한 포탈?

  엘리베이터의 거울이 무한하게 뻗어있는 것처럼, 포탈 역시 마주보도록 설치하게 되면 재귀적으로 무한한 포탈의 건너편을 볼 수 있을 것입니다. 하지만 씬 캡처 컴포넌트를 이용하여 화면을 렌더링하면, 포탈 안의 포탈의 모습은 볼 수 없습니다. 왜냐하면 포탈의 평면은 씬 캡처 컴포넌트가 찍은 렌더 타겟이 나타나게 되는 것이기 때문입니다. 씬 캡처 컴포넌트가 찍기 이전에는 아무것도 없겠죠. 두 번 찍었다면, 두 번째 깊이까지만 볼 수 있습니다. 다음 그림을 참고해보세요:


최초의 캡처 (깊이 2)

두 번째 캡처로 덮어쓰기 (깊이 1)

최종 렌더링

  두 번째 깊이 이후의 포탈은 볼 수 없습니다. 이를 해결하기 위해서는 결국 많은 재귀적인 캡처가 필요합니다. 먼저 포탈 안의 포탈의 모습을 보이기 위해서 캡처를 하고, 이후에 포탈의 모습을 보이기 위해서 또 캡처를 하는 것이죠. 하지만 씬 캡처 컴포넌트의 캡처 비용은 결코 싸지 않습니다. 우리는 이에 대해서 타협이 필요합니다.


직각의 벽에 설치된 포탈

  명확한 것은 적어도 포탈 하나당 두 번은 캡처를 해야 한다는 사실입니다. 직각의 벽에 설치된 포탈을 자연스럽게 보이려면 이는 불가피하죠. 하지만 그 이상의 캡처는 피하고 싶습니다. 프레임 저하가 심하기 때문입니다. 따라서 포탈이 마주보고 있는 특수한 경우를 위한 렌더링 방법을 생각해 봅시다.


  무한한 포탈을 바라보면 마치 같은 패턴이 계속해 반복되는 것처럼 보입니다. 이러한 현상을 우리가 이용해보는 것은 어떨까요? 가장 깊은 재귀 레벨에서 촬영된 포탈의 모습을, 그대로 더 깊은 포탈의 모습에 재활용하는 것입니다. 이는 완전한 재귀 포탈의 시각 효과를 보일 순 없겠지만, 그럴듯해 보일 것입니다. 다음 그림을 참고해보세요:

아이디어: 좌측의 영역을 그대로 우측의 영역에 덮어씁니다.

이상적인 포탈 재귀(좌)와 텍스처 복사를 이용한 최적화(우)


텍스처 복사

  우리는 렌더 타겟 재활용을 위해서, 가장 깊은 재귀 수준에서 캡처된 최초의 텍스처를 복사해놓아야 합니다. 최종 렌더 타겟을 그대로 다음 포탈의 렌더링에 재활용할 수는 없습니다. 왜냐하면 다음과 같은 문제가 발생할 수 있기 때문이죠. 자세히 봐보세요:

포탈에 비치는 포탈 건의 모습이 보이시나요?
최종 렌더 타겟의 텍스처를 복사하게 된다면 이런 문제가 발생할 수 있습니다.

  텍스처의 복사는 가장 깊은 재귀 수준의 포탈을 렌더링할 때, 언리얼 엔진에서 RHI를 통한 명령을 제출하면 됩니다. 다음 코드를 참고해보세요:


소스 코드: APortal::CapturePortalSceneRecur()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void APortal::CapturePortalSceneRecur(
    float DeltaTime,
    const FVector& CurrentCameraLocation,
    const FQuat& CurrentCameraRotation, int RecursionRemaining)
{
    if (RecursionRemaining <= 0)
        return;

    ... // Set the camera location and rotation.

    CapturePortalSceneRecur(
        DeltaTime, 
        CameraLocation, 
        CameraRotation, RecursionRemaining - 1);

    ... // Set the hide components. (i.e. First Person Mesh)
    ... // Set portal camera transform and capture.

    
    // In this case, we will save location of the farthest,
    // and second farthest portal in the clip space. We can
    // determine where the portal rectangle is in the render
    // target, and draw it recursively on the portal so
    // it generates an effect that the portal stands infinitely.

    // Last recursion(farthest portal)
    if (RecursionRemaining == 1)
    {
        ...
        
        ENQUEUE_RENDER_COMMAND(PortalTextureCopy)(
            [this](FRHICommandListImmediate& RHICmdList)
            {
                auto SrcTexture =
                    PortalTexture->GetRenderTargetResource()->GetRenderTargetTexture();
                auto DestTexture = 
                    PortalRecurTexture->GetRenderTargetResource()->GetRenderTargetTexture();
                FRHICopyTextureInfo Info{};
                RHICmdList.CopyTexture(SrcTexture,
                    DestTexture,
                    Info);
            });

        return;
    }

    ...
}
cs


텍스처 활용을 위한 투영

  텍스처들이 준비되었다면, 멀리 보이는 포탈의 부분들을 텍스처로 매핑해 재활용해야 합니다. 이를 구현하기 위해서는 먼저 포탈의 영역이 화면에서 어디를 차지하고 있는지를 알아야 합니다. 다음과 같은 화면이 플레이어에게 보일 때, 포탈의 모서리 점들이 화면에서 어느 위치에 있는지 알 수 있어야 하죠.

플레이어의 화면 예시

  저는 이후에 이루어질 bilinear interpolation을 위해, 포탈의 4개 모서리가 화면에서 어디에 투영되었는지를 계산했습니다. 플레이어 카메라의 투영 변환 행렬(view-projection matrix)를 얻어내서 이를 곱해주면 됩니다.

  유의할 사항은, 여전히 이들은 클립 공간(screen space라고도 합니다)의 좌표는 아니라는 것입니다. 투영 변환된 좌표를 w의 값으로 나누고, 정규화 작업을 거친 후 y를 반전시키면 언리얼 엔진에서 다루는 클립 공간의 좌표를 얻어낼 수 있습니다. 다음 코드를 참고해보세요:


소스 코드: CalculateClipSpaceLocation()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
void NormalizeToZeroOne(UE::Math::TVector4<double>& Vector)
{
    Vector.X += 1.0;
    Vector.Y += 1.0;

    Vector.X /= 2.0;
    Vector.Y /= 2.0;
}

void OneMinus(double& Value)
{
    Value = 1.0 - Value;
}

void CalculateClipSpaceLocation(
    const FMatrix& ViewProjectionMatrix,
    APortal* PortalToDraw, 
    UE::Math::TVector4<double>& ClipLeftUp, 
    UE::Math::TVector4<double>& ClipLeftDown, 
    UE::Math::TVector4<double>& ClipRightUp, 
    UE::Math::TVector4<double>& ClipRightDown)
{
    const auto PortalCenter = 
        PortalToDraw->GetPortalPlaneLocation();
    const auto PortalRight =
        PortalToDraw->GetPortalRightVector();
    const auto PortalUp =
        PortalToDraw->GetPortalUpVector();

    // TODO: Refactor hard coded portal size
    constexpr double PORTAL_HEIGHT_HALF = 150.0;
    constexpr double PORTAL_WIDTH_HALF = 100.0;

    const auto WorldLeftUp =
        PortalCenter + 
        PortalUp * PORTAL_HEIGHT_HALF - 
        PortalRight * PORTAL_WIDTH_HALF;
    const auto WorldLeftDown =
        PortalCenter -
        PortalUp * PORTAL_HEIGHT_HALF - 
        PortalRight * PORTAL_WIDTH_HALF;
    const auto WorldRightUp =
        PortalCenter + 
        PortalUp * PORTAL_HEIGHT_HALF +
        PortalRight * PORTAL_WIDTH_HALF;
    const auto WorldRightDown =
        PortalCenter -
        PortalUp * PORTAL_HEIGHT_HALF +
        PortalRight * PORTAL_WIDTH_HALF;

    ClipLeftUp = ViewProjectionMatrix.TransformPosition(WorldLeftUp);
    ClipLeftDown = ViewProjectionMatrix.TransformPosition(WorldLeftDown);
    ClipRightUp = ViewProjectionMatrix.TransformPosition(WorldRightUp);
    ClipRightDown = ViewProjectionMatrix.TransformPosition(WorldRightDown);

    ClipLeftUp /= ClipLeftUp.W;
    ClipLeftDown /= ClipLeftDown.W;
    ClipRightUp /= ClipRightUp.W;
    ClipRightDown /= ClipRightDown.W;

    NormalizeToZeroOne(ClipLeftUp);
    NormalizeToZeroOne(ClipLeftDown);
    NormalizeToZeroOne(ClipRightUp);
    NormalizeToZeroOne(ClipRightDown);

    OneMinus(ClipLeftUp.Y);
    OneMinus(ClipLeftDown.Y);
    OneMinus(ClipRightUp.Y);
    OneMinus(ClipRightDown.Y);

    ...
}
cs


  이렇게 얻어진 좌표는 언리얼 엔진의 material parameter collection으로 전달되어 머티리얼 에디터에서 활용할 수 있게 됩니다.


Inverse Bilinear Interpolation

  우리는 포탈 안 구역에 있는 화면의 픽셀이 포탈 평면의 어느 좌표에 해당하는지를 알아야 합니다. 화면에서 포탈의 평면은 직사각형으로 보이지 않습니다. 원근감에 의해 사다리꼴처럼 보이죠. 하지만 분명 사다리꼴 안의 각 픽셀은 직사각형 포탈의 한 점과 대응될 것입니다. 이들의 관계를 명확하게 정의한다면, 기존에 렌더링한 포탈의 텍스처를 이용해서 더 깊은 포탈의 텍스처를 매핑해줄 수 있을 것입니다. 다음 그림을 참고해보세요:

텍스처 매핑

  이러한 관계는 Bilinear Interpolation을 이용해 정의할 수 있습니다. 쉽게 말하면, 사각형의 꼭짓점에 해당하는 4개의 점이 주어졌을 때, 그 사각형 안의 점들을 (0, 0)부터 (1, 1) 사이의 점으로 정의하는 방법입니다. 이는 다음의 수식으로 표현될 수 있습니다:


Bilinear interpolation

  하지만 우리는 이러한 문제의 역이 필요합니다. 4개의 사각형 꼭짓점으로부터 주어진 좌표 (x, y)에 해당하는 점이 어디인지를 알아내는 것이 아니라, 반대로 임의의 점이 주어지면 그것의 좌표 (x, y)가 무엇인지를 알아내야 합니다. 이러한 역 문제들은 상당히 풀기 어려운 경우가 많아 수치해석적 방법론을 도입해야 합니다. 다행히 선형적인 보간에서는 2차 방정식의 형태로 나타나므로 이들의 해를 찾을 수 있습니다. 이에 대한 설명이 잘 기술된, 다음 링크로 대체합니다:

https://iquilezles.org/articles/ibilinear/


텍스처 복사를 통한 무한 포탈 렌더링의 한계

  텍스처 복사를 이용하는 방법은 다음과 같은 한계를 갖습니다.

  • 텍스처가 복사되어 다음 프레임에 반영되는 것이므로 1프레임의 딜레이가 존재합니다.
  • 첫 번째 깊이의 포탈이 화면을 벗어난다면, 온전한 재귀 포탈의 텍스처를 얻을 수 없으므로 적절한 조치가 필요합니다: 텍스처를 고무줄처럼 늘이는 것입니다.

  • 완전한 포탈의 현상을 보여주지 않으며 왜곡됩니다.
포탈에 진입 시 순간적으로 뒤에 그려지는 포탈의 형태가 변합니다.

  기쁜 사실은, 이 한계들이 별로 큰 문제가 되지 않는다는 것이 입증되었다는 것입니다. <Portal> 시리즈에서도 이와 같은 방식이 이미 적용돼 있으며, 여러분들을 포함한 수많은 플레이어들은 이러한 사실을 인지조차 할 수 없었습니다. 딱히 중요하지 않을 뿐더러, 이렇게 포탈을 면밀하게 관찰하는 플레이어는 별로 없었기 때문이죠.


결과


Q&A

렌더 타겟이 한 프레임 늦게 캡처됩니다.

  Physics가 적용되기 이전 시점에 캡처를 하게 되어 한 프레임이 늦는 것처럼 보이는 것입니다. 이는 SceneCaptureComponent를 지닌 Actor의 TickGroup을 PostUpdateWork로 변경해줌으로써 해결할 수 있습니다. TickGroup을 변경하는 것은 SetActorTickGroup 함수를 사용해야 함에 유의하세요(직접 PrimaryActorTick.TickGroup을 변경하는 것은 제대로 동작하지 않습니다). 또한, CaptureEveryFrame, CaptureWhenMoving을 비활성화해야 합니다.


캡처를 여러 번 해도 여전히 포탈의 평면이 검은색입니다.

  SceneCapture 컴포넌트가 캡처하였을 때 기존의 캡처를 유지하도록 해야 합니다. 이는 Composite 모드를 Overwrite, Additive가 아닌 Composite으로 설정해야 합니다. 또한 이를 활용하려면 씬 컬러 모드가 "HDR Color in RGB, Inv Opacity in"으로 설정되어야 합니다.


캐릭터의 속도 설정이 되지 않습니다. / 액터의 속도 설정이 되지 않습니다.

  CharacterMovement는 다양한 모듈과 엮여있고 복잡하므로 원하는대로 동작하도록 조작하는 것이 쉽지 않습니다. Character->MovementComponent()->Velocity를 조절하는 것은 동작합니다.

  마찬가지로, 일반적인 액터의 경우 PrimitiveComponent로 캐스팅해 SetAllPhysicsLinearVelocity()를 활용해야 합니다.


클론은 어떤 함수를 활용했나요?

  DuplicateObject() 함수를 활용했습니다.


머티리얼 함수 RotateAboutAxis가 제대로 동작하지 않습니다.

  해당 함수는 회전 오프셋을 반환합니다. 회전 이전의 Position과 회전 이후의 Position 사이의 차이를 반환합니다. 이를 다루는데 주의하세요!


CopyTexture()가 동작하지 않습니다.

  프로젝트 Build.cs의 의존성에서 RHI와 RenderCore가 포함되었는지 확인하세요.


Viewport의 종횡비와 Camera의 종횡비가 일치하지 않아 투영이 적절히 수행되지 않습니다.

  Camera의 ConstrainAspectRatio를 활성화해서 Viewport에서도 AspectRatio가 고정되게 할 수 있습니다.



참고

언리얼 엔진 공식 문서

https://iquilezles.org/articles/ibilinear/

Valve developers discuss Portal problems - CS50's Intro to Game Development

https://www.froyok.fr/blog/2019-03-creating-seamless-portals-in-unreal-engine-4/

오브젝트 마스크(머티리얼) 관련


소스 코드

https://github.com/SubinHan/ue-portal-revisited


프로젝트 기간: 5주


댓글

이 블로그의 인기 게시물

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링