---
title: "투영: 벽에 막힌 이동을 벽을 따라 미끄러지게 바꾸기"
description: "벽에 비스듬히 부딪힌 캐릭터가 멈추지 않고 벽을 따라 미끄러지게 하려면, 이동 벡터에서 벽으로 파고드는 조각만 빼면 됩니다. 그 조각을 내적으로 구하는 법과 ProjectOnPlane · Reflect 가 하는 일을 작은 숫자로 따라갑니다."
author: "유니얼"
pubDate: 2026-11-05
category: "game-dev/math"
tags: ["Unity", "기초"]
canonical: https://blog.unialgames.com/posts/math-projection/
---
캐릭터를 코드로 직접 움직이면, 벽에 비스듬히 달려갈 때 캐릭터가 벽에 딱 붙어 멈추는 일이 있습니다.
흔히 쓰는 충돌 처리가 "이번 이동이 벽 안으로 들어가는가" 만 보고 **이동 전체**를 막기 때문입니다.
(2, 1) 만큼 가려 했다면 벽으로 들어가는 몫은 오른쪽 2 뿐인데, 벽을 따라가는 위쪽 1 까지 같이 버려집니다.

이동 벡터를 **두 조각으로 나누면** 풀립니다. 벽으로 파고드는 조각과 벽을 따라가는 조각입니다.
파고드는 조각만 빼면 미끄러지고, 한 번 더 빼면 튕겨 나옵니다. 나누는 기준은 벽이 바라보는 방향인 **법선**이고, 계산은 내적입니다.

> **법선 n 이 길이 1 일 때, 이동 v 에서 벽으로 파고드는 조각은 `Dot(v, n) × n` 입니다.
> 이것을 빼면 벽을 따라가는 조각만 남습니다(`Vector3.ProjectOnPlane`).
> 두 번 빼면 반사입니다(`Vector3.Reflect`). 경사면을 따라 오르게 하는 것도 같은 계산입니다.**

## 벡터를 법선 방향과 나머지로 나눈다

위에서 내려다본 바닥으로 생각합니다. 숫자를 줄이려고 바닥의 가로(x)와 세로(z) 두 숫자로만 적습니다.
x = 3 자리에 벽이 세로로 서 있고, 캐릭터는 벽의 왼쪽에 있습니다.
법선은 바닥에 선 기둥처럼 면과 직각(90°), 곧 수직으로 서서 캐릭터가 있는 바깥쪽을 향하는 길이 1 인 방향입니다. 이 벽에서 캐릭터 쪽을 보는 법선은 왼쪽을 향하는 n = (−1, 0) 입니다.
캐릭터는 이번 프레임에 v = (2, 1) 만큼, 곧 오른쪽으로 2, 위로 1 가려고 합니다.

**내적**은 첫째 숫자끼리, 둘째 숫자끼리 곱해서 더한 숫자입니다(3D 면 x · y · z 세 칸 모두). n 이 길이 1 이면 `Dot(v, n)` 은 **v 를 n 위에 내린 그림자의 길이**가 됩니다.
내적은 `v 의 길이 × n 의 길이 × cos(사이각)` 과 같고, cos 는 직각삼각형에서 밑변 ÷ 빗변 이라 `빗변 × cos = 밑변` 이기 때문입니다. n 의 길이가 1 이니 v 의 길이 × cos, 곧 그림자 길이만 남습니다.
그림자가 n 과 반대쪽에 생기면 음수입니다.

그림자의 길이에 n 을 곱하면 **n 방향으로 간 조각**이 나옵니다. 빛을 수직으로 비춰 그림자를 내리듯 벡터를 한 방향 위로 눕혀 그 몫만 떼어 내는 이 계산을 **투영**(projection)이라고 부릅니다.
v 에서 이 조각을 빼면 n 과 수직인 조각, 곧 **벽면을 따라가는 조각**이 남습니다.

