Performance impact on thousands of Nodes

It shouldn’t be a problem, but I can’t say for certain. This will mostly depend on how the model is exported. E.g. it can happen that the model contains multiple geoms, or that the format of its vertex data is different from the one I assume in my code sample.

Let’s use the old smiley model as an example in this modified code:

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


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 "chunk" model parented to the scene root
        vertex_format = GeomVertexFormat.get_v3n3t2()
        vertex_data = GeomVertexData("chunk_data", vertex_format, GeomEnums.UH_dynamic)
        tris_prim = GeomTriangles(GeomEnums.UH_dynamic)
        # allow adding more than 65536 vertices
        tris_prim.set_index_type(GeomEnums.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)

        # load complex block model;
        # the actual model is a child of a `ModelRoot` node
        block_model = self.loader.load_model("smiley").children[0]
        block_geom = block_model.node().modify_geom(0)
        vertex_data = block_geom.modify_vertex_data()
        # make the format of the loaded model compatible with the chunk model
        vertex_data.format = vertex_format
        # keep a memoryview of the block vertex data around, so it can be
        # assigned to a subview of the chunk vertex data at any time
        self.block_view = memoryview(vertex_data.arrays[0]).cast("B").cast("f")
        block_prim = block_geom.modify_primitive(0)
        block_prim.set_index_type(GeomEnums.NT_uint32)
        # keep a memoryview of the block primitive around, so it can be
        # assigned to a subview of the chunk primitive at any time
        self.block_prim_view = memoryview(block_prim.get_vertices()).cast("B").cast("I")

        # the number of vertices in each block
        self.block_vert_count = vertex_data.get_num_rows()

        # the number of vertex indices in the GeomTriangles primitive of each block
        self.block_prim_vert_count = block_prim.get_num_vertices()

        # 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 blocks
        # (they form a pyramid shape in this example)
        self.positions = positions = []
        for i in range(-2, 3):
            pos = (i, -2, 0)
            positions.append(pos)
        for i in range(-2, 3):
            pos = (i, 2, 0)
            positions.append(pos)
        for i in range(-1, 2):
            pos = (-2, i, 0)
            positions.append(pos)
        for i in range(-1, 2):
            pos = (2, i, 0)
            positions.append(pos)
        for i in range(-1, 2):
            pos = (i, -1, 1)
            positions.append(pos)
        for i in range(-1, 2):
            pos = (i, 1, 1)
            positions.append(pos)
        positions.append((-1, 0, 1))
        positions.append((1, 0, 1))
        positions.append((0, 0, 2))
        # keep track of which positions are currently occupied
        self.free_positions = list(range(len(positions)))
        self.used_positions = []

        # the blocks that have been separated and are falling down
        self.falling_blocks = []

        # start a task that makes separated blocks fall down
        self.task_mgr.add(self.__drop_blocks, "drop_blocks")

        # enable randomly adding and removing blocks at runtime
        self.accept("+", self.__add_random_block)
        self.accept("-", self.__remove_random_block)

    def add_block(self, pos):

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

        model_view[model_vert_count * self.data_stride:] = self.block_view
        mat = Mat4.translate_mat(*pos)
        start = model_vert_count
        end = model_vert_count + self.block_vert_count
        model_data.transform_vertices(mat, start, end)
        model_prim_view[model_prim_vert_count:] = self.block_prim_view
        offset = self.block_vert_count * model_prim_vert_count // self.block_prim_vert_count
        start = model_prim_vert_count
        end = model_prim_vert_count + self.block_prim_vert_count
        model_prim.offset_vertices(offset, start, end)

    def prepare_separated_block(self):

        vertex_format = GeomVertexFormat.get_v3n3t2()
        vertex_data = GeomVertexData("block_data", vertex_format, GeomEnums.UH_static)
        block_array = vertex_data.modify_array(0)
        block_array.unclean_set_num_rows(self.block_vert_count)
        block_view = memoryview(block_array).cast("B").cast("f")
        tris_prim = GeomTriangles(GeomEnums.UH_static)
        tris_prim.set_index_type(GeomEnums.NT_uint32)
        block_geom = Geom(vertex_data)
        block_geom.add_primitive(tris_prim)
        block_node = GeomNode("block")
        block_node.add_geom(block_geom)
        separated_block = self.render.attach_new_node(block_node)

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

        block_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
        block_prim_array.unclean_set_num_rows(self.block_prim_vert_count)
        block_prim_view = memoryview(block_prim_array).cast("B").cast("I")

        # the separated block will start to fall
        self.falling_blocks.append(separated_block)

        # return the created memoryviews for further manipulation
        return block_view, block_prim_view

    def remove_block(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")
        block_data_size = self.block_vert_count * self.data_stride
        start = index * block_data_size
        end = start + block_data_size

        if separate:
            block_view, block_prim_view = self.prepare_separated_block()
            block_view[:] = model_view[start:end]

        model_view[start:-block_data_size] = model_view[end:]
        model_array.set_num_rows(model_vert_count - self.block_vert_count)

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

        if separate:
            block_prim_view[:] = model_prim_view[:self.block_prim_vert_count]

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

    def __add_random_block(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_block(pos)

    def __remove_random_block(self):

        if not self.used_positions:
            return

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

    def __drop_blocks(self, task):

        for block in self.falling_blocks[:]:
                
            z = block.get_z()

            if z < -10.:
                self.falling_blocks.remove(block)
                block.detach_node()
            else:
                block.set_z(z - .1)

        return task.cont


app = MyApp()
app.run()

So as you can see, I’ve explicitly set the intended format on the smiley data to make it compatible with the chunk model. Then I keep references to memoryviews of its vertex data and its primitive, which I assign to subviews of the chunk each time I want to add a new smiley.

If you have blocks of different complexity, then the code will get a bit more complicated as well. For instance, if you want to remove such a block, it won’t be as simple anymore as multiplying a constant vertex data size by the index of that block to figure out where its vertex data is located. Instead, you’ll have to keep track of which block has which data size and sum up all of the data sizes associated with the blocks preceding the one to be deleted in the list of blocks. It’s doable, just more work and more bookkeeping.

If you mean how to set a specific size (width == height == depth) for a cube, then you could alter the cube-generation code like this (only the changed portion is listed):

def create_cube(size):

    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.
    half_size = size * .5
    coords = (
        (-half_size, -half_size),
        (-half_size, half_size),
        (half_size, half_size),
        (half_size, -half_size)
    )

    for direction in (-1, 1):

        for i in range(3):

            normal = VBase3()
            normal[i] = direction

            for a, b in coords:

                pos = Point3()
                pos[i] = direction * half_size
    ...

What I failed to mention previously, is that the generated cube is actually 2 units in size (x-, y- and z-coordinates range from -1 to 1), hence the half_size variable in the revised code above, to make sure the resulting width, height and depth will equal the given size.

But if instead of a perfect cube you want a general box-shape for your blocks, with different dimensions, you can change the code to the following:

def create_cube(width, depth, height):

    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 ((-.5, -.5), (-.5, .5), (.5, .5), (.5, -.5)):

                pos = Point3()
                pos[i] = direction * .5
    ...
    memview = memoryview(data_array).cast("B").cast("f")
    memview[:] = values
    mat = Mat4.scale_mat(width, depth, height)
    vertex_data.transform_vertices(mat)

    tris_prim = GeomTriangles(GeomEnums.UH_static)
    ...

In fact, the latter version can also be used instead of the former; just pass the same value for all three dimensions. The first version would still be more efficient for setting a uniform size, though.

2 Likes