(NOTE: If you're using Godot 4.x, check out the new Godot 4 version of this tutorial!)
For the last few weeks, I've been working on adding support for WebXR to the Godot game engine, and recording periodic progress report videos on YouTube.
Things have gotten to a point where it'd be useful for other folks to try it out, to help find bugs and give feedback on the APIs. So, I've decided to write this short tutorial on how to use a development build to make a VR game!
Of course, since Godot's WebXR support is still a work-in-progress, there will be changes and after awhile, these instructions may no longer work exactly as written here, so be sure to check my site and YouTube channel for newer information.
UPDATE (2020-11-10): I've made a video version of this tutorial for those who prefer video!
What is WebXR and why should you care?
WebXR is an open standard for allowing web applications to work with virtual reality (VR) or augmented reality (AR) hardware.
This means you can visit a web page, click an "Enter VR" button, and then put on your VR headset to play a game, without having to download or install anything. And the Oculus Quest's built-in browser has great WebXR support too!
While playing a VR game on a web page might seem like a pretty weird thing to do, there are some compelling use-cases:
- Getting your games or experiences into "walled gardens" like the Oculus Quest, where Facebook has very tight control over what gets to appear in the Oculus store.
- Sharing hobbyist passion projects and game jam games. It's well known that if you make a web version of your game in a jam game, more people will try it, and the same applies to VR games.
- Supporting a wide-range of VR headsets, from the very capable (like Valve Index, HTC Vive, Oculus Rift and Quest) down to the less capable (like Google Cardboard, Oculus Go, GearVR).
Godot's WebXR support doesn't work with AR yet, but that is also pretty compelling: imagine browsing a furniture store's website on your smartphone, then hitting an "Enter AR" button, which allows you to see what that piece of furniture would look like in the room you're currently in.
Setting up the basics
The basics for making a VR game for WebXR in Godot, are very similar to making a game for any VR platform in Godot:
- Make a new main scene with a Spatial node as the root (the default for "3D Scene").
- Add an ARVROrigin node.
- Add three children to the ARVROrigin node: an ARVRCamera and two ARVRController's.
- Rename the first ARVRController to LeftController, and assign it a "Controller Id" of 1
- Rename the second ARVRController to RightController, and assign it a "Controller Id" of 2
- Add a MeshInstance node to each controller, and assign it a "New CubeMesh" with its dimensions set to 0.1 on each side. These cubes will stand in for your VR hands until you can replace them with something better.
Next, we need to add a one thing unique to WebXR, that you wouldn't normally use for other VR platforms:
- Add a Button node to the CanvasLayer, and change its "Text" property to "Enter VR". Users will need to click this button to switch from browsing a flat web page, to an immersive VR experience.
In the end, your scene tree should look like this:
UPDATE (2021-01-28): Previous versions of this tutorial put the Button under a CanvasLayer node. This can cause problems in AR, so using the CanvasLayer is no longer recommended.
Next, attach a script to the root node (called "Main" in our scene tree image above), with the following contents:
extends Spatial var webxr_interface var vr_supported = false func _ready() -> void: $Button.connect("pressed", self, "_on_Button_pressed") webxr_interface = ARVRServer.find_interface("WebXR") if webxr_interface: # With Godot 3.5-beta4 and later, the following setting is recommended! # Map to the standard button/axis ids when possible. webxr_interface.xr_standard_mapping = true # WebXR uses a lot of asynchronous callbacks, so we connect to various # signals in order to receive them. webxr_interface.connect("session_supported", self, "_webxr_session_supported") webxr_interface.connect("session_started", self, "_webxr_session_started") webxr_interface.connect("session_ended", self, "_webxr_session_ended") webxr_interface.connect("session_failed", self, "_webxr_session_failed") webxr_interface.connect("select", self, "_webxr_on_select") webxr_interface.connect("selectstart", self, "_webxr_on_select_start") webxr_interface.connect("selectend", self, "_webxr_on_select_end") webxr_interface.connect("squeeze", self, "_webxr_on_squeeze") webxr_interface.connect("squeezestart", self, "_webxr_on_squeeze_start") webxr_interface.connect("squeezeend", self, "_webxr_on_squeeze_end") # This returns immediately - our _webxr_session_supported() method # (which we connected to the "session_supported" signal above) will # be called sometime later to let us know if it's supported or not. webxr_interface.is_session_supported("immersive-vr") $ARVROrigin/LeftController.connect("button_pressed", self, "_on_LeftController_button_pressed") $ARVROrigin/LeftController.connect("button_release", self, "_on_LeftController_button_release") func _webxr_session_supported(session_mode: String, supported: bool) -> void: if session_mode == 'immersive-vr': vr_supported = supported func _on_Button_pressed() -> void: if not vr_supported: OS.alert("Your browser doesn't support VR") return # We want an immersive VR session, as opposed to AR ('immersive-ar') or a # simple 3DoF viewer ('viewer'). webxr_interface.session_mode = 'immersive-vr' # 'bounded-floor' is room scale, 'local-floor' is a standing or sitting # experience (it puts you 1.6m above the ground if you have 3DoF headset), # whereas as 'local' puts you down at the ARVROrigin. # This list means it'll first try to request 'bounded-floor', then # fallback on 'local-floor' and ultimately 'local', if nothing else is # supported. webxr_interface.requested_reference_space_types = 'bounded-floor, local-floor, local' # In order to use 'local-floor' or 'bounded-floor' we must also # mark the features as required or optional. webxr_interface.required_features = 'local-floor' webxr_interface.optional_features = 'bounded-floor' # This will return false if we're unable to even request the session, # however, it can still fail asynchronously later in the process, so we # only know if it's really succeeded or failed when our # _webxr_session_started() or _webxr_session_failed() methods are called. if not webxr_interface.initialize(): OS.alert("Failed to initialize") return func _webxr_session_started() -> void: $Button.visible = false # This tells Godot to start rendering to the headset. get_viewport().arvr = true # This will be the reference space type you ultimately got, out of the # types that you requested above. This is useful if you want the game to # work a little differently in 'bounded-floor' versus 'local-floor'. print ("Reference space type: " + webxr_interface.reference_space_type) func _webxr_session_ended() -> void: $Button.visible = true # If the user exits immersive mode, then we tell Godot to render to the web # page again. get_viewport().arvr = false func _webxr_session_failed(message: String) -> void: OS.alert("Failed to initialize: " + message) func _on_LeftController_button_pressed(button: int) -> void: print ("Button pressed: " + str(button)) func _on_LeftController_button_release(button: int) -> void: print ("Button release: " + str(button)) func _process(delta: float) -> void: var left_controller_id = 100 var thumbstick_x_axis_id = 2 var thumbstick_y_axis_id = 3 var thumbstick_vector := Vector2( Input.get_joy_axis(left_controller_id, thumbstick_x_axis_id), Input.get_joy_axis(left_controller_id, thumbstick_y_axis_id)) if thumbstick_vector != Vector2.ZERO: print ("Left thumbstick position: " + str(thumbstick_vector)) func _webxr_on_select(controller_id: int) -> void: print("Select: " + str(controller_id)) var controller: ARVRPositionalTracker = webxr_interface.get_controller(controller_id) print (controller.get_orientation()) print (controller.get_position()) func _webxr_on_select_start(controller_id: int) -> void: print("Select Start: " + str(controller_id)) func _webxr_on_select_end(controller_id: int) -> void: print("Select End: " + str(controller_id)) func _webxr_on_squeeze(controller_id: int) -> void: print("Squeeze: " + str(controller_id)) func _webxr_on_squeeze_start(controller_id: int) -> void: print("Squeeze Start: " + str(controller_id)) func _webxr_on_squeeze_end(controller_id: int) -> void: print("Squeeze End: " + str(controller_id))
And before we move on, save this scene, edit your project settings (Project -> Project Settings...) and under the Application -> Run section, set your "Main Scene" to the scene you just created:
Exporting
My PR adding WebXR support to Godot hasn't been merged yet, so you need to use a custom build of the HTML5 export templates.
UPDATE (2021-01-07): My PR adding WebXR support has been merged, and is now included in Godot 3.2.4-beta5 and later!
It's recommended that you use Godot 3.2.4-beta5 or later along with the official export templates for that version of Godot.
However, if you're stuck on an earlier version, I've made some pre-compiled builds that you can download:
Godot version | Builds | Notes |
---|---|---|
Godot 3.2.3 | Doesn't support the select*/squeeze* events described in "Input Handling" below | |
Godot 3.2.4-beta4 | Godot 3.2.4-beta4 is a beta build, so may have bugs or regressions, but I've found it quite stable! | |
Godot 3.2.4-beta5 or later | Use the official export templates! | |
Godot 3.3.0 | Unavailable | WebXR support is broken in the official export templates and I'm not aware of an alternative build. |
Godot 3.3.1 or later | Use the official export templates! |
Download both the debug and release builds for your version of Godot, and keep them somewhere outside of your project directory.
Next, we need to setup an HTML5 export:
- In the Godot editor, click Project -> Export... and then Add... -> HTML5
- If you're using Godot 3.2.3 or Godot 3.2.4-beta4 (you can skip this step with Godot 3.2.4-beta5 or later):
- Under "Custom Template" set Debug and Release to the paths to the debug and release builds you just downloaded
- Copy this into the "Head Include" field:
This adds the WebXR Polyfill, which will fill in holes in a web browser's WebXR support, allowing your app to work on more web browsers. For some browsers this isn't needed at all, but on others, it will actually fully implement WebXR from scratch, based on top of the older WebVR technology.
The export dialog should look something like this:
Finally, click "Export Project" to export your WebXR app!
WebXR only works from HTTPS!
If you open a web page over HTTP, your web browser will refuse to support WebXR, so you need to use HTTPS.
This is easy for production, since if you have a live website, you certainly use HTTPS these days. But when running locally for development, it can be a pain to setup an HTTPS server.
There's a number of ways to run a quick HTTPS server, but I've been using browser-sync. If you have NodeJS and NPM or your system, you can install browser-sync by running:
npm i -g browser-sync
And then go to the directory where you exported your WebXR app and run:
browser-sync start -s --https --no-open --port=5001
You can then run your app by visiting https://localhost:5001/<HTML-filename> in your web browser (of course, replacing <HTML-filename> with the name of the .html file you used when exporting).
Debugging on the Desktop
If you have a PCVR headset, debugging is easy, because you're using a desktop browser like Chrome or Firefox. To see any messages from your app (the sort you usually see in the "Output" tab in the Godot editor, or in the terminal), you just need to open the web developer console in your browser, which can be done in both Firefox and Chrome by pressing Ctrl+Shift+I.
However, it can be a pain to put your headset on and off. Or, maybe you don't have a PCVR headset at all. For those cases, there's a WebXR Emulator Extension for both Chrome and Firefox.
It gives you a new WebXR tab in the web developer console, that lets you move around the headset and controllers, as well as trigger some controller events, to make sure your app responds correctly.
Debugging on the Oculus Quest
If you're testing in the Oculus Browser on the Quest, unfortunately, there isn't a web developer console that you can access in your headset.
Luckily, however, the Oculus Browser is based on Chrome, and Chrome has the ability to remotely debug another instance of Chrome.
For the full instructions, see this article on the Oculus for Developers website. Warning: You'll need to install some Android developer tools on your system for it to work.
But once you're all setup, the short version is:
- Connect your Quest to your computer with a USB cable
- Open Chrome on your desktop
- Go to chrome://inspect#devices
- Click the "Inspect" link by the browser session that has your app running in it
This will give you the web developer console you're used to on the desktop, but connected to the Oculus Browser!
Handling input
Detecting button presses on the controllers is done in much the same way as on any other VR platform.
UPDATE (2022-04-13): With Godot 3.5-beta4 and later, it's recommended that you set:
webxr_interface.xr_standard_mapping = true
... as shown above, which will map the button and axis ids to match the Godot standard values when possible. Those values are described in this section.
You can connect to the button_pressed and button_release signals on ARVRController, for example:
func _ready() -> void: # ... previous code ... $ARVROrigin/LeftController.connect("button_pressed", self, "_on_LeftController_button_pressed") $ARVROrigin/LeftController.connect("button_release", self, "_on_LeftController_button_release") func _on_LeftController_button_pressed(button: int) -> void: print ("Button pressed: " + str(button)) func _on_LeftController_button_release(button: int) -> void: print ("Button release: " + str(button))
Here's all the button ids:
Button | Id | Constant |
---|---|---|
Trigger | 15 | JOY_VR_TRIGGER |
Grid | 2 | JOY_VR_GRIP |
A/X | 7 | JOY_OCULUS_AX |
B/Y | 1 | JOY_OCULUS_BY |
Thumbstick button | 3 | |
Touchpad button | 14 | JOY_VR_PAD |
Detecting thumbstick and touchpad movement is also quite straight-forward:
func _process(delta: float) -> void: var left_controller = $ARVROrigin/LeftController var thumbstick_x_axis_id := 0 var thumbstick_y_axis_id := 1 var thumbstick_vector := Vector2( left_controller.get_joystick_axis(thumbstick_x_axis_id), left_controller.get_joystick_axis(thumbstick_y_axis_id)) if thumbstick_vector != Vector2.ZERO: print ("Left thumbstick position: " + str(thumbstick_vector))
Here's all the axis ids:
Axis | Id | Constant |
---|---|---|
Thumbstick X | 0 | |
Thumbstick Y | 1 | |
Trigger | 2 | JOY_VR_ANALOG_TRIGGER |
Grip | 4 | JOY_VR_ANALOG_GRIP |
Touchpad X | 6 | |
Touchpad Y | 7 |
Without WebXRInterface.xr_standard_mapping...
If you're using Godot 3.4.x or earlier, or if you haven't set "xr_standard_mapping", then the button and axis ids are defined by Section 3.3 of the WebXR Gamepads Module - this image is particularly useful:
Select and Squeeze events
WebXR also provides an alternate way of handling input, that will work even for devices that don't support VR controllers (ex. Google Cardboard) via a set of special 'select' and 'squeeze' events. However, while I'm planning to add an API for these, they aren't currently exposed to Godot.
UPDATE (2020-12-16): The 'select' and 'squeeze' events are now supported! Here's a code snippet demonstrating how you'd use them:
func _ready() -> void: # ... previous code ... if webxr_interface: webxr_interface.connect("select", self, "_webxr_on_select") webxr_interface.connect("selectstart", self, "_webxr_on_select_start") webxr_interface.connect("selectend", self, "_webxr_on_select_end") webxr_interface.connect("squeeze", self, "_webxr_on_squeeze") webxr_interface.connect("squeezestart", self, "_webxr_on_squeeze_start") webxr_interface.connect("squeezeend", self, "_webxr_on_squeeze_end") func _webxr_on_select(controller_id: int) -> void: print("Select: " + str(controller_id)) var controller: ARVRPositionalTracker = webxr_interface.get_controller(controller_id) print (controller.get_orientation()) print (controller.get_position()) func _webxr_on_select_start(controller_id: int) -> void: print("Select Start: " + str(controller_id)) func _webxr_on_select_end(controller_id: int) -> void: print("Select End: " + str(controller_id)) func _webxr_on_squeeze(controller_id: int) -> void: print("Squeeze: " + str(controller_id)) func _webxr_on_squeeze_start(controller_id: int) -> void: print("Squeeze Start: " + str(controller_id)) func _webxr_on_squeeze_end(controller_id: int) -> void: print("Squeeze End: " + str(controller_id))
The select* signals are emitted when the a "primary action" is done, and the squeeze signals are emitted for a "primary squeeze action". What those actions are depends on the device. For an Oculus Quest, those actions are pressing the trigger and grip buttons on the Oculus Touch controllers, but for less capable devices, it could be tapping the screen, even speaking a voice command.
The 'controller_id' passed to each of signals is the same id used by the ARVRController node, so 1 is the left controller, 2 is the right controller and higher ids will refer to non-traditional input sources. You can get an ARVRPositionalTracker object with position and orientation information, as shown in the example above. In the case of non-traditional input sources, the position and orientation should be interpreted as a ray pointing at the object the user wishes to interact with.
A work-in-progress demo
I've been working on a WebXR demo where you drive remote-control cars around a track.
Here's the URL:
If you have an Oculus Quest, you can open that up in the Oculus Browser on your headset. Or, if you have a PCVR headset, connect it to your computer and visit that URL with the latest version of Chrome or Firefox. It can take quite awhile to load (especially on the Oculus Quest), which is another problem I'm still working on, but for now, just wait for a minute or two - it should come up eventually. :-)
I've still got lots of things I'd like to add and improve in the demo (especially around supporting more devices), but if you want to check out the code, it's public on GitLab.
Give it a try and let me know!
If you try to make something with WebXR in Godot, please let me know how it goes!
If everything works, I'd love to see what you make. Or, if things go wrong and you need some help, I'd love to collect your bug reports and get ideas for improving the documentation.
Happy Hacking! :-)
Comments
Wow! Thank you for the…
Wow! Thank you for the article. I tried your demo via browser in quest 2, everything works very well. To be honest, I was afraid that Godot Engine is not optimized enough for comfortable vr playing on a portable helmet, but it seems I was wrong =)
Thanks for this excellent…
Thanks for this excellent tutorial! Your blog is my only hope of trust for more such tutorials on WebXR :p
Hi, just followed the video…
Hi, just followed the video and text with the latest Godot release 3.3.2.stable and although the VR works with Oculus Quest 2 browser, in chrome with the webxr debug extension I get the error "WebGL: INVALID_OPERATION: bindTexture: attempt to use a deleted object" 254 times and the right eye window is solid white.
Hm. This isn't a problem I…
Hm. This isn't a problem I've ever personally seen!
What version of Chrome are you using? Does my demo game work for you, or does it have the same problem?
Also, are you using the WebXR Polyfill? Chrome shouldn't need it, but it will subtly change how WebXR works on Chrome. If you're using it, it might be interesting to try without it too.
Thanks!
Just to say, once I upgraded…
Just to say, once I upgraded my Godot to 3.3.2 to get round the "Browser.pauseAsyncCallbacks is not a function" error, this worked great on my Oculus Go.
Awesome, thanks for letting…
Awesome, thanks for letting me know! I don't think that I'd heard from anyone trying it with the Go yet. :-)
I only have an Oculus Go at…
I only have an Oculus Go at the moment although I do plan on buying a Quest 2 if they ever come back instock. I tried out your game and the performance is pretty terrible in the Oculus browser but in the Firefox Reality browser the performance is great but a black mosaic appears covering the screen with little holes that I can see the car and track through. My test project using Godot 3.3.2 didn't have the black mosaic issue your game had. Another note is my project only has the controller in the scene, no idea if it will get your issue after I fill the scene with objects. But I still did notice my controller mesh went from laggy on the Oculus Go browser to perfectly smooth on the Firefox Reality browser. New to Godot but I'm going to try making a game.
Thanks for trying it on the…
Thanks for trying it on the Go and reporting back! I don't recall if anyone has tried that before. I wish I could see a screenshot of what you're seeing. Is it possible to take a picture on the Go?
One random guess is that perhaps its just not rendering fast enough to make the vsync, and you're only seeing the pixels that managed to get rendered in time?
Anyway, I'm very curious to see how your game develops and if you eventually encounter this problem. Since you'd get to it iteratively, you'll hopefully have a better idea of what change causes it to happen.
I did some more testing on…
I did some more testing on the Oculus Go and my "great performance" comment is kind of funny considering it was only running at 30fps. On Firefox your game runs at around 30fps and on the Oculus browser it runs at around 13fps. I also tested a VR Experience Flappy Birds game that shows up in the Oculus browser (no idea what engine it uses) and it runs at 30fps in the Oculus browser. I then thought to test the Fappy Birds game on the Firefox browser and you would think it would run better since your game did but nope it runs at around 11fps.
My project with only a controller in the scene ran at around 50fps in Firefox and after adding a few things it ran at what yours does. Here is a screenshot showing the weird glitch happening in your game on Firefox but it doesn't happen on mine. (https://i.imgur.com/yE54lR2.png) Ignore the performance metrics because I had to stream my headset to get this screenshot which affects the performance.
Wow, that screenshot is…
Wow, that screenshot is... interesting. :-) Thanks for sharing it and the other testing info!
Interestingly I recently…
Interestingly I recently stumbled upon the same problem and David helped me sort it out. Basically the setup for the scene in the master branch / online demo is having issues, everything worked fine for me (and them) in the develop branch of the project.
Here is some background information: https://github.com/godotengine/godot/issues/53402
Thanks again David!
No problem! Thanks for…
No problem! Thanks for reporting the issue. :-)
It inspired me to update the demo and put a new link in this article:
https://bit.ly/godot-webxr-10
I wonder if that version would work better on the Oculus Go? If Travis or anyone else with the Go comes along, I'd love it if someone could test and let me know. :-)
WebXR is working great…
WebXR is working great. Tested on 2 Android cardboard devices and on the Oculus Rift S. I'm using it to visualize architecture and it gives a good sense of the size of the space. I had a problem with jaggies being really pronounced. Solved it via project settings > rendering > quality > Use Fxaa
The only other problem I have is a non stop error message. Everything works so I just ignore it for now. I see the message in the chrome console after entering debug VR mode.
**ERROR**: Condition "status != 0x8CD5" is true
At: drivers/gles2/rasterizer_storage_gles2.cpp: 5473: render_target_set_external_texture() - Condition "status != 0x8CD5" is true
framebuffer fail, status 8cd6
Hey! I was wondering if…
Hey! I was wondering if there was any way to get the grip and trigger analog/axis values, instead of just knowing if it's pressed or not. Perhaps using the ARVRController nodes? I tried using get_joystick_axis(JOY_VR_ANALOG_GRIP), but it didn't seem to work correctly. I am using the Godot XR Tools to get the premade hand meshes. Thanks!
I haven't double checked…
I haven't double checked this, but I think you can do it by watching for InputEventJoypadButton events:
https://docs.godotengine.org/en/stable/classes/class_inputeventjoypadbutton.html#class-inputeventjoypadbutton
The 'device' will be 100 or 101 (for the left or right controller, respectively) and the 'button_id' will be 0 or 1 (for the trigger or grip, respectively) and the info you're looking for is on the 'pressure' property.
If you try that, please let me know if it works! :-)
Thank you so much for your…
Thank you so much for your work on this! In about 3 hours, I was able to get this working with my Oculus Quest 2 and Godot 3.4.2, and I made a basic low-poly walking simulator. As you mentioned near the top of the article, webXR opens up a whole new realm of possibilities for getting experiences into Walled Gardens.
That's awesome! If you end…
That's awesome! If you end up publishing something publicly, please let me know! :-)
Thank you for an incredible…
Thank you for an incredible article!
Thanks for this, it's a…
Thanks for this, it's a great tutorial.
Having one odd issue though. I get the error message "Your browser doesn't support VR", but when I try someone else's version of the tutorial (from the asset library), I don't. I've checked every setting and code line, and the only difference is that theirs lacks the "webxr_interface.xr_standard_mapping" line. Adding it to their script causes the error, but removing it from mine doesn't fix it. I'm losing my mind. Any suggestions?
Thanks! Hm. What browser…
Thanks!
Hm. What browser are you using? Did you add the polyfill to the "Head Include" when exporting, per the instructions in the "Exporting" section above? My thought is maybe you're using a browser (like Firefox) that doesn't support WebXR without the polyfill.
Also, if you open up the Web Developer Console (Ctrl+Shift+I on most browsers) do you see any error messages in the "Console" tab?
Hopefully, that'll help! If not, feel free to come over to the Snopek Games Discord and we can try and chat through it :-)
After deleting files,…
After deleting files, emptying cookies and quite a lot of profanities, I can now get my version to work. The head script was already ok, so don't know what happened there.
It looks like the "webxr_interface.xr_standard_mapping = true" line might have been the cause of the problem. This is in the console:
"SCRIPT ERROR: Invalid set index 'xr_standard_mapping' (on base: 'WebXRInterfaceJS') with value of type 'bool'."
I'm also getting a message about navigator.userAgent but I'm ignoring that...
Thanks for your help, I might pop over to the discord anyway :D
Ah, awesome, glad you got it…
Ah, awesome, glad you got it working!
What version of Godot are you using? The "webxr_interface.xr_standard_mapping = true" will only work in Godot 3.5-beta4 or later (as noted in the comment right above it in the example). So, if you're still on Godot 3.4, then, yeah, you definitely need to delete that line! I wonder if I should maybe just remove that from the example until 3.5 stable is released?
Thanks for making WebXR…
Thanks for making WebXR incredibly easy and all you do for the Godot Community! Was surprised how quick it was to get a scene running between this tutorial, Godot 3.5 stable, and the OpenXR/XR Tools assets! My tiny test app is here:https://teddybear082.itch.io/godot-webxr-avatar-test