Performance impact on thousands of Nodes

What exactly are those block models that you are loading? Are they simple cubes, or something much more complex (e.g. more detailed geometry, with chamfered or rounded corners, etc.)? If they’re just simple cubes, there’s no need to model them in an external application (e.g. Blender) and then load them into Panda. Instead, you can easily create them procedurally in Panda at runtime.

Below is a code sample that allows you to create, add and remove cubes (with different UV coordinates for each side and correct normals); they all become part of one and the same model node:

from panda3d.core import *
from direct.showbase.ShowBase import ShowBase
import array
import random

def create_cube():

    vertex_count = 0
    values = array.array("f", [])
    indices = array.array("I", [])
    # use an offset along the U-axis to give each side of the cube different
    # texture coordinates, such that each side shows a different part of a
    # texture applied to the cube
    u_offset = 0.

    for direction in (-1, 1):

        for i in range(3):

            normal = VBase3()
            normal[i] = direction

            for a, b in ((-1., -1.), (-1., 1.), (1., 1.), (1., -1.)):

                pos = Point3()
                pos[i] = direction
                pos[(i + direction) % 3] = a
                pos[(i + direction * 2) % 3] = b
                u, v = [pos[j] for j in range(3) if j != i]
                u *= (-1. if i == 1 else 1.) * direction
                uv = (max(0., u) / 6. + u_offset, max(0., v))

                values.extend(pos)
                values.extend(normal)
                values.extend(uv)

            u_offset += 1. / 6.
            vertex_count += 4

            indices.extend((vertex_count - 2, vertex_count - 3, vertex_count - 4))
            indices.extend((vertex_count - 4, vertex_count - 1, vertex_count - 2))

    vertex_format = GeomVertexFormat.get_v3n3t2()

    vertex_data = GeomVertexData("cube_data", vertex_format, Geom.UH_static)
    vertex_data.unclean_set_num_rows(vertex_count)
    data_array = vertex_data.modify_array(0)
    memview = memoryview(data_array).cast("B").cast("f")
    memview[:] = values

    tris_prim = GeomTriangles(Geom.UH_static)
    tris_prim.set_index_type(Geom.NT_uint32)
    tris_array = tris_prim.modify_vertices()
    tris_array.unclean_set_num_rows(len(indices))
    memview = memoryview(tris_array).cast("B").cast("I")
    memview[:] = indices

    geom = Geom(vertex_data)
    geom.add_primitive(tris_prim)
    node = GeomNode("cube")
    node.add_geom(geom)

    return node