| 단계 | 계산 | 결과 |
| --- | --- | --- |
| 1. 그림자 길이 | Dot(v, n) = 2 × (−1) + 1 × 0 | −2 |
| 2. 파고드는 조각 | −2 × n = −2 × (−1, 0) | (2, 0) |
| 3. 벽을 따라가는 조각 | v − (2, 0) = (2, 1) − (2, 0) | **(0, 1)** |
| 4. 튕겨 나오는 방향 | v − 2 × (2, 0) = (2, 1) − (4, 0) | **(−2, 1)** |

1 단계가 음수인 것은 v 가 법선과 반대쪽, 곧 **벽 안쪽으로** 가고 있다는 뜻입니다.
3 단계의 (0, 1) 이 정말 벽을 따라가는지는 법선과의 내적이 0 인지로 확인합니다. 0 × (−1) + 1 × 0 = 0, 수직입니다.

<x-vec>
<script type="application/json">
{
  "label": "벽에 비스듬히 부딪힐 때",
  "view": [0, 0, 5, 4],
  "cases": [
    {
      "name": "미끄러지기",
      "steps": [
        {
          "caption": "세로로 선 벽과, 왼쪽을 향한 법선 n = (−1, 0). 캐릭터는 (2, 1) 만큼 벽으로 가고 있습니다.",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [1, 1], "to": [3, 2], "label": "이동 (2, 1)", "labelAt": [1.4, 1.8] }
          ]
        },
        {
          "caption": "이동을 두 조각으로 나눕니다. Dot(v, n) = −2 라서 벽으로 파고드는 조각은 −2 × n = (2, 0), 나머지는 (2, 1) − (2, 0) = (0, 1) 입니다.",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [1, 1], "to": [3, 2], "label": "이동 (2, 1)", "labelAt": [1.4, 1.8] },
            { "id": "a", "from": [1, 1], "to": [3, 1], "color": "warn", "label": "파고듦 (2, 0)", "labelAt": [2, 0.5] },
            { "id": "b", "from": [3, 1], "to": [3, 2], "color": "accent2", "label": "따라감 (0, 1)", "labelAt": [4.2, 1.5] }
          ]
        },
        {
          "caption": "파고드는 조각을 빼면 벽을 따라가는 (0, 1) 만 남습니다. 캐릭터는 멈추지 않고 벽을 타고 위로 미끄러집니다. ProjectOnPlane(v, n) 의 결과입니다.",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [1, 1], "to": [3, 2], "color": "muted" },
            { "id": "b", "from": [3, 2], "to": [3, 3], "color": "accent2", "label": "(0, 1)", "labelAt": [3.6, 3] }
          ]
        }
      ]
    },
    {
      "name": "튕기기",
      "steps": [
        {
          "caption": "같은 벽, 같은 이동 (2, 1).",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [1, 1], "to": [3, 2], "label": "이동 (2, 1)", "labelAt": [1.4, 1.8] }
          ]
        },
        {
          "caption": "똑같이 파고드는 조각 (2, 0) 과 따라가는 조각 (0, 1) 로 나눕니다.",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [1, 1], "to": [3, 2], "label": "이동 (2, 1)", "labelAt": [1.4, 1.8] },
            { "id": "a", "from": [1, 1], "to": [3, 1], "color": "warn", "label": "파고듦 (2, 0)", "labelAt": [2, 0.5] },
            { "id": "b", "from": [3, 1], "to": [3, 2], "color": "accent2", "label": "따라감 (0, 1)", "labelAt": [4.2, 1.5] }
          ]
        },
        {
          "caption": "파고드는 조각을 한 번 더 빼서 방향을 뒤집으면 (2, 1) − 2 × (2, 0) = (−2, 1). 들어온 각도 그대로 튕겨 나갑니다. Reflect(v, n) 의 결과입니다.",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [1, 1], "to": [3, 2], "color": "muted" },
            { "id": "b", "from": [3, 2], "to": [1, 3], "color": "warn", "label": "(−2, 1)", "labelAt": [1, 3.5] }
          ]
        }
      ]
    }
  ]
}
</script>
</x-vec>

