[OpenGL] - 지형 생성 알고리즘

gest·2026년 5월 5일

OpenGL

목록 보기
5/11

기존의 프로젝트는 평면이었다.

이대로는 게임으로서 재미가 없을 것 같아서, 마인크래프트처럼 울퉁불퉁한 지형을 랜덤으로 생성하기로 했다.


노이즈 함수

FBM

Fractal Brownian Motion
여러 스케일의 노이즈를 합쳐서 자연스러운 지형 형태를 만드는 기법이다. 큰 산맥부터 작은 굴곡까지 한 번에 표현할 수 있다.

static float fbm(float x, float z, int seed, int octaves)
{   
    float total = 0.0f;
    float freq = 1.0f; // 주파수 (얼마나 촘촘한 노이즈인가)
    float amp = 1.0f;  // 진폭 (얼마나 영향력이 큰가)
    float maxAmp = 0.0f;  // 진폭 합계 (정규화용)

    for (int i = 0; i < octaves; ++i)
    {
        total += valueNoise(x * freq, z * freq, seed + i) * amp; //대략 노이즈를 주고
        maxAmp += amp;
        amp *= 0.5f;
        freq *= 2.0f;
    }
    return total / maxAmp; // [0,1]
}

잠깐

amp *= 0.5f;
freq *= 2.0f;

여기서 왜 매번 freq와 amp를 곱할까?

이 두 줄이 FBM의 핵심이다. 매 옥타브마다 주파수는 2배로, 진폭은 절반으로 만들어서, 점점 더 촘촘하지만 영향력은 작은 노이즈를 쌓아간다.

첫 옥타브: 큰 산맥 (천천히 변하고 영향 큼)
다음 옥타브: 언덕 (좀 더 촘촘, 영향 절반)
그 다음: 작은 둔덕
마지막: 잔 굴곡

이렇게 다양한 스케일이 겹쳐야 자연스러운 지형이 나온다.
한번 버전을 비교해보자

곱한 버전

곱하지 않은 버전

딱봐도 뭔가 차이가 나지 않냐? 즉 처음에는 기울기가 크게크게 만들고 나중에 amp freq값에 따라 변하는 강도를 세심하게 작게작게 만든다.


Value Noise


FBM이 부르는 한 옥타브의 노이즈 함수다. 정수 격자 모서리에 랜덤 값을 박아두고, 그 사이의 점은 4 모서리 값으로부터 보간해서 계산한다.


//Value noise: 정수 격자 4점에서 [0,1] 보간
static float valueNoise(float x, float z, int seed)
{
    int xi = (int)floorf(x);
    int zi = (int)floorf(z);
    float xf = x - xi;
    float zf = z - zi;

    //랜덤같은 높이
    float a = hash01(xi,     zi,     seed);
    float b = hash01(xi + 1, zi,     seed);
    float c = hash01(xi,     zi + 1, seed);
    float d = hash01(xi + 1, zi + 1, seed);

    //x^3 + x^2같은 
    float u = smoothstep01(xf);
    float v = smoothstep01(zf);

    float ab = a + (b - a) * u;
    float cd = c + (d - c) * u;
    return ab + (cd - ab) * v;
}

smoothstep

smoothstep은 격자 사이를 매끄럽게 잇기 위해 t가 아닌 -t^3 + 2t^2 곡선을 사용한다.

한번 그래프를 살펴보자


이 곡선의 핵심은 t=0과 t=1에서 미분값(기울기)이 0이라는 점이다. 처음에 천천히 시작해서 중간에 빨라졌다가 끝에서 다시 천천히 멎는다.

한번 직접 비교해보자.


return t t (3.0f - 2.0f * t) 버전


return t 버전
직선 버전은 기울기가 날카롭고 부자연스러운 부분이 있다. smoothstep을 쓴 쪽이 훨씬 매끄럽다.

이중 선형 보간

float ab = a + (b - a) * u;
float cd = c + (d - c) * u;
return ab + (cd - ab) * v;


격자 4 모서리 값(a, b, c, d)을 가중치 u, v로 섞어서 격자 내부 임의의 점 값을 계산한다. 단계로 보면:

위쪽 변에서 a와 b 사이 보간 → ab
아래쪽 변에서 c와 d 사이 보간 → cd
위에서 구한 ab와 cd를 z방향으로 보간 → 최종 값

쉽게 말해 격자 내 위치(소수점 좌표)에 맞는 높이를 모서리 값들의 가중평균으로 구하는 것이다. 단, 가중치가 y = x 같은 직선이 아니라 smoothstep 곡선을 통과한 값이라 결과가 매끄럽다.


지형 충돌 처리

Terrian Collider
그러나 실제로 플레이하면 기존의 BoxCollider만 있어서 눈 속에 파묻힌 쥐가 보일 것이다. 즉, 랜덤으로 설정했다면 그 값에 맞춰 collider를 지정해야 하지만 기존의 BoxCollider로는 부족하다. 새로 TerrainCollider를 만들어 보자.

