Dynamic onscreenimage optimization tips

Hello, I am new to panda3d and really like it. I wanted to make a simple 2.5d zelda like game.

Here is what it looks like:

The scene is a 2d tile grid of OnscreenImage’s and when the character moves, the node that all these OnscreenImages is in gets translated. There’s a bit of an edge of tiles offscreen and when the character moves enough, the tiles shift back some amount and load new textures to give the appearance of infinite terrain.

The tiles are dynamically and randomly generated and the player can interact with them, causing their textures to change.

The FPS is not bad but when walking around it drops to around 70 FPS. I want to add more things like monsters walking around and other effects so I want to make sure there’s plenty of time to draw those features when I get there. I believe the large number of nodes is giving the large cull time.

I looked at the flattenLight|Medium|Strong functions but those break the dynamic nature of my textures. Anyone have any ideas or tips on how to improve this design in panda3d?

1 Like

looking at the threading section in the documentation I see that nodepath flatten can happen async so maybe I could group nodes near the center together and flatten them in the background continuously while the player is moving and interacting with the tiles.

It looks like the async flatten will also unflatten, so if the user interacts with a flattened group, it could unflatten for texture changes and then reflatten in the background. I might start down this path unless someone has a better idea.

Drops to 70 fps? From what? FPS is not a linear scale, so differences in large values aren’t as meaningful as differences in small values.

Using a separate OnscreenImage for each tile is inefficient. It would be better to put your textures in an atlas, and create fewer separate Geom objects each containing a grid of (say) 32x32 quads. Then you can switch which texture they are showing by changing the UV coordinates on those individual quads.

1 Like

Drops to 70 fps? From what? FPS is not a linear scale, so differences in large values aren’t as meaningful as differences in small values.

From 122 as in my screenshot. I should have been more explicit.

Using a separate OnscreenImage for each tile is inefficient. It would be better to put your textures in an atlas, and create fewer separate Geom objects each containing a grid of (say) 32x32 quads. Then you can switch which texture they are showing by changing the UV coordinates on those individual quads.

Yeah this sounds much better! Thanks! Can I make a single Geom node and with enough vertices in a grid layout and then do the UV coordinates that way? I suppose I would need to do multiple vertices at each grid point intersection so I can map separate UV coordinates for each of the 4 adjoining tiles. I have basic knowledge of graphics (did a few raw opengl tutorials) so if I made a mistake in my question please correct me.

Do you really need a grid? You could reduce the number of vertices by simply creating 4 vertices for each sprite.
Here is an example of what the sprite system could look like (everything is one single Geom):

sprite.py:

from panda3d.core import *
import array


