Creating a 2d zoomable, scrollable universe

hi,

i am very new to opengl. i am working on a space_rts game. basically it is a generated universe where one can travel from planet to planet, develop and so on.
because everything is written in python and rendered in pygame, the performance of the rendering is very bad so i decided to render everything with moderngl.

i managed to render about a million gl_points that are textured, with animated shaders with acceptable framerate.

But now, i struggle with the concept, how to manage all the rendering, and interaction. Basically the concept, which path to go.

if have made several systems:

  • one that is renderng quads ( about 100’000 objects with acceptable framerate)
  • one that renders gl_points with amazing framerate ( 1’000’000 points with 250 fps)
  • one using pygame_render library, which makes it easier but not performant.
  • one using a quadtree and renders the objects by sending the calculated positions using a vbo and a vao for every object(pygame_render library) — thats slow

so i came up with the idea to render all in one moderngl.context, using a type property to change between the different shaders. like this i can draw textured objects and animated shaders in one render loop- i guess this is the most performant way.

Questions:

  1. Updating VAO: according to my tiny knowledge to glsl, it is not possible to store anything in the shader itself, so we need to send a vao to the shader to update the objects, if anything changes like positions and so on. therefore we need to update the vao every frame, which can be quite costly. how would you manage the updating of the vao? should i setup several systems? one for static objects, like stars in the background and other systems for dynamic objects?

  2. Change between rendering gl_points and quads (triangles): is it possible and a good idea to change inside the shader between the render method? means: as long as the size if an object is in the range of the GL_POINT_SIZE_RANGE, then render them as gl_points. if the size is bigger than GL_POINT_SIZE_RANGE, then switch to TRIANGLES ?

  3. GeometryShader: many AI’s told me this is the way to do it. would that work to change between the rendering methods?

  4. Basic Concept1: send a vao with all the millions of points to the shader. the shader decides then what to render and what to render and how it will be rendered. if any objects needs to be updated, then only update these types by writing the vao like this: instance_buffer.write(np.hstack((points, types.reshape(-1, 1))).astype(“f4”)) ?

  5. Basic Concept2: create several contexts. one for the static objects that are not changing their position at all, one for moving objects like asteroids and one for objects that are interactable like suns, planets, moons. basically everithing that can be selected?

  6. Interaction: i cannot see any other way than handling the interaction in the python code and then send the the new vao to the shader. it would be possible to select an object in the shader by checking the mouse position and the gl_position or vertex_coordinates and change the appearance, but how to get tis data back into the python programm ?

i hope someone can give me some hints, which way to go, and above all, where i might be wrong with the concept. Thanks for reading !

You normally want to separate static and dynamic data. This includes separating static and dynamic properties of primitives. E.g. if you frequently change the position of a sprite but never change the texture coordinates, those attributes should be in different buffers, or at least in separate contiguous regions of a buffer, rather than interleaved.

You can’t really do this in the shader. Any given draw call is restricted to a single primitive type. Also, geometry shaders (which can convert one primitive type to another) tend to have a significant performance cost on discrete GPUs (Nvidia/AMD).

Rendering sprites by instancing quads (triangle pairs) used to be inefficient due to implementations not coalescing instances into a workgroup. Apparently that’s no longer the case with modern hardware and/or drivers, so that gives rise to a number of potential strategies.

Geometry shaders allow you to restructure the data. So you can perform a draw call using GL_POINTS with one vertex per point, and have a geometry shader convert each point to a pair of triangles. But the consensus seems to be that the performance hit of geometry shaders is too great, and it’s rare for them to be used in contexts where performance matters.

Ultimately you need to profile your code on the systems which you care about. Which means having access to those systems. One of the biggest issues for independent developers is being able to test the code on a wide range of hardware.

Note that a shader program can’t choose the output primitive type for each primitive independently. If you have a geometry shader with an input type of points and an output type of triangle_strip, every input will be a point and every output will be a triangle strip. If you want a mixture, you’ll need a separate draw call for each output primitive type.

1 Like

thanks a lot for the detailed answer! it definitely clears up my main questions ! :ok_hand:

so i managed to create several renderers using different render mechanisms, that works like a charm, fast and i have even more control over the different render mechanisms.

now i am struggling with rendering the orbits of the planets.
there is something strange i dont understand:

why is there some kind of artefact ?:
circle_artefact

( as one can see, the artefact only appears at the points that are exactly on 45 degrees, -45 and so on…)

i am using this code:

