-1

I just made this ASCII based renderer for my game and it doesnt seem to be very fast. Ive tried asking chatGPT and Github Copilot but those werent any help (Thats why you find comments in the form of questions). I sadly cant provide the code im using for my actual game but only drawing a single dot already brings the fps down to 10, and this doesnt change at all when you render more. Im doing a window resolution of 720P and the grid of actual characters is 200x50, ive tried gpu acceleration, threading, and PyOpenGL which did seem to fix the issue but made the code completely unreadable to me.

If you find any solution, please tell me. Heres the code :

from colorama import Fore, Style
import os
import time
import threading
import keyboard
import math
import pygame
import random

if os.name == "nt":  # Windows
    os.environ["SDL_VIDEODRIVER"] = "directx"  # or "windib"
elif os.name == "posix":  # Linux
    os.environ["SDL_VIDEODRIVER"] = "x11"

# the first list inside of screen is x, and the one inside of that is y
screen = [[]]
last_screen = [[]]
lines = []
stop_cls = False

# initialize pygame for the terminal class
pygame.init()
# make screenX and screenY variables 720p and make a screen
screenX = 1280
screenY = 720
try:
    screen = pygame.display.set_mode((screenX, screenY))
except:
    # try setting hardware acceleration to windib
    os.environ["SDL_VIDEODRIVER"] = "windib"
    screen = pygame.display.set_mode((screenX, screenY))

def render(clear=True):
    global screen
    lines = []
    for y in range(len(screen[0])):
        line = ""
        for x in range(len(screen)):
            line += screen[x][y]
        lines.append(line)
    text = "\n".join(lines)
    if clear:
        os.system("cls")
    print(text, end='', flush=True)  # Add flush=True to force immediate update

def clear(size=[10, 10], cnvrt=False):
    # cnvrt is if you want to convert the screen size to be more on the x axis so the screen is more symetrical.
    # usually if you did 50,50 it would be more of a rectangle than a square so this will provent that by making the size different
    """
    global screen
    # set the screen x and y size to size
    screen = []
    for i in range(size):
        screen.append([])
        for j in range(size):
            screen[i].append(" ")
    return screen
    """
    global screen
    
    if cnvrt:
        # make size bigger on the x axis
        size[1] = int(size[0] * 3)
    screen = []
    for i in range(size[1]):
        screen.append([])
        for j in range(size[0]):
            screen[i].append(" ")
    return size

def wait(fps):
    time.sleep(1/fps)

def get_input(key=pygame.K_0):
    # use pygame to return if a key is pressed
    return pygame.key.get_pressed()[key]

def set(x, y, char, font_size=10):
    global screen
    #y -= 38 #20 font size
    y -= 70 #10 font size
    try:
        screen[x][y] = char
    except:
        pass

def get(x, y):
    global screen
    #y -= 38 #20 font size
    y -= 70 #10 font size
    try:
        return screen[x][y]
    except:
        return " "

def set_cube(xy, char):
    # xy is a list like [x1, y1, x2, y2]
    # and x2 and y2 is x1 and y1 + x2 and y2 so if you do [10, 10, 2, 2] it will make a 2x2 cube at 10, 10
    global screen
    x1, y1, x2, y2 = xy
    screen_slice = [row[x1:x1+x2] for row in screen[y1:y1+y2]]
    for row in screen_slice:
        row[:] = char * len(row)

def set_line(x1, y1, x2, y2, char):
    global screen
    dx = abs(x2 - x1)
    dy = abs(y2 - y1)
    sx = 1 if x1 < x2 else -1
    sy = 1 if y1 < y2 else -1
    err = dx - dy
    while x1 != x2 or y1 != y2:
        set(x1, y1, char)
        e2 = 2 * err
        if e2 > -dy:
            err -= dy
            x1 += sx
        if e2 < dx:
            err += dx
            y1 += sy

def set_texture(filename, x, y, transparent=False):
    """Draw a texture from a text file at the given position."""
    with open(filename, 'r') as f:
        lines = f.readlines()
    for i, line in enumerate(lines):
        for j, char in enumerate(line.strip()):
            if char != ' ' or not transparent:
                set(x + j, y + i, char)

def rotate_x(x, y, z, angle):
    """Rotate a point around the x-axis."""
    rad = math.radians(angle)
    cos = math.cos(rad)
    sin = math.sin(rad)
    y1 = y * cos - z * sin
    z1 = y * sin + z * cos
    return x, y1, z1
# x, y, z = rotate_x(x, y, z, angle)

def rotate_y(x, y, z, angle):
    """Rotate a point around the y-axis."""
    rad = math.radians(angle)
    cos = math.cos(rad)
    sin = math.sin(rad)
    x1 = x * cos - z * sin
    z1 = x * sin + z * cos
    return x1, y, z1

def rotate_z(x, y, z, angle):
    """Rotate a point around the z-axis."""
    rad = math.radians(angle)
    cos = math.cos(rad)
    sin = math.sin(rad)
    x1 = x * cos - y * sin
    y1 = x * sin + y * cos
    return x1, y1, z

def project(x, y, z):
    """Project a 3D point onto a 2D plane."""
    factor = 200 / (200 + z)
    x1 = x * factor + 40
    y1 = -y * factor + 12
    return int(x1), int(y1)