bool TerrainCollider::overlaps(Collider* other)
{
    if (!other || !terrain) return false;

    glm::vec3 oMin = other->worldMin();
    glm::vec3 oMax = other->worldMax();

    glm::vec3 tMin = worldMin();
    glm::vec3 tMax = worldMax();

    //1) xz 평면에서 지형 영역과 안 겹치면 false. 마치 동 떨어진 지구와 명왕성
    if (oMax.x < tMin.x || oMin.x > tMax.x) return false;
    if (oMax.z < tMin.z || oMin.z > tMax.z) return false;

    //반례) 만약 봉우리가 0 ~ 100인 지형에서 y가 50으로 비둘기가 날라다닌다면? 기존의 AABB에서는 충돌됨
    //2) 박스 바닥이 지형의 최대 가능 높이보다 위면 컷
    if (oMin.y > tMax.y) return false;

    //3) 박스 풋프린트를 지형 영역으로 클램프 후 NxN 그리드 샘플
    float x0 = std::max(oMin.x, tMin.x);
    float x1 = std::min(oMax.x, tMax.x);
    float z0 = std::max(oMin.z, tMin.z);
    float z1 = std::min(oMax.z, tMax.z);

    int n = samplesPerSide;
    float maxHeight = -std::numeric_limits<float>::infinity();
    for (int iz = 0; iz < n; ++iz)
    {
        float v = (n == 1) ? 0.5f : float(iz) / float(n - 1);
        float wz = z0 + (z1 - z0) * v;
        for (int ix = 0; ix < n; ++ix)
        {
            float u = (n == 1) ? 0.5f : float(ix) / float(n - 1);
            float wx = x0 + (x1 - x0) * u;

            float h = terrain->getHeightAt(wx, wz);
            if (h > maxHeight) maxHeight = h;
        }
    }

    if (oMin.y >= maxHeight) return false;

    //4) 위치 보정: 상대(움직이는) 오브젝트를 지형 표면 위로 끌어올린다.
    //   addRepulsion은 속도만 0으로 만들고 위치는 안 고치므로, heightfield는
    
    
    //   **여기서 직접 박스를 표면에 붙여줘야 언덕을 자연스럽게 오를 수 있다.**
    GameObject* obj = other->getOwner();
    if (obj && !obj->getIsStatic())
    {
        float penetration = maxHeight - oMin.y;
        glm::vec3 p = obj->getPosition();
        p.y += penetration;            // 박스 바닥이 maxHeight에 닿도록
        obj->setPosition(p);

        glm::vec3 v = obj->getVelocity();
        if (v.y < 0.0f) v.y = 0.0f;    // 아래로 향한 속도만 제거 (점프는 보존)
        obj->setVelocity(v);
    }
    return true;
}

(1) AABB (xz)

if (oMax.x < tMin.x || oMin.x > tMax.x) return false;
if (oMax.z < tMin.z || oMin.z > tMax.z) return false;

기존의 AABB와 같지만, 한 가지 다른 점은 y축은 검사하지 않는다는 것이다.
왜 그럴까?

예를 들어 산맥 위를 지나가는 비둘기가 있다고 하자.
만약 기존처럼 XYZ를 모두 검사한다면, 위의 경우 y값은 AABB 안에 포함되게 된다.
즉, 물리적으로 충돌한 것으로 판정되어 true 값을 반환한다. 하지만 실제로 비둘기는 물리적으로 충돌하지 않고 날아다닌다. 바로 그 차이 때문에 y값은 별도로 처리한다.

(2) 최고점 상한선 검사

비둘기가 지형의 봉우리보다 높게 난다면 충돌하지 않는다.

if (oMin.y > tMax.y) return false;

자세히 보면 오른쪽 식만 검사하고 왼쪽 식은 검사하지 않는다.

(3) 박스 풋프린트

가장 핵심적인 단계다. 오브젝트가 밟고 있는 바닥 영역(풋프린트)을 그리드로 쪼개어, 실제 지형의 최고 높이를 찾아낸다.
한 마디로 밟을 수 있는 발의 높이를 구하는 공식이다.

float x0 = std::max(oMin.x, tMin.x);
float x1 = std::min(oMax.x, tMax.x);
float z0 = std::max(oMin.z, tMin.z);
float z1 = std::min(oMax.z, tMax.z);

int n = samplesPerSide;
float maxHeight = -std::numeric_limits<float>::infinity(); //가장 작은 값

for (int i = 0; i < n; ++i)
{
    float v = (n == 1) ? 0.5f : float(i) / float(n - 1); //0 0.5 1

    float wz = z0 + (z1 - z0) * v;

    for (int j = 0; j < n; ++j)
    {
        float u = (n == 1) ? 0.5f : float(j) / float(n - 1); //0 0.5 1
        float wx = x0 + (x1 - x0) * u;

        float h = terrain->getHeightAt(wx, wz); //그 위치의 높이를 구해라.

        if (h > maxHeight) 
            maxHeight = h;
    }
}

하나식 해결해보자.

float x0 = std::max(oMin.x, tMin.x);
float x1 = std::min(oMax.x, tMax.x);
float z0 = std::max(oMin.z, tMin.z);
float z1 = std::min(oMax.z, tMax.z);