class OrbitRenderEngine(BaseRenderEngine):
def init(self, ctx: moderngl.Context, width: int, height: int):
“”"
Render engine for drawing orbit circles using instanced rendering

    :param ctx: ModernGL rendering context
    :param width: Render window width
    :param height: Render window height
    """
    # Initialize with specialized shaders for orbit rendering
    super().__init__(
            ctx=ctx,
            width=width,
            height=height,
            vertex_shader_path='shaders/instanced_vertex_shader_orbit.glsl',
            fragment_shader_path='shaders/fragment_shader_orbit.glsl',
            texture_type='orbit'
            )

    # Number of segments in each circle
    self.segments = 64

    # Create circle template buffer (normalized unit circle)
    template_vertices = np.zeros((self.segments + 1, 2), dtype=np.float32)
    angles = np.linspace(0, 2.0 * np.pi, self.segments + 1)
    template_vertices[:, 0] = np.cos(angles)  # x coordinates
    template_vertices[:, 1] = np.sin(angles)  # y coordinates

    # Create template VBO
    self.template_vbo = self.ctx.buffer(template_vertices)

    # Create orbit instance data buffer (initially empty)
    self.max_orbits = 10000
    self.orbit_data_vbo = self.ctx.buffer(reserve=self.max_orbits * 12)  # 3 floats (x, y, radius) per orbit

    # Create orbit color buffer (initially empty)
    self.orbit_color_vbo = self.ctx.buffer(reserve=self.max_orbits * 16)  # 4 floats (r, g, b, a) per orbit

    # Create VAO with all buffers
    self.vao = self.ctx.vertex_array(
            self.shader_program,
            [
                # Regular per-vertex attributes
                (self.template_vbo, '2f', 'unit_circle'),

                # Per-instance attributes (/i indicates instanced)
                (self.orbit_data_vbo, '3f/i', 'orbit_data'),
                (self.orbit_color_vbo, '4f/i', 'orbit_color')
                ]
            )

    # Track actual orbit count
    self.orbit_count = 0

    # orbit colors
    self.normalised_orbit_colors = {}
    self.set_normalized_orbit_colors()


    # line width
    self.ctx.line_width = qt_config.orbit_line_width

def set_normalized_orbit_colors(self):
    self.normalised_orbit_colors["planet"] = normalize_color_arguments(0, 191, 255, 50)
    self.normalised_orbit_colors["moon"] = normalize_color_arguments(0, 170, 200, 50)

def update_orbit_data(self, json_data):
    """
    Updates orbit data from JSON dictionary

    :param json_data: Dictionary containing object data with positions and orbit radii
    :return: Boolean indicating if there are orbits to render
    """
    # Create array for orbit data
    orbit_data = np.zeros((self.max_orbits, 3), dtype=np.float32)
    orbit_colors = np.zeros((self.max_orbits, 4), dtype=np.float32)

    # Process each object in the JSON
    orbit_index = 0
    for obj_id, obj in json_data.items():
        if not isinstance(obj, dict) or orbit_index >= self.max_orbits:
            continue

        # Extract orbit center (x, y) and radius
        if 'x' in obj and 'y' in obj and 'orbit_radius' in obj:
            orbit_data[orbit_index, 0] = obj['x']  # center_x
            orbit_data[orbit_index, 1] = obj['y']  # center_y
            orbit_data[orbit_index, 2] = obj['orbit_radius']  # radius

            # Determine color based on object type
            if 'type' in obj and obj['type'] == "moon":
                orbit_colors[orbit_index] = self.normalised_orbit_colors["moon"]
            else:
                orbit_colors[orbit_index] = self.normalised_orbit_colors["planet"]

            orbit_index += 1

    # Update actual orbit count
    self.orbit_count = orbit_index

    # Write only the used portion of the buffers
    if self.orbit_count > 0:
        self.orbit_data_vbo.write(orbit_data[:self.orbit_count])
        self.orbit_color_vbo.write(orbit_colors[:self.orbit_count])

    return self.orbit_count > 0

def update_orbit_data_from_qt(self, qt_objects):
    """
    Updates orbit data directly from Qt objects

    :param qt_objects: List of Qt objects with position and orbit radius attributes
    :return: Boolean indicating if there are orbits to render
    """
    # Create array for orbit data
    orbit_data = np.zeros((self.max_orbits, 3), dtype=np.float32)
    orbit_colors = np.zeros((self.max_orbits, 4), dtype=np.float32)

    # Process each Qt object
    orbit_index = 0
    for obj in qt_objects:
        if orbit_index >= self.max_orbits:
            break

        try:
            # Extract position and orbit radius from Qt object
            # Adjust the attribute access according to your Qt object structure
            center_x = obj.orbit_object.x
            center_y = obj.orbit_object.y
            orbit_radius = obj.orbit_radius

            # Store the data
            orbit_data[orbit_index, 0] = center_x
            orbit_data[orbit_index, 1] = center_y
            orbit_data[orbit_index, 2] = orbit_radius

            # Determine color based on object type
            if hasattr(obj, 'type') and obj.type == "moon":
                orbit_colors[orbit_index] = self.normalised_orbit_colors["moon"]
            else:
                orbit_colors[orbit_index] = self.normalised_orbit_colors["planet"]

            orbit_index += 1

        except AttributeError:
            # Skip objects that don't have the required attributes
            continue

    # Update actual orbit count
    self.orbit_count = orbit_index

    # Write only the used portion of the buffers
    if self.orbit_count > 0:
        self.orbit_data_vbo.write(orbit_data[:self.orbit_count])
        self.orbit_color_vbo.write(orbit_colors[:self.orbit_count])

    return self.orbit_count > 0

def render(self, cam_pos: list):
    """
    Render all orbit circles with a single instanced draw call

    :param cam_pos: Camera position and zoom as [x, y, zoom]
    """
    if self.orbit_count == 0:
        return

    # Pass uniforms to the shader
    self.shader_program['cam_pos'] = cam_pos
    self.shader_program["iResolution"] = (self.width, self.height)

    # Render all orbits in one instanced draw call
    self.ctx.line_width = qt_config.orbit_line_width
    render_mode  = moderngl.LINE_STRIP if qt_config.strip else moderngl.LINE_LOOP
    self.vao.render(render_mode, instances=self.orbit_count)

with these shaders:

vertex_shader:

#version 330 core

// Inputs
layout(location = 0) in vec2 unit_circle; // Unit circle template points
layout(location = 1) in vec3 orbit_data; // x, y, radius for each orbit instance
layout(location = 2) in vec4 orbit_color; // Color for each orbit instance

// Uniforms
uniform vec3 cam_pos; // (x, y, zoom)
uniform vec2 iResolution; // Screen resolution
uniform float line_width; // Line width in world units

// Output to fragment shader
out vec4 frag_color;

void main() {
// Determine if this is an inner or outer vertex based on vertex ID
// Even vertex IDs are inner vertices, odd vertex IDs are outer vertices
float radius_modifier = (mod(float(gl_VertexID), 2.0) == 0.0) ? -0.5 : 0.5;

// Calculate the radius offset based on line_width
float radius_offset = radius_modifier * line_width;

// Apply the radius offset to the unit circle point
vec2 offset_point = unit_circle * (orbit_data.z + radius_offset);

// Calculate the final world position
vec2 position = orbit_data.xy + offset_point;

// Convert world position to screen space
vec2 screenPos = vec2(
    (position.x - cam_pos.x) * cam_pos.z,
    (position.y - cam_pos.y) * cam_pos.z
);

// Convert screen coordinates to clip space
vec2 clipPos = vec2(
    2.0 * screenPos.x / iResolution.x - 1.0,
    -(2.0 * screenPos.y / iResolution.y - 1.0)  // Flip Y for OpenGL
);

// Set the vertex position
gl_Position = vec4(clipPos, 0.0, 1.0);

// Pass the color to the fragment shader
frag_color = orbit_color;

}

and the fragment_shader:

#version 330 core

// Input from geometry shader
in vec4 frag_color;

// Output
out vec4 fragColor;

void main() {
// Simply use the passed color for the orbit line
fragColor = frag_color;
}

as soon as self.ctx.line_width is set to more than 1, the artefacts appear. Can someone see the the problem? my guess is there is something wrong with the vertecies… but on the other hand, if so, then a circle with line width = 1 would also look wrong, isnt it?
i hope someone can point out the problem.

ALSO: some AI told me that this is a common problem using moderngl.LINES. I am not sure to believe that, there must be a proper way to use LINES to draw a circle! We should use TRIANGLE rendering some people propose, that will work for shure ( i have also a triangle renderer for the orbits for testing) , but it is also much slower. Soi would prefer using LINES or similar rendering method without using TRIANGLE rendering. Thanks for your attention!