| <!doctype html> |
| <!-- |
| Copyright 2024 The Immersive Web Community Group |
| |
| Permission is hereby granted, free of charge, to any person obtaining a copy of |
| this software and associated documentation files (the "Software"), to deal in |
| the Software without restriction, including without limitation the rights to |
| use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
| the Software, and to permit persons to whom the Software is furnished to do so, |
| subject to the following conditions: |
| |
| The above copyright notice and this permission notice shall be included in all |
| copies or substantial portions of the Software. |
| |
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
| FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
| COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
| IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
| CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| --> |
| <html> |
| <head> |
| <meta charset='utf-8'> |
| <meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'> |
| <meta name='mobile-web-app-capable' content='yes'> |
| <meta name='apple-mobile-web-app-capable' content='yes'> |
| <link rel='icon' type='image/png' sizes='32x32' href='../favicon-32x32.png'> |
| <link rel='icon' type='image/png' sizes='96x96' href='../favicon-96x96.png'> |
| <link rel='stylesheet' href='../css/common.css'> |
| |
| <title>Barebones WebGPU VR</title> |
| </head> |
| <body> |
| <header> |
| <details open> |
| <summary>Barebones WebGPU VR</summary> |
| <p> |
| This sample demonstrates extremely simple use of an "immersive-vr" |
| session with the WebGPU API and no library dependencies. It doesn't |
| render anything exciting, just a rainbow triangle to prove it's working. |
| <a class="back" href="./">Back</a> |
| </p> |
| <button id="xr-button" class="barebones-button" disabled>WebXR or WebGPU not supported</button> |
| </details> |
| </header> |
| <canvas id='webgpu_canvas'></canvas> |
| <script type='module'> |
| // Because we are not using the 'secondary-views' feature we can be sure |
| // that WebXR will never provide more than two views. |
| const MAX_VIEWS = 2; |
| |
| // A simple shader that draws a single triangle |
| const SHADER_SRC = ` |
| struct Camera { |
| projection: mat4x4f, |
| view: mat4x4f, |
| } |
| @group(0) @binding(0) var<uniform> cameras: array<Camera, ${MAX_VIEWS}>; |
| |
| struct VertexOut { |
| @builtin(position) pos: vec4f, |
| @location(0) color: vec4f, |
| } |
| |
| @vertex |
| fn vertexMain(@builtin(vertex_index) vert_index: u32, |
| @builtin(instance_index) view_index: u32) -> VertexOut { |
| var pos = array<vec4f, 3>( |
| vec4f(0.0, 0.5, -1, 1), |
| vec4f(-0.5, -0.5, -1, 1), |
| vec4f(0.5, -0.5, -1, 1) |
| ); |
| |
| var color = array<vec4f, 3>( |
| vec4f(1, 0, 0, 1), |
| vec4f(0, 1, 0, 1), |
| vec4f(0, 0, 1, 1) |
| ); |
| |
| let posOut = cameras[view_index].projection * cameras[view_index].view * pos[vert_index]; |
| |
| return VertexOut(posOut, color[vert_index]); |
| } |
| |
| @fragment |
| fn fragmentMain(in: VertexOut) -> @location(0) vec4f { |
| return in.color; |
| } |
| `; |
| |
| // XR globals. |
| let xrButton = document.getElementById('xr-button'); |
| let xrSession = null; |
| let xrRefSpace = null; |
| |
| // WebGPU scene globals. |
| let gpuDevice = null; |
| let gpuContext = null; |
| let gpuUniformBuffer = null; |
| let gpuUniformArray = new Float32Array(32 * MAX_VIEWS); // Enough room for two matrices per view. |
| let gpuBindGroupLayout = null; |
| let gpuBindGroup = null; |
| let gpuModule = null; |
| let gpuPipeline = null; |
| let colorFormat = null; |
| |
| // WebXR/WebGPU interop globals. |
| let xrGpuBinding = null; |
| let projectionLayer = null; |
| |
| // Generate a projection matrix, borrowed from gl-matrix. |
| function perspectiveZO(out, fovy, aspect, near, far = Infinity) { |
| const f = 1.0 / Math.tan(fovy / 2); |
| out[0] = f / aspect; |
| out[1] = 0; |
| out[2] = 0; |
| out[3] = 0; |
| out[4] = 0; |
| out[5] = f; |
| out[6] = 0; |
| out[7] = 0; |
| out[8] = 0; |
| out[9] = 0; |
| out[11] = -1; |
| out[12] = 0; |
| out[13] = 0; |
| out[15] = 0; |
| if (far != null && far !== Infinity) { |
| const nf = 1 / (near - far); |
| out[10] = far * nf; |
| out[14] = far * near * nf; |
| } else { |
| out[10] = -1; |
| out[14] = -near; |
| } |
| return out; |
| } |
| |
| // Checks to see if WebXR and WebGPU is available and, if so, requests an |
| // tests to ensure it supports the desired session type. |
| async function initXR() { |
| // Is WebXR, WebGPU, and WebXR/WebGPU interop available on this UA? |
| if (!navigator.xr) { |
| xrButton.textContent = 'WebXR not supported'; |
| return; |
| } |
| |
| if (!navigator.gpu) { |
| xrButton.textContent = 'WebGPU not supported'; |
| return; |
| } |
| |
| if (!('XRGPUBinding' in window)) { |
| xrButton.textContent = 'WebXR/WebGPU interop not supported'; |
| return; |
| } |
| |
| // If the UA allows creation of immersive VR sessions enable the |
| // target of the 'Enter XR' button. |
| const supported = await navigator.xr.isSessionSupported('immersive-vr'); |
| if (!supported) { |
| xrButton.textContent = 'Immersive VR not supported'; |
| return; |
| } |
| |
| // Updates the button to start an XR session when clicked. |
| xrButton.addEventListener('click', onButtonClicked); |
| xrButton.textContent = 'Enter VR'; |
| xrButton.disabled = false; |
| |
| await initWebGPU(); |
| requestAnimationFrame(onFrame); |
| } |
| |
| // Initializes WebGPU resources |
| async function initWebGPU() { |
| if (!gpuDevice) { |
| // Create a WebGPU adapter and device to render with, initialized to be |
| // compatible with the XRDisplay we're presenting to. Note that a canvas |
| // is not necessary if we are only rendering to the XR device. |
| const adapter = await navigator.gpu.requestAdapter({ |
| xrCompatible: true |
| }); |
| gpuDevice = await adapter.requestDevice(); |
| colorFormat = navigator.gpu.getPreferredCanvasFormat(); |
| |
| gpuContext = webgpu_canvas.getContext('webgpu'); |
| gpuContext.configure({ |
| format: colorFormat, |
| device: gpuDevice, |
| }); |
| |
| // Allocate a uniform buffer with enough space for two uniforms per-view |
| gpuUniformBuffer = gpuDevice.createBuffer({ |
| size: Float32Array.BYTES_PER_ELEMENT * 32 * MAX_VIEWS, |
| usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, |
| }); |
| |
| // Set the uniform buffer to contain valid matrices initially so |
| // that we can see something. |
| let mat = [ |
| 1, 0, 0, 0, |
| 0, 1, 0, 0, |
| 0, 0, 1, 0, |
| 0, 0, 0, 1 |
| ]; |
| gpuUniformArray.set(mat, 16); |
| |
| perspectiveZO(mat, Math.PI * 0.5, webgpu_canvas.offsetWidth / webgpu_canvas.offsetHeight, 0.1); |
| gpuUniformArray.set(mat, 0); |
| |
| gpuDevice.queue.writeBuffer(gpuUniformBuffer, 0, gpuUniformArray); |
| |
| // Create a bind group for the uniforms |
| gpuBindGroupLayout = gpuDevice.createBindGroupLayout({ |
| entries: [{ |
| binding: 0, |
| visibility: GPUShaderStage.VERTEX, |
| buffer: {}, |
| }] |
| }); |
| |
| gpuBindGroup = gpuDevice.createBindGroup({ |
| layout: gpuBindGroupLayout, |
| entries: [{ |
| binding: 0, |
| resource: { buffer: gpuUniformBuffer } |
| }] |
| }); |
| |
| gpuModule = gpuDevice.createShaderModule({ code: SHADER_SRC }); |
| } |
| |
| gpuPipeline = gpuDevice.createRenderPipeline({ |
| layout: gpuDevice.createPipelineLayout({ bindGroupLayouts: [ gpuBindGroupLayout ]}), |
| vertex: { |
| module: gpuModule, |
| entryPoint: 'vertexMain', |
| }, |
| fragment: { |
| module: gpuModule, |
| entryPoint: 'fragmentMain', |
| targets: [{ |
| format: colorFormat, |
| }] |
| } |
| }); |
| } |
| |
| // Called when the user clicks the button to enter XR. If we don't have a |
| // session we'll request one, and if we do have a session we'll end it. |
| async function onButtonClicked() { |
| if (!xrSession) { |
| navigator.xr.requestSession('immersive-vr', { |
| requiredFeatures: ['webgpu'], |
| }).then(onSessionStarted); |
| } else { |
| xrSession.end(); |
| } |
| } |
| |
| // Called when we've successfully acquired a XRSession. In response we |
| // will set up the necessary session state and kick off the frame loop. |
| async function onSessionStarted(session) { |
| xrSession = session; |
| xrButton.textContent = 'Exit VR'; |
| |
| // Listen for the sessions 'end' event so we can respond if the user |
| // or UA ends the session for any reason. |
| session.addEventListener('end', onSessionEnded); |
| |
| // Create the WebXR/WebGPU binding, and with it create a projection |
| // layer to render to. |
| xrGpuBinding = new XRGPUBinding(xrSession, gpuDevice); |
| |
| // If the preferred color format doesn't match what we've been rendering |
| // with so far, rebuild the pipeline |
| if (colorFormat != xrGpuBinding.getPreferredColorFormat()) { |
| colorFormat = xrGpuBinding.getPreferredColorFormat(); |
| await initWebGPU(); |
| } |
| |
| projectionLayer = xrGpuBinding.createProjectionLayer({ |
| colorFormat |
| }); |
| |
| // Set the session's layers to display the projection layer. This allows |
| // any content rendered to the layer to be displayed on the XR device. |
| session.updateRenderState({ layers: [projectionLayer] }); |
| |
| // Get a reference space, which is required for querying poses. In this |
| // case an 'local' reference space means that all poses will be relative |
| // to the location where the XR device was first detected. |
| session.requestReferenceSpace('local').then((refSpace) => { |
| xrRefSpace = refSpace; |
| |
| // Inform the session that we're ready to begin drawing. |
| session.requestAnimationFrame(onXRFrame); |
| }); |
| } |
| |
| // Called either when the user has explicitly ended the session by calling |
| // session.end() or when the UA has ended the session for any reason. |
| // At this point the session object is no longer usable and should be |
| // discarded. |
| function onSessionEnded(event) { |
| xrSession = null; |
| xrGpuBinding = null; |
| xrButton.textContent = 'Enter VR'; |
| |
| // If the canvas color format is different than the XR one, rebuild the |
| // pipeline again upon switching back. |
| if (colorFormat != navigator.gpu.getPreferredCanvasFormat()) { |
| colorFormat = navigator.gpu.getPreferredCanvasFormat(); |
| } |
| |
| requestAnimationFrame(onFrame); |
| } |
| |
| // Called every time the XRSession requests that a new frame be drawn. |
| function onXRFrame(time, frame) { |
| let session = frame.session; |
| |
| // Inform the session that we're ready for the next frame. |
| session.requestAnimationFrame(onXRFrame); |
| |
| // Get the XRDevice pose relative to the reference space we created |
| // earlier. |
| let pose = frame.getViewerPose(xrRefSpace); |
| |
| // Getting the pose may fail if, for example, tracking is lost. So we |
| // have to check to make sure that we got a valid pose before attempting |
| // to render with it. If not in this case we'll just leave the |
| // framebuffer cleared, so tracking loss means the scene will simply |
| // disappear. |
| if (pose) { |
| |
| // If we do have a valid pose, begin recording GPU commands. |
| const commandEncoder = gpuDevice.createCommandEncoder(); |
| |
| // First loop through each view and write it's projection and view |
| // matrices into the uniform buffer. |
| for (let viewIndex = 0; viewIndex < pose.views.length; ++viewIndex) { |
| const view = pose.views[viewIndex]; |
| const offset = 32 * viewIndex; |
| gpuUniformArray.set(view.projectionMatrix, offset); |
| gpuUniformArray.set(view.transform.inverse.matrix, offset + 16); |
| } |
| gpuDevice.queue.writeBuffer(gpuUniformBuffer, 0, gpuUniformArray); |
| |
| // Now loop through each of the views and draw into the corresponding |
| // sub image of the projection layer. |
| for (let viewIndex = 0; viewIndex < pose.views.length; ++viewIndex) { |
| const view = pose.views[viewIndex]; |
| let subImage = xrGpuBinding.getViewSubImage(projectionLayer, view); |
| |
| // Start a render pass which uses the textures of the view's sub |
| // image as render targets. |
| const renderPass = commandEncoder.beginRenderPass({ |
| colorAttachments: [{ |
| view: subImage.colorTexture.createView(subImage.getViewDescriptor()), |
| // Clear the color texture to a solid color. |
| loadOp: viewIndex == 0 ? 'clear' : 'load', |
| storeOp: 'store', |
| // Clear to a non-black color so we can see if it's working |
| // even when the triangle isn't in view. |
| clearValue: [0.1, 0.0, 0.4, 1.0], |
| }] |
| }); |
| |
| let vp = subImage.viewport; |
| renderPass.setViewport(vp.x, vp.y, vp.width, vp.height, 0.0, 1.0); |
| |
| drawScene(renderPass, viewIndex); |
| |
| renderPass.end(); |
| } |
| |
| // Submit the rendering commands to the GPU. |
| gpuDevice.queue.submit([commandEncoder.finish()]); |
| } |
| } |
| |
| // Does a standard render to the canvas |
| function onFrame(time) { |
| // If a session has started since the last frame don't request a new one. |
| if (!xrSession) { |
| requestAnimationFrame(onFrame); |
| } |
| |
| const commandEncoder = gpuDevice.createCommandEncoder(); |
| |
| // Start a render pass which uses the textures of the view's sub |
| // image as render targets. |
| const renderPass = commandEncoder.beginRenderPass({ |
| colorAttachments: [{ |
| view: gpuContext.getCurrentTexture().createView(), |
| // Clear the color texture to a solid color. |
| loadOp: 'clear', |
| storeOp: 'store', |
| // Update the clear color each frame so that we can observe |
| // the color in the headset changing over time. |
| clearValue: [0.1, 0.1, 0.4, 1.0], |
| }] |
| }); |
| |
| drawScene(renderPass); |
| |
| renderPass.end(); |
| |
| // Submit the rendering commands to the GPU. |
| gpuDevice.queue.submit([commandEncoder.finish()]); |
| } |
| |
| function drawScene(renderPass, viewIndex = 0) { |
| // Render from the viewpoint of view using view.projectionMatrix as |
| // the projection matrix and view.transform to position the virtual |
| // camera. If you need a view matrix, use view.transform.inverse.matrix. |
| renderPass.setPipeline(gpuPipeline); |
| renderPass.setBindGroup(0, gpuBindGroup); |
| // Passing viewIndex as the firstInstance as an easy way to tell the |
| // shader which camera uniforms to use. |
| renderPass.draw(3, 1, 0, viewIndex); |
| } |
| |
| // Start the XR application. |
| initXR(); |
| </script> |
| </body> |
| </html> |