Skip to content

Commit 667367a

Browse files
committed
Improve support of concurrent OpenGL contexts for Rasterizer.
1 parent 5b1fca1 commit 667367a

File tree

9 files changed

+136
-78
lines changed

9 files changed

+136
-78
lines changed

.github/workflows/generic.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ jobs:
162162
163163
- name: Run unit tests
164164
run: |
165-
pytest -v --logical --dev --backend ${{ matrix.GS_BACKEND }} -m 'required and not slow' --forked ./tests
165+
pytest -v -ra --logical --dev --backend ${{ matrix.GS_BACKEND }} -m 'required and not slow' --forked ./tests
166166
167167
- name: Save Updated Taichi Kernel Cache
168168
if: >-

genesis/engine/simulator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import TYPE_CHECKING
2+
23
import numpy as np
34
import gstaichi as ti
45

genesis/ext/pyrender/offscreen.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,7 @@ def make_current(self):
7575

7676
self._platform.make_current()
7777

78-
# If platform does not support dynamically-resizing framebuffers,
79-
# destroy it and restart it
78+
# If platform does not support dynamically-resizing framebuffers, destroy it and restart it
8079
if (
8180
self._platform.viewport_height != self.viewport_height
8281
or self._platform.viewport_width != self.viewport_width
@@ -261,8 +260,8 @@ def _create(self, platform):
261260
else:
262261
raise ValueError("Unsupported PyOpenGL platform: {}".format(platform))
263262
self._platform.init_context()
264-
self._platform.make_current()
265263

264+
self._platform.make_current()
266265
try:
267266
from OpenGL.GL import glGetString, GL_RENDERER
268267

@@ -276,6 +275,7 @@ def _create(self, platform):
276275
"Software rendering context detected. Shadows and plane reflection not supported. Beware rendering "
277276
"will be extremely slow."
278277
)
278+
self._platform.make_uncurrent()
279279

280280
def __del__(self):
281281
try:

genesis/ext/pyrender/viewer.py