class Sprite:

    registry = {}
    _count = 0
    _model = None

    @classmethod
    def __create_model(cls):

        # Create empty model

        # define custom vertex format, with each column in a separate array
        GE = GeomEnums
        fl32 = GE.NT_float32
        vf = GeomVertexFormat()
        af = GeomVertexArrayFormat()
        af.add_column(InternalName.get_vertex(), 3, fl32, GE.C_point)
        vf.add_array(af)
        af = GeomVertexArrayFormat()
        af.add_column(InternalName.get_color(), 4, fl32, GE.C_color)
        vf.add_array(af)
        af = GeomVertexArrayFormat()
        af.add_column(InternalName.get_texcoord(), 2, fl32, GE.C_texcoord)
        vf.add_array(af)
        vertex_format = GeomVertexFormat.register_format(vf)

        v_data = GeomVertexData("chunk_data", vertex_format, GE.UH_dynamic)
        geom = Geom(v_data)
        prim = GeomTriangles(GE.UH_dynamic)
        prim.set_index_type(GE.NT_uint32)
        geom.add_primitive(prim)
        geom_node = GeomNode("sprite_chunk")
        geom_node.add_geom(geom)
        cls._model = NodePath(geom_node)
        cls._model.set_transparency(TransparencyAttrib.M_alpha)

    def __init__(self, width, height, uvs):

        if not self._model:
            self.__create_model()

        self.width = width
        self.height = height
        self._coords = array.array("f", [
            0., 0., 0.,
            0., 0., -height,
            width, 0., -height,
            width, 0., 0.,
        ])
        self._pos = (0., 0.)
        self._color_array = array.array("f", [0.] * 16)
        self._uv_array = array.array("f", uvs)
        self.start_vert_row = 0
        self.start_vert_index_row = 0

        self._id = Sprite._count
        Sprite._count += 1
        Sprite.registry[self._id] = self

        self.__add_geometry()

    def __add_geometry(self):

        geom = self._model.node().modify_geom(0)
        v_data = geom.modify_vertex_data()
        old_vert_count = v_data.get_num_rows()
        self.start_vert_row = old_vert_count
        v_data.set_num_rows(old_vert_count + 4)
        pos_view = memoryview(v_data.modify_array(0)).cast("B").cast("f")
        pos_view[old_vert_count*3:] = self._coords
        uv_view = memoryview(v_data.modify_array(2)).cast("B").cast("f")
        uv_view[old_vert_count*2:] = self._uv_array

        prim = geom.modify_primitive(0)
        prim_array = prim.modify_vertices()
        old_index_count = prim_array.get_num_rows()
        self.start_vert_index_row = old_index_count
        prim_array.set_num_rows(old_index_count + 6)
        vert_indices = array.array("I", [
            0, 1, 2,
            2, 3, 0
        ])
        prim_view = memoryview(prim_array).cast("B").cast("I")
        prim_view[old_index_count:] = vert_indices
        prim.offset_vertices(old_vert_count, old_index_count, old_index_count + 6)

    def __remove_geometry(self):

        geom = self._model.node().modify_geom(0)
        v_data = geom.modify_vertex_data()
        old_vert_count = v_data.get_num_rows()
        start = self.start_vert_row * 3
        size = 4 * 3
        pos_view = memoryview(v_data.modify_array(0)).cast("B").cast("f")
        pos_view[start:-size] = pos_view[start+size:]
        start = self.start_vert_row * 4
        size = 4 * 4
        color_view = memoryview(v_data.modify_array(1)).cast("B").cast("f")
        color_view[start:-size] = color_view[start+size:]
        start = self.start_vert_row * 2
        size = 4 * 2
        uv_view = memoryview(v_data.modify_array(2)).cast("B").cast("f")
        uv_view[start:-size] = uv_view[start+size:]
        v_data.set_num_rows(old_vert_count - 4)

        prim = geom.modify_primitive(0)
        prim_array = prim.modify_vertices()
        old_index_count = prim_array.get_num_rows()
        start = self.start_vert_index_row
        end = old_index_count - 6
        view = memoryview(prim_array).cast("B").cast("I")
        view[start:-6] = view[start+6:]
        prim_array.set_num_rows(old_index_count - 6)
        prim.offset_vertices(-4, start, end)

        sprites = list(self.registry.values())
        index = sprites.index(self)

        start_row = self.start_vert_row

        for sprite in sprites[index+1:]:
            sprite.start_vert_row = start_row
            start_row += 4

        start_row = self.start_vert_index_row

        for sprite in sprites[index+1:]:
            sprite.start_vert_index_row = start_row
            start_row += 6

    def destroy(self):

        if self._id not in Sprite.registry:
            return False

        self.__remove_geometry()
        del Sprite.registry[self._id]

        return True

    @property
    def model(self):

        return self._model

    @property
    def id(self):

        return self._id

    def get_pos(self):

        return self._pos

    def set_pos(self, x, y):

        x_old, y_old = self._pos

        mat = Mat4.translate_mat(x - x_old, 0., y - y_old)
        v_data = self._model.node().modify_geom(0).modify_vertex_data()
        start_row = self.start_vert_row
        v_data.transform_vertices(mat, start_row, start_row + 4)

        self._pos = (x, y)

    def move(self, x, y):

        mat = Mat4.translate_mat(x, 0., y)
        v_data = self._model.node().modify_geom(0).modify_vertex_data()
        start_row = self.start_vert_row
        v_data.transform_vertices(mat, start_row, start_row + 4)

        x_old, y_old = self._pos
        self._pos = (x_old + x, y_old + y)

    @property
    def color(self):

        if self._has_model:
            return tuple(self._color_array[:4])

    @color.setter
    def color(self, color):

        if self.color != color:
            self._color_array = array.array("f", color * 4)
            v_data = self._model.node().modify_geom(0).modify_vertex_data()
            color_view = memoryview(v_data.modify_array(1)).cast("B").cast("f")
            start = self.start_vert_row * 4
            end = start + 16
            color_view[start:end] = self._color_array

    @property
    def uvs(self):

        return tuple(self._uv_array)

    @uvs.setter
    def uvs(self, uvs):

        self._uv_array = array.array("f", uvs)
        v_data = self._model.node().modify_geom(0).modify_vertex_data()
        uv_view = memoryview(v_data.modify_array(2)).cast("B").cast("f")
        start = self.start_vert_row * 2
        end = start + 8
        uv_view[start:end] = self._uv_array

