pythonmathpygamegame-developmentraycasting

How do I fix wall warping in my raycaster?


So I have made a raycaster in Python using pygame. I cast my rays little bit by bit and once they've hit a wall, I calculate the distance and use that base the height of my walls. The problem is that my walls are curved.

I had thought I had fixed this problem by adding this

            angle = math.radians(self.view - degree)
            dist *= math.cos(angle)

But with that the walls to the left or right of me would still be curved.

Here is the full code or you can play it here!

#Import modules
import pygame
import math

#Set up window
pygame.init()

winWidth, winHeight = (1024, 512)
window = pygame.display.set_mode((winWidth, winHeight))

#Make player object
class Player:
    def __init__(self, x, y, tm, fov, view):
        self.x = x
        self.y = y
        self.tm = tm #tilemap
        self.fov = fov #field of view
        self.view = view #players angle
        self.movements = {'w': False, 'a':False, 's':False, 'd':False}
        self.distances = []
        self.img = pygame.image.load("tile_test.png").convert_alpha()
        self.img = pygame.transform.scale(self.img, (64, 64))
    
    def update(self): #Player movements
        radAngle = math.radians(self.view)
        if self.movements['w'] == True and self.tm[int(self.y+(math.sin(radAngle)*0.05))][int(self.x+(math.cos(radAngle)*0.05))] == 0:
            self.x += math.cos(radAngle)*0.05
            self.y += math.sin(radAngle)*0.05
        if self.movements['a'] == True:
            self.view -= 3
        if self.movements['d'] == True:
            self.view += 3
            
    def draw(self, window):
        #---TopDown View---
        #Map
        for y, row in enumerate(self.tm):
            for x, tile in enumerate(row):
                if tile == 1:
                    pygame.draw.rect(window, (255,255,255), (x*64, y*64, 64, 64))
                    pygame.draw.rect(window, (0,0,0), (x*64, y*64, 64, 64), 1)
                else:
                    pygame.draw.rect(window, (0,0,0), (x*64, y*64, 64, 64))
                    pygame.draw.rect(window, (255,255,255), (x*64, y*64, 64, 64), 1)
        #player
        pygame.draw.circle(window, (255,255,0), (self.x*64, self.y*64), 8)
        #Rays :D
        self.distances = []
        for degree in range(int(self.view-(self.fov/2)), int(self.view+(self.fov/2))):
            radAngle = math.radians(degree)
            rayx = self.x
            rayy = self.y
            
            stop = False
            while self.tm[int(rayy)][int(rayx)] == 0 and stop == False:
                rayx += math.cos(radAngle)*0.01
                rayy += math.sin(radAngle)*0.01

            #Calculate ray distance
            dist = math.sqrt(((rayx-self.x)*(rayx-self.x)+(rayy-self.y)*(rayy-self.y)))
            #Draw the ray
            pygame.draw.line(window, (0,255,0), (self.x*64, self.y*64), (rayx*64, rayy*64))
            

            #Decide if colides horizontally or vertically (To help with drawing tiles)
            rx = round(rayx - int(rayx), 5)
            ry = round(rayy - int(rayy), 5)
            h_col = False
            if rx > .5:
                if ry > .5 - (rx - .5) and ry < .5 + (rx - .5):
                    h_col = True
                else:
                    h_col = False
            elif rx <= .5:
                if ry > .5 - (.5 - rx) and ry < .5 + (.5 - rx):
                    h_col = True
                else:
                    h_col = False
            
            if h_col == True:
                num = ry
            else:
                num = rx

            #Attempt at fixing curved walls. Works somewhat but not really
            angle = math.radians(self.view - degree)
            dist *= math.cos(angle)
            
            self.distances.append((dist, num))
        #draw player view ray
        pygame.draw.line(window, (255,0,0), (self.x*64, self.y*64), ((self.x+math.cos(math.radians(self.view)))*64, (self.y+math.sin(math.radians(self.view)))*64))
        #---3D View---
        for x, line in enumerate(self.distances):
            height = 256 - round(line[0], 1)*42
            if height <= .5:
                height = .5
            w, h = self.img.get_width(), self.img.get_height()

            img_x = int(line[1]*w)
            if img_x > 63:
                img_x = 63
            elif img_x < 0:
                img_x = 0
            img = self.img.subsurface(img_x, 0, 1, h)
            img = pygame.transform.scale(img, (8, height*2))

            window.blit(img, (512+(x*8), 256-height))

        
class Control:
    def __init__(self):
        self.run = True
        self.clock = pygame.time.Clock()
    
    def update(self):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                self.run = False
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_w:
                    player.movements["w"] = True
                if event.key == pygame.K_a:
                    player.movements["a"] = True
                if event.key == pygame.K_s:
                    player.movements["s"] = True
                if event.key == pygame.K_d:
                    player.movements["d"] = True
                if event.key == pygame.K_ESCAPE:
                    self.run = False
            elif event.type == pygame.KEYUP:
                if event.key == pygame.K_w:
                    player.movements["w"] = False
                if event.key == pygame.K_a:
                    player.movements["a"] = False
                if event.key == pygame.K_s:
                    player.movements["s"] = False
                if event.key == pygame.K_d:
                    player.movements["d"] = False

game = Control()
tm = [
    [1,1,1,1,1,1,1,1],
    [1,0,1,0,0,0,0,1],
    [1,0,1,1,0,1,0,1],
    [1,0,0,0,0,1,0,1],
    [1,1,1,1,0,1,0,1],
    [1,0,1,1,0,0,0,1],
    [1,0,0,0,0,0,0,1],
    [1,1,1,1,1,1,1,1]
]

player = Player(3.5, 3.5, tm, 64, 0)

