Skip to main content

スプライン

ゲームで使うスプライン曲線

学習目標

この授業では、次の内容を扱います。

  • スプライン曲線がゲームのどこで使われるか説明できる
  • ベジェ曲線、エルミート曲線、Catmull-Rom曲線、Bスプラインの違いを説明できる
  • 制御点とパラメータ $t$ の意味を理解する
  • Catmull-Romスプライン上の座標をDirectXMathで求められる
  • 求めた座標を使って、オブジェクトを曲線上で移動できる

本時の流れ

時間 内容
0~5分 スプライン曲線を使う場面
5~15分 曲線の基本原理
15~25分 スプライン曲線の種類
25~35分 Catmull-Romスプラインの原理
35~50分 DirectXMathによる実装
50~57分 オブジェクトを曲線上で移動
57~60分 確認問題とまとめ

1. スプライン曲線は何に使うのか

ゲームでは、複数の点をなめらかにつなぎたい場面があります。

  • 敵を決められた経路に沿って移動させる
  • カメラをレールに沿って動かす
  • レーシングゲームのコースを作る
  • ジェットコースターや道路を作る
  • ミサイルや魔法の軌道を曲げる
  • UIを直線ではなく、曲線的に移動させる

点と点を直線で結ぶだけでも経路は作れます。

P0 -------- P1 -------- P2 -------- P3

しかし、方向が切り替わる場所で急に折れ曲がります。

P0
  \
   \
    P1 -------- P2
                  \
                   \
                    P3

スプライン曲線を使うと、複数の点の間をなめらかにつなげられます。

P0
  \____
       \____ P1 ____ P2
                      \____
                           \____ P3

スプライン曲線は、複数の制御点から、なめらかな曲線を作る方法です。


2. 曲線の基本原理

2.1 制御点

曲線の形を決めるために置く点を制御点と呼びます。

P0     P1     P2     P3
●      ●      ●      ●

制御点を動かすと、曲線の形も変わります。

2.2 パラメータ t

曲線上の位置は、$t$という値を使って求めます。

1区間の曲線では、通常は次の範囲を使います。

$$ 0 \leq t \leq 1 $$

  • $t=0$:区間の始点
  • $t=0.5$:区間の中間付近
  • $t=1$:区間の終点
t = 0.0                  t = 1.0
始点 ●----------------------● 終点
           t = 0.5

$t$を少しずつ増やしながら座標を計算すると、曲線上を移動できます。

2.3 補間と近似

スプライン曲線には、制御点を通るものと、通らないものがあります。

種類 説明
補間曲線 制御点を通る
近似曲線 制御点の近くを通るが、必ずしも制御点を通らない

ゲームの移動経路では、指定した点を通ってほしいことが多いため、補間曲線が扱いやすい場合があります。

2.4 曲線のなめらかさ

曲線のつなぎ目には、なめらかさの段階があります。

記号 状態
$C^0$連続 曲線の位置がつながっている
$C^1$連続 位置と進行方向がなめらかにつながっている
$C^2$連続 位置、進行方向、曲がり方までなめらかにつながっている

ゲーム内の移動では、少なくとも$C^1$連続であると、急に向きが変わりにくくなります。


3. スプライン曲線の種類

3.1 ベジェ曲線

ベジェ曲線は、始点、終点、曲がり方を決める制御点から作る曲線です。

3次ベジェ曲線では、4個の点を使います。

P0:始点
P1:始点側の曲がり方
P2:終点側の曲がり方
P3:終点

3次ベジェ曲線の式は次のとおりです。

$$ P(t)=(1-t)^3P_0+3(1-t)^2tP_1+3(1-t)t^2P_2+t^3P_3 $$

特徴は次のとおりです。

  • 始点と終点は必ず通る
  • 中間の制御点は通常通らない
  • 少ない点で形を調整しやすい
  • UIアニメーションや弾道の設計に向いている

3.2 エルミート曲線

エルミート曲線は、始点、終点、それぞれの接線ベクトルから作る曲線です。

始点 P0 + 始点の向き T0
終点 P1 + 終点の向き T1

