Skip to content
Merged
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
4 changes: 4 additions & 0 deletions genesis/ext/pyrender/offscreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ def make_uncurrent(self):
self._platform.make_uncurrent()
self._has_valid_context = False

def save_current_context(self):
"""Capture the current GL context as a restore callable (see 'Platform.save_current_context')."""
return self._platform.save_current_context()

def render(
self,
scene,
Expand Down
11 changes: 11 additions & 0 deletions genesis/ext/pyrender/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ def make_uncurrent(self):
"""Make the OpenGL context uncurrent."""
pass

def save_current_context(self):
"""Capture the GL context currently current, returning a zero-argument callable that restores it, or None.

Returns None when nothing is current. The current context is process- or thread-global and shared across all
renderers, so tearing one down - which makes its own context current - must restore whatever was current
before, otherwise a renderer that was current (e.g. one mid-render) is left stranded. The callable is
self-contained, so it can be invoked after this renderer's own context has been destroyed. The base platform
has no global current context to capture.
"""
return None

@abc.abstractmethod
def delete_context(self):
"""Delete the OpenGL context."""
Expand Down
13 changes: 13 additions & 0 deletions genesis/ext/pyrender/platforms/egl.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ctypes
import functools
import os

import OpenGL.platform
Expand Down Expand Up @@ -264,6 +265,18 @@ def make_uncurrent(self):
if self._egl_display is not None:
eglMakeCurrent(self._egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)

def save_current_context(self):
# Capture the current context as a restore callable, returning None when nothing is current. EGL's
# 'make_uncurrent' releases the context, so our own context is never current here - only an external context
# (e.g. another renderer mid-render) or none. Use the module-level 'egl' alias rather than a local import so
# the returned callable stays valid during teardown, including at interpreter shutdown. Genesis EGL contexts
# are surfaceless, so only the display and context are needed to restore (matching 'make_current').
egl_display = egl.eglGetCurrentDisplay()
egl_context = egl.eglGetCurrentContext()
if not egl_context:
return None
return functools.partial(egl.eglMakeCurrent, egl_display, egl.EGL_NO_SURFACE, egl.EGL_NO_SURFACE, egl_context)

def delete_context(self):
from OpenGL.EGL import eglDestroyContext, eglGetCurrentContext

Expand Down
14 changes: 10 additions & 4 deletions genesis/ext/pyrender/platforms/pyglet_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .base import Platform

import OpenGL
import pyglet


__all__ = ["PygletPlatform"]
Expand All @@ -17,8 +18,6 @@ def __init__(self, viewport_width, viewport_height):
self._window = None

def init_context(self):
import pyglet

pyglet.options["shadow_window"] = False

try:
Expand Down Expand Up @@ -73,11 +72,18 @@ def make_current(self):

def make_uncurrent(self):
try:
import pyglet

pyglet.gl.xlib.glx.glXMakeContextCurrent(self._window.context.x_display, 0, 0, None)
except Exception:
pass
# The glx call above is a no-op off X11, so explicitly clear pyglet's current-context bookkeeping too. This
# keeps it accurate on every platform (e.g. Cocoa), so 'save_current_context' never mistakes this renderer's
# own context for an external one to restore, and the next 'make_current' rebinds rather than short-circuiting.
pyglet.gl.current_context = None

def save_current_context(self):
# 'set_current' is a bound method of the current Context, i.e. a self-contained zero-argument restore callable.
context = pyglet.gl.current_context
return context.set_current if context is not None else None

def delete_context(self):
if self._window is not None:
Expand Down
32 changes: 26 additions & 6 deletions genesis/utils/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import sys
from collections.abc import Callable
from dataclasses import field
from importlib import import_module
from itertools import combinations
from typing import Any, NoReturn, Optional, Sequence

Expand Down Expand Up @@ -408,19 +409,38 @@ def try_get_display_size() -> tuple[int | None, int | None, float | None]:
screen_scale : float | None
The scale of the screen.
"""
# Resolve pyglet's native display backend directly (under 'pyglet.canvas' before 2.0, 'pyglet.display' from 2.0 on),
# never the placeholder headless one whose finalizer calls eglTerminate on the EGL display the offscreen renderers
# share. A headless process then raises here, reported as no display - the fallback to a default size is the
# viewer's concern. Reuse a display pyglet already has open if any (the isinstance check skips a headless one).
native = {
"darwin": ("cocoa", "CocoaDisplay"),
"win32": ("win32", "Win32Display"),
"cygwin": ("win32", "Win32Display"),
"linux": ("xlib", "XlibDisplay"),
}.get(pyglet.compat_platform)
if native is None:
raise NotImplementedError(f"No display interface available for platform '{pyglet.compat_platform}'.")
# The backend submodule depends on the platform and pyglet version, and a foreign-platform one fails to import
# (e.g. 'win32' off Windows needs Windows-only ctypes), so it cannot be a top-level import; resolving it by
# computed name avoids a platform-by-version tree of local imports.
if pyglet.version < "2.0":
Display = getattr(import_module(f"pyglet.canvas.{native[0]}"), native[1])
display = next((d for d in pyglet.canvas._displays if isinstance(d, Display)), None)
else:
Display = getattr(import_module(f"pyglet.display.{native[0]}"), native[1])
Comment thread
duburcqa marked this conversation as resolved.
display = next((d for d in pyglet.display._displays if isinstance(d, Display)), None)
if display is None:
display = Display()

screen = display.get_default_screen()
if pyglet.version < "2.0":
display = pyglet.canvas.Display()
screen = display.get_default_screen()
screen_scale = 1.0
else:
display = pyglet.display.get_display()
screen = display.get_default_screen()
try:
screen_scale = screen.get_scale()
except NotImplementedError:
# Probably some headless screen
screen_scale = 1.0

return screen.height, screen.width, screen_scale


Expand Down
47 changes: 30 additions & 17 deletions genesis/vis/rasterizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,24 +158,37 @@ def destroy(self):
for node in self._camera_nodes.values():
self._context.remove_node(node)
self._camera_nodes.clear()
for camera_target in self._camera_targets.values():
try:
if self._offscreen:
camera_target.delete()
elif self._viewer is not None:
self._viewer.close_offscreen(camera_target)
except (OpenGL.error.NullFunctionError, OSError):
pass
self._camera_targets.clear()

if self._offscreen and self._renderer is not None:
try:
self._renderer.make_current()
self._renderer.delete()
except (OpenGL.error.GLError, OpenGL.error.NullFunctionError, ImportError):
pass
del self._renderer
self._renderer = None
if self._offscreen:
if self._renderer is not None:
# Freeing this renderer's GL resources (its camera targets and its own context) requires its
# context to be current, but the current context is process/thread-global and may belong to
# another renderer (e.g. one mid-render). Save it, free on our context, then restore it after
# destroying our context so the other renderer is left current rather than stranded. The saved
# restore callable is self-contained, so it still works once our own platform is gone.
try:
restore_context = self._renderer.save_current_context()
self._renderer.make_current()
for camera_target in self._camera_targets.values():
try:
camera_target.delete()
except (OpenGL.error.NullFunctionError, OSError):
pass
self._renderer.delete()
if restore_context is not None:
restore_context()
except (OpenGL.error.GLError, OpenGL.error.NullFunctionError, ImportError):
pass
del self._renderer
self._renderer = None
else:
for camera_target in self._camera_targets.values():
try:
if self._viewer is not None:
self._viewer.close_offscreen(camera_target)
except (OpenGL.error.NullFunctionError, OSError):
pass
self._camera_targets.clear()

@property
def viewer(self):
Expand Down
23 changes: 17 additions & 6 deletions genesis/vis/visualizer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import sys

import pyglet

import genesis as gs
from genesis.repr_base import RBC

Expand All @@ -8,6 +10,9 @@

VIEWER_DEFAULT_HEIGHT_RATIO = 0.5
VIEWER_DEFAULT_ASPECT_RATIO = 0.75
# Headless fallback screen height. With the ratios above it yields a 640x480 default window, small enough to fit the
# constrained virtual displays of CI runners (e.g. GitHub-hosted macOS).
VIEWER_DEFAULT_SCREEN_HEIGHT = 960


class DummyViewerLock:
Expand Down Expand Up @@ -44,13 +49,19 @@ def __init__(self, scene, show_viewer, vis_options, viewer_options, renderer_opt
gs.raise_exception_from("Rendering not working on this machine.", e)
self._context = RasterizerContext(vis_options)

try:
screen_height, _screen_width, screen_scale = gs.utils.try_get_display_size()
self._has_display = True
except Exception as e:
if show_viewer:
gs.raise_exception_from("No display detected. Use `show_viewer=False` for headless mode.", e)
if pyglet.options["headless"]:
# Headless: there is no real display to measure. The viewer renders offscreen, so fall back to a default
# screen size for computing the default window resolution below.
screen_height, screen_scale = VIEWER_DEFAULT_SCREEN_HEIGHT, 1.0
self._has_display = False
else:
try:
screen_height, _screen_width, screen_scale = gs.utils.try_get_display_size()
self._has_display = True
except Exception as e:
if show_viewer:
gs.raise_exception_from("No display detected. Use `show_viewer=False` for headless mode.", e)
self._has_display = False

if show_viewer:
if gs._scene_registry:
Expand Down
22 changes: 13 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
from PIL import Image
from syrupy.extensions.image import PNGImageSnapshotExtension

from .utils import IMG_BLUR_KERNEL_SIZE, IMG_NUM_ERR_THR, IMG_STD_ERR_THR, assert_pixel_match

# Mock tkinter module for backward compatibility because it is a hard dependency for old Genesis versions
has_tkinter = False
try:
Expand Down Expand Up @@ -76,7 +74,6 @@

IS_INTERACTIVE_VIEWER_AVAILABLE = has_display or has_egl


TOL_SINGLE = 5e-5
TOL_DOUBLE = 1e-9

Expand Down Expand Up @@ -885,18 +882,25 @@ def box_obj_path(asset_tmp_path, cube_verts_and_faces):


class PixelMatchSnapshotExtension(PNGImageSnapshotExtension):
_std_err_threshold: float = IMG_STD_ERR_THR
_ratio_err_threshold: float = IMG_NUM_ERR_THR
_blurred_kernel_size: int = IMG_BLUR_KERNEL_SIZE
_std_err_threshold: float | None = None
_ratio_err_threshold: float | None = None
_blurred_kernel_size: int | None = None

def matches(self, *, serialized_data, snapshot_data) -> bool:
# Imported here rather than at module top: conftest must not be coupled to any other test module at load
# time, as that can cause hard-to-debug side effects.
from .utils import IMG_BLUR_KERNEL_SIZE, IMG_NUM_ERR_THR, IMG_STD_ERR_THR, assert_pixel_match

std_err_threshold = IMG_STD_ERR_THR if self._std_err_threshold is None else self._std_err_threshold
ratio_err_threshold = IMG_NUM_ERR_THR if self._ratio_err_threshold is None else self._ratio_err_threshold
blurred_kernel_size = IMG_BLUR_KERNEL_SIZE if self._blurred_kernel_size is None else self._blurred_kernel_size
try:
assert_pixel_match(
Image.open(BytesIO(serialized_data)),
Image.open(BytesIO(snapshot_data)),
std_err_threshold=self._std_err_threshold,
ratio_err_threshold=self._ratio_err_threshold,
blurred_kernel_size=self._blurred_kernel_size,
std_err_threshold=std_err_threshold,
ratio_err_threshold=ratio_err_threshold,
blurred_kernel_size=blurred_kernel_size,
)
except AssertionError:
return False
Expand Down
42 changes: 29 additions & 13 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from genesis.vis.keybindings import Key

from .conftest import IS_INTERACTIVE_VIEWER_AVAILABLE, SKIP_NO_LUISA, SKIP_NO_MADRONA, SKIP_NO_VIEWER
from .utils import assert_allclose, assert_equal, get_hf_dataset, rgb_array_to_png_bytes
from .utils import assert_allclose, assert_equal, assert_pixel_match, get_hf_dataset, rgb_array_to_png_bytes

IMG_STD_ERR_THR = 1.0

Expand Down Expand Up @@ -1455,36 +1455,52 @@ def test_render_planes(tmp_path, png_snapshot, renderer_type, renderer):

@pytest.mark.required
@pytest.mark.parametrize("renderer_type", [RENDERER_TYPE.RASTERIZER])
def test_offscreen_context_isolation(renderer_type, renderer):
def test_offscreen_context_isolation(renderer_type):
# Each offscreen scene owns a separate GL context, but the platform's current-context state is process/thread
# global. Destroying one scene's context must not strand another scene that is mid-render - which cyclic GC
# routinely does under parallel runs, by collecting a stale scene during another's render. Force that exact
# interleaving (tear down scene B in the middle of scene A's render) and assert A's render still completes
# rather than raising OpenGL "Attempt to retrieve context when no valid context".
scene_a = gs.Scene(renderer=renderer, show_viewer=False, show_FPS=False)
# global. Tearing down one scene's renderer while another is mid-render - which happens under cyclic GC, or
# when the render thread deletes a retired renderer - must leave the rendering scene's context untouched. Force
# that interleaving by destroying scene B in the middle of scene A's render, and assert A renders exactly as it
# does without the interference, rather than rendering on B's destroyed context or losing its context entirely.
# The two scenes must own independent renderers, hence a fresh Rasterizer each rather than a shared instance.
scene_a = gs.Scene(renderer=gs.renderers.Rasterizer(), show_viewer=False, show_FPS=False)
scene_a.add_entity(gs.morphs.Box(pos=(0.0, 0.0, 0.5), size=(0.3, 0.3, 0.3)))
camera_a = scene_a.add_camera(res=(64, 64), pos=(1.5, 1.5, 1.0), lookat=(0.0, 0.0, 0.0))
scene_a.build()
rgb_reference = camera_a.render(rgb=True)[0]

scene_b = gs.Scene(renderer=renderer, show_viewer=False, show_FPS=False)
scene_b.add_entity(gs.morphs.Box(pos=(0.0, 0.0, 0.5), size=(0.3, 0.3, 0.3)))
scene_b.add_camera(res=(64, 64), pos=(1.5, 1.5, 1.0), lookat=(0.0, 0.0, 0.0))
# Distinct content so that rendering A on B's context would be visibly wrong rather than coincidentally equal.
scene_b = gs.Scene(renderer=gs.renderers.Rasterizer(), show_viewer=False, show_FPS=False)
scene_b.add_entity(gs.morphs.Box(pos=(0.0, 0.0, 5.0), size=(0.3, 0.3, 0.3)))
camera_b = scene_b.add_camera(res=(64, 64), pos=(1.5, 1.5, 1.0), lookat=(0.0, 0.0, 0.0))
scene_b.build()
camera_b.render(rgb=True)

# Destroy scene B right after scene A's renderer makes its context current, i.e. mid-render.
# Destroy scene B right after scene A's renderer makes its context current, i.e. mid-render, then check the current
# context directly: a stranded context does not necessarily corrupt the rendered pixels on every driver, so
# asserting on pixels alone is not enough to catch it. 'save_current_context' returns a restore callable for the
# current context, or None when none is current, so a stranded context surfaces as None here.
renderer_a = scene_a.visualizer._rasterizer._renderer
original_make_current = renderer_a.make_current
destroyed = False
restore_after_destroy = None

def make_current_then_destroy_b():
nonlocal destroyed
nonlocal destroyed, restore_after_destroy
original_make_current()
if not destroyed:
destroyed = True
scene_b.destroy()
restore_after_destroy = renderer_a.save_current_context()

renderer_a.make_current = make_current_then_destroy_b
camera_a.render(rgb=True)
rgb_after = camera_a.render(rgb=True)[0]

assert restore_after_destroy is not None
assert_pixel_match(
rgb_after,
rgb_reference,
err_msg="Scene A rendered differently after scene B was destroyed mid-render (wrong or lost GL context).",
)


@pytest.mark.slow # ~200s
Expand Down
Loading