blob: 36cff98506c008256ef53c6a0791f991d22cb3e0 [file] [edit]
<!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 &gt;
Advanced), then Develop &gt; Feature Flags &gt; 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>