Writing panda3d to movie file [status in 2022]

I’m not the first person to ask about this, but it’s been a while since the question has been asked. Here are related topics I’ve found dating back more than a decade:

And here is a blog post from 2015 where someone streams frames into an ffmpeg command:

https://pytan.hatenablog.com/entry/2015/07/21/223331

Is there any status update on movie making capabilities in panda3d? I am trying to programmatically save thousands of panda3d clips as movies.

You can extract frames to memory, or have them saved as individual images on disk, but there is no built-in video encoding in Panda3D.

Your options are:

  1. Use external software to record (eg. OBS)
  2. Save images to disk and use an external command (eg. ffmpeg) to encode them as video
  3. Use another Python library to encode frames you’ve asked Panda to copy to RAM using RTMCopyRam.

Hey @rdb thanks for the status update (and sorry for the late reply). This is very useful to know–thank you!

I have a related question for you. Is it possible to save frames to a PNG without displaying the scene in a window? Although this is admittedly a niche use case of panda3d, I do have a genuine need for this. I’m using panda3d as a visualization tool for a billiard simulation, and want to save hundreds of thousands of simulated shots as image stacks for machine learning purposes. This is going to be run from the command line on a cluster, without a screen or user interface.

Currently, calling self.closeWindow(base.win) leads to an error when calling base.win.saveScreenshot(output):

AttributeError: 'NoneType' object has no attribute 'saveScreenshot'

In the docs (WindowProperties — Panda3D Manual) it says:

It is legal to create a GraphicsWindow in the closed state, and later request it to open by changing this flag.

I’m in over my head here, but could this be useful for me?

Of course! Panda was made for niche use cases!

You can set window-type offscreen in Config.prc or pass “offscreen” to the ShowBase constructor to get it to construct an offscreen buffer instead of an on-screen window. Then what you want to do is easily possible.

If you have to do this every frame, I suggest doing something like this:

tex = Texture()
base.win.addRenderTexture(tex, GraphicsOutput.RTMCopyRam, GraphicsOutput.RTPColor)

That will make Panda automatically copy the window’s contents into a texture every frame and transfer it from the GPU to the CPU, which you can then read out in a task. Check tex.hasRamImage(), if it returns true, write it to disk with write('something.png').

Since writing it to disk can be expensive, what you can do (requires Panda3D 1.10.13) is use copy.deepcopy(tex) to create a copy of the texture in that task, put that copy in a thread-safe queue, and have a separate thread pop textures off that queue to do the write() call.

:smiley: I love to hear that.

Ok this is very awesome. I got this to work.

This, I am struggling with. I’ve made a small example that exhibits the problem:

import sys
from math import pi, sin, cos
from pathlib import Path
from shutil import rmtree

from direct.showbase import ShowBaseGlobal
from direct.showbase.ShowBase import ShowBase
from direct.actor.Actor import Actor
from panda3d.core import Texture, GraphicsOutput


class HelloWorldStuff(ShowBase):
    """Cram a small example from the hello world tutorial into this class"""

    def __init__(self, *args, **kwargs):
        ShowBase.__init__(self, *args, **kwargs)
        self.scene = self.loader.loadModel("models/environment")
        self.scene.reparentTo(self.render)
        self.scene.setScale(0.25, 0.25, 0.25)
        self.scene.setPos(-8, 42, 0)
        self.taskMgr.add(self.spinCameraTask, "SpinCameraTask")
        self.pandaActor = Actor("models/panda-model", {"walk": "models/panda-walk4"})
        self.pandaActor.setScale(0.005, 0.005, 0.005)
        self.pandaActor.reparentTo(self.render)
        self.pandaActor.loop("walk")

        self.base = ShowBaseGlobal.base

    def spinCameraTask(self, task):
        angleDegrees = task.time * 6.0
        angleRadians = angleDegrees * (pi / 180.0)
        self.camera.setPos(20 * sin(angleRadians), -20 * cos(angleRadians), 3)
        self.camera.setHpr(angleDegrees, 0, 0)
        return task.cont


class App(HelloWorldStuff):
    def __init__(self):
        HelloWorldStuff.__init__(self, windowType="offscreen")

        self.taskMgr.add(self.task_is_done, "is_done")
        self.taskMgr.add(self.task_increment_frame, "increment_frame")
        self.taskMgr.add(self.task_save_frame, "save_frame")

        self.frame = 0
        self.init_save_dir()

    def task_increment_frame(self, task):
        self.frame += 1
        return task.cont

    def task_is_done(self, task):
        if self.frame == 10:
            sys.exit()
        return task.cont

    def task_save_frame(self, task):
        tex = Texture()

        self.base.win.addRenderTexture(
            tex,
            GraphicsOutput.RTMCopyRam,
            GraphicsOutput.RTPColor
        )

        if tex.hasRamImage():
            tex.write(self._get_next_filepath())

        return task.cont

    def init_save_dir(self):
        self.dir_name = Path("screenshot_output")
        if self.dir_name.exists():
            rmtree(self.dir_name)
        self.dir_name.mkdir()

    def _get_next_filepath(self):
        return f"{self.dir_name}/frame_{self.frame:05d}.png"


app = App()
app.run()