特徴は次のとおりです。

  • 始点と終点を通る
  • 始点と終点での進行方向を直接指定できる
  • カメラやキャラクターの向きを細かく制御したい場合に使いやすい

3.3 Catmull-Romスプライン

Catmull-Romスプラインは、連続する制御点をなめらかに通る曲線です。

P0 ---- P1 ==== 曲線区間 ==== P2 ---- P3

4個の点を使って、$P_1$から$P_2$までの曲線を計算します。

特徴は次のとおりです。

  • 制御点を通る
  • 通過させたい場所を指定しやすい
  • 敵の移動経路やカメラレールに向いている
  • 4点の間隔が大きく違うと、曲線がふくらむことがある

ゲームの経路移動では、最初に学ぶスプラインとして扱いやすい曲線です。

3.4 Bスプライン

Bスプラインは、多数の制御点からなめらかな曲線を作る方法です。

特徴は次のとおりです。

  • 通常は制御点を通らない
  • 一部の制御点を動かしても、曲線全体が大きく変化しにくい
  • 長い道路、コース、地形の輪郭などに向いている

3.5 NURBS

NURBSは、Bスプラインを拡張した曲線です。

各制御点に重みを設定できます。

特徴は次のとおりです。

  • 円や円弧を正確に表現できる
  • CADや3Dモデリングソフトでよく使われる
  • ゲームの実行時処理よりも、モデルやコースの制作側で使われることが多い

3.6 種類の比較

曲線 制御点を通るか 主な用途
ベジェ曲線 始点と終点を通る UI、弾道、演出
エルミート曲線 始点と終点を通る 向きを細かく決める移動
Catmull-Rom 基本的に制御点を通る 敵の経路、カメラレール
Bスプライン 通常は通らない 道路、長いコース
NURBS 設定による CAD、モデリング

4. Catmull-Romスプラインの原理

4.1 4個の点を使う理由

Catmull-Romスプラインでは、次の4点を使います。

P0:P1へ入ってくる方向を決めるための点
P1:曲線区間の始点
P2:曲線区間の終点
P3:P2から出ていく方向を決めるための点

曲線が実際に通る区間は、$P_1$から$P_2$までです。

$P_0$と$P_3$は、$P_1$と$P_2$の前後の向きを決めるために使われます。

4.2 Catmull-Romの式

$$ \begin{aligned} P(t)=\frac{1}{2}\bigl[&2P_1 +(-P_0+P_2)t \ &+(2P_0-5P_1+4P_2-P_3)t^2 \ &+(-P_0+3P_1-3P_2+P_3)t^3\bigr] \end{aligned} $$

式をすべて暗記する必要はありません。

重要なのは次の3点です。

  • 4個の点を使う
  • 計算される区間は$P_1$から$P_2$
  • $t$を0から1へ増やすと、$P_1$から$P_2$へ進む

4.3 複数区間をつなぐ

制御点が6個ある場合を考えます。

P0  P1  P2  P3  P4  P5

計算できる区間は次のとおりです。

使用する4点 実際に描かれる区間
$P_0, P_1, P_2, P_3$ $P_1$から$P_2$
$P_1, P_2, P_3, P_4$ $P_2$から$P_3$
$P_2, P_3, P_4, P_5$ $P_3$から$P_4$

制御点が$n$個ある場合、曲線の区間数は次の式になります。

$$ N_{\mathrm{segment}} = n - 3 $$

4.4 最初と最後の点を通したい場合

Catmull-Romでは、最初の点と最後の点が補助点として使われます。

経路をAからDまで通したい場合は、端の点を重複させます。

A, A, B, C, D, D

これにより、曲線はAから始まり、Dで終わります。


5. DirectXMathによる実装

DirectXMathには、Catmull-Romスプラインを計算する関数が用意されています。

XMVectorCatmullRom(P0, P1, P2, P3, t)

4個の点と$t$を渡すと、曲線上の座標が返されます。

5.1 Spline.h

#pragma once

#include <DirectXMath.h>
#include <vector>

