Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions dm_control/_render/glfw_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

"""An OpenGL renderer backed by GLFW."""

import sys

from dm_control._render import base
from dm_control._render import executor

Expand All @@ -24,10 +26,29 @@
import glfw # pylint: disable=g-import-not-at-top
except (ImportError, IOError, OSError) as exc:
raise ImportError from exc
try:
glfw.init()
except glfw.GLFWError as exc:
raise ImportError from exc


def _init_glfw():
"""Calls `glfw.init()`, re-raising failures as `ImportError`.

`glfwInit` is a no-op if GLFW has already been initialized, so this is safe
to call multiple times.
"""
try:
glfw.init()
except glfw.GLFWError as exc:
raise ImportError from exc


# On macOS, initializing GLFW at import time disrupts the shared NSApplication
# state used by other GUI toolkits, most notably matplotlib's default 'MacOSX'
# backend (https://github.com/google-deepmind/dm_control/issues/377), so we
# defer initialization until a context is actually created. On other platforms
# we initialize at import time so that the automatic backend selection in
# `dm_control/_render/__init__.py` can still fall back to EGL/OSMesa when GLFW
# cannot create a context (e.g. on headless Linux machines).
if sys.platform != 'darwin':
_init_glfw()


class GLFWContext(base.ContextBase):
Expand All @@ -37,6 +58,7 @@ def __init__(self, max_width, max_height):
# GLFWContext always uses `PassthroughRenderExecutor` rather than offloading
# rendering calls to a separate thread because GLFW can only be safely used
# from the main thread.
_init_glfw()
super().__init__(max_width, max_height, executor.PassthroughRenderExecutor)

def _platform_init(self, max_width, max_height):
Expand Down
74 changes: 74 additions & 0 deletions dm_control/_render/glfw_renderer_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@

"""Tests for GLFWContext."""

import os
import re
import subprocess
import sys
import textwrap
import unittest
from absl.testing import absltest
from dm_control import _render
Expand Down Expand Up @@ -64,5 +69,74 @@ def test_repeatedly_create_and_destroy_context(self):
renderer = _render.Renderer(MAX_WIDTH, MAX_HEIGHT)
wrapper.MjrContext(wrapper.MjModel.from_xml_string('<mujoco/>'), renderer)


_COUNT_INIT_CALLS_SCRIPT = textwrap.dedent("""
import sys
import types
sys.platform = {platform}
stub = types.ModuleType('glfw')
stub.GLFWError = type('GLFWError', (Exception,), {{}})
stub.VISIBLE = 1
stub.DOUBLEBUFFER = 2
stub._init_calls = []
stub.init = lambda: stub._init_calls.append(1)
stub.window_hint = lambda *args, **kwargs: None
stub.create_window = lambda **kwargs: object()
stub.make_context_current = lambda window: None
stub.get_current_context = lambda: None
stub.destroy_window = lambda window: None
sys.modules['glfw'] = stub
import dm_control._render.glfw_renderer as glfw_renderer
print('INIT_AT_IMPORT', len(stub._init_calls))
glfw_renderer.GLFWContext(max_width=64, max_height=64)
print('INIT_AFTER_CONTEXT', len(stub._init_calls))
""")


class GlfwInitAtImportTest(absltest.TestCase):
"""Tests when `glfw.init()` runs relative to importing `glfw_renderer`.

Initializing GLFW at import time interferes with other GUI toolkits that
share the process's NSApplication on macOS, most notably matplotlib's
default 'MacOSX' backend (https://github.com/google-deepmind/dm_control/
issues/377), so on macOS the initialization must be deferred until a
`GLFWContext` is actually created. On other platforms it must still happen
at import time so that the automatic backend selection in
`dm_control/_render/__init__.py` can fall back to EGL/OSMesa when GLFW
cannot create a context (e.g. on headless Linux machines).
"""

def _run_with_stubbed_glfw(self, forced_platform):
script = _COUNT_INIT_CALLS_SCRIPT.format(platform=repr(forced_platform))
env = dict(os.environ, MUJOCO_GL='glfw')
completed_process = subprocess.run(
args=[sys.executable, '-c', script],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
# The stub module injected here only defines the attributes that
# `glfw_renderer` accesses at import time and at initialization time.
timeout=120,
check=True,
text=True)
init_at_import = re.search(r'^INIT_AT_IMPORT (\d+)$',
completed_process.stdout, re.M)
init_after_context = re.search(r'^INIT_AFTER_CONTEXT (\d+)$',
completed_process.stdout, re.M)
assert init_at_import and init_after_context, (
completed_process.stdout + completed_process.stderr)
return int(init_at_import.group(1)), int(init_after_context.group(1))

def test_defers_initialization_on_macos(self):
init_at_import, init_after_context = self._run_with_stubbed_glfw('darwin')
self.assertEqual(init_at_import, 0)
self.assertEqual(init_after_context, 1)

def test_initializes_at_import_on_other_platforms(self):
init_at_import, init_after_context = self._run_with_stubbed_glfw('linux')
self.assertEqual(init_at_import, 1)
self.assertEqual(init_after_context, 2)


if __name__ == '__main__':
absltest.main()
Loading