**파고드는 조각을 한 번 빼면 미끄러지고, 두 번 빼면 튕깁니다.** 벽을 따라가는 조각은 두 경우 모두 그대로입니다.
튕긴 (−2, 1) 은 처음 (2, 1) 과 길이가 같습니다(둘 다 √5). 반사는 방향만 바꾸고 빠르기는 그대로 둡니다.

## Project · ProjectOnPlane · Reflect

유니티에는 세 가지가 함수로 있습니다. 벽이나 바닥처럼 평평하게 펼쳐진 면을 수학에서는 평면(Plane)이라고 부르고, `ProjectOnPlane` 은 평면 위로 투영한다는 뜻입니다.

| 함수 | 계산 | 위 예의 결과 |
| --- | --- | --- |
| `Vector3.Project(v, n)` | Dot(v, n) × n | n 방향 조각 (2, 0) |
| `Vector3.ProjectOnPlane(v, n)` | v − Project(v, n) | 면을 따라가는 조각 (0, 1) |
| `Vector3.Reflect(v, n)` | v − 2 × Dot(v, n) × n | 반사된 방향 (−2, 1) |

법선이 **길이 1 이어야 하는 이유**도 숫자로 보입니다. 같은 벽의 법선을 길이 2 인 (−2, 0) 으로 넣으면 Dot(v, n) = −4, 여기에 n 을 곱하면 (8, 0) 으로 진짜 조각 (2, 0) 의 네 배가 됩니다.
Dot(v, n) 을 구할 때 n 의 길이 2 가 한 번, 그 결과에 n 을 곱할 때 또 한 번 곱해졌기 때문이라, 2 × 2 = 4 로 나눠야 (2, 0) 이 됩니다.

`Project` 와 `ProjectOnPlane` 은 안에서 이 나눗셈을 해 주므로 n 이 길이 1 이 아니어도 됩니다.

`Reflect` 는 나누지 않고 위 식을 그대로 계산하므로, 직접 만든 법선이면 `.normalized` 를 붙여 넣습니다. `.normalized` 는 방향은 그대로 두고 길이만 1 로 맞춘 벡터(정규화)입니다. (3, 4) 는 길이 5 라서 5 로 나누면 (0.6, 0.8) 입니다.
레이캐스트(한 점에서 한 방향으로 보이지 않는 선을 쏴서, 처음 닿은 물체와 그 지점의 법선을 알려 주는 기능)가 돌려주는 `RaycastHit.normal` 은 이미 길이 1 입니다.

## 벽에서 멀어질 때는 빼지 않는다

미끄러지기를 코드로 옮길 때 자주 빠뜨리는 것이 있습니다. **벽으로 가고 있을 때만** 빼야 합니다.
벽에 붙은 채로 벽에서 멀어지는 쪽, 예를 들어 (−2, 1) 로 가려고 하면 Dot(v, n) = (−2) × (−1) + 1 × 0 = 2 로 양수입니다.
이때 무조건 빼면 벽에 달라붙고, 음수일 때만 빼면 그대로 떨어져 나갑니다.