namespace Spline
{
    // 4点から、P1~P2の区間上の座標を求める
    DirectX::XMFLOAT3 CatmullRom(
        const DirectX::XMFLOAT3& p0,
        const DirectX::XMFLOAT3& p1,
        const DirectX::XMFLOAT3& p2,
        const DirectX::XMFLOAT3& p3,
        float t);

    // 複数の制御点から、経路全体の座標を求める
    // pathTの整数部分が区間番号、小数部分が区間内のtになる
    DirectX::XMFLOAT3 EvaluatePath(
        const std::vector<DirectX::XMFLOAT3>& controlPoints,
        float pathT);

    // 曲線の進行方向を求める
    DirectX::XMFLOAT3 EvaluateTangent(
        const std::vector<DirectX::XMFLOAT3>& controlPoints,
        float pathT);

    // 曲線を線で表示するための頂点座標を作る
    std::vector<DirectX::XMFLOAT3> BuildLinePoints(
        const std::vector<DirectX::XMFLOAT3>& controlPoints,
        int divisionPerSegment);
}

5.2 Spline.cpp

#include "Spline.h"

#include <algorithm>
#include <cmath>

using namespace DirectX;

namespace Spline
{
    XMFLOAT3 CatmullRom(
        const XMFLOAT3& p0,
        const XMFLOAT3& p1,
        const XMFLOAT3& p2,
        const XMFLOAT3& p3,
        float t)
    {
        t = std::clamp(t, 0.0f, 1.0f);

        const XMVECTOR v0 = XMLoadFloat3(&p0);
        const XMVECTOR v1 = XMLoadFloat3(&p1);
        const XMVECTOR v2 = XMLoadFloat3(&p2);
        const XMVECTOR v3 = XMLoadFloat3(&p3);

        const XMVECTOR result = XMVectorCatmullRom(
            v0,
            v1,
            v2,
            v3,
            t);

        XMFLOAT3 position;
        XMStoreFloat3(&position, result);
        return position;
    }

    XMFLOAT3 EvaluatePath(
        const std::vector<XMFLOAT3>& controlPoints,
        float pathT)
    {
        if (controlPoints.size() < 4)
        {
            return XMFLOAT3(0.0f, 0.0f, 0.0f);
        }

        const int segmentCount =
            static_cast<int>(controlPoints.size()) - 3;

        pathT = std::clamp(
            pathT,
            0.0f,
            static_cast<float>(segmentCount));

        int segmentIndex = static_cast<int>(std::floor(pathT));
        float localT = pathT - static_cast<float>(segmentIndex);

        // 経路の最後では、最後の区間のt=1として扱う
        if (segmentIndex >= segmentCount)
        {
            segmentIndex = segmentCount - 1;
            localT = 1.0f;
        }

        return CatmullRom(
            controlPoints[segmentIndex],
            controlPoints[segmentIndex + 1],
            controlPoints[segmentIndex + 2],
            controlPoints[segmentIndex + 3],
            localT);
    }

    XMFLOAT3 EvaluateTangent(
        const std::vector<XMFLOAT3>& controlPoints,
        float pathT)
    {
        if (controlPoints.size() < 4)
        {
            return XMFLOAT3(0.0f, 0.0f, 1.0f);
        }

        const float segmentCount =
            static_cast<float>(controlPoints.size() - 3);

        const float epsilon = 0.001f;
        const float prevT = std::max(0.0f, pathT - epsilon);
        const float nextT = std::min(segmentCount, pathT + epsilon);

        const XMFLOAT3 prevPosition =
            EvaluatePath(controlPoints, prevT);

        const XMFLOAT3 nextPosition =
            EvaluatePath(controlPoints, nextT);

        const XMVECTOR prevVector = XMLoadFloat3(&prevPosition);
        const XMVECTOR nextVector = XMLoadFloat3(&nextPosition);
        XMVECTOR direction = XMVectorSubtract(nextVector, prevVector);

        const float lengthSq =
            XMVectorGetX(XMVector3LengthSq(direction));

        if (lengthSq < 0.000001f)
        {
            return XMFLOAT3(0.0f, 0.0f, 1.0f);
        }

        direction = XMVector3Normalize(direction);

        XMFLOAT3 tangent;
        XMStoreFloat3(&tangent, direction);
        return tangent;
    }