def draw_cube(x, y, z, size, char, angle_x=0, angle_y=0, angle_z=0):
    """Draw a cube at the given 3D position."""
    # Define the 8 corners of the cube
    corners = [
        (x - size, y - size, z - size),
        (x + size, y - size, z - size),
        (x + size, y + size, z - size),
        (x - size, y + size, z - size),
        (x - size, y - size, z + size),
        (x + size, y - size, z + size),
        (x + size, y + size, z + size),
        (x - size, y + size, z + size),
    ]
    # Connect the corners to form the edges of the cube
    edges = [
        (0, 1), (1, 2), (2, 3), (3, 0),
        (4, 5), (5, 6), (6, 7), (7, 4),
        (0, 4), (1, 5), (2, 6), (3, 7),
    ]
    # Rotate the corners around the x, y, and z axes
    for i, corner in enumerate(corners):
        corners[i] = rotate_x(*corner, angle_x)
        corners[i] = rotate_y(*corners[i], angle_y)
        corners[i] = rotate_z(*corners[i], angle_z)
    # Project the corners onto the 2D plane and draw the edges
    for edge in edges:
        x1, y1 = project(*corners[edge[0]])
        x2, y2 = project(*corners[edge[1]])
        set_line(x1, y1, x2, y2, char)

import pygame

class PygameTerminal:
    def __init__(self, width=800, height=600, font_size=20):
        pygame.init()
        self.width = width
        self.height = height
        self.font_size = font_size
        self.font = pygame.font.SysFont("Courier New", font_size)  # Use a monospaced font
        self.screen = pygame.display.set_mode((width, height))
        self.background_color = (0, 0, 0)
        self.text_color = (255, 255, 255)
        self.text_buffer = []
        self.text_margin = 10
        self.line_height = font_size
        self.max_lines = (height - 2 * self.text_margin) // self.line_height
        pygame.display.set_caption("Pygame Terminal")

        self.buffer_needs_update = True  # Flag to check if buffer needs re-rendering

    def _render_text(self):
        # Create an off-screen buffer to render the text
        buffer = pygame.Surface((self.width, self.height))
        buffer.fill(self.background_color)

        # Render the new lines to the buffer
        lines_to_render = self.text_buffer[-self.max_lines:]
        start_index = max(0, len(self.text_buffer) - self.max_lines)
        y_offset = self.text_margin + (start_index * self.line_height)
        for line in lines_to_render:
            for split_line in line.split("\n"):
                text_surface = self.font.render(split_line, True, self.text_color)
                buffer.blit(text_surface, (self.text_margin, y_offset))
                y_offset += self.line_height

        # Copy the buffer to the screen in a single operation
        self.screen.blit(buffer, (0, 0))
        pygame.display.flip()

        # Update the last rendered line index
        self.last_rendered_line = len(self.text_buffer)

    def print(self, text):
        if not text:
            return

        # Split the text into lines that fit within the terminal height
        lines = text.split('\n')
        self.text_buffer.extend(lines)
        self.text_buffer = self.text_buffer[-self.max_lines:]  # Keep the buffer size within limit
        self.buffer_needs_update = True  # Set the flag to indicate buffer update is required

    def update(self):
        if self.buffer_needs_update:
            self._render_text()
            self.buffer_needs_update = False

    def clear(self):
        self.text_buffer = []
        self.buffer_needs_update = True  # Clearing buffer requires re-rendering

    def run_event_loop(self):
        running = True
        while running:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False

        pygame.quit()

def updates(dirty_rect=False):
    global screen, lines, last_screen
    # Update the existing PygameTerminal instance instead of creating a new one
    term = PygameTerminal(font_size=10, width=1280, height=720)
    if screen != last_screen:
        term.clear()
        beginX = 0
        beginY = 0
        endX = 0
        endY = 0
        if len(screen) != len(last_screen) or len(screen[0]) != len(last_screen[0]) or random.randint(0, 20) == 0 or dirty_rect == False:
            # if the screen size changed, just draw the whole screen
            lines = []
            for y in range(len(screen[0])):
                line = ""
                for x in range(len(screen)):
                    line += screen[x][y]
                lines.append(line)
            last_screen = screen
        else:
            try:
                # now calculate the dirty rect
                for y in range(len(screen[0])):
                    for x in range(len(screen)):
                        if screen[x][y] != last_screen[x][y]:
                            if beginX == 0 and beginY == 0:
                                beginX = x
                                beginY = y
                            endX = x
                            endY = y
                            # make the begin and end a little bigger so it doesnt make shadows
                            beginX -= 1
                            beginY -= 1
                            endX += 1
                            endY += 1
                # now draw the dirty rect
                for y in range(beginY, endY + 1):
                    line = ""
                    for x in range(beginX, endX + 1):
                        line += screen[x][y]
                    # calculate what needs to change inside lines to change the screen only in the dirty rect
                    lines[y] = lines[y][:beginX] + line + lines[y][endX + 1:]
                last_screen = screen
            except:
                # just draw the whole screen if there is an error
                lines = []
                for y in range(len(screen[0])):
                    line = ""
                    for x in range(len(screen)):
                        line += screen[x][y]
                    lines.append(line)
                last_screen = screen
    term.print("\n".join(lines))
    term.update()

Sorry that its so long, but im extremely certain that the problem lies inside the 'updates()' function and the PygameTerminal class.

I tried PyOpenGL, ive tried rewriting it completely and i was expecting it to render at more then 60 frames per second seeing how this is not suppost to be graphically demanding.

  • You're going through a 1280x720 screen image pixel by pixel, and you're surprised that it's slow? Have you done any instrumentation to figure out where you spend your time? – Tim Roberts Aug 05 '23 at 22:37
  • Are you sure you have x and y correct in `screen[x][y]`? In most cases, a screen consts of rows of columns, which means `y` is first. – Tim Roberts Aug 05 '23 at 22:41

0 Answers0