<x-vec>
<script type="application/json">
{
  "label": "벽에서 떨어지려는 (−2, 1)",
  "view": [0, 0, 5, 4],
  "cases": [
    {
      "name": "무조건 뺄 때",
      "steps": [
        {
          "caption": "벽에 붙은 캐릭터가 벽에서 멀어지는 (−2, 1) 로 가려고 합니다. Dot(v, n) = 2, 양수입니다.",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [3, 1], "to": [1, 2], "label": "이동 (−2, 1)", "labelAt": [0.9, 1.4] }
          ]
        },
        {
          "caption": "그래도 ProjectOnPlane 을 하면 벽에서 멀어지는 조각 (−2, 0) 까지 지워져 (0, 1) 만 남습니다. 캐릭터가 벽에서 떨어지지 못하고 달라붙습니다.",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [3, 1], "to": [1, 2], "color": "muted" },
            { "id": "r", "from": [3, 1], "to": [3, 2], "color": "warn", "label": "(0, 1) 달라붙음", "labelAt": [3.9, 1.2] }
          ]
        }
      ]
    },
    {
      "name": "음수일 때만 뺄 때",
      "steps": [
        {
          "caption": "같은 이동 (−2, 1). Dot(v, n) = 2 는 0 보다 크니 벽으로 파고드는 중이 아닙니다.",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [3, 1], "to": [1, 2], "label": "이동 (−2, 1)", "labelAt": [0.9, 1.4] }
          ]
        },
        {
          "caption": "빼지 않고 그대로 둡니다. 캐릭터는 (−2, 1) 로 벽에서 떨어져 나갑니다.",
          "items": [
            { "id": "w", "type": "line", "from": [3, 0], "to": [3, 4], "color": "muted", "label": "벽", "labelAt": [3.5, 3.6] },
            { "id": "n", "from": [3, 2], "to": [2, 2], "color": "muted", "label": "n", "labelAt": [2.2, 2.4] },
            { "id": "v", "from": [3, 1], "to": [1, 2], "color": "accent2", "label": "(−2, 1) 그대로", "labelAt": [0.6, 2.4] }
          ]
        }
      ]
    }
  ]
}
</script>
</x-vec>

```csharp
// hit 은 이동 방향으로 쏜 레이캐스트의 결과. normal 은 벽이 바라보는 방향
float into = Vector3.Dot(move, hit.normal);

// 0 보다 작을 때만 벽으로 파고드는 중이다
if (into < 0f)
{
    move = Vector3.ProjectOnPlane(move, hit.normal);
}
```

| Dot(v, n) | 뜻 | 할 일 |
| --- | --- | --- |
| 음수 | 벽으로 들어감 | 파고드는 조각을 뺀다 |
| 0 | 벽과 나란히 감 | 그대로 |
| 양수 | 벽에서 멀어짐 | 그대로 (빼면 벽에 달라붙음) |

## 경사면을 따라 오르기

경사면에서도 같은 계산을 합니다. 이번에는 옆에서 본 그림이라 가로가 앞(z), 세로가 위(y) 입니다.
오르막 경사면의 법선을 n = (−0.6, 0.8) 이라고 합니다. 0.6² + 0.8² = 0.36 + 0.64 = 1 이라 길이 1 입니다.
앞으로 (5, 0) 만큼 가려고 하면, 수평으로 곧장 가서 경사면 속으로 파고듭니다.

| 단계 | 계산 | 결과 |
| --- | --- | --- |
| 1. 그림자 길이 | Dot((5, 0), n) = 5 × (−0.6) + 0 × 0.8 | −3 |
| 2. 파고드는 조각 | −3 × (−0.6, 0.8) | (1.8, −2.4) |
| 3. 경사면을 따라가는 조각 | (5, 0) − (1.8, −2.4) | **(3.2, 2.4)** |
| 4. 그 길이 | √(3.2² + 2.4²) = √(10.24 + 5.76) | **4** |