    std::vector<XMFLOAT3> BuildLinePoints(
        const std::vector<XMFLOAT3>& controlPoints,
        int divisionPerSegment)
    {
        std::vector<XMFLOAT3> linePoints;

        if (controlPoints.size() < 4)
        {
            return linePoints;
        }

        divisionPerSegment = std::max(1, divisionPerSegment);

        const int segmentCount =
            static_cast<int>(controlPoints.size()) - 3;

        for (int segment = 0; segment < segmentCount; ++segment)
        {
            for (int i = 0; i <= divisionPerSegment; ++i)
            {
                // 区間のつなぎ目を二重に追加しない
                if (segment > 0 && i == 0)
                {
                    continue;
                }

                const float localT =
                    static_cast<float>(i) /
                    static_cast<float>(divisionPerSegment);

                const float pathT =
                    static_cast<float>(segment) + localT;

                linePoints.push_back(
                    EvaluatePath(controlPoints, pathT));
            }
        }

        return linePoints;
    }
}

5.3 制御点を用意する

開始点と終了点を通すため、端の点を重複させます。

std::vector<DirectX::XMFLOAT3> controlPoints =
{
    // 開始点を2回入れる
    DirectX::XMFLOAT3(-6.0f, 0.0f,  0.0f),
    DirectX::XMFLOAT3(-6.0f, 0.0f,  0.0f),

    DirectX::XMFLOAT3(-3.0f, 2.0f,  3.0f),
    DirectX::XMFLOAT3( 0.0f, 0.0f,  6.0f),
    DirectX::XMFLOAT3( 3.0f, 3.0f,  3.0f),
    DirectX::XMFLOAT3( 6.0f, 0.0f,  0.0f),

    // 終了点を2回入れる
    DirectX::XMFLOAT3( 6.0f, 0.0f,  0.0f)
};

この場合、区間数は次のようになります。

$$ 7 - 3 = 4 $$

5.4 曲線を表示するための点を作る

std::vector<DirectX::XMFLOAT3> linePoints =
    Spline::BuildLinePoints(controlPoints, 20);

20は、1区間を20分割するという意味です。

値を大きくすると曲線がなめらかに見えますが、頂点数が増えます。

分割数 見た目 頂点数
4 角ばって見える 少ない
10 ややなめらか 普通
20 十分なめらか やや多い
50 とてもなめらか 多い

6. DirectX 11で曲線を線として描画する

曲線そのものは、DirectXの特別な図形ではありません。

曲線上の点を多数求め、それらをLINESTRIPでつないで表示します。

計算した点
●---●---●---●---●---●---●

表示方法
D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP

6.1 頂点構造体

struct SplineLineVertex
{
    DirectX::XMFLOAT3 position;
    DirectX::XMFLOAT4 color;
};

6.2 頂点バッファを作る関数

#include <d3d11.h>
#include <DirectXMath.h>
#include <vector>

bool CreateSplineLineVertexBuffer(
    ID3D11Device* device,
    const std::vector<DirectX::XMFLOAT3>& linePoints,
    ID3D11Buffer** vertexBuffer,
    UINT* vertexCount)
{
    if (device == nullptr ||
        vertexBuffer == nullptr ||
        vertexCount == nullptr ||
        linePoints.size() < 2)
    {
        return false;
    }

    std::vector<SplineLineVertex> vertices;
    vertices.reserve(linePoints.size());

    for (const DirectX::XMFLOAT3& point : linePoints)
    {
        SplineLineVertex vertex;
        vertex.position = point;
        vertex.color = DirectX::XMFLOAT4(
            1.0f,
            1.0f,
            0.0f,
            1.0f);

        vertices.push_back(vertex);
    }

    D3D11_BUFFER_DESC bufferDesc{};
    bufferDesc.Usage = D3D11_USAGE_DEFAULT;
    bufferDesc.ByteWidth =
        static_cast<UINT>(
            sizeof(SplineLineVertex) * vertices.size());
    bufferDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;

    D3D11_SUBRESOURCE_DATA initialData{};
    initialData.pSysMem = vertices.data();

    const HRESULT result = device->CreateBuffer(
        &bufferDesc,
        &initialData,
        vertexBuffer);

    if (FAILED(result))
    {
        return false;
    }

    *vertexCount = static_cast<UINT>(vertices.size());
    return true;
}

