How to make a VR game for WebXR with Godot

Submitted by David Snopek on Tue, 11/03/2020 - 11:05

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!

Animated GIF of entering VR in a WebXR experience

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:

  1. Make a new main scene with a Spatial node as the root (the default for "3D Scene").
  2. Add an ARVROrigin node.
  3. Add three children to the ARVROrigin node: an ARVRCamera and two ARVRController's.
  4. Rename the first ARVRController to LeftController, and assign it a "Controller Id" of 1
  5. Rename the second ARVRController to RightController, and assign it a "Controller Id" of 2
  6. 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 couple things unique to WebXR, that you wouldn't normally use for other VR platforms:

  1. Add a CanvasLayer node as a child of the root node.
  2. 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:

Picture of Godot scene tree

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:
	$CanvasLayer/Button.connect("pressed", self, "_on_Button_pressed")
	webxr_interface = ARVRServer.find_interface("WebXR")
	if webxr_interface:
		# 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")
		# 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.
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")
	# 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")
func _webxr_session_started() -> void:
	# 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:
	# 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)

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:

Godot project settings, highlighting the "Main Scene" configuration setting


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! No need to download anything special :-)

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:

  1. In the Godot editor, click Project -> Export... and then Add... -> HTML5
  2. 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
  3. Copy this into the "Head Include" field:
    <script src="[email protected]/build/webxr-polyfill.min.js"></script>
    var polyfill = new WebXRPolyfill();

    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:

Godot export dialog with WebXR settings showing

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.

The WebXR Emulator Extension in Chrome

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:

  1. Connect your Quest to your computer with a USB cable
  2. Open Chrome on your desktop
  3. Go to chrome://inspect#devices
  4. 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.

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))

Detecting thumbstick and touchpad movement is also quite straightforward, except that the joystick device ids are 100 (for the left, or only, controller) and 101 (for the right controller):

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))

The button and axis ids are defined by Section 3.3 of the WebXR Gamepads Module - this image is particularly useful:

Image from WebXR spec showing ids of buttons and axes


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.

Animated GIF showing a couple seconds playing the toy racer VR demo

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! :-)