while game.run:
    window.fill((50,50,50))
    player.draw(window)

    pygame.display.update()
    game.update()
    player.update()

    game.clock.tick(30)

pygame.quit()

Solution

  • Your attempt to project the distance to the line of sight is almost correct:

    dist *= math.cos(math.radians(degree - self.view))
    

    However, the calculation of the height is wrong:

    height = 256 - round(line[0], 1)*42

    height = round(256 / line[0])
    

    Also calculate the angle from a projection to a plane:

    class Player:
        # [...]
    
        def draw(self, window):
            # [...]
    
            for dy in range(-self.fov//2, self.fov//2):
                degree = self.view + math.degrees(math.atan2(dy, 50))
                radAngle = math.radians(degree)
    
                # [...]
    
                # project distance
                dist *= math.cos(math.radians(degree - self.view))
    
            # [...]
    
            for x, line in enumerate(self.distances):
    
                # calculate height
                height = round(256 / line[0])
    
                # [...]
    


    Optimized minimal example:

    import pygame
    import math
    
    pygame.init()
    
    tile_size, map_size = 50, 8
    board = [
        '########',
        '#   #  #',
        '#   # ##',
        '#  ##  #',
        '#      #',
        '###  ###',
        '#      #',
        '########']
    
    def cast_rays(sx, sy, angle):
        rx = math.cos(angle)
        ry = math.sin(angle)
        map_x = sx // tile_size
        map_y = sy // tile_size
    
        t_max_x = sx/tile_size - map_x
        if rx > 0:
            t_max_x = 1 - t_max_x
        t_max_y = sy/tile_size - map_y
        if ry > 0:
            t_max_y = 1 - t_max_y
    
        while True:
            if ry == 0 or t_max_x < t_max_y * abs(rx / ry):
                side = 'x'
                map_x += 1 if rx > 0 else -1
                t_max_x += 1
                if map_x < 0 or map_x >= map_size:
                    break
            else:
                side = 'y'
                map_y += 1 if ry > 0 else -1
                t_max_y += 1
                if map_x < 0 or map_y >= map_size:
                    break
            if board[int(map_y)][int(map_x)] == "#":
                break
    
        if side == 'x':
            x = (map_x + (1 if rx < 0 else 0)) * tile_size
            y = player_y + (x - player_x) * ry / rx
            direction = 'r' if x >= player_x else 'l'
        else:
            y = (map_y + (1 if ry < 0 else 0)) * tile_size
            x = player_x + (y - player_y) * rx / ry
            direction = 'd' if y >= player_y else 'u'
        return (x, y), math.hypot(x - sx, y - sy), direction   
    
    def cast_fov(sx, sy, angle, fov, no_ofrays):
        max_d = math.tan(math.radians(fov/2))
        step = max_d * 2 / no_ofrays
        rays = []
        for i in range(no_ofrays):
            d = -max_d + (i + 0.5) * step
            ray_angle = math.atan2(d, 1)
            pos, dist, direction = cast_rays(sx, sy, angle + ray_angle)
            rays.append((pos, dist, dist * math.cos(ray_angle), direction))
        return rays
    
    window = pygame.display.set_mode((tile_size*map_size*2, tile_size*map_size))
    clock = pygame.time.Clock()
    
    board_surf = pygame.Surface((tile_size*map_size, tile_size*map_size))
    for row in range(8):
        for col in range(8):
            color = (192, 192, 192) if board[row][col] == '#' else (96, 96, 96)
            pygame.draw.rect(board_surf, color, (col * tile_size, row * tile_size, tile_size - 2, tile_size - 2))
    
    player_x, player_y = round(tile_size * 4.5) + 0.5, round(tile_size * 4.5) + 0.5
    player_angle = 0
    max_speed = 3
    colors = {'r' : (196, 128, 64), 'l' : (128, 128, 64), 'd' : (128, 196, 64), 'u' : (64, 196, 64)}
    
    run = True
    while run:
        clock.tick(30)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                run = False    
        
        keys = pygame.key.get_pressed()
        hit_pos_front, dist_front, side_front = cast_rays(player_x, player_y, player_angle)
        hit_pos_back, dist_back, side_back = cast_rays(player_x, player_y, player_angle + math.pi)
        player_angle += (keys[pygame.K_RIGHT] - keys[pygame.K_LEFT]) * 0.1
        speed = ((0 if dist_front <= max_speed else keys[pygame.K_UP]) - (0 if dist_back <= max_speed else keys[pygame.K_DOWN])) * max_speed
        player_x += math.cos(player_angle) * speed
        player_y += math.sin(player_angle) * speed
        rays = cast_fov(player_x, player_y, player_angle, 60, 40)
    
        window.blit(board_surf, (0, 0))
        for ray in rays:
            pygame.draw.line(window, (0, 255, 0), (player_x, player_y), ray[0])
        pygame.draw.line(window, (255, 0, 0), (player_x, player_y), hit_pos_front)
        pygame.draw.circle(window, (255, 0, 0), (player_x, player_y), 8)
    
        pygame.draw.rect(window, (128, 128, 255), (400, 0, 400, 200))
        pygame.draw.rect(window, (128, 128, 128), (400, 200, 400, 200))
        for i, ray in enumerate(rays):
            height = round(256 / (ray[2]/50))
            color = pygame.Color((0, 0, 0)).lerp(colors[ray[3]], min(height/256, 1))
            rect = pygame.Rect(400 + i*10, 200-height//2, 10, height)
            pygame.draw.rect(window, color, rect)
        pygame.display.flip()
    
    pygame.quit()
    exit()
    

    Also see PyGameExamplesAndAnswers - Raycasting