class MyApp(ShowBase):

    def __init__(self):

        ShowBase.__init__(self)

        # set up a light source
        p_light = PointLight("point_light")
        p_light.set_color((1., 1., 1., 1.))
        self.light = self.camera.attach_new_node(p_light)
        self.light.set_pos(5., -100., 7.)
        self.render.set_light(self.light)

        # create an initially empty model parented to the scene root
        vertex_format = GeomVertexFormat.get_v3n3t2()
        vertex_data = GeomVertexData("model_data", vertex_format, Geom.UH_dynamic)
        tris_prim = GeomTriangles(Geom.UH_dynamic)
        # allow adding more than 65536 vertices
        tris_prim.set_index_type(Geom.NT_uint32)
        model_geom = Geom(vertex_data)
        model_geom.add_primitive(tris_prim)
        model_node = GeomNode("model")
        model_node.add_geom(model_geom)
        self.model = self.render.attach_new_node(model_node)

        # the number of vertices in each cube
        self.cube_vert_count = 24  # 6 sides, 8 vertices per side

        # the number of vertex indices in the GeomTriangles primitive of each cube
        self.cube_prim_vert_count = 36  # 6 sides, 6 index rows per side

        # the number of float values used for the data of a single vertex
        self.data_stride = 8  # 3 coordinates + 3 normal components + 2 UVs

        # define the possible positions for the cubes
        # (they form a pyramid shape in this example)
        self.positions = positions = []
        for i in range(-2, 3):
            pos = (i * 2, -4, 0)
            positions.append(pos)
        for i in range(-2, 3):
            pos = (i * 2, 4, 0)
            positions.append(pos)
        for i in range(-1, 2):
            pos = (-4, i * 2, 0)
            positions.append(pos)
        for i in range(-1, 2):
            pos = (4, i * 2, 0)
            positions.append(pos)
        for i in range(-1, 2):
            pos = (i * 2, -2, 2)
            positions.append(pos)
        for i in range(-1, 2):
            pos = (i * 2, 2, 2)
            positions.append(pos)
        positions.append((-2, 0, 2))
        positions.append((2, 0, 2))
        positions.append((0, 0, 4))
        # keep track of which positions are currently occupied
        self.free_positions = list(range(len(positions)))
        self.used_positions = []

        # the cubes that have been separated and are falling down
        self.falling_cubes = []

        # start a task that makes separated cubes fall down
        self.task_mgr.add(self.__drop_cubes, "drop_cubes")

        # enable randomly adding and removing cubes at runtime
        self.accept("+", self.__add_random_cube)
        self.accept("-", self.__remove_random_cube)

    def add_cube(self, pos):

        model_geom = self.model.node().modify_geom(0)
        model_array = model_geom.modify_vertex_data().modify_array(0)
        model_vert_count = model_array.get_num_rows()
        model_array.set_num_rows(model_vert_count + self.cube_vert_count)
        model_view = memoryview(model_array).cast("B").cast("f")
        prim_array = model_geom.modify_primitive(0).modify_vertices()
        model_prim_vert_count = prim_array.get_num_rows()
        prim_array.set_num_rows(model_prim_vert_count + self.cube_prim_vert_count)
        model_prim_view = memoryview(prim_array).cast("B").cast("I")

        cube_node = create_cube()
        cube_geom = cube_node.modify_geom(0)
        vertex_data = cube_geom.modify_vertex_data()
        mat = Mat4.translate_mat(*pos)
        vertex_data.transform_vertices(mat)
        vert_array_data = vertex_data.get_array(0)
        vert_array_view = memoryview(vert_array_data).cast("B").cast("f")
        model_view[model_vert_count * self.data_stride:] = vert_array_view
        prim = cube_geom.modify_primitive(0)
        offset = self.cube_vert_count * model_prim_vert_count // self.cube_prim_vert_count
        prim.offset_vertices(offset, 0, self.cube_prim_vert_count)
        cube_prim_array = prim.get_vertices()
        cube_prim_view = memoryview(cube_prim_array).cast("B").cast("I")
        model_prim_view[model_prim_vert_count:] = cube_prim_view

    def prepare_separated_cube(self):

        vertex_format = GeomVertexFormat.get_v3n3t2()
        vertex_data = GeomVertexData("cube_data", vertex_format, Geom.UH_static)
        cube_array = vertex_data.modify_array(0)
        cube_array.unclean_set_num_rows(self.cube_vert_count)
        cube_view = memoryview(cube_array).cast("B").cast("f")
        tris_prim = GeomTriangles(Geom.UH_static)
        tris_prim.set_index_type(Geom.NT_uint32)
        cube_geom = Geom(vertex_data)
        cube_geom.add_primitive(tris_prim)
        cube_node = GeomNode("cube")
        cube_node.add_geom(cube_geom)
        separated_cube = self.render.attach_new_node(cube_node)

        # give the separated cube a red color to easily identify it as a
        # falling cube
        separated_cube.set_color(1., 0., 0.)

        cube_prim_array = tris_prim.modify_vertices()
        # the call to `GeomPrimitive.unclean_set_num_rows` fills the primitive
        # with random integers, which will cause an AssertionError when an
        # integer bigger than the number of vertex data rows is encountered
        # while adding the primitive to the geometry, so only call this method
        # after the primitive, its geom and its node have been added to the
        # scenegraph
        cube_prim_array.unclean_set_num_rows(self.cube_prim_vert_count)
        cube_prim_view = memoryview(cube_prim_array).cast("B").cast("I")

        # the separated cube will start to fall
        self.falling_cubes.append(separated_cube)

        # return the created memoryviews for further manipulation
        return cube_view, cube_prim_view

    def remove_cube(self, index, separate=True):

        model_geom = self.model.node().modify_geom(0)
        model_array = model_geom.modify_vertex_data().modify_array(0)
        model_vert_count = model_array.get_num_rows()
        model_view = memoryview(model_array).cast("B").cast("f")
        cube_data_size = self.cube_vert_count * self.data_stride
        start = index * cube_data_size
        end = start + cube_data_size

        if separate:
            cube_view, cube_prim_view = self.prepare_separated_cube()
            cube_view[:] = model_view[start:end]

        model_view[start:-cube_data_size] = model_view[end:]
        model_array.set_num_rows(model_vert_count - self.cube_vert_count)

        prim = model_geom.modify_primitive(0)
        prim_array = prim.modify_vertices()
        start = index * self.cube_prim_vert_count
        end = start + self.cube_prim_vert_count
        model_prim_vert_count = prim_array.get_num_rows()
        prim.offset_vertices(-self.cube_vert_count, end, model_prim_vert_count)
        model_prim_view = memoryview(prim_array).cast("B").cast("I")

        if separate:
            cube_prim_view[:] = model_prim_view[:self.cube_prim_vert_count]

        model_prim_view[start:-self.cube_prim_vert_count] = model_prim_view[end:]
        prim_array.set_num_rows(model_prim_vert_count - self.cube_prim_vert_count)

    def __add_random_cube(self):

        if not self.free_positions:
            return

        index = random.choice(self.free_positions)
        pos = self.positions[index]
        self.free_positions.remove(index)
        self.used_positions.append(index)
        self.add_cube(pos)

    def __remove_random_cube(self):

        if not self.used_positions:
            return

        index = random.randint(0, len(self.used_positions) - 1)
        self.remove_cube(index)
        pos_index = self.used_positions[index]
        self.used_positions.remove(pos_index)
        self.free_positions.append(pos_index)

    def __drop_cubes(self, task):

        for cube in self.falling_cubes[:]:
                
            z = cube.get_z()

            if z < -10.:
                self.falling_cubes.remove(cube)
                cube.detach_node()
            else:
                cube.set_z(z - .1)

        return task.cont


app = MyApp()
app.run()

When you run the code, press + on the keyboard to add a cube and - to remove a random cube.

Each cube will be placed at a different position. There is a limited number of available positions, which form a pyramid-like shape, so it is easier to see which cubes are added or removed.
The geometry of each cube is associated with a specific position index, and the code keeps track of which positions are used and which are still free.
The relationship between a cube and its position is determined like this:
when a cube is created, its geometry is added to the model node and the index of its position is added to the list of used positions.
So when a cube is removed, it is done by index; this index is used to locate its vertex data in the model geometry, and it is also used to find the position index in the list of used positions.

As for the generation and manipulation of the geometry itself, memoryviews are used, which is more efficient than the use of GeomVertexReaders/Writers, flattenStrong operations or rigid body combiners (which just call flattenStrong under the hood anyway).

It is indeed not simple, but the complexity of what you’re trying to do should not be underestimated either, so I can strongly recommend that you work as much as possible with the low-level methods like I’ve shown in the sample.

Even if the models that you use are quite complex, it is still possible to read their geometry data and copy it into a single model node, as I’ve done with the simple cube in the sample.

If you need more help, just ask.

3 Likes