Lines changed: 64 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ def __init__(
385385
if not self._is_active:
386386
if self._exception:
387387
raise RuntimeError("Unable to initialize an OpenGL 3+ context.") from self._exception
388-
raise OpenGL.error.Error("Invalid OpenGL context.")
388+
raise OpenGL.error.Error("Invalid OpenGL context (unknown exception).")
389389
else:
390390
if self.auto_start:
391391
self.start()
@@ -1116,38 +1116,76 @@ def start(self, auto_refresh=True):
11161116
confs[1],
11171117
]
11181118
while confs:
1119-
# Keep the window invisible for now. It will be displayed only if everything is working fine.
1120-
# This approach avoids "flickering" when creating and closing an invalid context. Besides, it avoids
1121-
# "frozen" graphical window during compilation that would be interpreted as as bug by the end-user.
11221119
conf = confs.pop(0)
1120+
1121+
# Close any existing context and window
11231122
try:
1124-
super().__init__(
1125-
config=conf,
1126-
visible=False,
1127-
resizable=True,
1128-
width=self._viewport_size[0],
1129-
height=self._viewport_size[1],
1130-
)
1123+
OpenGL.contextdata.cleanupContext()
1124+
self.set_visible(False)
1125+
except Exception:
1126+
pass
1127+
try:
1128+
super().close()
1129+
except Exception:
1130+
pass
1131+
1132+
try:
1133+
# Keep the window invisible for now. It will be displayed only if everything is working fine.
1134+
# This approach avoids "flickering" when creating and closing an invalid context. Besides, it avoids
1135+
# "frozen" graphical window during compilation that would be interpreted as as bug by the end-user.
1136+
try:
1137+
super().__init__(
1138+
config=conf,
1139+
visible=False,
1140+
resizable=True,
1141+
width=self._viewport_size[0],
1142+
height=self._viewport_size[1],
1143+
)
1144+
except xlib_exceptions as e:
1145+
# Trying again without UTF8 support as a fallback.
1146+
# See: https://github.com/pyglet/pyglet/issues/1024
1147+
if pyglet.window.xlib._have_utf8:
1148+
pyglet.window.xlib._have_utf8 = False
1149+
confs.insert(0, conf)
1150+
raise
1151+
1152+
# Refresh first without window, to make sure all the necessary kernels are compiled
1153+
self.refresh()
1154+
1155+
# At this point, we are all set to display the graphical window if requested
1156+
if not pyglet.options["headless"]:
1157+
self.set_visible(True)
1158+
1159+
# Run the entire rendering pipeline once, to make sure that everything is fine
1160+
self.refresh()
1161+
11311162
break
1132-
except xlib_exceptions as e:
1133-
# Trying again without UTF8 support as a fallback.
1134-
# See: https://github.com/pyglet/pyglet/issues/1024
1135-
if not pyglet.window.xlib._have_utf8:
1136-
if self._run_in_thread:
1137-
self.on_close()
1138-
self._exception = e
1139-
return
1140-
else:
1141-
raise RuntimeError("Unable to initialize an OpenGL 3+ context.") from e
1142-
pyglet.window.xlib._have_utf8 = False
1143-
confs.insert(0, conf)
1144-
except (pyglet.window.NoSuchConfigException, pyglet.gl.ContextException) as e:
1163+
except (
1164+
pyglet.window.NoSuchConfigException,
1165+
pyglet.gl.ContextException,
1166+
pyglet.gl.GLException,
1167+
OpenGL.error.Error,
1168+
AttributeError,
1169+
ArgumentError,
1170+
RuntimeError,
1171+
) as e:
11451172
if not confs:
1173+
# It is essential to set the exception before closing the viewer, otherwise the main thread preempt
1174+
# execution of this thread and wrongly report unknown exception.
11461175
if self._run_in_thread:
1147-
self.on_close()
11481176
self._exception = e
1177+
1178+
# Now the viewer can be safely cause to avoid leaving any global OpenGL context or window dangling
1179+
try:
1180+
self.on_close()
1181+
except Exception:
1182+
pass
1183+
1184+
if self._run_in_thread:
1185+
# Reporting the exception for the main thread to raise it
11491186
return
11501187
else:
1188+
# Raise the exception right away
11511189
raise RuntimeError("Unable to initialize an OpenGL 3+ context.") from e
11521190

11531191
if self._run_in_thread:
@@ -1157,27 +1195,7 @@ def start(self, auto_refresh=True):
11571195
pyglet.clock.schedule(Viewer._time_event, self)
11581196

11591197
# Update window title
1160-
self.switch_to()
11611198
self.set_caption(self.viewer_flags["window_title"])
1162-
1163-
# Run the entire rendering pipeline once, to make sure that everything is fine
1164-
try:
1165-
self.refresh()
1166-
except (OpenGL.error.Error, RuntimeError) as e:
1167-
# Invalid OpenGL context and crossing threading boundaries. Closing before anything else
1168-
self.on_close()
1169-
1170-
if self._run_in_thread:
1171-
# Reporting the exception for the main thread to raise it
1172-
self._exception = e
1173-
return
1174-
else:
1175-
# Raise the exception right away
1176-
raise
1177-
1178-
# At this point, we are all set to display the graphical window if requested, finally!
1179-
if not pyglet.options["headless"]:
1180-
self.set_visible(True)
11811199
self.activate()
11821200

11831201
# The viewer can be considered as fully initialized at this point
@@ -1220,6 +1238,7 @@ def refresh(self):
12201238
self._event_loop_step_offscreen()
12211239
self._offscreen_event.clear()
12221240

1241+
self.switch_to()
12231242
pyglet.clock.tick()
12241243

12251244
if gs.platform != "Windows":
@@ -1229,7 +1248,6 @@ def refresh(self):
12291248
# this is a workaround on Windows. not sure if it's correct
12301249
time.sleep(0.001)
12311250

1232-
self.switch_to()
12331251
self.dispatch_pending_events()
12341252
if self._is_active:
12351253
self.dispatch_events()

