Hi yoahn 개발블로그

[42Seoul/Cub3d] #2 cub3D 벡터를 이용해 화면 그리기 본문

42 SEOUL/배운 것들 정리

[42Seoul/Cub3d] #2 cub3D 벡터를 이용해 화면 그리기

hi._.0seon 2021. 3. 27. 19:16
반응형

먼저, 코드를 짜면서 대충 감을 익히고 짠 코드와 설명을 비교하면서 공부하려고 한다.

참고한 자료 >

github.com/365kim/raycasting_tutorial/blob/master/3_untextured_raycaster.md

 

365kim/raycasting_tutorial

(한글) 레이캐스팅 튜토리얼 번역. Contribute to 365kim/raycasting_tutorial development by creating an account on GitHub.

github.com

typedef struct  s_img
{
    void        *img;
    int         *data;

    int         img_width;
    int         img_height;
    int         size_l;
    int         bpp;
    int         endian;
}               t_img;

typedef struct  s_game
{
    void        *mlx;
    void        *win;
    t_img       img;
    int         buf[screenHeight][screenWidth];
    int         **texture;

    double      posX;
    double      posY;
    double      dirX;
    double      dirY;
    double      planeX;
    double      planeY;

    double      moveSpeed;
    double      rotSpeed;
}               t_game;

int main(int argc, char *argv[])
{
    t_game game;
    game.posX = 22.0; // x and y start position
    game.posY= 11.5; // player start position vector
    game.dirX = -1.0;
    game.dirY = 0.0; // player 초기 direction vector
    game.planeX = 0; //the 2d raycaster version of camera plane
    game.planeY = 0.66; // player 카메라 평면
    game.moveSpeed = 0.05; // 이동 속도
    game.rotSpeed = 0.05; // 회전 속도 rotate speed

먼저, 변수들을 선언하고 초기화한다.

posX, posY : 플레이어의 초기 위치를 나타내는 변수

dirX, dirY : 플레이어의 방향벡터

planeX, planeY : 플레이어의 카메라 평면

 - 카메라 평면은 플레이어의 방향벡터와 수직이어야 한다.

moveSpeed : 플레이어 이동 속도

rotSpeed : 플레이어 회전 속도 (rotate)

 

time, oldTime 은 프레임 계산하는데 사용 -> 아직 안씀

 

    window_init(); // window 생성
    
    // 변수 초기화
    for (int i = 0;i < screenHeight;i++)
    {
        for (int j = 0;j < screenWidth;j++)
            game.buf[i][j] = 0;
    }
    // 변수 할당
    if (!(game.texture = (int**)malloc(sizeof(int*) * 8)))
        return -1;
    for (int i = 0;i < 8;i++)
    {
        if (!(game.texture[i] = (int*)malloc(sizeof(int)*(texHeight * texWidth))))
            return -1;
    }
    
    for (int i = 0;i < 8;i++)
    {
        for (int j = 0;j < texHeight * texWidth;j++)
            game.texture[i][j] = 0;
    }
    
    load_texture(&game);
    
    game.img.img = mlx_new_image(game.mlx. screenWidth, screenHeight);
    game.img.data = (int*)mlx_get_data_addr(game.img.img, &game.img.bpp, &game.img.size_l, &game.img.endian);
    
    mlx_hook(game.win, X_EVENT_KEY_PRESS, &key_press, &game);
    mlx_loop_hook(game.mlx, &main_loop, &game);
    mlx_loop(game.mlx);
}

load_texture(&game);

game.texture[] 배열 변수에 2차원 이미지 정보를 1차원 배열에 저장

 

mlx_new_image(game.mlx, screenWidth, screeenHeight);

새로운 이미지를 메모리에 생성

 

mlx_get_data_addr(game.img.img, &game.img.bpp, &game.img.size_l, &game.img.endian);

새로운 이미지를 생성하여 정보를 반환

user가 이미지 정보를 가지고 수정할 수 있다.

 