<x-vec>
<script type="application/json">
{
  "label": "앞으로 가는 이동을 경사면 위로",
  "view": [-1, -3, 6, 4],
  "steps": [
    {
      "caption": "오르막 경사면과 법선 n = (−0.6, 0.8). 앞으로 (5, 0) 을 그대로 가면 경사면 속으로 들어갑니다.",
      "items": [
        { "id": "s", "type": "line", "from": [0, 0], "to": [5, 3.75], "color": "muted", "label": "경사면", "labelAt": [4.4, 3.9] },
        { "id": "n", "from": [2, 1.5], "to": [1.4, 2.3], "color": "muted", "label": "n", "labelAt": [1.1, 2.6] },
        { "id": "v", "from": [0, 0], "to": [5, 0], "color": "warn", "label": "앞 (5, 0)", "labelAt": [4.4, -0.5] }
      ]
    },
    {
      "caption": "Dot((5, 0), n) = −3. 파고드는 조각은 −3 × n = (1.8, −2.4), 나머지는 (5, 0) − (1.8, −2.4) = (3.2, 2.4) 입니다.",
      "items": [
        { "id": "s", "type": "line", "from": [0, 0], "to": [5, 3.75], "color": "muted", "label": "경사면", "labelAt": [4.4, 3.9] },
        { "id": "n", "from": [2, 1.5], "to": [1.4, 2.3], "color": "muted", "label": "n", "labelAt": [1.1, 2.6] },
        { "id": "v", "from": [0, 0], "to": [5, 0], "color": "warn", "label": "앞 (5, 0)", "labelAt": [4.4, -0.5] },
        { "id": "p", "from": [0, 0], "to": [3.2, 2.4], "color": "accent2", "label": "따라감 (3.2, 2.4)", "labelAt": [1.2, 2.6] },
        { "id": "d", "type": "line", "from": [3.2, 2.4], "to": [5, 0], "color": "warn", "label": "파고듦 (1.8, −2.4)", "labelAt": [5.0, 1.6] }
      ]
    },
    {
      "caption": "경사면을 따라 비스듬히 올라가는 (3.2, 2.4) 가 남았습니다. 길이는 5 에서 4 로 줄었습니다. 비탈에서 걸음이 느려지는 셈입니다.",
      "items": [
        { "id": "s", "type": "line", "from": [0, 0], "to": [5, 3.75], "color": "muted", "label": "경사면", "labelAt": [4.4, 3.9] },
        { "id": "n", "from": [2, 1.5], "to": [1.4, 2.3], "color": "muted", "label": "n", "labelAt": [1.1, 2.6] },
        { "id": "v", "from": [0, 0], "to": [5, 0], "color": "muted" },
        { "id": "p", "from": [0, 0], "to": [3.2, 2.4], "color": "accent2", "label": "(3.2, 2.4) 길이 4", "labelAt": [1.2, 2.6] }
      ]
    }
  ]
}
</script>
</x-vec>

**투영하면 길이가 줄어듭니다.** 파고드는 조각을 잘라 냈으니 당연합니다. 경사가 가파를수록 많이 줄고, 직각인 벽이면 0 이 됩니다.
비탈에서도 평지와 같은 빠르기로 걷게 하려면, 투영한 뒤 길이를 1 로 맞추고(`normalized`) 빠르기를 다시 곱합니다.

```csharp
// 발밑 경사면의 법선. groundHit 은 아래로 쏜 레이캐스트의 결과
Vector3 slopeNormal = groundHit.normal;

Vector3 alongSlope = Vector3.ProjectOnPlane(moveDirection, slopeNormal);

// 비탈이어도 같은 빠르기
Vector3 velocity = alongSlope.normalized * speed;
```

다만 벽에서는 이렇게 길이를 되살리면 안 됩니다. 벽에 거의 정면으로 부딪혀 따라가는 조각이 아주 작은데, 그것을 길이 1 로 키우면 **벽에 닿기만 해도 옆으로 전속력으로 미끄러집니다.**
벽은 줄어든 길이 그대로, 경사면은 길이를 되살려서 쓰는 것이 보통입니다.

## 정리

- 법선은 면에 수직이고 바깥을 향하는 길이 1 인 방향입니다
- 이동 v 는 법선 방향 조각 `Dot(v, n) × n` 과, 면을 따라가는 나머지로 나뉩니다. n 이 길이 1 이면 내적이 곧 그림자 길이입니다
- 법선 방향 조각을 한 번 빼면 미끄러짐(`ProjectOnPlane`), 두 번 빼면 반사(`Reflect`)입니다. 반사는 길이를 지킵니다
- `Dot(v, n)` 이 음수일 때만 면으로 파고드는 중입니다. 양수일 때 빼면 벽에 달라붙습니다
- `Project` 와 `ProjectOnPlane` 은 법선 길이로 나눠 주고, `Reflect` 는 법선이 길이 1 이라고 보고 계산합니다
- 투영하면 길이가 줄어듭니다. 경사면은 다시 정규화해서 빠르기를 맞추고, 벽은 줄어든 대로 씁니다