Examples or ideas for procedural morphing?

I’m making procedural Geoms, and when they spawn I want them to start in a ‘wrong’ initial configuration then morph into their permanent final configuration. Each geometry is unique-ish and generated procedurally, so I don’t want to use a 3d modeling program. Picture a cube spawning, then morphing into a sphere, but with scene-dependent features on both.

Does someone have an example of a way to do this? Or a good idea for how to proceed? I’m having trouble finding a starting point.

It would be nice to be able to simply generate the displaced vertex coordinates as I generate the final coordinates all in the same process to keep displacements correctly registered. I’m scared to learn shaders, but if that’s the way forward, okay.

For example, you can look at this.

Does that morph? Reading through the code, it just looks like it uses a cube as a template for a sphere. I’m looking for the morph to take several seconds and be animated. I can see that snippet being the starting point for the solution I’m looking for… I just don’t know how to tell it to use the newly defined state as the target

For this to happen, you need to calculate the intermediate positions for the vertices, and start playback, for example, as here. In this example, there are only two positions.

from direct.showbase.ShowBase import ShowBase

from direct.task.Task import Task
from panda3d.core import GeomVertexRewriter
from direct.showbase.PythonUtil import *

class MyApp(ShowBase):

    def __init__(self):
        ShowBase.__init__(self)
        
        f=loader.loadModel('plane.egg')
        f.reparentTo(render)
        self.time = 1
        self.timerVertexPosZ = 0
        self.mf = 0

        geomNodeCollection = f.findAllMatches('**/+GeomNode')

        for nodePath in geomNodeCollection:
            geomNode = nodePath.node()
            for i in range(geomNode.getNumGeoms()):
                geom = geomNode.modifyGeom(i)
            self.vdata = geom.modifyVertexData()
            
        taskMgr.add(self.mLoop,'mLoop')

    def processVertexData(self, vdata):

        if self.mf==1:
            self.timerVertexPosZ = self.timerVertexPosZ+.1
        elif self.mf==0:
            self.timerVertexPosZ = self.timerVertexPosZ-.1
        if self.timerVertexPosZ>=2:
            self.mf=0
        elif self.timerVertexPosZ<=-2:
            self.mf=1
        vertex = GeomVertexRewriter(vdata, 'vertex')
        tnv=0
        while not vertex.isAtEnd():
            tnv=tnv+1
            v = vertex.getData3f()
            vPosZ=0
            if tnv==6 or tnv==7:
                vPosZ=self.timerVertexPosZ
            vertex.setData3f(v[0],v[1],v[2]+vPosZ-v[2])

    def mLoop(self, t):
    
        self.time = self.time-.05
        if self.time <= 0:
            self. processVertexData(self.vdata)
            self.time = 1
        return Task.cont

app = MyApp()
app.run()

plane.egg (1.7 KB)

Rather than processing the geometry every frame, it may be more efficient to use morph targets (also known as shape keys) if all you want is to morph a piece of geometry between different shapes. The way this works is that you would generate the geometry as you would normally, probably in their final configuration. But you’d add another column to the vertex table containing deltas that move the vertices from their final configuration to the initial, wrong configuration. Then by setting the morph slider value between 0.0 and 1.0, Panda will morph between the two geometry configurations.

When creating the GeomVertexFormat, you’d create an additional vertex column called “vertex.morph.yourSlider” (set to GeomEnums.C_morph_delta contents mode) that contains the difference between the vertex position in the right configuration and in the wrong configuration. You’d also need to tell Panda that it is animated. Your format set-up would look something like this:

aformat1 = GeomVertexArrayFormat()
aformat1.addColumn(...regular vertex column...)
... other columns...

# Create a second array for the morphs
aformat2 = GeomVertexArrayFormat()
aformat2.addColumn("vertex.morph.yourSlider", 3, GeomEnums.NT_float32, GeomEnums.C_morph_delta)
# You can also morph other columns as needed, just change "vertex"

format = GeomVertexFormat()
format.addArray(aformat1)
format.addArray(aformat2)

# Indicate that this format should be animated by Panda.
aspec = GeomVertexAnimationSpec()
aspec.setPanda()
format.setAnimation(aspec)

format = GeomVertexFormat.registerFormat(format)

You would furthermore need to attach a SliderTable to the geometry with GeomVertexData.setSliderTable. The easiest way to create a slider you can control is to create a UserVertexSlider, with the name matching the one you chose in the vertex column:

# Create a SliderTable holding a slider, affecting all vertex rows.
slider = UserVertexSlider("yourSlider")
table = SliderTable()
table.addSlider(slider, SparseArray.allOn())

# vdata is the GeomVertexData you created and filled in
vdata.setSliderTable(SliderTable.registerTable(table))