int     main_loop(t_game *game)
{
    for (int x = 0; x < screenWidth; x++)
    {
        double cameraX = 2 * x / (double)screenWidth - 1;
        double rayDirX = game->dirX + game->planeX * cameraX;
        double rayDirY = game->dirY + game->planeY * cameraX;

        int mapX = (int)game->posX; // 현재 광선의 위치
        int mapY = (int)game->posY;

        double sideDistX, sideDistY;

        double deltaDistX = fabs(1 / rayDirX);
        double deltaDistY = fabs(1 / rayDirY);
        double perpWallDist;

        int stepX;
        int stepY;

        int hit = 0;
        int side; // NS or EW hit?

for 문을 돌면서 화면에 세로줄을 왼쪽 끝 부터 한줄씩 그린다.

 

cameraX : for문의 x 값 (화면의 수직선)이 카메라 평면에서 차지하는 x 좌표

( 범위: -1 ~ 1 )

rayDirX, rayDirY : 광선의 방향벡터

광선의 방향 = ( 방향벡터 ) + ( 카메라 평면 x 배수 )

 

sideDistX : 시작점 ~ 첫번째 x 축 면을 만나는 점까지의 거리

sideDistY : 시작점 ~ 첫번째 y 축 면을 만나는 점까지의 거리

deltaDistX : 첫번째 x 축 면 ~ 바로 다음 x 면까지의 광선의 이동 거리 ( x += 1 )

deltaDistY : 첫번째 y 축 면 ~ 바로 다음 y 면까지의 광선의 이동 거리 ( y += 1 )

 

perpWallDist : 나중에 광선의 이동거리를 계산하는데 사용

 

DDA 알고리즘은 반복문을 실행할때마다 x / y 방향으로 딱 한칸씩 점프한다.

(광선의 방향에 따라 건너뛰는 방향이 달라진다 -> stepX, stepY 에 +1 또는 -1 로 저장됨)

	if (rayDirX < 0)
        {
            stepX = -1;
            sideDistX = (game->posX - mapX) * deltaDistX;
        }
        else
        {
            stepX = 1;
            sideDistX = (mapX + 1.0 - game->posX) * deltaDistX;
        }
        if (rayDirY < 0)
        {
            stepY = -1;
            sideDistY = (game->posY - mapY) * deltaDistY;
        }
        else
        {
            stepY = 1;
            sideDistY = (mapY + 1.0 - game->posY) * deltaDistY;
        }

step의 값은 광선의 방향이 음수이면 -1, 양수이면 +1로 설정

rayDirX가 0이면 stepX는 사용되지 않음

 

        while (hit == 0)
        {
            if (sideDistX < sideDistY)
            {
                sideDistX += deltaDistX;
                mapX += stepX;
                side = 0;
            }
            else
            {
                sideDistY += deltaDistY;
                mapY += stepY;
                side = 1;
            }
            if (worldMap[mapX][mapY] > 0)
                hit = 1;
        }

벽에 부딪힐 때까지 매번 한 칸씩 광선을 이동시키는 루프

hit 이 1이 되면 벽에 부딪혔음을 의미, 반복문을 종료

 

stepX를 사용하면 x방향으로 한칸, stepY를 사용하면 y 방향으로 한칸 점프

광선의 방향이 x 축 방향과 완전히 일치하면, 반목문을 돌 때 x 방향으로만 한 칸씩 점프

광선의 y축 방향과 일치하면 x방향으로는 점프하지 않음

 

광선이 점프할 때마다 sideDistX, sideDistY에 deltaDistX, deltaDistY가 더해지면서 값이 업데이트된다.

광선이 점프할 때마다 mapX, mapY는 stepX, Y 값이 더해지면서 값이 업데이트 된다.

 

광선이 벽에 부딪히면 루프가 종료된다.

이 때 side 값이 0이면 x면 벽에 부딪힌 것이고, 1이면 y면 벽에 부딪힌 것을 알 수 있다.

mapX, mapY 값으로 어떤 벽과 부딪힌 건지 알 수 있다.

 

벽을 만나 dda가 완료되었으니 광선의 시작점에서 벽까지의 이동거리 계산

- 그릴 벽의 높이를 알아내는데 사용된다.

- 실제 거리 값을 사용하면 벽이 둥글게 보이는 어안렌즈 효과가 생겨서, 이를 피하기 위해 카메라 평면까지의 거리를 사용한다.

        if (side == 0)
            perpWallDist = (mapX - game->posX + (1 - stepX) / 2) / rayDirX;
        else
            perpWallDist = (mapY - game->posY + (1 - stepY) / 2) / rayDirY;

if (side == 0)

// 광선이 처음으로 부딪힌 면이 x 면 이라면, mapX - posX + (1 - stepX) / 2)는 광선이 x 방향으로 몇 칸이나 지나갔는지를 나타내는 수이다. (정수일 필요 없음)

광선의 방향이 x 면에 수직이면 이미 정확한 수직 거리의 값이다. 하지만 대부분 광선의 방향이 있고 이때 구해진 값은 실제 수직거리보다 큰 값이므로 rayDirX로 나누어준다.

else

// side == 1 , y 면에 부딪힌 경우에도 같은 방식으로 계산

 

        // screen 에 그릴 선의 높이
        int lineHeight = (int)(screenHeight / perpWallDist);

        // 수직선의 높이에서 시작과 끝 위치 계산
        int drawStart = -lineHeight / 2 + screenHeight / 2;
        if (drawStart < 0)
            drawStart = 0;
        int drawEnd = lineHeight / 2 + screenHeight / 2;
        if (drawEnd >= screenHeight)
            drawEnd = screenHeight - 1;

계산한 거리 perpWallDist로 화면에 그려야하는 선의 높이를 구할 수 있다.

-> perpWallDist를 역수로 취하고, 픽셀 단위로 맞추기 위해 픽셀단위의 화면 높이를 곱해서 구할 수 있다.

- 벽의 높이를 조절하고 싶으면 곱하는 화면 높이의 값을 늘리거나 줄인다.

- screenHeight 값은 일정한 벽의 높이, 너비 및 깊이를 가진 박스처럼 보이게 해준다. 값이 클수록 높은 박스를 만들어준다.

-> 구해진 lineHeight 값에서 (화면에 그릴 수직선의 높이), 실제로 선을 그릴 위치의 시작 및 끝 위치를 알 수 있다.

- 벽의 중심은 화면의 중심에 있어야 하고, 이 중심점이 화면 범위 아래에 놓여있다면 0으로, 화면 범위 위에 놓여있다면 h - 1 로 덮어 씌운다.

 

1 > 특정 색으로 벽을 칠하는 경우

        int color;
        if (worldMap[mapY][mapX] == 1)
            color = 0xff0000;
        else if (worldMap[mapY][mapX] == 2)
            color = 0x00ff00;
        else if (worldMap[mapY][mapX] == 3)
            color = 0x0000ff;
        else if (worldMap[mapY][mapX] == 4)
            color = 0xffffff;
        else
            color = 0xffff00;
        if (side == 1)
            color /= 2;
        verLine(game, x, drawStart, drawEnd, color);
    }
    return (0);
}