The problem is that tex.hasRamImage() returns False so nothing is written. If I write anyways I get:

AssertionError: do_has_ram_mipmap_image(cdata, n) at line 5104 of panda/src/gobj/texture.cxx

This is my OS:

I may be mistaken, but I think that the problem might be that you’re creating your texture and adding it as a render-texture every time you save–and right before doing so, meaning that the renderer hasn’t yet had a chance to do anything with the new texture.

What I might suggest is to create the texture and add it as a render-texture just once, before you start saving frames. Thereafter calls to “tex.write” should save out the most-recently rendered frame.

Hey @Thaumaturge, thanks for your help. You were right. For future readers, here is modified script which works:

import sys
from math import pi, sin, cos
from pathlib import Path
from shutil import rmtree

from direct.showbase import ShowBaseGlobal
from direct.showbase.ShowBase import ShowBase
from direct.actor.Actor import Actor
from panda3d.core import Texture, GraphicsOutput


class HelloWorldStuff(ShowBase):
    """Cram a small example from the hello world tutorial into this class"""

    def __init__(self, *args, **kwargs):
        ShowBase.__init__(self, *args, **kwargs)
        self.scene = self.loader.loadModel("models/environment")
        self.scene.reparentTo(self.render)
        self.scene.setScale(0.25, 0.25, 0.25)
        self.scene.setPos(-8, 42, 0)
        self.taskMgr.add(self.spinCameraTask, "SpinCameraTask")
        self.pandaActor = Actor("models/panda-model", {"walk": "models/panda-walk4"})
        self.pandaActor.setScale(0.005, 0.005, 0.005)
        self.pandaActor.reparentTo(self.render)
        self.pandaActor.loop("walk")

        self.base = ShowBaseGlobal.base

    def spinCameraTask(self, task):
        angleDegrees = task.time * 6.0
        angleRadians = angleDegrees * (pi / 180.0)
        self.camera.setPos(20 * sin(angleRadians), -20 * cos(angleRadians), 3)
        self.camera.setHpr(angleDegrees, 0, 0)
        return task.cont


class App(HelloWorldStuff):
    def __init__(self):
        HelloWorldStuff.__init__(self, windowType="offscreen")

        self.taskMgr.add(self.task_save_frame, "save_frame")
        self.taskMgr.add(self.task_increment_frame, "increment_frame")
        self.taskMgr.add(self.task_is_done, "is_done")

        self.frame = 0
        self.init_save_dir()
        self.init_texture()

    def task_increment_frame(self, task):
        self.frame += 1
        return task.cont

    def task_is_done(self, task):
        if self.frame == 10:
            sys.exit()
        return task.cont

    def task_save_frame(self, task):
        if self.tex.hasRamImage():
            self.tex.write(self._get_next_filepath())

        return task.cont

    def init_save_dir(self):
        self.dir_name = Path("screenshot_output")
        if self.dir_name.exists():
            rmtree(self.dir_name)
        self.dir_name.mkdir()

    def init_texture(self):
        self.tex = Texture()

        self.base.win.addRenderTexture(
            self.tex,
            GraphicsOutput.RTMCopyRam,
            GraphicsOutput.RTPColor
        )

    def _get_next_filepath(self):
        return f"{self.dir_name}/frame_{self.frame:05d}.png"


app = App()
app.run()

This is worth keeping in mind. For my workflow, I plan on writing a single-threaded routine that simulates a shot (or multiple shots) and then saves all the images. I’ll definitely want to parallelize, but it’s probably easier to do so upstream of this routine. If I change my mind, I’ll ask for more details. Though out of interest, if you wanted to spell out in further detail how one would create this thread-safe queue, I would update the example I’ve created for future users.

1 Like

One last question here. How can I inject base.win (the GraphicsBuffer object) with WindowProperties or FrameBufferProperties settings? For example, if I wanted the window size to be a different aspect ratio, or I wanted to disable the alpha channel?

I tried this (as well as messing with the sort parameter), but all I got was a grey screen:

        self.base.win = self.base.graphicsEngine.makeOutput(
            pipe=self.base.pipe,
            name="internal",
            sort=-1,
            fb_prop=fb_prop,
            win_prop=win_prop,
            flags=GraphicsPipe.BFRefuseWindow,
        )

You can change the buffer size with setSize() (assuming you created it with the BFResizeable flag), that is the only “window property” that a buffer has. For a normal window, it would be done with requestProperties().

You can’t change the FrameBufferProperties on the fly, you’d need to open a new buffer. You get a grey screen because you haven’t reconfigured the existing camera and scene graph to render inside the new buffer. It may be easier to just use the ShowBase interfaces, closeWindow and openMainWindow.

1 Like

Thanks @rdb, I opted to use openMainWindow. For any passersby, I wrote the following method which I called in App.__init__ before init_texture:

    def init_window(self):
        fb_prop = FrameBufferProperties()
        fb_prop.setRgbColor(True)
        fb_prop.setRgbaBits(8, 8, 8, 0)
        fb_prop.setDepthBits(24)

        self.openMainWindow(fbprops=fb_prop, size=(640, 480))

You might also find these threads interesting, which appeared since your original post:

2 Likes