main.py:

#!/usr/bin/env python

from panda3d.core import *
from direct.showbase.ShowBase import ShowBase
from sprite import Sprite
import random

load_prc_file_data("", "sync-video false")


class MyGame(ShowBase):

    def __init__(self):

        ShowBase.__init__(self)

        self.set_frame_rate_meter(True)

        self.add_sprites()
        self.task_mgr.do_method_later(.5, self.__move_sprites, "move_sprites")
        self.task_mgr.do_method_later(1.5, self.__delete_sprites, "delete_sprites")

        self.run()

    def add_sprites(self):

        self.sprites = []
        uvs = (
            0., 1.,  # upper left corner
            0., 0.,  # lower left corner
            1., 0.,  # lower right corner
            1., 1.   # upper right corner
        )
        sprite = Sprite(100, 70, uvs)
        # after creating the first sprite, the model to represent all sprites
        # has been created
        model = sprite.model
        model.reparent_to(self.pixel2d)
        tex_atlas = self.loader.load_texture("atlas.png")
        model.set_texture(tex_atlas)
        sprite.set_pos(200, -100)
        self.sprites.append(sprite)

        sprite = Sprite(80, 110, uvs)
        sprite.set_pos(100, -200)
        self.sprites.append(sprite)

        # fill the window with sprites
        for i in range(500):
            x = (i % 20) * 50
            y = -(i // 20) * 50
            sprite = Sprite(50, 50, uvs)
            sprite.set_pos(x, y)
            self.sprites.append(sprite)

    def __move_sprites(self, task):

        for sprite in self.sprites:
            sprite.move(random.randint(-5, 5), random.randint(-5, 5))

        return task.again

    def __delete_sprites(self, task):

        if self.sprites:
            sprite = self.sprites[random.randint(0, len(self.sprites) - 1)]
            sprite.destroy()
            self.sprites.remove(sprite)

        return task.again


MyGame()

Enjoy the improved framerate :wink: .

The code shows an entire window filled with sprites, each of them moving randomly, while they get removed one by one.

The Sprite class has a uvs property that you can easily assign the desired UV coordinates to, as a tuple of eight float values: 2 for the upper left corner of the sprite, 2 for the lower left corner, 2 for the lower right and finally 2 for the upper right corner.
It’s also possible to change the vertex colors of any sprite.
And last but not least, you can set the position of a sprite in two different ways: by setting exact coordinates (calling set_pos) or by offsetting its current position (using move).

If you have any questions, please ask :slight_smile: !

2 Likes

Sounds like you know what to do :slight_smile:

This sprite class is similar in terms of geometry to what I was talking about, but this has a much better interface (and you already did it for me!). This is awesome, thanks so much! A lot of points for the panda3d community! Is this a custom class you just made or is this something standard in panda3d? (also interested about licensing here if I use this)

One question, what is the difference or reason for the GeomNode and the NodePath?

You’re welcome :slight_smile: !

It’s basically a high-level interface I developed around existing low-level Panda-methods to manipulate geometry. Not really sure if it’s worth putting this up on GitHub and adding a license, since it’s specifically targeted at this particular use-case. If you feel more comfortable with a license, I’d be willing to do it, although it’s fine by me if you just mention my username in the credits should you decide to use it :slight_smile: .

The GeomNode class is needed to effectively add the geometry into the “scenegraph”; without it, the geometry wouldn’t be rendered. A NodePath is essentially a thin wrapper around a PandaNode but offers lots of convenience methods, so it’s used in most cases. Instead of creating a NodePath explicitly, you could also parent the GeomNode to the pixel2d node using a call to pixel2d.attach_new_node(geom_node), which implicitly creates the NodePath (and returns it). (Actually, you could also call pixel2d.node().add_child(geom_node) without needing a NodePath.)