diff --git a/DOCKER.md b/DOCKER.md index 8b808d9..3e56d2b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -1,9 +1,10 @@ # Docker Guide -The Docker image provides a fully headless rendering environment using -[OSMesa](https://docs.mesa3d.org/osmesa.html) (Mesa's off-screen software -renderer). No display server, `xvfb`, or GPU is required — rendering runs -entirely in software on the CPU. +The Docker image provides a fully headless rendering environment. By default +it uses [OSMesa](https://docs.mesa3d.org/osmesa.html) (Mesa's CPU software +renderer) — no display server, `xvfb`, or GPU required. If a GPU render +device is available, pass `--device /dev/dri/renderD128` to enable EGL GPU +rendering instead; `libegl1` is already included in the image. The default entry point is `whippersnap4` (four-view batch rendering). `whippersnap1` (single-view snapshot and rotation video) can be invoked by @@ -188,7 +189,18 @@ parent directory to retrieve them on the host. not root. - The interactive GUI (`whippersnap`) is **not** available in the Docker image — it requires a display server and PyQt6, which are not installed. -- Headless rendering uses **OSMesa** (Mesa's CPU software renderer, provided by - the `libosmesa6` system package). No GPU, no `/dev/dri/` device, and no - `--privileged` flag are needed. +- **Default rendering** uses **OSMesa** (Mesa's CPU software renderer, provided + by the `libosmesa6` system package). No GPU or `/dev/dri/` device needed. +- **GPU rendering via EGL** works out of the box — `libegl1` is included in the + image. Pass the render device into the container and WhipperSnapPy will + automatically prefer EGL over OSMesa when `/dev/dri/renderD*` is accessible: + ```bash + docker run --rm --init \ + --device /dev/dri/renderD128 \ + -v /path/to/subject:/subject \ + -v /path/to/output:/output \ + whippersnappy \ + -lh /subject/surf/lh.thickness -rh /subject/surf/rh.thickness \ + -sd /subject -o /output/snap4.png + ``` diff --git a/Dockerfile b/Dockerfile index 1d6686d..c450d0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,13 @@ FROM python:3.11-slim -# libosmesa6 — OSMesa software renderer for headless OpenGL (no GPU/display needed) -# libgl1 — base OpenGL shared library required by PyOpenGL +# libosmesa6 — OSMesa CPU software renderer (default headless path, no GPU needed) +# libegl1 — EGL dispatch library; enables GPU rendering when /dev/dri/renderD* +# is passed via --device (e.g. docker run --device /dev/dri/renderD128) +# libgl1 — base OpenGL dispatch library required by PyOpenGL # libglib2.0-0, libfontconfig1, libdbus-1-3 — runtime deps for Pillow / font rendering RUN apt-get update && apt-get install -y --no-install-recommends \ libosmesa6 \ + libegl1 \ libgl1 \ libglib2.0-0 \ libfontconfig1 \ diff --git a/README.md b/README.md index 1ca6518..994da30 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,18 @@ For interactive 3D in Jupyter notebooks: pip install 'whippersnappy[notebook]' ``` -Off-screen (headless) rendering on **Linux** is supported natively via -OSMesa — no `xvfb` or GPU required. On **Windows**, GLFW creates an -invisible window; a GPU driver is sufficient — no display needed. On -**macOS**, a real display connection is required (NSGL does not support +Off-screen (headless) rendering on **Linux** uses a three-path fallback: +1. **GLFW invisible window** — used when a display is available (`DISPLAY` set). +2. **EGL** (GPU, no display needed) — used when no display is detected and a + GPU render device (`/dev/dri/renderD*`) is accessible with `libEGL` installed + (`libegl1` on Debian/Ubuntu). This is the recommended path for SSH servers + with a GPU — no `DISPLAY`, `xvfb`, or OSMesa required. +3. **OSMesa** (CPU software renderer) — final fallback; requires + `sudo apt-get install libosmesa6` (Debian/Ubuntu) or + `sudo dnf install mesa-libOSMesa` (RHEL/Fedora). + +On **Windows**, GLFW creates an invisible window; a GPU driver is sufficient. +On **macOS**, a real display connection is required (NSGL does not support headless rendering). See the Docker guide for headless Linux usage. @@ -209,8 +217,9 @@ See `tutorials/whippersnappy_tutorial.ipynb` for complete notebook examples. ## Docker -The Docker image provides a fully headless OSMesa rendering environment — no -display server, `xvfb`, or GPU required. See DOCKER.md for details. +The Docker image provides a fully headless rendering environment using +OSMesa (CPU software renderer) — no display server, `xvfb`, or GPU required. +See DOCKER.md for details. ## API Documentation diff --git a/whippersnappy/gl/__init__.py b/whippersnappy/gl/__init__.py index 1dc0183..5859522 100644 --- a/whippersnappy/gl/__init__.py +++ b/whippersnappy/gl/__init__.py @@ -3,11 +3,14 @@ This package contains the low-level OpenGL helpers used by the renderers: - :mod:`~whippersnappy.gl._headless` — headless platform detection; sets - ``PYOPENGL_PLATFORM`` on headless Linux *before* any ``OpenGL.GL`` import. - Imported first here and in every GL submodule so the guard takes effect even - when a submodule is imported directly. + ``PYOPENGL_PLATFORM`` to ``"egl"`` or ``"osmesa"`` on headless Linux + *before* any ``OpenGL.GL`` import. Imported first here and in every GL + submodule so the guard takes effect even when a submodule is imported + directly. PyOpenGL binds its platform backend on the first + ``import OpenGL.GL`` and cannot be changed afterwards, so this ordering + is critical. - :mod:`~whippersnappy.gl.context` — context lifecycle (create, capture, destroy). - On Linux tries GLFW → EGL (GPU) → OSMesa (CPU) in order. + On Linux tries GLFW → EGL (only when ``PYOPENGL_PLATFORM=egl``) → OSMesa. - :mod:`~whippersnappy.gl.egl_context` — EGL headless GPU context (Linux). - :mod:`~whippersnappy.gl.osmesa_context` — OSMesa headless CPU context (Linux). - :mod:`~whippersnappy.gl.pipeline` — one-time GPU upload: VAO, buffers, uniforms, shader setup. diff --git a/whippersnappy/gl/_headless.py b/whippersnappy/gl/_headless.py index 4f705f2..3a8214a 100644 --- a/whippersnappy/gl/_headless.py +++ b/whippersnappy/gl/_headless.py @@ -2,21 +2,23 @@ This module MUST be imported before any ``import OpenGL.GL`` statement in the package. On Linux with no display server it sets ``PYOPENGL_PLATFORM`` so -that PyOpenGL resolves function pointers via the right backend. +that PyOpenGL resolves function pointers via the correct backend before +``OpenGL.GL`` is first imported. Priority chain when no display is detected (Linux only): 1. **EGL + GPU device** — ``/dev/dri/renderD*`` readable and ``libEGL`` - loadable. ``PYOPENGL_PLATFORM`` is left **unset** here so that - :mod:`whippersnappy.gl.egl_context` can set it to ``"egl"`` before - ``OpenGL.GL`` is imported. + loadable. Sets ``PYOPENGL_PLATFORM=egl`` immediately so that PyOpenGL + binds function pointers via EGL when ``OpenGL.GL`` is first imported. 2. **OSMesa** — CPU software renderer. Sets ``PYOPENGL_PLATFORM=osmesa``. 3. **Neither** — raises ``RuntimeError`` with install instructions. -When GLFW fails even though ``DISPLAY`` is set (e.g. ``ssh -Y`` with a -forwarded X that lacks GLX 3.3), ``PYOPENGL_PLATFORM`` is already bound to -whatever was set at import time. :func:`init_offscreen_context` handles -this by trying EGL as a second step after GLFW failure, before OSMesa. +When ``DISPLAY`` is set (e.g. normal desktop or ``ssh -Y``), ``_headless`` +does not intervene: GLFW is tried first in :func:`init_offscreen_context`. +If GLFW fails (e.g. GLX 3.3 unavailable on the forwarded display), EGL is +attempted only when ``PYOPENGL_PLATFORM`` was already set to ``"egl"`` by +this module at import time — i.e. only for the no-display + EGL-device case. +In all other GLFW-failure scenarios, OSMesa is used as the final fallback. No OpenGL, GLFW, or other heavy imports are done here — only stdlib. """ @@ -78,11 +80,13 @@ def egl_device_is_available(): and not os.environ.get("WAYLAND_DISPLAY") ): if egl_device_is_available(): - # Defer: egl_context.py will set PYOPENGL_PLATFORM=egl before importing - # OpenGL.GL. Do NOT set osmesa here or GL will bind to the wrong backend. + # Set PYOPENGL_PLATFORM=egl NOW, before OpenGL.GL is imported anywhere. + # PyOpenGL selects its platform backend on first import and cannot be + # changed afterwards; deferring to egl_context.py would mean OpenGL.GL + # is already bound to the wrong backend by the time EGL is tried. + os.environ["PYOPENGL_PLATFORM"] = "egl" logger.debug( - "No display, EGL + GPU device available — " - "deferring platform selection to EGL context creation." + "No display, EGL + GPU device available — PYOPENGL_PLATFORM=egl set." ) elif _osmesa_is_available(): os.environ["PYOPENGL_PLATFORM"] = "osmesa" @@ -109,6 +113,6 @@ def egl_device_is_available(): elif sys.platform == "linux": _display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") logger.debug( - "Display set (%s) — will try GLFW; EGL on failure if GPU device available.", + "Display set (%s) — will try GLFW first.", _display, ) diff --git a/whippersnappy/gl/context.py b/whippersnappy/gl/context.py index 3043a20..60ab95c 100644 --- a/whippersnappy/gl/context.py +++ b/whippersnappy/gl/context.py @@ -3,22 +3,26 @@ Owns the full lifecycle of an OpenGL context — creation, scene rendering, framebuffer capture, and teardown. -Context creation tries three paths in order (Linux; macOS/Windows use GLFW only): +Context creation tries up to three paths (Linux; macOS/Windows use GLFW only): 1. **GLFW invisible window** — standard path when a display is available. -2. **EGL pbuffer** — headless GPU rendering; no display required. - Tried when GLFW fails for any reason, including when ``DISPLAY`` is set - via ``ssh -Y`` but the forwarded X lacks GLX 3.3. Requires - ``libEGL`` and an accessible ``/dev/dri/renderD*`` device node. -3. **OSMesa** — CPU software renderer; no GPU required. +2. **EGL pbuffer** — headless GPU rendering (Linux, no display needed). + Only used when :mod:`~whippersnappy.gl._headless` set + ``PYOPENGL_PLATFORM=egl`` at import time (no display + accessible + ``/dev/dri/renderD*``). PyOpenGL selects its platform backend on the + first ``import OpenGL.GL`` and cannot be changed afterwards — so EGL is + only safe when it was selected before any ``OpenGL.GL`` import. +3. **OSMesa** — CPU software renderer (Linux only). Used when neither GLFW nor EGL succeeds. The :mod:`whippersnappy.gl._headless` guard runs before ``OpenGL.GL`` is -imported and sets ``PYOPENGL_PLATFORM`` appropriately (or defers to EGL). +imported and sets ``PYOPENGL_PLATFORM`` to ``"egl"`` or ``"osmesa"`` +as appropriate. """ # ruff: noqa: I001 — import order is intentional: _headless must precede OpenGL.GL import logging +import os import sys import warnings from typing import Any @@ -136,15 +140,18 @@ def init_window(width, height, title="WhipperSnapPy", visible=True): def init_offscreen_context(width, height): """Create an invisible OpenGL context for off-screen rendering. - Tries three paths in order on Linux; macOS and Windows use GLFW only. + Tries up to three paths on Linux; macOS and Windows use GLFW only. 1. **GLFW invisible window** — standard path when a display is available. - 2. **EGL pbuffer** — headless GPU rendering (Linux only). Attempted - whenever GLFW fails, including when ``DISPLAY`` is set via ``ssh -Y`` - but the forwarded X server lacks GLX 3.3. Requires ``libEGL`` and - an accessible ``/dev/dri/renderD*`` device node. + 2. **EGL pbuffer** — headless GPU rendering (Linux only, no display needed). + Only attempted when :mod:`~whippersnappy.gl._headless` already set + ``PYOPENGL_PLATFORM=egl`` at import time (i.e. no display detected AND + ``/dev/dri/renderD*`` is accessible). This guarantees ``OpenGL.GL`` + was bound to the EGL backend before any GL call; attempting EGL after + ``OpenGL.GL`` has already been imported with a different backend would + silently break function resolution. 3. **OSMesa** — CPU software renderer (Linux only). Used when neither - GLFW nor EGL succeeds. + GLFW nor EGL succeeds, or when ``PYOPENGL_PLATFORM=osmesa`` was set. Parameters ---------- @@ -181,10 +188,13 @@ def init_offscreen_context(width, height): ) # --- Step 2: EGL headless GPU rendering --- - # Tried whenever GLFW fails: no display, or DISPLAY set but GLX unavailable - # (e.g. ssh -Y with software X forwarding that lacks GLX 3.3). - from ._headless import egl_device_is_available # noqa: PLC0415 - if egl_device_is_available(): + # Only safe when PYOPENGL_PLATFORM=egl was set by _headless.py before + # OpenGL.GL was imported — meaning the process has no display AND an EGL + # device was found at import time. PyOpenGL binds its platform backend on + # first import and cannot be switched afterwards; importing egl_context.py + # here when PYOPENGL_PLATFORM is already something else (e.g. "osmesa" or + # unset/GLX) would cause silent function-pointer mismatches. + if os.environ.get("PYOPENGL_PLATFORM") == "egl": logger.info("GLFW failed — trying EGL headless GPU rendering.") try: from .egl_context import EGLContext # noqa: PLC0415 @@ -218,14 +228,14 @@ def terminate_context(window): """Release the active OpenGL context regardless of how it was created. This is a drop-in replacement for ``glfw.terminate()`` that also - handles the OSMesa headless path. Call it at the end of every rendering - function instead of calling ``glfw.terminate()`` directly. + handles EGL and OSMesa headless paths. Call it at the end of every + rendering function instead of calling ``glfw.terminate()`` directly. Parameters ---------- window : GLFWwindow or None The GLFW window handle returned by :func:`init_offscreen_context`, - or ``None`` when an OSMesa context is active. + or ``None`` when an EGL or OSMesa offscreen context is active. """ global _offscreen_context if _offscreen_context is not None: @@ -249,7 +259,8 @@ def capture_window(window): Parameters ---------- window : GLFWwindow or None - GLFW window handle, or ``None`` when an OSMesa context is active. + GLFW window handle, or ``None`` when an EGL or OSMesa offscreen + context is active. Returns ------- diff --git a/whippersnappy/gl/egl_context.py b/whippersnappy/gl/egl_context.py index 8f74b28..46b0d6e 100644 --- a/whippersnappy/gl/egl_context.py +++ b/whippersnappy/gl/egl_context.py @@ -30,14 +30,24 @@ if sys.platform == "darwin": raise ImportError("EGL is not available on macOS; use GLFW/CGL instead.") -# Must be set before OpenGL.GL is imported anywhere in the process. -# If already set (e.g. user set it, or GLFW succeeded), respect it. -# We set it here because this module is only imported when EGL is needed. -if os.environ.get("PYOPENGL_PLATFORM") != "egl": +# PYOPENGL_PLATFORM=egl should already be set by _headless.py before +# OpenGL.GL was first imported in this process. This guard is a safety net +# for the case where egl_context is imported in isolation (e.g. in tests). +_current_platform = os.environ.get("PYOPENGL_PLATFORM") +if _current_platform is None: os.environ["PYOPENGL_PLATFORM"] = "egl" - -import OpenGL.GL as gl -from PIL import Image +elif _current_platform != "egl": + raise RuntimeError( + f"PYOPENGL_PLATFORM is already set to {_current_platform!r}, " + "but whippersnappy.gl.egl_context requires 'egl'. " + "Importing egl_context with a different platform already bound would " + "cause silent GL function-pointer mismatches. " + "Unset PYOPENGL_PLATFORM or set it to 'egl' before importing any " + "OpenGL modules." + ) + +import OpenGL.GL as gl # noqa: E402 +from PIL import Image # noqa: E402 logger = logging.getLogger(__name__)