이 코드는 교집합의 좌표 4개를 구한다.

🟡   🟡   🟡
🟡   🟡   🟡
🟡   🟡   🟡
만약 n=3이라면, 위 그림처럼 밟을 수 있는 바닥을 3x3 (총 9곳)으로 나누어 찔러본다. 그리고 그중 가장 높게 튀어나온 돌부리(maxHeight)를 찾는다.
만약 내 발(oMin.y)이 그 돌부리보다 아래에 있다면, 땅에 파묻힌 것이므로 최종 보정 단계로 넘어간다.

포지션 복원(4)

//오브젝트가 정적이지 않은 경우. (고정된 물체가 아닌 경우)
if (obj && !obj->getIsStatic())
{
    float penetration = maxHeight - oMin.y; //y 차이값
    glm::vec3 p = obj->getPosition();
    p.y += penetration;            // 박스 바닥이 maxHeight에 닿도록
    obj->setPosition(p);

    //밑으로 가는 속력 제거 (점프는 살림)
    glm::vec3 v = obj->getVelocity();
    if (v.y < 0.0f) v.y = 0.0f;
    obj->setVelocity(v);
}
return true;

파묻힌 깊이만큼 캐릭터를 위로 올려주고, 아래로 떨어지던 중력 가속도를 0으로 초기화해 준다. 단, 위로 향하는 속도는 살려두어 점프 기능이 정상적으로 작동하게 해준다. 이 코드가 있어야 캐릭터가 경사진 언덕을 부드럽게 타고 오를 수 있다.

추가 Terrain 코드

당연하겠지만 Collider를 추가해야한다.
추가로 TerrianCollider 매개변수인 AABB의 min, max값을 추가하자.

//지형 코스 AABB — Y 하한은 박스가 깊이 박혀도 reject 안 되도록 충분히 낮게
glm::vec3 localMin(-half, -1e6f, -half);
glm::vec3 localMax( half, heightScale, half);

TerrainCollider* col = new TerrainCollider(this, this, localMin, localMax, /*samplesPerSide=*/3);

Mouse.cpp 개선

이렇게 하다보니 Mouse 코드에서도 개선해야할 거 같다.

const int stride = 9;
float minX =  std::numeric_limits<float>::infinity();
float minY =  std::numeric_limits<float>::infinity();
float minZ =  std::numeric_limits<float>::infinity();
float maxX = -std::numeric_limits<float>::infinity();
float maxY = -std::numeric_limits<float>::infinity();
float maxZ = -std::numeric_limits<float>::infinity();
for (int i = 0; i < nVerts; ++i)
{
    float x = verts[i * stride + 0];
    float y = verts[i * stride + 1];
    float z = verts[i * stride + 2];
    if (x < minX) minX = x; if (x > maxX) maxX = x;
    if (y < minY) minY = y; if (y > maxY) maxY = y;
    if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
}

메쉬를 불러올 때, 전체 정점(Vertex) 배열을 순회하며 가장 끝부분의 좌표들을 찾아 min, max 값을 갱신하는 코드를 추가했다.


결과


잘 된다.


추가


노멀 맵 게시물을 작성하다 보니 쥐의 그림자가 보이지 않는다. 바닥의 그림자는 구현되어 있는데, 왜 쥐의 그림자만 구현되지 않은 걸까?
알고 보니 저 그림자는 디퓨즈(Diffuse), 즉 난반사 광원에 의한 효과였다. 난반사로 표현된 부분만 보고 그림자 시스템이 제대로 작동하고 있다고 착각한 것이다.

그래서 일단 그림자 버퍼를 확인할 수 있는 fs 코드를 적어보자.

//.fs
#version 330 core
out vec4 FragColor;

in vec3 Normal;  
in vec3 FragPos;  
in vec2 TexCoord;
in vec4 FragPosLightSpace;

uniform sampler2D texture1;
uniform sampler2D shadowMap;
//uniform sampler2D normalMap;

uniform vec3 lightPos; 
uniform vec3 viewPos; 
uniform vec3 lightColor;

void main()
{
    vec3 projCoords = FragPosLightSpace.xyz / FragPosLightSpace.w;
    projCoords = projCoords * 0.5 + 0.5;

    float closestDepth = texture(shadowMap, projCoords.xy).r;
    FragColor = vec4(closestDepth, closestDepth, closestDepth, 1.0);
}

만약 흰색이라면 depthmap이 적용 안된거다.

또는

    depthProcessing(depthMapFBO, depthMap); //그림자 처리

    glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
    GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
    std::cout << "FBO status: " << std::hex << status
        << " (complete=" << GL_FRAMEBUFFER_COMPLETE << ")" << std::endl;
    std::cout << "depthMap=" << depthMap << " depthMapFBO=" << depthMapFBO << std::endl;
    glBindFramebuffer(GL_FRAMEBUFFER, 0);

그러나 depthMap은 잘 작동이 되었고 저기 멀리서 쥐 그림자가 찍혔다. 즉 전에 배웠던 그림자 최적화에 문제가 생긴 거 같다.

0개의 댓글