genesis/utils/mjcf.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
from itertools import chain
55
from bisect import bisect_right
66

7+
# Note the importing mujoco with env var `MUJOCO_GL=EGL` forcibly defines `PYOPENGL_PLATFORM=egl`
8+
import mujoco
9+
710
import numpy as np
811
import trimesh
12+
import z3
913
from trimesh.visual.texture import TextureVisuals
1014
from PIL import Image
1115

12-
import z3
13-
import mujoco
1416
import genesis as gs
1517
from genesis.ext import urdfpy
1618

genesis/vis/viewer.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import importlib
22
import os
33
import threading
4+
from traceback import TracebackException
45
from typing import TYPE_CHECKING
56

67
import numpy as np
@@ -114,9 +115,10 @@ def build(self, scene):
114115
self._pyrender_viewer.start(auto_refresh=False)
115116
self._pyrender_viewer.wait_until_initialized()
116117
break
117-
except (OpenGL.error.Error, RuntimeError):
118+
except (OpenGL.error.Error, RuntimeError) as e:
118119
# Invalid OpenGL context. Trying another platform if any...
119-
gs.logger.debug("Invalid OpenGL context.")
120+
traceback = TracebackException.from_exception(e)
121+
gs.logger.debug("".join(traceback.format()))
120122

121123
# Clear broken OpenGL context if it went this far
122124
if self._pyrender_viewer is not None:
@@ -126,6 +128,7 @@ def build(self, scene):
126128
if i == len(all_opengl_platforms) - 1:
127129
raise
128130
finally:
131+
# Restore original platform systematically
129132
del os.environ["PYOPENGL_PLATFORM"]
130133
if opengl_platform_orig is not None:
131134
os.environ["PYOPENGL_PLATFORM"] = opengl_platform_orig

tests/conftest.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,34 +19,49 @@
1919
from PIL import Image
2020
from syrupy.extensions.image import PNGImageSnapshotExtension
2121

22-
has_display = True
22+
# Mock tkinter module for backward compatibility because it is a hard dependency for old Genesis versions
23+
has_tkinter = False
2324
try:
24-
from tkinter import Tk
25+
import tkinter
2526

26-
root = Tk()
27-
root.withdraw()
28-
root.destroy()
29-
except Exception: # ImportError, TclError
30-
# Mock tkinter module for backward compatibility because it is a hard dependency for old Genesis versions
27+
has_tkinter = True
28+
except ImportError:
3129
tkinter = type(sys)("tkinter")
3230
tkinter.Tk = type(sys)("Tk")
3331
tkinter.filedialog = type(sys)("filedialog")
3432
sys.modules["tkinter"] = tkinter
3533
sys.modules["tkinter.Tk"] = tkinter.Tk
3634
sys.modules["tkinter.filedialog"] = tkinter.filedialog
3735

38-
# Assuming headless server if tkinder is not installed
39-
has_display = False
36+
# Determine whether a screen is available
37+
if has_tkinter:
38+
has_display = True
39+
try:
40+
root = tkinter.Tk()
41+
root.withdraw()
42+
root.destroy()
43+
except tkinter.TclError:
44+
has_display = False
45+
else:
46+
# Assuming headless server if tkinter is not installed unless DISPLAY env var is available on Linux
47+
if sys.platform.startswith("linux"):
48+
has_display = bool(os.environ.get("DISPLAY"))
49+
else:
50+
has_display = False
4051

52+
# Determine whether EGL driver is available
4153
has_egl = True
4254
try:
4355
pyglet.lib.load_library("EGL")
4456
except ImportError:
4557
has_egl = False
4658

59+
# Forcibly disable Mujoco OpenGL to avoid conflicts with Genesis
60+
os.environ["MUJOCO_GL"] = "0"
61+
62+
# pyglet must be configured in headless mode before importing Genesis if necessary.
63+
# Note that environment variables are used instead of global options to ease option propagation to subprocesses.
4764
if not has_display and has_egl:
48-
# It is necessary to configure pyglet in headless mode if necessary before importing Genesis.
49-
# Note that environment variables are used instead of global options to ease option propagation to subprocesses.
5065
pyglet.options["headless"] = True
5166
os.environ["PYGLET_HEADLESS"] = "1"
5267

