Dynamic, Per-Update Alteration of Geometry

Here below is a small snippet that demonstrates one way of modifying geometry dynamically, every frame.

Simply put, it defines a triangle, places it in the scene-graph, and then makes its vertices pulsate a bit.

On the technical side, this makes use of Python’s “memoryview” and “array” classes, as well as Panda’s low-level geometry-specification classes (e.g. GeomVertexData). For more detail on the fundaments of using the Panda classes, see the manual.

And so, the snippet!
(Note that it’s not quite as long as it may seem: the comments are quite long!)

from direct.showbase.ShowBase import ShowBase

from panda3d.core import Vec3, Vec2
from panda3d.core import TextureStage
from panda3d.core import Geom, GeomNode, NodePath
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomTriangles

import math
import array

class Game(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # We'll be modifying our geometry every frame,
        # so we start an update-task in which to do so
        self.taskMgr.add(self.update, "update")

        # Here below we set up our procedural geometry...

        # First we specify the format of our geometry.
        # Or, in this case, just get one of the default formats
        format = GeomVertexFormat.getV3t2()
        # Note that, while the above formats only use float-values,
        # there are others that use byte-values as well. This complicates
        # things a little bit, so I'm purposefully avoiding such.
        # Check the manual, on the page regarding
        # "Using memoryviews" for the means to handle that!

        # Now we create our actual data-object
        # Note that, since we want to modify our geometry
        # every frame, we want to retain access to this--
        # and so we keep it as an instance-variable.
        # (i.e. We put "self." before it.)
        self.vertexData = GeomVertexData("pulsating triangle data", format, Geom.UHStatic)

        # Next, initialise the data-object to indicate
        # How much data we intend for it to hold.
        # We're just making a single triangle for this snippet,
        # hence we specify a row-count of 3.
        # Again, I think that the use of a format
        # that uses bytes complicates this a bit.
        self.vertexData.setNumRows(3)

        # Now we construct the GeomNode and Geom within it
        # that will hold this data.
        node = GeomNode("pulsating triangle")

        geom = Geom(self.vertexData)
        node.addGeom(geom)

        # A "primitive"-object then specifies how the vertices
        # go together to form geometry.
        # In this case, we just have three vertices, identified
        # by their indices: 0, 1, and 2
        primitive = GeomTriangles(Geom.UHStatic)
        primitive.addVertices(0, 1, 2)
        geom.addPrimitive(primitive)

        # Of course, at this point our triangle isn't yet actually
        # in the scene-graph, and so won't be rendered.
        # So, we add it to the scene-graph!
        self.triangleNodePath = self.render.attachNewNode(node)

        # Now, by default a node will be at position (0, 0, 0)--which is
        # also the default position of the camera. So that our node
        # is immediately visible, we set its y-coordinate (via the node-path)
        # so that it's in front of the camera.
        self.triangleNodePath.setY(10)

        # And finally, we load a texture--here just one of those provided
        # by Panda--and apply it to the node that holds our triangle
        texture = loader.loadTexture("maps/noise.rgb")
        self.triangleNodePath.setTexture(TextureStage.getDefault(), texture)

    def update(self, task):
        # We want our geometry to change over time, so we first get the time
        time = self.clock.getFrameTime()
        
        # Now, let's calculate positions and UVs for our triangle
        # This just uses the "sine"-function with offset phases, effectively
        # making the vertices move in-and-outwards
        
        # Topmost vertex
        vertexPos1 = Vec3(0, 0, 1)
        vertexPos1 *= 1 + math.sin(time) * 0.5
        # Quick explanation: Sine goes from -1 to 1. But multiplying our
        # position by that would have the vertices becoming negative!
        # So we scale the result by 0.5--bringing us to a range from -0.5 to 0.5--
        # and then add 1--bringing us to a final range of 0.5 to 1.5.
        # Thus the vertex goes from half its default extent to a little more
        # than its default extent--0.5 times that extent to 1.5 times that extent

        # We're not actually varying our UVs in this snippet, so we just set them
        # via simple hard-coded values
        uv1 = Vec2(0.5, 1)

        # And then much the same for the other two vertices:
        
        # Bottom-left vertex
        vertexPos2 = Vec3(-1, 0, -1)
        vertexPos2 *= 1 + math.sin(time + math.pi / 3) * 0.5
        
        uv2 = Vec2(0, 0)
        
        # Bottom-right vertex
        vertexPos3 = Vec3(1, 0, -1)
        vertexPos3 *= 1 + math.sin(time + math.pi * 2 / 3) * 0.5
        
        uv3 = Vec2(1, 0)
        
        # Now, the actual modification of our geometry...

        # First, we use our stored data-object to gain access
        # to the array that holds our geometry
        vertexArray = self.vertexData.modifyArray(0)

        # Then we create a memoryview to actually modify that array.
        # Note that we create a new memoryview every update!
        # Storing a single memoryview for use across multiple updates
        # doesn't work, I believe.
        view = memoryview(vertexArray).cast("B").cast("f")
        # (If I'm not much mistaken, the casts there first
        # transform it from its default format to unsigned bytes, then to float.)

        # Next we create a Python array in which to place our modified data.
        # We'll copy this into our memoryview, later
        dataArray = array.array("f", [])
        # This is arguably overkill for a single triangle--but I do find it
        # to be more readable than handling every value one call at a time,
        # and may scale better to more-complex geometry.

        # Then we fill our array with the position- and UV- values
        # that we computed earlier.
        # Note that the positions and UVs are interleaved--that is,
        # we add all values for a given vertex before moving on
        # to the next vertex.
        dataArray.fromlist(
            [
                # Vertex 1
                vertexPos1.x, vertexPos1.y, vertexPos1.z,
                uv1.x, uv1.y,
                
                # Vertex 2
                vertexPos2.x, vertexPos2.y, vertexPos2.z,
                uv2.x, uv2.y,
                
                # Vertex 3
                vertexPos3.x, vertexPos3.y, vertexPos3.z,
                uv3.x, uv3.y,
            ]
        )

        # And finally, we copy the data into our memoryview!
        view[:21] = dataArray

        # And then we return from the method, indicating that
        # we want the task to continue on
        return task.cont

# And of course, we run the program!
game = Game()
game.run()

And a (scaled) gif showing the expected results:
dynamicGeometrySnippet

4 Likes