-
Notifications
You must be signed in to change notification settings - Fork 8
[WIP] proof of concept: HTMLCanvas bitmap context in pyodide #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Vipitis
wants to merge
29
commits into
pygfx:main
Choose a base branch
from
Vipitis:browser
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
c81ab50
js bitmap context
Vipitis 393b590
might need a loop -.-
Vipitis f0f533d
working loop
Vipitis d62dc39
assing js_array directly
Vipitis 93e3639
add context class
Vipitis bd9b3ab
fix channels
Vipitis 9017288
register auto backend
Vipitis a10970d
working events!
Vipitis e62274a
fix pixel order
Vipitis d79656c
cleanup testing code
Vipitis 8d51188
remove unused context class
Vipitis 1044d0f
add all events
Vipitis d393c52
add basic documentation
Vipitis 8b71ed7
typos pass
Vipitis f197d7d
embed examples into docs
Vipitis 529c3ec
maybe fix wheel location
Vipitis 7025f87
maybe fix files
Vipitis 58bc775
add canvas selector
Vipitis 51459d3
add multicanvas example
Vipitis 6cf0ba5
use asyncio loop
Vipitis d9f8fc0
ruff format
Vipitis fcd2d1c
add canvas element arg
Vipitis 3e13446
simplify selector argument
Vipitis 75e3ddc
icorrect type hints
Vipitis 879399e
enbled wgpu context
Vipitis 7544b77
fix button ids in pointer events
Vipitis 2dd4824
add resize event
Vipitis 053f2db
make the example resize
Vipitis ef8d9d3
fix pointer_move just inside or down
Vipitis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| import rendercanvas | ||
| print("rendercanvas version:", rendercanvas.__version__) | ||
| from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup, BaseLoop | ||
|
|
||
| import numpy as np | ||
|
|
||
| # packages available inside pyodide | ||
| from pyodide.ffi import run_sync | ||
| from js import document, ImageData, Uint8ClampedArray, window | ||
| # import sys | ||
| # assert sys.platform == "emscripten" # use in the future to direct the auto backend? | ||
|
|
||
| # TODO event loop for js? https://rendercanvas.readthedocs.io/stable/backendapi.html#rendercanvas.stub.StubLoop | ||
| # https://pyodide.org/en/stable/usage/sdl.html#working-with-infinite-loop | ||
| # https://pyodide.org/en/stable/usage/api/python-api/webloop.html | ||
| # https://github.com/pyodide/pyodide/blob/0.28.2/src/py/pyodide/webloop.py | ||
| # also the asyncio.py implementation | ||
| class HTMLLoop(BaseLoop): | ||
| def __init__(self): | ||
| super().__init__() | ||
| self._webloop = None | ||
| self.__pending_tasks = [] | ||
| self._stop_event = None | ||
|
|
||
| def _rc_init(self): | ||
| from pyodide.webloop import WebLoop | ||
| import asyncio | ||
| self._webloop = WebLoop() | ||
|
|
||
| # TODO later try this | ||
| # try: | ||
| # self._interactive_loop = self._webloop.get_running_loop() | ||
| # self._stop_event = PyodideFuture() | ||
| # self._mark_as_interactive() | ||
| # except Exception: | ||
| # self._interactive_loop = None | ||
| self._interactive_loop = None | ||
|
|
||
| def _rc_run(self): | ||
| import asyncio #so the .run method is now overwritten I guess | ||
| if self._interactive_loop is not None: | ||
| return | ||
| # self._webloop.run_forever() # or untill stop event? | ||
| asyncio.run(self._rc_run_async()) | ||
|
|
||
| async def _rc_run_async(self): | ||
| import asyncio | ||
| self._run_loop = self._webloop | ||
|
|
||
| while self.__pending_tasks: | ||
| self._rc_add_task(*self.__pending_tasks.pop(-1)) | ||
|
|
||
| if self._stop_event is None: | ||
| self._stop_event = asyncio.Event() | ||
| await self._stop_event.wait() | ||
|
|
||
| # untested maybe... | ||
| def _rc_stop_(self): | ||
| while self.__tasks: | ||
| task = self.__tasks.pop() | ||
| task.cancel() | ||
|
|
||
| self._stop_event.set() | ||
| self._stop_event = None | ||
| self._run_loop = None | ||
|
|
||
| def _rc_call_later(self, delay, callback, *args): | ||
| self._webloop.call_later(delay, callback, *args) | ||
|
|
||
| pyodide_loop = HTMLLoop() | ||
|
|
||
| # needed for completeness? somehow is required for other examples - hmm? | ||
| class HTMLCanvasGroup(BaseCanvasGroup): | ||
| pass | ||
|
|
||
| # TODO: make this a proper RenderCanvas, just a poc for now | ||
| # https://rendercanvas.readthedocs.io/stable/backendapi.html#rendercanvas.stub.StubRenderCanvas | ||
| class HTMLBitmapCanvas(BaseRenderCanvas): | ||
| _rc_canvas_group = HTMLCanvasGroup(pyodide_loop) # todo do we need the group? | ||
| def __init__(self, *args, **kwargs): | ||
| super().__init__(*args, **kwargs) | ||
| canvas_element = document.getElementById("canvas") | ||
| self.canvas_element = canvas_element | ||
| self.context = canvas_element.getContext("bitmaprenderer") | ||
|
|
||
| self._final_canvas_init() | ||
|
|
||
| def _rc_gui_poll(self): | ||
| # not sure if anything has to be done | ||
| pass | ||
|
|
||
| def _rc_get_present_methods(self): | ||
| # in the future maybe we can get the webgpu context (as JsProxy) or something... future stuff! | ||
| return { | ||
| "bitmap": { | ||
| "formats": ["rgba-u8"], | ||
| } | ||
| } | ||
|
|
||
| def _rc_request_draw(self): | ||
| # loop.call_soon? | ||
| loop = self._rc_canvas_group.get_loop() | ||
| loop.call_soon(self._draw_frame_and_present) | ||
| # window.requestAnimationFrame(self._rc_present_bitmap) #doesn't feel like this is the way... maybe more reading | ||
| # self._rc_force_draw() | ||
| # print("request draw called?") | ||
|
|
||
| def _rc_force_draw(self): | ||
| self._draw_frame_and_present() # returns without calling the present it seems | ||
|
|
||
| def _rc_present_bitmap(self): | ||
| # this actually "writes" the data to the canvas I guess. | ||
| self.context.transferFromImageBitmap(self._image_bitmap) | ||
|
|
||
| # reimplement so we might understand what's going on | ||
| def _draw_frame_and_present(self): | ||
| self._draw_frame() | ||
| self._rc_present_bitmap() | ||
|
|
||
| def _rc_get_physical_size(self): | ||
| return self.canvas_element.style.width, self.canvas_element.style.height | ||
|
|
||
| def _rc_get_logical_size(self): | ||
| return float(self.canvas_element.width), float(self.canvas_element.height) | ||
|
|
||
| def _rc_get_pixel_ratio(self) -> float: | ||
| ratio = window.devicePixelRatio | ||
| return ratio | ||
|
|
||
| def _rc_set_logical_size(self, width: float, height: float): | ||
| ratio = self._rc_get_pixel_ratio() | ||
| self.canvas_element.width = int(width * ratio) # only positive, int() -> floor() | ||
| self.canvas_element.height = int(height * ratio) | ||
| # also set the physical scale here? | ||
| # self.canvas_element.style.width = f"{width}px" | ||
| # self.canvas_element.style.height = f"{height}px" | ||
|
|
||
| def set_bitmap(self, bitmap): | ||
| # TODO: improve performance https://pyodide.org/en/stable/usage/type-conversions.html#buffers | ||
| # TODO: avoid memory leak!!! | ||
| # doesn't really exist? as it's part of the context? maybe we move it into the draw function... | ||
| self._last_bitmap = bitmap # keep track? | ||
| h, w, _ = bitmap.shape | ||
| flat_bitmap = bitmap.flatten() | ||
| js_array = Uint8ClampedArray.new(flat_bitmap.tolist()) | ||
| image_data = ImageData.new(js_array, w, h) | ||
| # now this is the fake async call so it should be blocking | ||
| self._image_bitmap = run_sync(window.createImageBitmap(image_data)) | ||
|
|
||
| def _rc_close(self): | ||
| # self.canvas_element.remove() # shouldn't really be needed? | ||
| pass | ||
|
|
||
| def _rc_get_closed(self): | ||
| # TODO: like check if the element still exists? | ||
| return False | ||
|
|
||
| def _rc_set_title(self, title: str): | ||
| # canvas element doens't have a title directly... but maybe the whole page? | ||
| document.title = title | ||
|
|
||
| # TODO: events | ||
|
|
||
|
|
||
| canvas = HTMLBitmapCanvas(title="RenderCanvas in Pyodide", max_fps=3.0) | ||
| def animate(): | ||
| # based on the noise.py example | ||
| w, h = canvas._rc_get_logical_size() | ||
| shape = (int(h), int(w), 4) # third dimension sounds like it's needed | ||
| bitmap = np.random.uniform(0, 255, shape).astype(np.uint8) | ||
| canvas.set_bitmap(bitmap) | ||
|
|
||
| canvas.request_draw(animate) | ||
| pyodide_loop.run() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <!-- adapted from: https://traineq.org/imgui_bundle_online/projects/min_bundle_pyodide_app/demo_heart.source.txt --> | ||
| <!doctype html> | ||
| <html> | ||
| <head> | ||
| RenderCanvas HTML canvas via Pyodide <br> | ||
| <script src="https://cdn.jsdelivr.net/pyodide/v0.28.2/full/pyodide.js"></script> | ||
| </head> | ||
| <body> | ||
| <canvas id="canvas" width="640" height="480"></canvas> | ||
| some text below the canvas! | ||
| <script type="text/javascript"> | ||
| async function main(){ | ||
|
|
||
| // fetch the file locally for easier scripting | ||
| // --allow-file-access-from-files or local webserver | ||
| // TODO: replace the actual code here (unless you have the module) | ||
| pythonCode = await (await fetch("html_canvas.py")).text(); | ||
|
|
||
| // Load Pyodide | ||
| let pyodide = await loadPyodide(); | ||
|
|
||
| await pyodide.loadPackage("micropip"); | ||
| const micropip = pyodide.pyimport("micropip"); | ||
| await micropip.install('numpy'); | ||
| await micropip.install('rendercanvas'); | ||
|
|
||
| // Run the Python code async because some calls are async it seems. | ||
| pyodide.runPythonAsync(pythonCode); | ||
| } | ||
| main(); | ||
| </script> | ||
| </body> | ||
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.