| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta name="viewport" content="width=600"> | |
| <meta http-equiv="Content-type" content="text/html; charset=utf-8"> | |
| <title>WebGPU Hello Triangle (XR)</title> | |
| <style> | |
| body { | |
| font-family: system-ui; | |
| color: #f7f7ff; | |
| background-color: rgb(38, 38, 127); | |
| text-align: center; | |
| } | |
| canvas { | |
| margin: 0 auto; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header></header> | |
| <div id="contents"> | |
| <h1>Simple Triangle (WebXR)</h1> | |
| <canvas></canvas> | |
| </div> | |
| <div id="error"> | |
| <h2>WebGPU or WebXR not available</h2> | |
| <p> | |
| Make sure you are on a system with WebGPU and WebXR enabled. In | |
| Safari, first make sure the Developer Menu is visible (Settings > | |
| Advanced), then Develop > Feature Flags > WebGPU and WebXR. | |
| </p> | |
| </div> | |
| <script> | |
| // ===== webxr-button.js (inlined) ===== | |
| // Copyright 2016 Google Inc., Apache 2.0 License. | |
| const _LOGO_SCALE = 0.8; | |
| let _WEBXR_UI_CSS_INJECTED = {}; | |
| const generateInnerHTML = (cssPrefix, height) => { | |
| const logoHeight = height * _LOGO_SCALE; | |
| const svgString = generateXRIconString(cssPrefix, logoHeight) + generateNoXRIconString(cssPrefix, logoHeight); | |
| return `<button class="${cssPrefix}-button"> | |
| <div class="${cssPrefix}-title"></div> | |
| <div class="${cssPrefix}-logo" >${svgString}</div> | |
| </button>`; | |
| }; | |
| const injectCSS = (cssText) => { | |
| const style = document.createElement('style'); | |
| style.innerHTML = cssText; | |
| let head = document.getElementsByTagName('head')[0]; | |
| head.insertBefore(style, head.firstChild); | |
| }; | |
| const createDefaultView = (options) => { | |
| const fontSize = options.height / 3; | |
| if (options.injectCSS) { | |
| if (!_WEBXR_UI_CSS_INJECTED[options.cssprefix]) { | |
| injectCSS(generateCSS(options, fontSize)); | |
| _WEBXR_UI_CSS_INJECTED[options.cssprefix] = true; | |
| } | |
| } | |
| const el = document.createElement('div'); | |
| el.innerHTML = generateInnerHTML(options.cssprefix, fontSize); | |
| return el.firstChild; | |
| }; | |
| const generateXRIconString = (cssPrefix, height) => { | |
| let aspect = 28 / 18; | |
| return `<svg class="${cssPrefix}-svg" version="1.1" x="0px" y="0px" | |
| width="${aspect * height}px" height="${height}px" viewBox="0 0 28 18" xml:space="preserve"> | |
| <path d="M26.8,1.1C26.1,0.4,25.1,0,24.2,0H3.4c-1,0-1.7,0.4-2.4,1.1C0.3,1.7,0,2.7,0,3.6v10.7 | |
| c0,1,0.3,1.9,0.9,2.6C1.6,17.6,2.4,18,3.4,18h5c0.7,0,1.3-0.2,1.8-0.5c0.6-0.3,1-0.8,1.3-1.4l | |
| 1.5-2.6C13.2,13.1,13,13,14,13v0h-0.2 h0c0.3,0,0.7,0.1,0.8,0.5l1.4,2.6c0.3,0.6,0.8,1.1,1.3, | |
| 1.4c0.6,0.3,1.2,0.5,1.8,0.5h5c1,0,2-0.4,2.7-1.1c0.7-0.7,1.2-1.6,1.2-2.6 V3.6C28,2.7,27.5, | |
| 1.7,26.8,1.1z M7.4,11.8c-1.6,0-2.8-1.3-2.8-2.8c0-1.6,1.3-2.8,2.8-2.8c1.6,0,2.8,1.3,2.8,2.8 | |
| C10.2,10.5,8.9,11.8,7.4,11.8z M20.1,11.8c-1.6,0-2.8-1.3-2.8-2.8c0-1.6,1.3-2.8,2.8-2.8C21.7 | |
| ,6.2,23,7.4,23,9 C23,10.5,21.7,11.8,20.1,11.8z"/> | |
| </svg>`; | |
| }; | |
| const generateNoXRIconString = (cssPrefix, height) => { | |
| let aspect = 28 / 18; | |
| return `<svg class="${cssPrefix}-svg-error" x="0px" y="0px" | |
| width="${aspect * height}px" height="${aspect * height}px" viewBox="0 0 28 28" xml:space="preserve"> | |
| <path d="M17.6,13.4c0-0.2-0.1-0.4-0.1-0.6c0-1.6,1.3-2.8,2.8-2.8s2.8,1.3,2.8,2.8s-1.3,2.8-2.8,2.8 | |
| c-0.2,0-0.4,0-0.6-0.1l5.9,5.9c0.5-0.2,0.9-0.4,1.3-0.8 | |
| c0.7-0.7,1.1-1.6,1.1-2.5V7.4c0-1-0.4-1.9-1.1-2.5c-0.7-0.7-1.6-1-2.5-1 | |
| H8.1 L17.6,13.4z"/> | |
| <path d="M10.1,14.2c-0.5,0.9-1.4,1.4-2.4,1.4c-1.6,0-2.8-1.3-2.8-2.8c0-1.1,0.6-2,1.4-2.5 | |
| L0.9,5.1 C0.3,5.7,0,6.6,0,7.5v10.7c0,1,0.4,1.8,1.1,2.5c0.7,0.7,1.6,1,2.5,1 | |
| h5c0.7,0,1.3-0.1,1.8-0.5c0.6-0.3,1-0.8,1.3-1.4l1.3-2.6 L10.1,14.2z"/> | |
| <path d="M25.5,27.5l-25-25C-0.1,2-0.1,1,0.5,0.4l0,0C1-0.1,2-0.1,2.6,0.4l25,25c0.6,0.6,0.6,1.5 | |
| ,0,2.1l0,0 C27,28.1,26,28.1,25.5,27.5z"/> | |
| </svg>`; | |
| }; | |
| const generateCSS = (options, fontSize = 18) => { | |
| const height = options.height; | |
| const borderWidth = 2; | |
| const borderColor = options.background ? options.background : options.color; | |
| const cssPrefix = options.cssprefix; | |
| let borderRadius; | |
| if (options.corners == 'round') { | |
| borderRadius = options.height / 2; | |
| } else if (options.corners == 'square') { | |
| borderRadius = 2; | |
| } else { | |
| borderRadius = options.corners; | |
| } | |
| return ` | |
| button.${cssPrefix}-button { | |
| font-family: 'Karla', sans-serif; | |
| border: ${borderColor} ${borderWidth}px solid; | |
| border-radius: ${borderRadius}px; | |
| box-sizing: border-box; | |
| background: ${options.background ? options.background : 'none'}; | |
| height: ${height}px; | |
| min-width: ${fontSize * 9.6}px; | |
| display: inline-block; | |
| position: relative; | |
| cursor: pointer; | |
| transition: border 0.5s; | |
| } | |
| button.${cssPrefix}-button:focus { outline: none; } | |
| .${cssPrefix}-logo { | |
| width: ${height}px; | |
| height: ${height}px; | |
| position: absolute; | |
| top: 0px; | |
| left: 0px; | |
| width: ${height - 4}px; | |
| height: ${height - 4}px; | |
| } | |
| .${cssPrefix}-svg { | |
| fill: ${options.color}; | |
| margin-top: ${(height - fontSize * _LOGO_SCALE) / 2 - 2}px; | |
| margin-left: ${height / 3}px; | |
| } | |
| .${cssPrefix}-svg-error { | |
| fill: ${options.color}; | |
| display: none; | |
| margin-top: ${(height - 28 / 18 * fontSize * _LOGO_SCALE) / 2 - 2}px; | |
| margin-left: ${height / 3}px; | |
| } | |
| .${cssPrefix}-title { | |
| color: ${options.color}; | |
| position: relative; | |
| font-size: ${fontSize}px; | |
| padding-left: ${height * 1.05}px; | |
| padding-right: ${(borderRadius - 10 < 5) ? height / 3 : borderRadius - 10}px; | |
| transition: color 0.5s; | |
| } | |
| button.${cssPrefix}-button[disabled=true] { opacity: ${options.disabledOpacity}; } | |
| button.${cssPrefix}-button[disabled=true] > .${cssPrefix}-logo > .${cssPrefix}-svg { display: none; } | |
| button.${cssPrefix}-button[disabled=true] > .${cssPrefix}-logo > .${cssPrefix}-svg-error { display: initial; } | |
| button.${cssPrefix}-button[error=true] { animation: errorShake 0.4s; } | |
| @keyframes errorShake { | |
| 0% { transform: translate(1px, 0) } | |
| 10% { transform: translate(-2px, 0) } | |
| 20% { transform: translate(2px, 0) } | |
| 30% { transform: translate(-2px, 0) } | |
| 40% { transform: translate(2px, 0) } | |
| 50% { transform: translate(-2px, 0) } | |
| 60% { transform: translate(2px, 0) } | |
| 70% { transform: translate(-2px, 0) } | |
| 80% { transform: translate(2px, 0) } | |
| 90% { transform: translate(-1px, 0) } | |
| 100% { transform: translate(0px, 0) } | |
| } | |
| `; | |
| }; | |
| class WebXRButton { | |
| constructor(options) { | |
| options = options || {}; | |
| options.color = options.color || 'rgb(80,168,252)'; | |
| options.background = options.background || false; | |
| options.disabledOpacity = options.disabledOpacity || 0.5; | |
| options.height = options.height || 55; | |
| options.corners = options.corners || 'square'; | |
| options.cssprefix = options.cssprefix || 'webvr-ui'; | |
| options.textEnterXRTitle = options.textEnterXRTitle || 'ENTER VR'; | |
| options.textXRNotFoundTitle = options.textXRNotFoundTitle || 'VR NOT FOUND'; | |
| options.textExitXRTitle = options.textExitXRTitle || 'EXIT VR'; | |
| options.onRequestSession = options.onRequestSession || (function() {}); | |
| options.onEndSession = options.onEndSession || (function() {}); | |
| options.injectCSS = options.injectCSS !== false; | |
| this.options = options; | |
| this._enabled = false; | |
| this.session = null; | |
| this.domElement = options.domElement || createDefaultView(options); | |
| this.__defaultDisplayStyle = this.domElement.style.display || 'initial'; | |
| this.domElement.addEventListener('click', () => this.__onXRButtonClick()); | |
| this.__forceDisabled = false; | |
| this.__setDisabledAttribute(true); | |
| this.setTitle(this.options.textXRNotFoundTitle); | |
| } | |
| set enabled(enabled) { this._enabled = enabled; this.__updateButtonState(); return this; } | |
| get enabled() { return this._enabled; } | |
| setSession(session) { this.session = session; this.__updateButtonState(); return this; } | |
| setTitle(text) { | |
| this.domElement.title = text; | |
| ifChild(this.domElement, this.options.cssprefix, 'title', (title) => { | |
| if (!text) { title.style.display = 'none'; } | |
| else { title.innerText = text; title.style.display = 'initial'; } | |
| }); | |
| return this; | |
| } | |
| setTooltip(tooltip) { this.domElement.title = tooltip; return this; } | |
| show() { this.domElement.style.display = this.__defaultDisplayStyle; return this; } | |
| hide() { this.domElement.style.display = 'none'; return this; } | |
| enable() { this.__setDisabledAttribute(false); this.__forceDisabled = false; return this; } | |
| disable() { this.__setDisabledAttribute(true); this.__forceDisabled = true; return this; } | |
| remove() { if (this.domElement.parentElement) this.domElement.parentElement.removeChild(this.domElement); } | |
| __setDisabledAttribute(disabled) { | |
| if (disabled || this.__forceDisabled) this.domElement.setAttribute('disabled', 'true'); | |
| else this.domElement.removeAttribute('disabled'); | |
| } | |
| __onXRButtonClick() { | |
| if (this.session) { | |
| this.options.onEndSession(this.session); | |
| } else if (this._enabled) { | |
| let requestPromise = this.options.onRequestSession(); | |
| if (requestPromise) { | |
| requestPromise.catch((err) => { | |
| let errorMsg = `XRSession creation failed: ${err.message}`; | |
| this.setTooltip(errorMsg); | |
| console.error(errorMsg); | |
| this.__setDisabledAttribute(true); | |
| this.domElement.setAttribute('error', 'true'); | |
| setTimeout(() => { | |
| this.__setDisabledAttribute(false); | |
| this.domElement.setAttribute('error', 'false'); | |
| }, 1000); | |
| }); | |
| } | |
| } | |
| } | |
| __updateButtonState() { | |
| if (this.session) { | |
| this.setTitle(this.options.textExitXRTitle); | |
| this.setTooltip('Exit XR presentation'); | |
| this.__setDisabledAttribute(false); | |
| } else if (this._enabled) { | |
| this.setTitle(this.options.textEnterXRTitle); | |
| this.setTooltip('Enter XR'); | |
| this.__setDisabledAttribute(false); | |
| } else { | |
| this.setTitle(this.options.textXRNotFoundTitle); | |
| this.setTooltip('No XR headset found.'); | |
| this.__setDisabledAttribute(true); | |
| } | |
| } | |
| } | |
| const ifChild = (el, cssPrefix, suffix, fn) => { | |
| const c = el.querySelector('.' + cssPrefix + '-' + suffix); | |
| c && fn(c); | |
| }; | |
| // ===== hello-triangle-xr.js (inlined) ===== | |
| let xrButton = null; | |
| function onSessionEnded(event) { | |
| xrButton.setSession(null); | |
| } | |
| async function onSessionStarted(xrSession) { | |
| if (!navigator.gpu || GPUBufferUsage.COPY_SRC === undefined) { | |
| document.body.className = 'error'; | |
| return; | |
| } | |
| if (navigator.xr == undefined) { | |
| document.writeln("WebXR is not supported"); | |
| return; | |
| } | |
| try { | |
| xrButton.setSession(xrSession); | |
| xrSession.addEventListener('end', onSessionEnded); | |
| const adapter = await navigator.gpu.requestAdapter({ xrCompatible: true }); | |
| const device = await adapter.requestDevice(); | |
| const xrGpuBinding = new XRGPUBinding(xrSession, device); | |
| const projectionLayer = xrGpuBinding.createProjectionLayer({ | |
| colorFormat: xrGpuBinding.getPreferredColorFormat(), | |
| depthStencilFormat: 'depth24plus', | |
| }); | |
| xrSession.updateRenderState({ layers: [projectionLayer] }); | |
| xrSession.requestAnimationFrame(onXRFrame); | |
| const xrReferenceSpace = await xrSession.requestReferenceSpace("viewer"); | |
| /*** Vertex Buffer Setup ***/ | |
| const vertexStride = 8 * 4; | |
| const vertexDataSize = vertexStride * 3; | |
| const vertexDataBufferDescriptor = { | |
| size: vertexDataSize, | |
| usage: GPUBufferUsage.VERTEX | |
| }; | |
| const vertexBuffer = device.createBuffer(vertexDataBufferDescriptor); | |
| /*** Shader Setup ***/ | |
| const wgslSource = ` | |
| struct Vertex { | |
| @builtin(position) Position: vec4<f32>, | |
| @location(0) color: vec4<f32>, | |
| } | |
| @vertex fn vsmain(@builtin(vertex_index) VertexIndex: u32, @builtin(instance_index) InstanceID: u32) -> Vertex | |
| { | |
| let triangle = VertexIndex / 3; | |
| let index = VertexIndex - 3 * triangle; | |
| let ftriangle = f32(triangle); | |
| const k: f32 = 0.25; | |
| var pos: array<vec2<f32>, 3> = array<vec2<f32>, 3>( | |
| vec2<f32>( 0.0 + k - 2 * k * ftriangle, -0.5), | |
| vec2<f32>(-0.5 + k - 2 * k * ftriangle, 0.5), | |
| vec2<f32>( 0.5 + k - 2 * k * ftriangle, 0.5) | |
| ); | |
| var vertex_out : Vertex; | |
| vertex_out.Position = vec4<f32>(pos[index], 0.5, 1.0); | |
| let shiftedIndex = (index + InstanceID / 270) % 3; | |
| vertex_out.color = vec4<f32>(pos[u32(shiftedIndex)] + vec2<f32>(0.5, 0.5), 0.0, 1.0); | |
| return vertex_out; | |
| } | |
| @fragment fn fsmain(in: Vertex) -> @location(0) vec4<f32> | |
| { | |
| return in.color; | |
| } | |
| `; | |
| const shaderModule = device.createShaderModule({ code: wgslSource }); | |
| const vertexStageDescriptor = { module: shaderModule, entryPoint: "vsmain" }; | |
| const fragmentStageDescriptor = { module: shaderModule, entryPoint: "fsmain", targets: [{ format: "bgra8unorm-srgb" }] }; | |
| const renderPipelineDescriptor = { | |
| layout: 'auto', | |
| vertex: vertexStageDescriptor, | |
| fragment: fragmentStageDescriptor, | |
| primitive: { topology: "triangle-list" }, | |
| depthStencil: { | |
| depthWriteEnabled: true, | |
| depthCompare: 'always', | |
| format: 'depth24plus-stencil8', | |
| }, | |
| }; | |
| const renderPipeline = device.createRenderPipeline(renderPipelineDescriptor); | |
| const canvas = document.querySelector("canvas"); | |
| canvas.width = 600; | |
| canvas.height = 600; | |
| const gpuContext = canvas.getContext("webgpu"); | |
| const canvasConfiguration = { device: device, format: "bgra8unorm" }; | |
| gpuContext.configure(canvasConfiguration); | |
| let frameID = 0; | |
| let offsets = [0, 0, 0]; | |
| let offsetIndex = 0; | |
| function onXRFrame(time, xrFrame) { | |
| xrSession.requestAnimationFrame(onXRFrame); | |
| const darkBlue = { r: 0.15 + offsets[0], g: 0.15 + offsets[1], b: 0.5 + offsets[2], a: 1 }; | |
| let xrViewerPose = xrFrame.getViewerPose(xrReferenceSpace); | |
| const commandEncoder = device.createCommandEncoder(); | |
| for (let i = 0; i < xrViewerPose.views.length; ++i) { | |
| const view = xrViewerPose.views[i]; | |
| const subImage = xrGpuBinding.getViewSubImage(projectionLayer, view); | |
| const passEncoder = commandEncoder.beginRenderPass({ | |
| colorAttachments: [{ | |
| view: subImage.colorTexture.createView(subImage.getViewDescriptor()), | |
| loadOp: 'clear', | |
| clearValue: darkBlue, | |
| storeOp: 'store', | |
| }], | |
| depthStencilAttachment: { | |
| view: subImage.depthStencilTexture.createView(subImage.getViewDescriptor()), | |
| depthLoadOp: 'clear', | |
| depthClearValue: 0.05, | |
| depthStoreOp: 'store', | |
| stencilLoadOp: 'clear', | |
| stencilClearValue: 0, | |
| stencilStoreOp: 'store', | |
| } | |
| }); | |
| let vp = subImage.viewport; | |
| passEncoder.setViewport(vp.x, vp.y, vp.width, vp.height, 0.0, 1.0); | |
| passEncoder.setScissorRect(vp.x, vp.y, vp.width, vp.height); | |
| passEncoder.setPipeline(renderPipeline); | |
| const vertexBufferSlot = 0; | |
| passEncoder.setVertexBuffer(vertexBufferSlot, vertexBuffer, 0); | |
| const instanceID = parseInt(frameID, 10); | |
| passEncoder.draw(3, 1, i * 3, instanceID); | |
| passEncoder.end(); | |
| } | |
| const commandBuffer = commandEncoder.finish(); | |
| const queue = device.queue; | |
| queue.submit([commandBuffer]); | |
| ++frameID; | |
| offsets[offsetIndex] += 0.01; | |
| if (offsets[offsetIndex] > 1) { | |
| offsets[offsetIndex] = 0; | |
| offsetIndex = offsetIndex + 1; | |
| if (offsetIndex > 2) | |
| offsetIndex = 0; | |
| } | |
| } | |
| } catch (error) { | |
| document.writeln(error); | |
| } | |
| } | |
| function onEndSession(session) { | |
| session.end(); | |
| } | |
| function onRequestSession() { | |
| return navigator.xr.requestSession('immersive-vr').then(onSessionStarted); | |
| } | |
| function initXR() { | |
| xrButton = new WebXRButton({ | |
| onRequestSession: onRequestSession, | |
| onEndSession: onEndSession | |
| }); | |
| document.querySelector('header').appendChild(xrButton.domElement); | |
| if (navigator.xr) { | |
| navigator.xr.isSessionSupported('immersive-vr').then((supported) => { | |
| xrButton.enabled = supported; | |
| }); | |
| } | |
| } | |
| window.addEventListener("DOMContentLoaded", initXR); | |
| </script> | |
| </body> | |
| </html> |