6.3 描画する関数

頂点シェーダ、ピクセルシェーダ、入力レイアウト、定数バッファは、通常の色付き3D描画と同じものを使用します。

void DrawSplineLine(
    ID3D11DeviceContext* context,
    ID3D11Buffer* vertexBuffer,
    UINT vertexCount)
{
    if (context == nullptr ||
        vertexBuffer == nullptr ||
        vertexCount < 2)
    {
        return;
    }

    const UINT stride = sizeof(SplineLineVertex);
    const UINT offset = 0;

    context->IASetVertexBuffers(
        0,
        1,
        &vertexBuffer,
        &stride,
        &offset);

    context->IASetPrimitiveTopology(
        D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP);

    context->Draw(vertexCount, 0);
}

曲線の表示手順は次のとおりです。

1. 制御点を用意する
2. BuildLinePointsで曲線上の点を作る
3. 点から頂点バッファを作る
4. LINESTRIPで描画する

7. オブジェクトを曲線上で移動する

7.1 移動用の変数

float pathT = 0.0f;
float pathSpeed = 0.5f;

ここでのpathSpeedは、1秒間に何区間進むかを表します。

0.5fの場合、1区間を2秒で進みます。

7.2 Update処理

void UpdateSplineMove(
    float deltaTime,
    const std::vector<DirectX::XMFLOAT3>& controlPoints,
    float& pathT,
    DirectX::XMFLOAT3& position,
    DirectX::XMFLOAT3& forward)
{
    if (controlPoints.size() < 4)
    {
        return;
    }

    const float segmentCount =
        static_cast<float>(controlPoints.size() - 3);

    const float pathSpeed = 0.5f;
    pathT += pathSpeed * deltaTime;

    if (pathT > segmentCount)
    {
        pathT = 0.0f;
    }

    position = Spline::EvaluatePath(
        controlPoints,
        pathT);

    forward = Spline::EvaluateTangent(
        controlPoints,
        pathT);
}

positionをワールド行列の平行移動に使うと、オブジェクトが曲線上を移動します。

forwardは曲線の進行方向です。

進行方向からY軸回転角を求める場合は、atan2を使います。

float yaw = std::atan2(
    forward.x,
    forward.z);

これにより、オブジェクトを曲線の進行方向へ向けられます。

7.3 ワールド行列の例

DirectX::XMMATRIX rotation =
    DirectX::XMMatrixRotationY(yaw);

DirectX::XMMATRIX translation =
    DirectX::XMMatrixTranslation(
        position.x,
        position.y,
        position.z);

DirectX::XMMATRIX world =
    rotation * translation;

使用しているエンジンの行列規則に合わせて、回転行列と平行移動行列の順番を確認してください。


8. tを一定速度で増やしても、移動速度は一定にならない

次のように$t$を一定量ずつ増やしても、ワールド座標上の移動距離は一定になりません。

pathT += speed * deltaTime;

曲線の場所によって、同じ$t$の変化でも移動距離が異なるためです。

tの増加量は同じ

●--●------●-●----------●
短い  長い   短い   長い

そのため、速度が少し速くなったり遅くなったりします。

一定速度で移動させるには、曲線を細かく分割して各点間の距離を調べ、距離から$t$を逆算する方法を使います。

この方法を弧長パラメータ化と呼びます。

最初の実装では$t$による移動を使い、速度の変化を確認します。


9. よくある失敗

制御点が4個未満

Catmull-Romスプラインは最低4個の点が必要です。

if (controlPoints.size() < 4)
{
    return;
}

最初の点から曲線が始まらない

Catmull-Romは$P_1$から$P_2$を描きます。

最初と最後の点を通したい場合は、端の点を重複させます。

A, A, B, C, D, D