Now, you will be able to call slider.setSlider(v), where v is between 0.0 and 1.0 to morph between the two shapes, or even outside that range to exaggerate the effect.

This has been really helpful! I distilled it down to the simplest case I could, a single triangle that morphs, but I’m getting an error that I can’t understand caused by my slider usage in main_loop:
Assertion failed: !rows.is_inverse() at line 1498 of c:\buildslave\sdk-windows-amd64\build\panda\src\gobj\geomVertexData.cxx

Here’s the code:

from direct.showbase.ShowBase import ShowBase
from panda3d.core import GeomVertexFormat, GeomVertexArrayFormat, GeomVertexData
from panda3d.core import Geom, GeomNode, GeomTriangles, GeomVertexWriter, GeomEnums, GeomVertexAnimationSpec
from panda3d.core import UserVertexSlider, SliderTable, SparseArray
from direct.task.Task import Task
from direct.task.TaskManagerGlobal import taskMgr

#   ----------------------------------------------------------------------------------------------------------------   #


# Create the primary configuration format:
base_format = GeomVertexArrayFormat()
base_format.add_column("vertex", 3,
                       GeomEnums.NT_float32, GeomEnums.C_point)
base_format.add_column("color", 4,
                       GeomEnums.NT_float32, GeomEnums.C_color)

# Create a second array for the morphs:
morph_format = GeomVertexArrayFormat()
morph_format.addColumn("vertex.morph.init_slider", 3, GeomEnums.NT_float32, GeomEnums.C_morph_delta)
# To morph other columns as needed, just change "vertex"

init_morph_format = GeomVertexFormat()
init_morph_format.addArray(base_format)
init_morph_format.addArray(morph_format)

# Indicate that this format should be animated by Panda.
ani_spec = GeomVertexAnimationSpec()
ani_spec.set_panda()
init_morph_format.set_animation(ani_spec)

init_morph_format = GeomVertexFormat.registerFormat(init_morph_format)

# Create a SliderTable holding a slider, affecting all vertex rows.
slider = UserVertexSlider("init_slider")
table = SliderTable()
table.addSlider(slider, SparseArray.allOn())


#   ----------------------------------------------------------------------------------------------------------------   #


def make_demo_morph_triangle(vect_0, vect_1, vect_2,
                             vect_0_init, vect_1_init, vect_2_init):
    vert_data = GeomVertexData('demo_triangle', init_morph_format, GeomEnums.UH_dynamic)

    vertex = GeomVertexWriter(vert_data, 'vertex')
    color = GeomVertexWriter(vert_data, 'color')
    morph = GeomVertexWriter(vert_data, 'vertex.morph.init_slider')

    for v_final, v_init in zip([vect_0, vect_1, vect_2],
                               [vect_0_init, vect_1_init, vect_2_init]):
        vertex.add_data3(v_final[0], v_final[1], v_final[2])
        morph.addData3(v_init[0] - v_final[0],
                       v_init[1] - v_final[1],
                       v_init[2] - v_final[2])

    color.add_data4f(1.0, 0.0, 0.0, 1.0)
    color.add_data4f(0.0, 1.0, 0.0, 1.0)
    color.add_data4f(0.0, 0.0, 1.0, 1.0)

    tris = GeomTriangles(GeomEnums.UHDynamic)
    tris.add_vertices(0, 2, 1)

    vert_data.setSliderTable(SliderTable.registerTable(table))

    tri = Geom(vert_data)
    tri.add_primitive(tris)

    return tri


#   ----------------------------------------------------------------------------------------------------------------   #

class TestMorphScene(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)
        self._active_slider = slider

    def add_triangle(self,
                     v0, v1, v2,
                     v0_init, v1_init, v2_init):

        tri_0 = make_demo_morph_triangle(v0, v1, v2,
                                         v0_init, v1_init, v2_init)
        tri_node = GeomNode('triangle')
        tri_node.addGeom(tri_0)
        self.render.attach_new_node(tri_node)
        taskMgr.add(self.main_loop, 'loop_task')

    def main_loop(self, task: Task):
        self._active_slider.set_slider((task.time / 2) % 1)
        return task.cont


#   ----------------------------------------------------------------------------------------------------------------   #

if __name__ == "__main__":
    pt_0 = (-2, 15, -2)
    pt_1 = (-2, 15, 2)
    pt_2 = (2, 15, -2)

    pt_0_init = (-4, 5, -4)
    pt_1_init = (-4, 5, 4)
    pt_2_init = (4, 5, -4)

    MyApp = TestMorphScene()
    MyApp.add_triangle(pt_0, pt_1, pt_2,
                       pt_0_init, pt_1_init, pt_2_init)

    MyApp.run()

