floooh / sokol

minimal cross-platform standalone C headers
https://floooh.github.io/sokol-html5
zlib License
7.09k stars 500 forks source link

Cleanup Emscripten canvas target selector string stuff. #1154

Open floooh opened 4 days ago

floooh commented 4 days ago

See: https://github.com/floooh/sokol/issues/407#issuecomment-2480549987

TL;DR:

konsumer commented 3 days ago

This path is still using strings (with querySelector.)

I recommend passing canvas dom-object (with a selector on #canvas/sapp_desc.html5_canvas_selector by default, for backwards compatibility.) I think emscripten might even set it up for you, if you just use Module.canvas. You can see an example in glfw

This way I can override the canvas object, even if there is no document-selector (like in the case of a web-component, which is in a shadow-dom.)

floooh commented 3 days ago

You can't pass a JS object across the WASM and C boundary, and you also can't pass a JS object into the emscripten_* functions.

(AFAIK the special Module['canvas'] thingie is from ancient times when the WebGL canvas was treated as a special object in the Emscripten SDK)

floooh commented 3 days ago

PS: e.g. see here: https://emscripten.org/docs/tools_reference/settings_reference.html#disable-deprecated-find-event-target-behavior

Module['canvas'] is deprecated but still works for backward compatibility.

konsumer commented 3 days ago

While I see that it's marked deprecated there, for my situation it really makes it less useful. I wonder what usecase that improves things for (seems like less control, with no payoff.) Still, it seems like a quick check once if canvas is a string (css selector) or DOM element is the most consistent way to handle it, looking at other libs, even if it is deprecated behavior. I could PR for both (so it handles new case and this deprecated thing that works everywhere else, and web components rely on.)

konsumer commented 3 days ago

You can't pass a JS object across the WASM and C boundary, and you also can't pass a JS object into the emscripten_* functions.

I am not totally sure what you mean. You definitely can embed js in emscripten, which I'm sure you are aware of, since it's is in some of the sokol-code. The glfw code I pointed to sets things up in an emscritpen-level js library, but you can also do things like this in anything:

EMJS(void, whatever, () {
  // your js here
});

The current sokol_app setup uses this to actually resolve that id for canvas. Like you could keep the current behavior of html5_canvas_name, or change it to follow that deprecation-notice (make it a querySelector) and also support a canvas param, since anything in the options gets injected into Module.

Like maybe this is just that I don't know how to do it better, but there are situations where document does not point to the DOM that the canvas is in (like in case of shadow-dom) so document.querySelector('#whatever') and document.getElementById('whatever') doesn't work.

I have just been experimenting with sokol and seeing if it will work for my game-engine, but this has been in the back of my mind as something I need to eventually fix, or remove as a feature, if I use sokol. This means only 1 game-instance per page, which is a small bummer because I could not make a browse-page like this that actually worked, but to be fair, having a ton of instances actually running on a single page is tricky for performance, anyway (like tiny8bit use static screenshots that link to a single instance) so maybe that is ok. As a sidenote, tiny8bit is using sokol, and it may have been part of the decision to only run 1 instance on a page. I am not sure.

I am willing to rethink that "need" for my engine, though, honestly. I still don't think using a querySelector instead of a dom-element improves anything, and supporting both is sort of trivial (I am happy to PR for it.) Only sort of related: I also lock users into a small fixed screensize: 320x240, for similar reasons (so a huge list of games will look better together) which I have been rethinking lately, anyway. Maybe I could solve both things by just taking the tiny8bit route of showing a bunch of cropped-screenshot-links, and letting people use whatever size they want for their game. My original intent was a "fantasy console" so a fixed size seemed ok, but maybe some games would just suck that small :)

(AFAIK the special Module['canvas'] thingie is from ancient times when the WebGL canvas was treated as a special object in the Emscripten SDK)

That may be true, but no alternative standard was put in place that handles things that you cannot get in document.querySelector, so following what SDL & glfw do, if things like web-components (or anything with a shadow-dom, etc) is meant to be supported, does seem sensible to me.

Also, I think that link you pointed to may be saying something subtly different. To my eyes, it seems like it's saying "you can disable it automatically selecting the canvas using an id or just auto-grabbing #canvas" (which is the "legacy behavior") but it doesn't really mean "you should not use Module.canvas", it means "you will get/have to grab it yourself, and store it however you want, and we recommend using a querySelector string", and I agree with my read of it, and think using an ID-string is a bit jenky, and querySelector is more versatile (even though it still would not support shadow-dom.)

I made raylib-wasm and so I just followed what I did there for my game-engine, for example this codepen uses a web-component to make it easy to tie the draw-loop, instance & user-code to a single canvas, so you can have multiple instances on a page to show ideas. Here is an instance of a game for my engine, and I can tie the lil mute-button (for click-to-play web-audio security stuff) and other things directly to the canvas. I use focus so I could do other things, like if there are multiple instances, only listen to joystick in the "active" one. Is there a different way I should do that with sokol?

While working on games/engine locally, I have several demos loaded in a single page that auto-refreshes, so I can check that they all work right, in 1 place. This is possible because each web-component has it's own canvas & runtime instance.

Screencast from 11-16-2024 04:10:36 PM.webm

floooh commented 3 days ago

Does your raylib glue code call any of the emscripten_* functions from the C side that take a 'target' as parameter, like these?

https://emscripten.org/docs/api_reference/html5.h.html#functions

This target string is a 'css selector', and Emscripten calls document.querySelector() in its own JS shim functions to get a DOM object from that string. That's the crux of the issue.

you definitely can embed js in emscripten

Yes, but you can only pass primitive types across the JS/WASM boundary (anything that can be converted to a JS 'number' value), but no Javascript object references. So you could only access the canvas object from JS code, but you can't tunnel it through C code (without additional boilerplate code at least - like what the WebGL and WebGPU Emscripten shims do).

konsumer commented 2 days ago

This target string is a 'css selector', and Emscripten calls document.querySelector() in its own JS shim functions to get a DOM object from that string. That's the crux of the issue.

Yep, I get how it works. There are several ways to to do this, using EM_JS, EM_ASM, as well as the c-wrappers in html5.h (which use EM_JS and EM_ASM.)

I think maybe you are misunderstanding what I propose. I will make an example that modifies sokol_app, and works how I mean.

Does your raylib glue code call any of the emscripten_* functions from the C side that take a 'target' as parameter, like these?

No, not mine, directly. I don't really add much glue code, the C entry-point pretty much just includes raylib, and adds a couple of utils. Most of what my thing does is wrap it up in wasm, and expose a bunch of js to make it more ergonomic to work with. Raylib games can already be compiled to emscripten, though, my thing just makes all that work nicer in browser with js/web-components/etc, so you can write your game in js instead of C.

You can see here upstream raylib uses emscripten, directly, pretty lightly, to just setup main loop and things like that.

Yes, but you can only pass primitive types across the JS/WASM boundary (anything that can be converted to a JS 'number' value), but no Javascript object references. So you could only access the canvas object from JS code, but you can't tunnel it through C code (without additional boilerplate code at least - like what the WebGL and WebGPU Emscripten shims do).

Yep, I get it. I do a lot of wasm things, with and without emscripten. My point is with emscripten, you can grab the canvas once (and use the options however you want to do that) and then use that grabbed canvas later, instead of selecting it from the DOM every time. This is exactly what the SDL and glfw shims do, and raylib uses glfw.

konsumer commented 2 days ago

I think the "full solution" is to setup Module.canvas in _sapp_init_state, and remove all other direct-use of _sapp.html5_canvas_selector. For this quick-demo, I just did it for basic graphics (didn't touch any input handlers or anything.) This is all easy enough to add (can be done in EM_JS, or using the emscripten html5 C wrapper things, which themselves use EM_JS) but it took me a while to just get a basic demo working (not a prob with emscripten API, more like just getting sokol working in build-env and stuff) and I need to get back to working on my game-engine.

I marked my changes with DKDEMO comments, but if this seems like a sensible path, I can PR and integrate it all much better. The main idea is I grab Module.canvas, in _sapp_init_state first, then later use it for context in _sapp_emsc_webgl_init. After that, any use of the current context (emscripten has wrapping around this) should work fine, and also, you have Module.canvas that you can use in any EM_JS/EM_ASM code, if needed. If I remember right, there are also some js-side helpers for integrating with emscripten C-side canvas/ctx helper-things, but I didn't bother figuring it all out, since it's simple enough to just do it in JS, directly. This supports the old behavior, as well as the new proposed behavior, and demo illustrates that.

I also have some js-side debugging I will remove for a PR, but it shows which canvas is in use.

Screenshot from 2024-11-17 18-47-05

To further illustrate the problem it solves, I get this on my demo-page, meaning only the legacy canvas is found (since the other 2 are hiding in the shadow-dom):

Screenshot from 2024-11-17 18-44-15

Even though this all meant to solve my usecase, it helps others, even if they are not using web-components, because they can just pass canvas in options, but also still use it the way it was before.

konsumer commented 2 days ago

There is also another trick:

specialHTMLTargets["!canvas"] = Module.canvas;

Combined with setting html5_canvas_selector at top-level to !canvas this should make all use of that in anything (C or JS) work, which might be the lowest-friction change, something like:

#if EMSCRIPTEN
EM_JS(bool, _dk_grab_canvas, (char* selector), {
    if (Module.canvas) {
        console.log('canvas set with param', Module.canvas);
        specialHTMLTargets["!canvas"] = Module.canvas;
        return true;
    }
    const e = document.querySelector(UTF8ToString(selector));
    if (e) {
        Module.canvas = e;
    }
    console.log('canvas set', Module.canvas);
    return false;
});
#endif

// later in _sapp_init_state
#if EMSCRIPTEN
if (_dk_grab_canvas(_sapp.html5_canvas_selector)) {
     char* s = "!canvas";
     strcpy(_sapp.html5_canvas_selector, s);
}
#endif

It could be merged into the id-copy stuff, so it's all 1 thing that sets up Module.canvas, and sets html5_canvas_selector to the right thing (#canvas or !canvas.)

floooh commented 2 days ago

Thanks for taking the time to create an example, much appreciated, and I will set aside some time to properly understand it.

I also asked around on the Emscripten Discord what the state of the special Module['canvas'] handling in Emscripten is.

One thing I'm seeing is that you removed the emscripten_get_element_css_size and emscripten_set_canvas_element_size calls with your own versions which directly modify the JS canvas object. Is the reason for this that those functions stopped working?

OTH the calls to emscripten_get_element_css_size and emscripten_set_canvas_element_size in the _sapp_emsc_run() function are still there, I don't understand why the function is replaced in one place and not the other.

Also there's all those event handlers here:

https://github.com/konsumer/sokol-canvas-example-1154/blob/e72fb5488ee105a0930c35121ad04832a81fe696/sokol/sokol_app.h#L5895-L5911

...I wonder if those still work.

Also a question: wouldn't the problem of multiple canvases on the same page also be solved by giving each canvas its unique id and then give each demo that id-string in the sokol_main() function? This assumes that the .html5_canvas_selector is passed through as is, and the special # prefixing is removed.

...another thing we should check: assuming the special lookup behaviour still working (e.g. Emscripten looking at Module['canvas'] for the special #canvas selector instead of doing a querySelector('#canvas') I wonder if it is enough to initialize Module['canvas'] outside WASM to make all the emscripten_* functions work. The only thing that would need to be fixed is the places where sokol_app.h directly calls getElementById() and instead replace those with the Emscripten runtime's findCanvasEventTarget() - and with that change sokol_app.h would also behave the same as the Emscripten runtime functions, no matter if the special Module['canvas'] behaviour is activated or deactivated.

This would allow application to communicate the WebGL canvas by setting Module['canvas'] in index.html before the WASM instantiation happens, but at the same time all other scenarios would work too (by passing a selector string in sokol_main()). And the only change in sokol_app.h would be to replace getElementById() with Emscripten's findCanvasEventTarget() (and of course also removing the special #-prefix hack, so that the selector string is passed through as is.

konsumer commented 2 days ago

OTH the calls to emscripten_get_element_css_size and emscripten_set_canvas_element_size in the _sapp_emsc_run() function are still there, I don't understand why the function is replaced in one place and not the other.

maybe. That was a quick hack to move away from a selector, but as I said !canvas should work, too. Overall, it seems like the lightest way to keep all the code the same, except for the init entry-point (where Module.canvas is setup.)

...I wonder if those still work.

Not in my demo, I didn't touch them, but if the selector is set to !canvas it should. We did similar int pntr_app (I wrote the base of the web-target stuff.)

Also a question: wouldn't the problem of multiple canvases on the same page also be solved by giving each canvas its unique id and then give each demo that id-string in the sokol_main() function? This assumes that the .html5_canvas_selector is passed through as is, and the special # prefixing is removed.

Maybe, if you are not using web-components, but in my example, as I said, the canvas in web-component is not even in document, so you can't grab it with an id/selector. Additionally, since the way it's setup is that the selector is hard-coded in the wasm-code, I could not run 2 instances of the same wasm in the same page, even outside of the web-component.

floooh commented 2 days ago

(I added more stuff to my previous comment while you wrote your reply).

Maybe, if you are not using web-components

...if that means that the current Emscripten runtime functions which take a target-string parameter are not compatible with web-components, then I would rather prefer to not use web components instead of not being able to use those Emscripten functions tbh.

floooh commented 2 days ago

I'll see if I can make some time to tinker around with the ideas in this thread (with the main target to easily enable multiple WebGL canvases on the same page).

In any case, thanks for the input so far :)

floooh commented 2 days ago

PS: the specialHTMLTargets thing looks like a good solution, unless this is also part of the deprecated behaviour... hmm.

konsumer commented 2 days ago

I wonder if it is enough to initialize Module['canvas'] outside WASM to make all the emscripten_* functions work.

Yeh, that is what I am proposing, now. I forgot about specialHTMLTargets, which works great for this specific usecase in pntr_app, but my original point was "it's all js under that C code" like you can rewire anything in EM_JS. I think with specialHTMLTargets everything will basically work the same, though, with 1 lil js hook at the init.

...if that means that the current Emscripten runtime functions which take a target-string parameter are not compatible with web-components, then I would rather prefer to not use web components instead of not being able to use those Emscripten functions tbh

There are several parts to your question, I will elaborate:

PS: the specialHTMLTargets thing looks like a good solution, unless this is also part of the deprecated behaviour... hmm.

Again, the deprecated part, as I understand it, is that emscripten used to automatically wire itself to #canvas via Module.canvas. Supporting both things doesn't take anything away or tie sokol to some old, bad behavior. Currently sokol is more like the deprecated behavior, anyway (it binds itself to #canvas by default.)

floooh commented 2 days ago

the Emscripten functions are just wrappers around EM_JS. it's all the same thing.

...that's true, but the Emscripten shim functions also sometimes have convenient workarounds for web API problems I don't necessarily want to deal with myself. In general I prefer the Emscripten functions over my own EM_JS functions, unless there's a really good reason not to use them.

konsumer commented 2 days ago

...that's true, but the Emscripten shim functions also sometimes have convenient workarounds for web API problems I don't necessarily want to deal with myself. In general I prefer the Emscripten functions over my own EM_JS functions, unless there's a really good reason not to use them.

I disagree, but it's a matter of taste I think, and I am fine with doing it either way. Again, with !canvas it's a bit of a moot point, since almost all of the code will be exactly the same (doing everything as you do, now.)

floooh commented 2 days ago

but it's not really up to you to "use web components" in my project

Well if it means kicking out all emscripten_* functions from the sokol header then that's a pretty good reason because it means increased maintenance burden on my side. It looks like the specialHTMLTarget[] might solve that issue though.

konsumer commented 2 days ago

Well if it means kicking out all emscripten_* functions

it doesn't. that is what I'm saying.

It looks like the specialHTMLTarget[] might solve that issue though.

exactly, I think it's just a trick to support all those (in my opinion jenky) functions that wrap js apis in C, that require query-selectors, so very little needs to change.

konsumer commented 2 days ago

I am happy to make another demo that uses !canvas/specialHTMLTarget and changes absolutely as little sokol code as possible. I wasn't really aiming for that before, because I was saving time (ha!) by not having to look things up (I am already pretty good with the js apis, or they are at least familiar) but you have done all the C work already, and it really just needs like 1 dumb little EM_JS function to tie !canvas to a sensible thing. I wish I had not forgotten about specialHTMLTarget, but I did use it in pntr_app, and it does make things easier, if you like those C functions better.

floooh commented 2 days ago

If you try that, you'd might at least want to replace this getElementById() line:

https://github.com/floooh/sokol/blob/2c6fc7470e9b9121a178e6e68c55f2f06fac4647/sokol_app.h#L5020

Ideally this would call Emscripten's findCanvasEventTarget() function, hopefully that's available anywhere.

You might also need to add $findCanvasEventTarget to the EM_JS_DEPS() here:

https://github.com/floooh/sokol/blob/2c6fc7470e9b9121a178e6e68c55f2f06fac4647/sokol_app.h#L4783

floooh commented 2 days ago

PS: also look for places which skip the first character in like _sapp.html5_canvas_selector[1], e.g. change this line:

https://github.com/floooh/sokol/blob/2c6fc7470e9b9121a178e6e68c55f2f06fac4647/sokol_app.h#L5934

...to sapp_js_init(_sapp.html5_canvas_selector);

...I think that should take care of most things (drag'n'drop won't work but that's not critical for the experiment).

konsumer commented 2 days ago

yep, I think there are sort of 2 parts to this, like:

I think the first is only in a few places, since everything mostly uses selectors anyway.

konsumer commented 2 days ago

drag'n'drop won't work but that's not critical for the experiment

I think if it all uses query-selector, it should work fine, right? Things in js-space can use Module.canvas and things in C-space can use !canvas as the selector.

konsumer commented 2 days ago

Ok, I made more minimal changes, swapping out !canvas where appropriate. I also swapped out Module.sapp_emsc_target, since it's a duplication of Module.canvas, anyway.

You might also need to add $findCanvasEventTarget to the EM_JS_DEPS()

I ended up just doing this:

const canvas = Module.canvas;

Since now we have a link to it, you don't need anything more in js-space. You actually already had Module.sapp_emsc_target, so all that dom-stuff (passing query-selectors to js functions or doing getElementById) was a bit redundant anyway. Those EM_JS functions could have the selector-argument removed, but it's not hurting anything to keep them the same.

I didn't test DnD, but it should work, too.

I also made a PR for it, which helps to see how little changed (all together 6 lines changed, but most of that was other cleanup.)

floooh commented 1 day ago

Thanks! Looks a bit different than I imagined. I might do a counterproposal, which we can compare then ;)

konsumer commented 15 hours ago

Thanks! Looks a bit different than I imagined. I might do a counterproposal, which we can compare then ;)

There are basically 2 ways to target the "screen canvas", depending on if you are in JS or C, in my solution, and I am not sure how that could be improved upon:

As I said, I will suggest an improvement to my path, though: any sokol functions themselves do not need a string-selector arg, anymore. Like after init, id/selector should just not be touched/passed, ever. I tried to keep everything as it is, which means they all have unused (well, used, but it's set to !canvas already) selector-string params.

I think my PR could be merged for an immediate improvement (supports canvas option, which enables multiple canvases on a page for separate instances of same wasm, and all old stuff works same) and that code-cleanup could be done later. Since I am not actually using sokol in anything, it would be more appropriate for you or someone that works on/uses sokol to change it, or just never touch it again, since it doesn't really hurt anything (except maybe a bit misleading, code-wise.)