I’ve been working on embedding a Panda3D window inside a PySide2/Qt5 widget. There’s a previous effort here that’s a bit aged:
However since that time, Qt4 has changed to Qt5, the Qt OpenGL interface class has changed to QOpenGLWidget
and the previous solution deprecated, and Panda3D has moved into preferring the ShowBase
class. So time for an update. I tried working from the snippets posted above but ended up more relying on WxPandaWindow.py
and trying to infer the differences required in PySide2
.
The main issues that I’ve not solved yet:
-
Cannot resize. I try setting
WindowProperties
as follows:wp = WindowProperties() pos = self.pos() wp.setOrigin(pos.x(), pos.y()) wp.setSize(width, height) self.base.win.requestProperties(wp)
It appears to change for one frame which is only partially rendered, and then back to the
original size.
2. Not sure how to propagate mouse events to Panda3D in a nice manner.
3. Any required cleanup on application exit?
4. Sometimes I get janky frames where it looks to me that the frame buffer is being swapped unexpectedly.
The provided snippet here is a port of the samples/shader_terrain
module, so you will have to put the file in that directory to allow it to find the required assets. The other requirement is pyside2
, which can be installed via pip install pyside2
:
from typing import Tuple
import logging
logging.basicConfig(level=logging.DEBUG, datefmt='%H:%M')
log = logging.getLogger('p3d_qt')
import os, sys, platform
from functools import partial
from PySide2 import QtGui as qg, QtCore as qc, QtWidgets as qw
from PySide2.QtCore import Qt
import OpenGL # install as `pyopengl`
OpenGL.ERROR_CHECKING = True
# Panda imports
import panda3d.core as p3c
from panda3d.core import Thread
from panda3d.core import WindowProperties, CallbackGraphicsWindow, Texture
# Set up Panda environment
from direct.showbase.ShowBase import ShowBase
from panda3d.core import ShaderTerrainMesh, Shader, load_prc_file_data
from panda3d.core import SamplerState
from panda3d.core import KeyboardButton, MouseButton
class MainWindow(qw.QMainWindow):
def __init__(self):
qw.QMainWindow.__init__(self)
self.setupUi()
def setupUi(self):
splitter = qw.QSplitter(parent=self)
self.setCentralWidget(splitter)
# Sidebar
# -------
sidebar = qw.QWidget()
sidebar.setFixedWidth(200)
splitter.addWidget(sidebar)
self.sideForm = qw.QFormLayout(parent=sidebar)
sidebar.setLayout(self.sideForm)
cbWireframe = qw.QCheckBox('Wireframe', parent=sidebar)
self.sideForm.addRow('', cbWireframe)
# Panda3D
# -------
self.view3d = ShaderTerrainWidget(parent=self)
splitter.addWidget(self.view3d)
# Menubar
# -------
menubar = qw.QMenuBar(self)
self.setMenuBar(menubar)
menuFile = qw.QMenu(menubar)
menuFile.setTitle('File')
actionQuit = qw.QAction(self)
actionQuit.setText('Quit')
actionQuit.triggered.connect(self.quitApp)
menuFile.addAction(actionQuit)
menuHelp = qw.QMenu(menubar)
menuHelp.setTitle('Help')
menubar.addAction(menuFile.menuAction())
menubar.addAction(menuHelp.menuAction())
# Status Bar
# ==========
self.statusbar = qw.QStatusBar(parent=self)
self.setStatusBar(self.statusbar)
statusWidget = qw.QWidget(parent=self)
statusLayout = qw.QHBoxLayout(statusWidget)
statusLayout.setContentsMargins(5, 0, 5, 3)
self.statusbar.addPermanentWidget(statusWidget)
self.progressbar = qw.QProgressBar(parent=self)
self.progressbar.setGeometry(30, 40, 100, 25)
self.progressbar.setFormat('%p %')
self.progressbar.reset()
statusLayout.addStretch()
statusLayout.addWidget(self.progressbar)
# Connect signals
# ===============
cbWireframe.toggled.connect(self.view3d.base.toggleWireframe)
self.resize(1920, 1080)
self.setWindowTitle('Panda3D embedded in PySide2/Qt5')
def quitApp(self):
log.info('Shutting down...')
del self.view3d
Q_APP.exit()
class ShaderTerrainWidget(qw.QOpenGLWidget):
"""
Here we extend the `panda3d/samples/shader_terrain/main.py` module to be
embedded as a `QtWidgets.QOpenGLWidget` inside a Qt5/PySide2 application.
An effort was made very long ago to embed Panda3D in PyQt4:
https://discourse.panda3d.org/t/panda-in-pyqt/3964
However, since then both the API for Panda3D has changed to use `ShowBase`
and the API for Qt5 has changed to use `QOpenGLWidget` (instead of the
deprecated `QGLWidget`).
Therefore this effort is mostly from scratch. The model is the
`direct/wxwidgets` module, but there are substantial differences in the
programming model of Wx and Qt5. The general desire, however, is to disable
the Panda3D event loop and use the Qt main GUI thread event loop to trigger
rendering and buffer swaps.
The relevant Qt classes are:
QtWidget.QOpenGLWidget:
https://doc-snapshots.qt.io/qtforpython/PySide2/QtWidgets/QOpenGLWidget.html
QtGui.QOpenGLContext:
https://doc-snapshots.qt.io/qt5-5.11/qopenglcontext.html
QtGui.QSurface:
https://doc-snapshots.qt.io/qt5-5.11/qsurface.html
Here I'm using the Qt5 C++ docs instead of the PySide2 docs as they are
considerably more complete for the OpenGL side.
In terms of Panda3D classes, I recommend looking at the source as the API
docs are not illuminating.
direct.wxwidgets.WxPandaWindow:
https://github.com/panda3d/panda3d/blob/master/direct/src/wxwidgets/WxPandaWindow.py
Main issues:
1. Cannot resize. Or it does but then the frame buffer changes back again afterward?
2. Not sure how to propagate mouse events to Panda3D in a nice manner.
3. TODO: any required cleanup?
4. Collision detection. See for example `direct/src/controls/ObserverWalker.py`
"""
def __init__(self, parent=None):
qw.QOpenGLWidget.__init__(self, parent=parent)
# Load some configuration variables, its important for this to happen
# before the ShowBase is initialized
load_prc_file_data("", """
window-type offscreen
load-display pandagl
gl-coordinate-system default
show-frame-rate-meter #t
gl-debug #t
""")
self.visible = False
self.base = ShowBase()
# Initialize OpenGL in Qt
self.initializeGL()
# Can't share the GSG when a new wxgl.GLContext is created automatically.
gsg = None
if not self.base.pipe:
self.base.makeDefaultPipe()
pipe = self.base.pipe
if pipe.getInterfaceName() != 'OpenGL':
self.base.makeAllPipes()
for pipe in self.base.pipeList:
if pipe.getInterfaceName() == 'OpenGL':
break
if pipe.getInterfaceName() != 'OpenGL':
raise Exception("Couldn't get an OpenGL pipe.")
callbackWindowDict = {
'Events' : self._eventsCallback,
'Properties' : self._propertiesCallback,
'Render' : self._renderCallback,
}
self.base.win = self.base.openWindow(callbackWindowDict=callbackWindowDict,
pipe=pipe, gsg=gsg, type='onscreen')
self.hasCapture = False
self.inputDevice = None
if hasattr(self.base.win, 'getInputDevice'):
self.inputDevice = self.base.win.getInputDevice(0)
else:
log.info(f'WARNING: no `getInputDevice` in {self.base.win}')
self.base.setFrameRateMeter(True)
# Increase camera FOV as well as the far plane
self.base.camLens.set_fov(110)
self.base.camLens.set_near_far(0.1, 50000)
# Construct the terrain
self.base.terrain_node = ShaderTerrainMesh()
# Set a heightfield, the heightfield should be a 16-bit png and
# have a quadratic size of a power of two.
# TODO generate with `pyfastnoisesimd`
heightfield = self.base.loader.loadTexture("heightfield.png")
heightfield.wrap_u = SamplerState.WM_clamp
heightfield.wrap_v = SamplerState.WM_clamp
self.base.terrain_node.heightfield = heightfield
# Set the target triangle width. For a value of 10.0 for example,
# the terrain will attempt to make every triangle 10 pixels wide on screen.
self.base.terrain_node.target_triangle_width = 10.0
# Generate the terrain
self.base.terrain_node.generate()
# Attach the terrain to the main scene and set its scale. With no scale
# set, the terrain ranges from (0, 0, 0) to (1, 1, 1)
self.base.terrain = self.base.render.attach_new_node(self.base.terrain_node)
self.base.terrain.set_scale(1024, 1024, 100)
self.base.terrain.set_pos(-512, -512, -70.0)
# Set a shader on the terrain. The ShaderTerrainMesh only works with
# an applied shader. You can use the shaders used here in your own application
terrain_shader = Shader.load(Shader.SL_GLSL, 'terrain.vert.glsl', 'terrain.frag.glsl')
self.base.terrain.set_shader(terrain_shader)
self.base.terrain.set_shader_input('camera', self.base.camera)
# Set some texture on the terrain
grass_tex = self.base.loader.loadTexture('textures/grass.png')
grass_tex.set_minfilter(SamplerState.FT_linear_mipmap_linear)
grass_tex.set_anisotropic_degree(4)
self.base.terrain.set_texture(grass_tex)
# Load a skybox
skybox = self.base.loader.loadModel('models/skybox.bam')
skybox.reparent_to(self.base.render)
skybox.set_scale(20000)
skybox_texture = self.base.loader.loadTexture('textures/skybox.jpg')
skybox_texture.set_minfilter(SamplerState.FT_linear)
skybox_texture.set_magfilter(SamplerState.FT_linear)
skybox_texture.set_wrap_u(SamplerState.WM_repeat)
skybox_texture.set_wrap_v(SamplerState.WM_mirror)
skybox_texture.set_anisotropic_degree(16)
skybox.set_texture(skybox_texture)
skybox_shader = Shader.load(Shader.SL_GLSL, 'skybox.vert.glsl', 'skybox.frag.glsl')
skybox.set_shader(skybox_shader)
self._key_map = {
Qt.Key_Up : partial(self.shiftCamera, ( 0.0, 2.5, 0.0)),
Qt.Key_Down : partial(self.shiftCamera, ( 0.0, -2.5, 0.0)),
Qt.Key_Left : partial(self.shiftCamera, (-2.5, 0.0, 0.0)),
Qt.Key_Right : partial(self.shiftCamera, ( 2.5, 0.0, 0.0)),
Qt.Key_PageDown : partial(self.shiftCamera, ( 0.0, 0.0, -2.5)),
Qt.Key_PageUp : partial(self.shiftCamera, ( 0.0, 0.0, 2.5)),
Qt.Key_F3 : self.base.toggleWireframe,
}
# Setup a timer in Qt that runs taskMgr.step() to simulate Panda's own main loop
# Looking at ShowBase.spawnWxLoop() here for guidance
self.pandaTimer = qc.QTimer(self)
self.pandaTimer.timeout.connect(self.paintGL)
self.pandaTimer.start(1)
def __del__(self):
"""
Parent windows should call cleanup() to clean up the
wxPandaWindow explicitly (since we can't catch EVT_CLOSE
directly).
"""
log.info('TODO: cleanup the hidden Panda3D window?')
def initializeGL(self):
log.info('TODO: what does tasks does Panda3D need done before start?')
self.visible = True
def resizeGL(self, width: int, height: int):
"""
The Qt event loop calls this function whenever it wants to resize the
OpenGL frame.
"""
if self.base.win:
# Taken from `WxPandaWindow.py` but does not appear to actually work...
# Or it seems to change for one frame and then change back...
wp = WindowProperties()
pos = self.pos()
wp.setOrigin(pos.x(), pos.y())
wp.setSize(width, height)
# log.info(f'Resize to extent: {pos.x(), pos.y(), width, height}')
# log.info(f'Qt5 QSurfaceFormat: {self.format()}')
self.base.win.requestProperties(wp)
else:
log.error('base.win is None')
def paintGL(self):
"""
The Qt event loops calls this function whenever it needs to repaint the
OpenGL frame.
"""
self.makeCurrent()
self.visible = True
if Thread.getCurrentThread().getCurrentTask():
# This happens when the QTimer expires while igLoop is
# rendering. Ignore it.
return
self.base.taskMgr.step()
self.doneCurrent()
# TODO: try to push keys/mouse to the Panda3D instance.
def mousePressEvent(self, event: qg.QMouseEvent) -> None:
button = event.button()
log.info(f'Pressed button {button}')
self.setFocus(qc.Qt.MouseFocusReason)
def mouseReleaseEvent(self, event: qg.QMouseEvent) -> None:
button = event.button()
log.info(f'Released button {button}')
def mouseEnterEvent(self, event: qg.QMouseEvent) -> None:
log.info('Enter event')
def mouseMoveEvent(self, event: qg.QMouseEvent):
self.setFocus(qc.Qt.MouseFocusReason)
def wheelEvent(self, event: qg.QMouseEvent) -> None:
if event.delta() > 0:
self.shiftCamera((0.0, 0.0, -2.5))
else:
self.shiftCamera((0.0, 0.0, 2.5))
def keyPressEvent(self, event: qg.QKeyEvent) -> None:
# Focus doesn't automatically come to the window so generally it has
# to be managed in the mouse events.
key = event.key()
# There are some more sophisticated camera controls in the
# `direct/src/controls` directory
if key in self._key_map:
self._key_map[key]()
else:
log.info(f'Received unhandled key press: {key}')
def _eventsCallback(self, data):
data.upcall()
def _propertiesCallback(self, data):
data.upcall()
def paintEvent(self, event) -> None:
"""
Some confusion here on whether this should be a do-nothing. It seems
like if this is not empty then it paints gray over the Panda3D buffer.
From Qt docs:
Drawing directly to the QOpenGLWidget's framebuffer outside the
GUI/main thread is possible by reimplementing paintEvent() to do
nothing. The context's thread affinity has to be changed via
QObject::moveToThread(). After that, makeCurrent() and
doneCurrent() are usable on the worker thread. Be careful to move
the context back to the GUI/main thread afterwards.
Unlike QGLWidget, triggering a buffer swap just for the
QOpenGLWidget is not possible since there is no real, onscreen
native surface for it. Instead, it is up to the widget stack to
manage composition and buffer swaps on the gui thread. When a
thread is done updating the framebuffer, call update() on the
GUI/main thread to schedule composition.
"""
return
def _renderCallback(self, data: 'p3c.RenderCallbackData'):
"""
It looks to me that we get two of these, one of the RCTBeginFrame and
then the RCTEndFlip when we should swap the buffer. Except Qt does the
buffer swap in `self.paintGL`.
"""
fb = self.defaultFramebufferObject()
cbType = data.getCallbackType()
if cbType == CallbackGraphicsWindow.RCTBeginFrame:
if not self.visible:
# TODO: any nicer way than having some sentinel flag to prevent
# the _renderCallback from executing?
data.setRenderFlag(False)
return
# makeCurrent is called in paintGL
# Don't upcall() in this case.
return
elif cbType == CallbackGraphicsWindow.RCTEndFlip:
# Old WxPython means of swapping frame buffer:
# self.SwapBuffers()
#
# According to Qt docs, we should just call `self.update()` here
# and it will swap the buffers and call `self.doneCurrent().
self.update()
self.visible = False
data.upcall()
def shiftCamera(self, delta: Tuple[float]) -> None:
"""
Moves the camera. Designed to be connected via Qt slots using `partial`.
Parameters
----------
delta
a (x,y,z) tuple giving the desired relative shift from the current position.
"""
camera = self.base.camera
camera.setPos(camera.getX() + delta[0], camera.getY() + delta[1], camera.getZ() + delta[2])
if __name__ == '__main__':
Q_APP = qw.QApplication(sys.argv)
mainWindow = MainWindow()
mainWindow.show()
Q_APP.exec_()
del mainWindow
del Q_APP
sys.exit(0)
If anyone has any advice, particularly on the resizing, that would be appreciated.