광선이 부딪힌 벽 == worldMap[mapY][mapX]

광선이 부딪힌 벽의 값에 따라 색상값을 정하고, 그 색상값을 저장해둔다.

 

color /= 2 는 

색상 숫자를 2로 나눠 더 어둡게 보이도록 설정한다.

 

if (side == 1)

y면에 부딪힌 경우 색상을 어둡게 보이도록 설정한 것

void    verLine(t_game *game, int x, int y1, int y2, int color)
{
    int y;
    y = y1;
    while (y <= y2)
    {
        mlx_pixel_put(game->mlx, game->win, x, y, color);
        y++;
    }
}

세로줄을 한줄씩 그리는 것이기 때문에 세로줄을 그릴 x축 좌표, 세로줄을 그릴 y축의 시작과 끝 좌표만 주어지면 된다.

2 > 텍스쳐를 표현한 레이캐스터

수직선을 그리는 방식은 사용할 수 없다.

대신 픽셀을 하나하나 그려주어야 한다.

-> 스크린버퍼를 사용하여 정보를 모아 한번에 화면에 출력한다.

        int texNum = worldMap[mapX][mapY] - 1; // - 1

        double wallX;
        if (side == 0)
            wallX = game->posY + perpWallDist * rayDirY;
        else
            wallX = game->posX + perpWallDist * rayDirX;
        wallX -= floor(wallX);

        int texX = (int)(wallX * (double)texWidth);
        if (side == 0 && rayDirX > 0)
            texX = texWidth - texX - 1;
        if (side == 1 && rayDirY < 0)
            texX = texWidth - texX - 1;

texNum은 map에 저장된 값의 변수에 따라 다른 텍스처 종류를 의미한다.

( -1 을 해서 0번째 텍스처와 벽이 없는 것을 구분한다. 값이 1 일 때 0번째 텍스처이미지를 사용할 수 있게 함 )

 

wallX 값은 벽의 정확히 어디에 부딪혔는지 double 형 좌표로 나타낸다.

이 값은 텍스처를 적용할 때 어떤 x 좌표를 사용해야 하는지 판단할 때 사용함

-> x 면과 부딪힌 경우 (side == 0) wallX 는 x 좌표가 되지만, 

       y 면과 부딪힌 경우 (side == 1)에는 벽의 y 좌표가 된다.

벽면 어디에 부딪혔는지 double 값으로 정확히 구해( posY + perpWallDist * rayDirY = 현재 위치 + 부딪힌 벽의 거리 * 광선 방향 ) int값을 빼서 판단한다. ( 0 - 1 범위 )

 