@@ -115,7 +130,7 @@ def pytest_cmdline_main(config: pytest.Config) -> None:
115130
config.option.forked = False
116131

117132
# Force disabling distributed framework if interactive viewer is enabled
118-
show_viewer = config.getoption("--vis")
133+
show_viewer = config.getoption("--vis", IS_INTERACTIVE_VIEWER_AVAILABLE)
119134
if show_viewer:
120135
config.option.numprocesses = 0
121136

@@ -383,7 +398,8 @@ def pytest_addoption(parser):
383398
parser.addoption(
384399
"--logical", action="store_true", default=False, help="Consider logical cores in default number of workers."
385400
)
386-
parser.addoption("--vis", action="store_true", default=False, help="Enable interactive viewer.")
401+
if IS_INTERACTIVE_VIEWER_AVAILABLE:
402+
parser.addoption("--vis", action="store_true", default=False, help="Enable interactive viewer.")
387403
parser.addoption("--dev", action="store_true", default=False, help="Enable genesis debug mode.")
388404
supported, _reason = is_mem_monitoring_supported()
389405
help_text = (
@@ -396,7 +412,7 @@ def pytest_addoption(parser):
396412

397413
@pytest.fixture(scope="session")
398414
def show_viewer(pytestconfig):
399-
return pytestconfig.getoption("--vis") and IS_INTERACTIVE_VIEWER_AVAILABLE
415+
return pytestconfig.getoption("--vis", IS_INTERACTIVE_VIEWER_AVAILABLE)
400416

401417

402418
@pytest.fixture(scope="session")

tests/test_render.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,19 +1621,41 @@ def test_rasterizer_camera_sensor_with_viewer(renderer):
16211621
This verifies that the sensor properly shares the viewer's OpenGL context instead of
16221622
creating a conflicting separate context.
16231623
"""
1624+
CAM_RES = (128, 64)
1625+
16241626
scene = gs.Scene(
1625-
viewer_options=gs.options.ViewerOptions(run_in_thread=False),
1627+
viewer_options=gs.options.ViewerOptions(
1628+
res=CAM_RES,
1629+
run_in_thread=False,
1630+
),
16261631
renderer=renderer,
16271632
show_viewer=True,
16281633
)
16291634
# At least one entity is needed to ensure the rendered image is not entirely blank,
16301635
# otherwise it is not possible to verify that something was actually rendered.
16311636
scene.add_entity(morph=gs.morphs.Plane())
1632-
camera_sensor = scene.add_sensor(RasterizerCameraOptions(res=(256, 256)))
1637+
camera_sensor = scene.add_sensor(
1638+
RasterizerCameraOptions(
1639+
res=CAM_RES,
1640+
)
1641+
)
16331642
scene.build()
16341643

1635-
assert scene.visualizer.viewer._pyrender_viewer.is_active
1644+
pyrender_viewer = scene.visualizer.viewer._pyrender_viewer
1645+
assert pyrender_viewer.is_active
16361646

16371647
scene.step()
1648+
1649+
if sys.platform == "linux":
1650+
glinfo = pyrender_viewer.context.get_info()
1651+
renderer = glinfo.get_renderer()
1652+
if "llvmpipe" in renderer:
1653+
llvm_version = re.search(r"LLVM\s+([\d.]+)", renderer).group(1)
1654+
if llvm_version < "20":
1655+
pytest.skip(
1656+
"OpenGL function 'glBlitFramebuffer' involved in offscreen rendering with the interactive viewer "
1657+
"takes ages on old CPU-based Mesa rendering driver. Skipping..."
1658+
)
1659+
16381660
data = camera_sensor.read()
16391661
assert data.rgb.float().std() > 1.0, "RGB std too low, image may be blank"

0 commit comments

Comments
 (0)