曲線が制御点の外側へ大きくふくらむ

制御点の間隔が大きく違う場合や、急な角度で並んでいる場合に発生します。

対策は次のとおりです。

  • 制御点の間隔をそろえる
  • 急な角を作らない
  • 制御点を追加する
  • 必要に応じてCentripetal Catmull-Romを使う

分割数が少なく、曲線が角ばる

表示用の分割数を増やします。

Spline::BuildLinePoints(controlPoints, 20);

ただし、分割数を増やすと頂点数も増えます。

オブジェクトが逆向きになる

モデルの正面方向と、プログラム上の前方向が一致していない可能性があります。

たとえば、モデルが+Z方向を正面としていない場合は、追加の回転補正が必要です。


10. 練習

練習1 制御点を変更する

次の制御点を変更し、曲線の形がどのように変化するか確認してください。

DirectX::XMFLOAT3(-3.0f, 2.0f, 3.0f)

y5.0fにすると、曲線はどのように変化するでしょうか。

練習2 分割数を変更する

次の値を順番に試してください。

Spline::BuildLinePoints(controlPoints, 4);
Spline::BuildLinePoints(controlPoints, 10);
Spline::BuildLinePoints(controlPoints, 20);
Spline::BuildLinePoints(controlPoints, 50);

見た目となめらかさの違いを確認してください。

練習3 移動速度を変更する

float pathSpeed = 0.5f;

次の値に変更してください。

0.2f
1.0f
2.0f

pathSpeed = 2.0fでは、1秒間に何区間進むでしょうか。

練習4 進行方向へ向ける

EvaluateTangentで求めた方向からatan2を使い、モデルを曲線の進行方向へ向けてください。

float yaw = std::atan2(
    forward.x,
    forward.z);

発展練習 往復移動

終点まで進んだら、始点へ瞬間移動せず、逆方向に戻るようにしてください。

ヒントとして、移動方向を表す変数を用意します。

float pathDirection = 1.0f;

終点で-1.0f、始点で1.0fへ切り替えます。


11. 確認問題

問題1

制御点を必ず通る曲線を何と呼びますか。

  • A. 近似曲線
  • B. 補間曲線
  • C. 直線補間
  • D. 法線曲線

問題2

Catmull-Romスプラインで、P0、P1、P2、P3を使った場合、実際に描かれる区間はどこからどこまでですか。

  • A. P0からP1
  • B. P0からP3
  • C. P1からP2
  • D. P2からP3

問題3

DirectX 11で、計算した曲線上の点を順番につないで表示するプリミティブトポロジはどれですか。

  • A. TRIANGLELIST
  • B. POINTLIST
  • C. LINESTRIP
  • D. TRIANGLESTRIP

問題4

制御点が8個ある場合、Catmull-Romスプラインの区間数はいくつですか。

$$ 8-3=5 $$

問題5

$t$を一定速度で増加させても、ワールド座標上の速度が一定にならない理由を説明してください。


12. 確認問題の解答

問題 解答
1 B. 補間曲線
2 C. $P_1$から$P_2$
3 C. LINESTRIP
4 5区間
5 同じ$t$の変化量でも、曲線上で進む距離が場所によって異なるため

13. まとめ

  • スプライン曲線は、複数の点をなめらかにつなぐために使う
  • 曲線の形は制御点によって決まる
  • 曲線上の位置はパラメータ$t$で求める
  • Catmull-Romスプラインは制御点を通るため、ゲームの経路移動に使いやすい
  • Catmull-Romでは、4点から$P_1$~$P_2$の区間を計算する
  • DirectXMathではXMVectorCatmullRomを使える
  • 曲線は細かい点に分割し、LINESTRIPで表示できる
  • 進行方向は、少し前後の位置を引き算して求められる
  • $t$を一定速度で増やしても、実際の移動速度は一定にならない

次に扱う内容

  • 弧長テーブルを使った一定速度移動
  • 制御点をマウスで編集する経路エディタ
  • Catmull-Romスプラインを使ったカメラレール
  • スプライン上の向き、右方向、上方向の計算
  • 閉じたスプラインによる周回コース