하지만 텍스처를 적용할 때의 wallX 값은 텍스처의 x 좌표에 사용된다.

 

wallX의 값을 이용해서 텍스처의 x 좌표를 나타내는 texX 를 계산 ( wallX * texWidth )

-> 이 x 좌표는 해당 수직선 상에서 그대로 유지된다.

        double step = 1.0 * texHeight / lineHeight;
        double texPos = (drawStart - screenHeight / 2 + lineHeight / 2) * step;
        for (int y = drawStart; y < drawEnd; y++)
        {
            int texY = (int)texPos & (texHeight - 1);
            texPos += step;
            int color = game->texture[texNum][texHeight * texY + texX];
            if (side == 1)
                color = (color >> 1) & 8355711;
            game->buf[y][x] = color;
        }
    }

이제 수직선 상의 각 픽셀이 텍스처의 어떤 y 좌표 (= texY ) 값을 갖게 할건지 정하기 위해 y방향 반복문을 돌 차례이다.

texY의 값은 첫 줄에서 계산한 step 의 크기만큼 증가하면서 계산된다.

step의 크기는 텍스처의 좌표를 수직선 상에 있는 좌표에 대해 얼마나 늘려야 하는지에 따라 결정된다. double 형에서 int 형으로 타입캐스팅하여 텍스처 픽셀값을 선택하도록 한다.

 

texY = (int)texPos & (texHeight - 1);  ==  texPos % texHeight 와 같은 결과

 

buffer[y][x]에 넣을 픽셀의 색상 color = texture[texNum][texX][texY]

 

만약 side가 1이면 벽의 y면에 부딪힌 것이다. -> 더 어둡게 표현하기 위해 2로 나눔

 

1. 입력 키에 따라 플레이어 이동하기

int     key_press(int keycode, t_game *game)
{
    if (keycode == KEY_ESC)
        exit(0);
    if (keycode == KEY_W || keycode == 126)
    {
        if (!worldMap[(int)(game->posX + game->dirX * game->moveSpeed)][(int)(game->posY + game->dirY * game->moveSpeed)])
        {
            game->posX += game->dirX * game->moveSpeed;
            game->posY += game->dirY * game->moveSpeed;
        }
    }
    if (keycode == KEY_S || keycode == 125)
    {
        if (!worldMap[(int)(game->posX - game->dirX * game->moveSpeed)][(int)(game->posY - game->dirY * game->moveSpeed)])
        {
            game->posX -= game->dirX * game->moveSpeed;
            game->posY -= game->dirY * game->moveSpeed;
        }
    }
    if (keycode == KEY_D || keycode == 124)
    {
        double oldDirX = game->dirX;
        game->dirX = game->dirX * cos(-game->rotSpeed) - game->dirY * sin(-game->rotSpeed);
        game->dirY = oldDirX * sin(-game->rotSpeed) + game->dirY * cos(-game->rotSpeed);
        double oldPlaneX = game->planeX;
        game->planeX = game->planeX * cos(-game->rotSpeed) - game->planeY * sin(-game->rotSpeed);
        game->planeY = oldPlaneX * sin(-game->rotSpeed) + game->planeY * cos(-game->rotSpeed);
    }
    if (keycode == KEY_A || keycode == 123)
    {
        double oldDirX = game->dirX;
        game->dirX = game->dirX * cos(game->rotSpeed) - game->dirY * sin(game->rotSpeed);
        game->dirY = oldDirX * sin(game->rotSpeed) + game->dirY * cos(game->rotSpeed);
        double oldPlaneX = game->planeX;
        game->planeX = game->planeX * cos(game->rotSpeed) - game->planeY * sin(game->rotSpeed);
        game->planeY = oldPlaneX * sin(game->rotSpeed) + game->planeY * cos(game->rotSpeed);
    }
    return (0);
}

위쪽 화살표 - 앞으로 이동

worldMap[posX + dirX * moveSpeed][posY + dirY * moveSpeed] == 0 이면

움직이려고 하는 위치에 벽이 없으므로

posX += dirX * moveSpeed

posY += dirY * moveSpeed

연산을 한다

 

-> 충돌 감지 기능

플레이어가 움직이려고 하는 최종 위치는 x, y 이므로 움직이려고 하는 위치에 벽이 없으면 posX, posY 값을 변화시키고, 벽이 있으면 아무런 작업을 하지 않는다.

원래 코드는 x 위치 y위치를 나눠서 비교했는데, 그랬더니 앞에 벽이 있으면 한쪽으로 밀리는 현상이 발생하여 변경했다.

 

 

 

 

반응형
Comments