From 2046310460e6ed25043604078b0075fd9abff1c3 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Thu, 30 Mar 2023 17:18:31 +0900 Subject: [PATCH] ENH Support SDL-based packages and add pyxel (#3508) Co-authored-by: Hood Chatham Co-authored-by: Roman Yurchak --- Makefile | 7 +++ Makefile.envs | 8 ++- docs/project/changelog.md | 3 + docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py | 6 +- docs/usage/api/js-api.md | 12 ++++ docs/usage/loading-packages.md | 1 + docs/usage/sdl.md | 49 +++++++++++++++ packages/pyxel/meta.yaml | 28 +++++++++ packages/pyxel/test_pyxel.py | 62 +++++++++++++++++++ src/core/error_handling.ts | 22 +++++++ src/core/pyproxy.ts | 7 ++- src/js/api.ts | 9 +++ src/js/canvas.ts | 68 +++++++++++++++++++++ src/js/module.ts | 1 + src/tests/test_canvas.py | 37 +++++++++++ 15 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 docs/usage/sdl.md create mode 100644 packages/pyxel/meta.yaml create mode 100644 packages/pyxel/test_pyxel.py create mode 100644 src/js/canvas.ts create mode 100644 src/tests/test_canvas.py diff --git a/Makefile b/Makefile index c149cbec1..a8e357d64 100644 --- a/Makefile +++ b/Makefile @@ -89,6 +89,7 @@ dist/libpyodide.a: \ dist/pyodide.asm.js: \ src/core/main.o \ $(wildcard src/py/lib/*.py) \ + libgl \ $(CPYTHONLIB) \ dist/libpyodide.a date +"[%F %T] Building pyodide.asm.js..." @@ -206,6 +207,12 @@ dist/module_webworker_dev.js: src/templates/module_webworker.js dist/webworker_dev.js: src/templates/webworker.js cp $< $@ +.PHONY: libgl +libgl: + # TODO(ryanking13): Link this to a side module not to the main module. + # For unknown reason, a side module cannot see symbols when libGL is linked to it. + embuilder build libgl + .PHONY: lint lint: pre-commit run -a --show-diff-on-failure diff --git a/Makefile.envs b/Makefile.envs index f90e562ff..d023781b3 100644 --- a/Makefile.envs +++ b/Makefile.envs @@ -132,7 +132,13 @@ export MAIN_MODULE_LDFLAGS= $(LDFLAGS_BASE) \ -lproxyfs.js \ -lworkerfs.js \ -lwebsocket.js \ - -leventloop.js + -leventloop.js \ + \ + -lGL \ + -legl.js \ + -lwebgl.js \ + -lhtml5_webgl.js \ + -sGL_WORKAROUND_SAFARI_GETCONTEXT_BUG=0 export SIDE_MODULE_CXXFLAGS = $(CXXFLAGS_BASE) diff --git a/docs/project/changelog.md b/docs/project/changelog.md index a6f0a630a..d70b572c4 100644 --- a/docs/project/changelog.md +++ b/docs/project/changelog.md @@ -241,12 +241,15 @@ iterable`. (Python async _iterables_ that were not also iterators were already ### Packages - New packages: fastparquet {pr}`3590`, cramjam {pr}`3590`, pynacl {pr}`3500`, + pyxel {pr}`3508`. mypy {pr}`3504`, multidict {pr}`3581`, yarl {pr}`3702`, idna {pr}`3702`. - Upgraded to micropip 0.3.0 (see [changelog](https://github.com/pyodide/micropip/blob/main/CHANGELOG.md) {pr}`3709` +- Added experimental [support for SDL based packages](using-sdl) {pr}`3508` + - Upgraded packages: see the list of packages versions in this release in {ref}`packages-in-pyodide`. diff --git a/docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py b/docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py index c3569ef2c..458aa2349 100644 --- a/docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py +++ b/docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py @@ -519,7 +519,7 @@ class PyodideAnalyzer: def get_val(): return OrderedDict([("attribute", []), ("function", []), ("class", [])]) - modules = ["globalThis", "pyodide", "pyodide.ffi"] + modules = ["globalThis", "pyodide", "pyodide.ffi", "pyodide.canvas"] self.js_docs = {key: get_val() for key in modules} items = {key: list[Any]() for key in modules} pyproxy_subclasses = [] @@ -561,6 +561,10 @@ class PyodideAnalyzer: # isinstance(doclet, Function) and doclet.is_static. continue + if filename == "canvas.": + items["pyodide.canvas"].append(doclet) + continue + if filename == "pyproxy.gen." and isinstance(doclet, Class): pyproxy_subclasses.append(doclet) diff --git a/docs/usage/api/js-api.md b/docs/usage/api/js-api.md index 255e99a8b..23f7ae6ce 100644 --- a/docs/usage/api/js-api.md +++ b/docs/usage/api/js-api.md @@ -35,3 +35,15 @@ import type { PyProxy } from "pyodide/ffi"; .. js-doc-content:: pyodide.ffi ``` + +(js-api-pyodide-canvas)= + +## pyodide.canvas + +This provides APIs to set a canvas for rendering graphics. + +```{eval-rst} +.. js-doc-summary:: pyodide.canvas + +.. js-doc-content:: pyodide.canvas +``` diff --git a/docs/usage/loading-packages.md b/docs/usage/loading-packages.md index 6d8017d67..29d41a84d 100644 --- a/docs/usage/loading-packages.md +++ b/docs/usage/loading-packages.md @@ -189,4 +189,5 @@ main(); :hidden: packages-in-pyodide.md + sdl.md ``` diff --git a/docs/usage/sdl.md b/docs/usage/sdl.md new file mode 100644 index 000000000..a4b987719 --- /dev/null +++ b/docs/usage/sdl.md @@ -0,0 +1,49 @@ +(using-sdl)= + +# Using SDL-based packages in Pyodide + +```{admonition} This is experimental +:class: warning + +SDL support in Pyodide is experimental. +Pyodide relies on undocumented behavior of Emscripten and SDL, +so it may break or change in the future. + +In addition, this feature requires to enable an opt-in flag, +`pyodide._api._skip_unwind_fatal_error = true;` +which can lead to stack unwinding issues (see {ref}`sdl-known-issues`). +``` + +Pyodide provides a way to use SDL-based packages in the browser, +This document explains how to use SDL-based packages in Pyodide. + +## Setting canvas + +Before using SDL-based packages, you need to set the canvas to draw on. + +The `canvas` object must be a +[HTMLCanvasElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement) object, +with the `id` attribute set to `"canvas"`. +For example, you can set a canvas like this: + +```js +let sdl2Canvas = document.createElement("canvas"); +sdl2Canvas.id = "canvas"; +pyodide.canvas.setCanvas2D(sdl2Canvas); +``` + +See also: {ref}`js-api-pyodide-canvas` + +(sdl-known-issues)= + +## Known issues + +There is a known issue that with, + +``` +pyodide._api._skip_unwind_fatal_error = true; +``` + +Python call stacks are not being unwound after calling `emscripten_set_main_loop()`. + +see: [pyodide#3697](https://github.com/pyodide/pyodide/issues/3697) diff --git a/packages/pyxel/meta.yaml b/packages/pyxel/meta.yaml new file mode 100644 index 000000000..622c4bdc1 --- /dev/null +++ b/packages/pyxel/meta.yaml @@ -0,0 +1,28 @@ +package: + name: pyxel + version: 1.9.10 +source: + url: https://github.com/kitao/pyxel/archive/refs/tags/v1.9.10.tar.gz + sha256: e23a0c52daaa9c4967c402b3ad2c623f33c4a01e36c4b37b884d7b8c726521f4 +build: + script: | + rustup toolchain install ${RUST_TOOLCHAIN} && rustup default ${RUST_TOOLCHAIN} + rustup target add wasm32-unknown-emscripten --toolchain ${RUST_TOOLCHAIN} + + embuilder build sdl2 --pic + export RUSTFLAGS="\ + -C link-arg=-sUSE_SDL=2 \ + -C link-arg=-fPIC \ + -C link-arg=-lSDL2 \ + " + +requirements: + executable: + - rustup +test: + imports: + - pyxel +about: + home: https://github.com/kitao/pyxel + PyPI: https://pypi.org/project/pyxel/ + summary: A retro game engine for Python diff --git a/packages/pyxel/test_pyxel.py b/packages/pyxel/test_pyxel.py new file mode 100644 index 000000000..ad94f0c0e --- /dev/null +++ b/packages/pyxel/test_pyxel.py @@ -0,0 +1,62 @@ +import pytest + + +@pytest.fixture(scope="function") +def selenium_sdl(selenium_standalone): + if selenium_standalone.browser == "node": + pytest.skip("No document object") + + selenium_standalone.run_js( + """ + var sdl2Canvas = document.createElement("canvas"); + sdl2Canvas.id = "canvas"; + + document.body.appendChild(sdl2Canvas); + // Temporary workaround for pyodide#3697 + pyodide._api._skip_unwind_fatal_error = true; + pyodide.canvas.setCanvas2D(sdl2Canvas); + """ + ) + selenium_standalone.load_package("pyxel") + yield selenium_standalone + + +@pytest.mark.skip_refcount_check +@pytest.mark.skip_pyproxy_check +def test_show(selenium_sdl): + selenium_sdl.run( + """ + import pyxel + + pyxel.init(120, 120) + pyxel.cls(1) + pyxel.circb(60, 60, 40, 7) + pyxel.show() + """ + ) + + +@pytest.mark.skip_refcount_check +@pytest.mark.skip_pyproxy_check +def test_run(selenium_sdl): + selenium_sdl.run( + """ + import time + import pyxel + + pyxel.init(160, 120) + + st = time.time() + + def update(): + cur = time.time() + if cur - st > 2: + pyxel.quit() + + def draw(): + pyxel.cls(0) + pyxel.rect(10, 10, 20, 20, 11) + + pyxel.run(update, draw) + """ + ) diff --git a/src/core/error_handling.ts b/src/core/error_handling.ts index 440f353e7..4d4fb3b69 100644 --- a/src/core/error_handling.ts +++ b/src/core/error_handling.ts @@ -78,6 +78,7 @@ API.fatal_error = function (e: any) { if (e && e.pyodide_fatal_error) { return; } + if (fatal_error_occurred) { console.error("Recursive call to fatal_error. Inner error was:"); console.error(e); @@ -138,6 +139,27 @@ API.fatal_error = function (e: any) { throw e; }; +/** + * Signal a fatal error if the exception is not an expected exception. + * + * @argument e {any} The cause of the fatal error. + * @private + */ +API.maybe_fatal_error = function (e: any) { + // Emscripten throws "unwind" to stop current code and return to the main event loop. + // This is expected behavior and should not be treated as a fatal error. + // However, after the "unwind" exception is caught, the call stack is not unwound + // properly and there are dead frames remaining on the stack. + // This might cause problems in the future, so we need to find a way to fix it. + // See: 1) https://github.com/emscripten-core/emscripten/issues/16071 + // 2) https://github.com/kitao/pyxel/issues/418 + if (e && e == "unwind") { + return; + } + + return API.fatal_error(e); +}; + let stderr_chars: number[] = []; API.capture_stderr = function () { stderr_chars = []; diff --git a/src/core/pyproxy.ts b/src/core/pyproxy.ts index f544a18c4..a00c464c0 100644 --- a/src/core/pyproxy.ts +++ b/src/core/pyproxy.ts @@ -414,7 +414,12 @@ Module.callPyObjectKwargs = function ( ); Py_EXIT(); } catch (e) { - API.fatal_error(e); + if (API._skip_unwind_fatal_error) { + API.maybe_fatal_error(e); + } else { + API.fatal_error(e); + } + return; } finally { Hiwire.decref(idargs); Hiwire.decref(idkwnames); diff --git a/src/js/api.ts b/src/js/api.ts index 079473d51..fc4b10b64 100644 --- a/src/js/api.ts +++ b/src/js/api.ts @@ -3,6 +3,7 @@ declare var Hiwire: any; declare var API: any; import "./module"; import { ffi } from "./ffi"; +import { CanvasInterface, canvas } from "./canvas"; import { loadPackage, loadedPackages } from "./load-package"; import { PyBufferView, PyBuffer, TypedArray, PyProxy } from "./pyproxy.gen"; @@ -121,6 +122,14 @@ export class PyodideAPI { */ static PATH = {} as any; + /** + * This provides APIs to set a canvas for rendering graphics. + * + * For example, you need to set a canvas if you want to use the + * SDL library. See :ref:`using-sdl` for more information. + */ + static canvas: CanvasInterface = canvas; + /** * A map from posix error names to error codes. */ diff --git a/src/js/canvas.ts b/src/js/canvas.ts new file mode 100644 index 000000000..e5c86daec --- /dev/null +++ b/src/js/canvas.ts @@ -0,0 +1,68 @@ +declare var Module: any; + +/** + * This interface contains the helper functions for using the HTML5 canvas. + * + * As of now, Emscripten uses Module.canvas to get the canvas element. + * This might change in the future, so we abstract it here. + * @private + */ +export interface CanvasInterface { + setCanvas2D(canvas: HTMLCanvasElement): void; + getCanvas2D(): HTMLCanvasElement | undefined; + setCanvas3D(canvas: HTMLCanvasElement): void; + getCanvas3D(): HTMLCanvasElement | undefined; +} + +// We define methods here to make sphinx-js generate documentation for them. + +/** + * @param canvas The HTML5 canvas element to use for 2D rendering. For now, + * Emscripten only supports one canvas element, so setCanvas2D and setCanvas3D + * are the same. + */ +export const setCanvas2D = (canvas: HTMLCanvasElement) => { + if (canvas.id !== "canvas") { + console.warn( + "If you are using canvas element for SDL library, it should have id 'canvas' to work properly.", + ); + } + + Module.canvas = canvas; +}; +/** + * + * @returns The HTML5 canvas element used for 2D rendering. For now, + * Emscripten only supports one canvas element, so getCanvas2D and getCanvas3D + * are the same. + */ +export const getCanvas2D = (): HTMLCanvasElement | undefined => { + return Module.canvas; +}; +/** + * @param canvas The HTML5 canvas element to use for 3D rendering. For now, + * Emscripten only supports one canvas element, so setCanvas2D and setCanvas3D + * are the same. + */ +export const setCanvas3D = (canvas: HTMLCanvasElement) => { + setCanvas2D(canvas); +}; +/** + * + * @returns The HTML5 canvas element used for 3D rendering. For now, + * Emscripten only supports one canvas element, so getCanvas2D and getCanvas3D + * are the same. + */ +export const getCanvas3D = (): HTMLCanvasElement | undefined => { + return getCanvas2D(); +}; + +/** + * @private + */ +export const canvas: CanvasInterface = { + setCanvas2D, + getCanvas2D, + setCanvas3D, + getCanvas3D, +}; diff --git a/src/js/module.ts b/src/js/module.ts index ab37a5693..9cf70cf32 100644 --- a/src/js/module.ts +++ b/src/js/module.ts @@ -55,6 +55,7 @@ export interface Module { PATH: any; TTY: any; FS: FS; + canvas?: HTMLCanvasElement; addRunDependency: (id: string) => void; removeRunDependency: (id: string) => void; } diff --git a/src/tests/test_canvas.py b/src/tests/test_canvas.py new file mode 100644 index 000000000..242ec2828 --- /dev/null +++ b/src/tests/test_canvas.py @@ -0,0 +1,37 @@ +import pytest + + +@pytest.mark.xfail_browsers(node="No document object") +def test_canvas2D(selenium_standalone): + selenium_standalone.run_js( + """ + const canvas = document.createElement('canvas'); + canvas.id = "canvas"; + + // Temporary workaround for pyodide#3697 + pyodide._api._skip_unwind_fatal_error = true; + + pyodide.canvas.setCanvas2D(canvas); + + assert(() => pyodide._module.canvas === canvas); + assert(() => pyodide.canvas.getCanvas2D() === canvas); + """ + ) + + +@pytest.mark.xfail_browsers(node="No document object") +def test_canvas3D(selenium_standalone): + selenium_standalone.run_js( + """ + const canvas = document.createElement('canvas'); + canvas.id = "canvas"; + + // Temporary workaround for pyodide#3697 + pyodide._api._skip_unwind_fatal_error = true; + + pyodide.canvas.setCanvas3D(canvas); + + assert(() => pyodide._module.canvas === canvas); + assert(() => pyodide.canvas.getCanvas3D() === canvas); + """ + )