Ah, my bad, it’s the fault of SparseArray.allOn(). Use this instead (replacing 4 with however many rows you have in your vertex data):

table.add_slider(slider, SparseArray.lower_on(4))

If you are only morphing a couple of vertices in the entire vertex data, you can make it more efficient by passing in a SparseArray that only contains the row numbers that the morph target affects, but if you are modifying a majority of the vertices, the above is best.

Yup, that solved it, thanks! Here’s the working demo code for other’s reference:

from direct.showbase.ShowBase import ShowBase
from panda3d.core import GeomVertexFormat, GeomVertexArrayFormat, GeomVertexData
from panda3d.core import Geom, GeomNode, GeomTriangles, GeomVertexWriter, GeomEnums, GeomVertexAnimationSpec
from panda3d.core import UserVertexSlider, SliderTable, SparseArray
from direct.task.Task import Task
from direct.task.TaskManagerGlobal import taskMgr

#   ----------------------------------------------------------------------------------------------------------------   #


# Create the primary configuration format:
base_format = GeomVertexArrayFormat()
base_format.add_column("vertex", 3,
                       GeomEnums.NT_float32, GeomEnums.C_point)
base_format.add_column("color", 4,
                       GeomEnums.NT_float32, GeomEnums.C_color)

# Create a second array for the morphs:
morph_format = GeomVertexArrayFormat()
morph_format.addColumn("vertex.morph.init_slider", 3, GeomEnums.NT_float32, GeomEnums.C_morph_delta)
# To morph other columns as needed, just change "vertex"

init_morph_format = GeomVertexFormat()
init_morph_format.addArray(base_format)
init_morph_format.addArray(morph_format)

# Indicate that this format should be animated by Panda.
ani_spec = GeomVertexAnimationSpec()
ani_spec.set_panda()
init_morph_format.set_animation(ani_spec)

init_morph_format = GeomVertexFormat.registerFormat(init_morph_format)

# Create a SliderTable holding a slider, affecting all vertex rows.
slider = UserVertexSlider("init_slider")
table = SliderTable()


#   ----------------------------------------------------------------------------------------------------------------   #


def make_demo_morph_triangle(vect_0, vect_1, vect_2,
                             vect_0_init, vect_1_init, vect_2_init):
    vert_data = GeomVertexData('demo_triangle', init_morph_format, GeomEnums.UH_dynamic)

    vertex = GeomVertexWriter(vert_data, 'vertex')
    color = GeomVertexWriter(vert_data, 'color')
    morph = GeomVertexWriter(vert_data, 'vertex.morph.init_slider')

    for v_final, v_init in zip([vect_0, vect_1, vect_2],
                               [vect_0_init, vect_1_init, vect_2_init]):
        vertex.add_data3(v_final[0], v_final[1], v_final[2])
        morph.addData3(v_init[0] - v_final[0],
                       v_init[1] - v_final[1],
                       v_init[2] - v_final[2])

    color.add_data4f(1.0, 0.0, 0.0, 1.0)
    color.add_data4f(0.0, 1.0, 0.0, 1.0)
    color.add_data4f(0.0, 0.0, 1.0, 1.0)

    tris = GeomTriangles(GeomEnums.UHDynamic)
    tris.add_vertices(0, 2, 1)

    num_vert_rows = 3
    table.addSlider(slider, SparseArray.lower_on(num_vert_rows))
    vert_data.setSliderTable(SliderTable.registerTable(table))

    tri = Geom(vert_data)
    tri.add_primitive(tris)

    return tri


#   ----------------------------------------------------------------------------------------------------------------   #

class TestMorphScene(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)
        self._active_slider = slider

    def add_triangle(self,
                     v0, v1, v2,
                     v0_init, v1_init, v2_init):

        tri_0 = make_demo_morph_triangle(v0, v1, v2,
                                         v0_init, v1_init, v2_init)
        tri_node = GeomNode('triangle')
        tri_node.addGeom(tri_0)
        self.render.attach_new_node(tri_node)
        taskMgr.add(self.main_loop, 'loop_task')

    def main_loop(self, task: Task):
        self._active_slider.set_slider((task.time / 2) % 1)
        return task.cont


#   ----------------------------------------------------------------------------------------------------------------   #

if __name__ == "__main__":
    pt_0 = (-2, 15, -2)
    pt_1 = (-2, 15, 2)
    pt_2 = (2, 15, -2)

    pt_0_init = (-4, 15, -4)
    pt_1_init = (-4, 15, 4)
    pt_2_init = (4, 15, -4)

    MyApp = TestMorphScene()
    MyApp.add_triangle(pt_0, pt_1, pt_2,
                       pt_0_init, pt_1_init, pt_2_init)

    MyApp.run()