Created
April 19, 2026 07:36
-
-
Save joaomelorodrigues/100356582f67672c8c9ec926a52c59d5 to your computer and use it in GitHub Desktop.
Three.js + HTML-in-canvas + WebXR example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <title>three.js webgpu - materials - html texture (WebXR)</title> | |
| <meta charset="utf-8" /> | |
| <meta | |
| name="viewport" | |
| content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" | |
| /> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background: #111; | |
| color: #fff; | |
| font-family: sans-serif; | |
| } | |
| #info { | |
| position: absolute; | |
| top: 8px; | |
| left: 8px; | |
| z-index: 10; | |
| background: rgba(0, 0, 0, 0.5); | |
| padding: 8px 12px; | |
| border-radius: 6px; | |
| font-size: 13px; | |
| pointer-events: none; | |
| } | |
| #info a { | |
| color: #7cf; | |
| } | |
| #draw_element { | |
| width: 600px; | |
| background-color: #aaaaaa; | |
| color: #000000; | |
| font-family: sans-serif; | |
| font-size: 30px; | |
| line-height: 1.5; | |
| text-align: center; | |
| padding: 30px; | |
| } | |
| #draw_element img { | |
| animation: swing 1s ease-in-out infinite alternate; | |
| } | |
| #draw_element input[type='text'] { | |
| font-size: 24px; | |
| padding: 8px 12px; | |
| border: 2px solid #888; | |
| border-radius: 6px; | |
| width: 80%; | |
| margin-top: 10px; | |
| } | |
| #draw_element button { | |
| font-size: 24px; | |
| padding: 8px 20px; | |
| margin-top: 10px; | |
| border: none; | |
| border-radius: 6px; | |
| background-color: #4caf50; | |
| color: white; | |
| cursor: pointer; | |
| } | |
| #draw_element button:hover { | |
| background-color: #2196f3; | |
| } | |
| @keyframes swing { | |
| from { | |
| transform: rotate(-15deg); | |
| } | |
| to { | |
| transform: rotate(15deg); | |
| } | |
| } | |
| /* VR/AR addons set bottom + centered left — they stack. Flex bar + !important wins over inline. */ | |
| #xr-enter-bar { | |
| position: fixed; | |
| bottom: 16px; | |
| right: 16px; | |
| left: auto; | |
| display: flex; | |
| flex-direction: row; | |
| gap: 10px; | |
| align-items: stretch; | |
| z-index: 10000; | |
| } | |
| #xr-enter-bar > button, | |
| #xr-enter-bar > a { | |
| position: relative !important; | |
| left: auto !important; | |
| right: auto !important; | |
| bottom: auto !important; | |
| width: auto !important; | |
| min-width: 108px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="info"> | |
| three.js — HTML as a texture in <b>WebXR</b><br /> | |
| Click <em>Enter VR</em>, point the controller, pull the trigger to click. | |
| </div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.webgpu.js", | |
| "three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.webgpu.js", | |
| "three/tsl": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.tsl.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/", | |
| "three-html-render/polyfill": "https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.mjs" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { | |
| installHtmlInCanvasPolyfill, | |
| getHtmlRenderer, | |
| } from 'three-html-render/polyfill'; | |
| import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'; | |
| import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; | |
| import { VRButton } from 'three/addons/webxr/VRButton.js'; | |
| import { ARButton } from 'three/addons/webxr/ARButton.js'; | |
| import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| // Force the polyfill even if native HTML-in-Canvas is available — we only use | |
| // it to rasterize the DOM into a canvas, then we upload that canvas ourselves | |
| // via THREE.CanvasTexture. This sidesteps three.js's HTMLTexture path which | |
| // currently has color-space / upload issues on WebGL (r184). | |
| installHtmlInCanvasPolyfill({ force: true }); | |
| let camera, scene, renderer, mesh, element, texture, controls; | |
| const meshPosDesktopVR = new THREE.Vector3(0, 1.5, -0.8); | |
| const meshPosAR = new THREE.Vector3(0, 0, -2); | |
| let controller1, controller2; | |
| const raycaster = new THREE.Raycaster(); | |
| const tempMatrix = new THREE.Matrix4(); | |
| const hoverState = new Map(); | |
| init(); | |
| async function init() { | |
| renderer = new THREE.WebGPURenderer({ antialias: true }); | |
| await renderer.init(); | |
| renderer.toneMapping = THREE.NeutralToneMapping; | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.xr.enabled = true; | |
| renderer.xr.setReferenceSpaceType('local-floor'); | |
| renderer.setAnimationLoop(animate); | |
| document.body.appendChild(renderer.domElement); | |
| const xrBar = document.createElement('div'); | |
| xrBar.id = 'xr-enter-bar'; | |
| const arBtn = ARButton.createButton(renderer, { | |
| optionalFeatures: ['local-floor', 'bounded-floor', 'hand-tracking'], | |
| }); | |
| const vrBtn = VRButton.createButton(renderer); | |
| // AR left, VR right (LTR). Addon inline styles are overridden by #xr-enter-bar CSS. | |
| xrBar.appendChild(arBtn); | |
| xrBar.appendChild(vrBtn); | |
| document.body.appendChild(xrBar); | |
| camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.05, 2000); | |
| camera.position.set(0, 1.6, 1.2); | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x202030); | |
| scene.environment = new THREE.PMREMGenerator(renderer).fromScene( | |
| new RoomEnvironment(), | |
| 0.02 | |
| ).texture; | |
| const grid = new THREE.GridHelper(10, 20, 0x555577, 0x333344); | |
| scene.add(grid); | |
| element = createHTMLElement(); | |
| // Register the element with the polyfill: attach to the canvas (it's | |
| // redirected into the polyfill's shadow host) and wait for first paint. | |
| const canvas = renderer.domElement; | |
| canvas.setAttribute('layoutsubtree', 'true'); | |
| canvas.appendChild(element); | |
| await firstPaint(canvas); | |
| const snapshot = getHtmlRenderer().getCanvas(element); | |
| texture = new THREE.CanvasTexture(snapshot); | |
| texture.colorSpace = THREE.SRGBColorSpace; | |
| texture.anisotropy = renderer.capabilities?.getMaxAnisotropy?.() ?? 8; | |
| // Re-upload only when the DOM actually repaints. | |
| canvas.onpaint = () => { | |
| texture.needsUpdate = true; | |
| }; | |
| const material = new THREE.MeshStandardMaterial({ | |
| map: texture, | |
| roughness: 0.6, | |
| metalness: 0.0, | |
| }); | |
| mesh = new THREE.Mesh(new RoundedBoxGeometry(0.4, 0.4, 0.4, 10, 0.04), material); | |
| mesh.position.copy(meshPosDesktopVR); | |
| scene.add(mesh); | |
| controls = new OrbitControls(camera, renderer.domElement); | |
| controls.target.copy(mesh.position); | |
| controls.update(); | |
| renderer.xr.addEventListener('sessionstart', () => { | |
| const isAR = renderer.xr.getSession()?.environmentBlendMode !== 'opaque'; | |
| if (isAR) { | |
| scene.background = null; | |
| mesh.position.copy(meshPosAR); | |
| } else { | |
| mesh.position.copy(meshPosDesktopVR); | |
| } | |
| }); | |
| renderer.xr.addEventListener('sessionend', () => { | |
| scene.background = new THREE.Color(0x202030); | |
| mesh.position.copy(meshPosDesktopVR); | |
| controls.target.copy(mesh.position); | |
| controls.update(); | |
| }); | |
| element.querySelector('button').addEventListener('click', function () { | |
| this.textContent = 'Clicked!'; | |
| }); | |
| setupXRControllers(); | |
| setupDesktopPointerInteraction(); | |
| window.addEventListener('resize', onWindowResize); | |
| } | |
| function firstPaint(canvas) { | |
| return new Promise((resolve) => { | |
| let done = false; | |
| const finish = () => { | |
| if (done) return; | |
| done = true; | |
| canvas.onpaint = null; | |
| resolve(); | |
| }; | |
| canvas.onpaint = finish; | |
| canvas.requestPaint(); | |
| setTimeout(finish, 800); | |
| }); | |
| } | |
| function createHTMLElement() { | |
| const el = document.createElement('div'); | |
| el.id = 'draw_element'; | |
| el.innerHTML = ` | |
| Hello world!<br>I'm multi-line, <b>formatted</b>, | |
| rotated text with emoji (😀), RTL text | |
| <span dir=rtl>من فارسی صحبت میکنم</span>, | |
| vertical text, | |
| <p style="writing-mode: vertical-rl;"> | |
| 这是垂直文本 | |
| </p> | |
| an inline image (<img width="150" src="https://threejs.org/examples/textures/758px-Canestra_di_frutta_(Caravaggio).jpg" crossorigin="anonymous">), and | |
| <svg width="50" height="50"> | |
| <circle cx="25" cy="25" r="20" fill="green" /> | |
| <text x="25" y="30" font-size="15" text-anchor="middle" fill="#fff">SVG</text> | |
| </svg>! | |
| <br> | |
| <input type="text" placeholder="Type here..."> | |
| <button>Click me</button> | |
| `; | |
| return el; | |
| } | |
| function setupXRControllers() { | |
| const factory = new XRControllerModelFactory(); | |
| const laserGeom = new THREE.BufferGeometry().setFromPoints([ | |
| new THREE.Vector3(0, 0, 0), | |
| new THREE.Vector3(0, 0, -1), | |
| ]); | |
| const makeLaser = () => { | |
| const line = new THREE.Line( | |
| laserGeom, | |
| new THREE.LineBasicMaterial({ color: 0x88ccff }) | |
| ); | |
| line.name = 'laser'; | |
| line.scale.z = 5; | |
| return line; | |
| }; | |
| controller1 = renderer.xr.getController(0); | |
| controller2 = renderer.xr.getController(1); | |
| controller1.add(makeLaser()); | |
| controller2.add(makeLaser()); | |
| scene.add(controller1, controller2); | |
| for (const idx of [0, 1]) { | |
| const grip = renderer.xr.getControllerGrip(idx); | |
| grip.add(factory.createControllerModel(grip)); | |
| scene.add(grip); | |
| } | |
| for (const c of [controller1, controller2]) { | |
| c.addEventListener('selectstart', () => onSelect(c, 'pointerdown')); | |
| c.addEventListener('selectend', () => onSelect(c, 'pointerup')); | |
| } | |
| } | |
| function onSelect(controller, type) { | |
| const hit = intersectMesh(controller); | |
| if (!hit || !hit.uv) return; | |
| const { clientX, clientY, target } = uvToClient(hit.uv); | |
| dispatchPointer(target, type, clientX, clientY); | |
| if (type === 'pointerup') { | |
| dispatchPointer(target, 'click', clientX, clientY); | |
| } | |
| } | |
| function intersectMesh(controller) { | |
| tempMatrix.identity().extractRotation(controller.matrixWorld); | |
| raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld); | |
| raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix); | |
| return raycaster.intersectObject(mesh, false)[0] || null; | |
| } | |
| function uvToClient(uv) { | |
| let rect = element.getBoundingClientRect(); | |
| if (rect.width === 0 || rect.height === 0) { | |
| rect = renderer.domElement.getBoundingClientRect(); | |
| } | |
| const w = element.offsetWidth || rect.width; | |
| const h = element.offsetHeight || rect.height; | |
| const x = rect.left + uv.x * w; | |
| const y = rect.top + (1 - uv.y) * h; | |
| const target = deepestElementAt(element, x, y) || element; | |
| return { clientX: x, clientY: y, target }; | |
| } | |
| function deepestElementAt(root, x, y) { | |
| let best = root; | |
| const stack = [root]; | |
| while (stack.length) { | |
| const node = stack.pop(); | |
| for (const child of node.children) { | |
| const r = child.getBoundingClientRect(); | |
| if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) { | |
| best = child; | |
| stack.push(child); | |
| } | |
| } | |
| } | |
| return best; | |
| } | |
| function dispatchPointer(target, type, clientX, clientY) { | |
| target.dispatchEvent( | |
| new PointerEvent(type, { | |
| bubbles: true, | |
| cancelable: true, | |
| composed: true, | |
| clientX, | |
| clientY, | |
| pointerType: 'mouse', | |
| isPrimary: true, | |
| button: type === 'click' ? 0 : -1, | |
| buttons: type === 'pointerdown' ? 1 : 0, | |
| }) | |
| ); | |
| } | |
| function updateHover(controller) { | |
| const hit = intersectMesh(controller); | |
| const prev = hoverState.get(controller); | |
| const laser = controller.getObjectByName('laser'); | |
| if (!hit || !hit.uv) { | |
| if (prev) { | |
| dispatchPointer(prev.target, 'pointerleave', prev.x, prev.y); | |
| dispatchPointer(prev.target, 'pointerout', prev.x, prev.y); | |
| hoverState.delete(controller); | |
| } | |
| if (laser) laser.material.color.setHex(0x88ccff); | |
| return; | |
| } | |
| const { clientX, clientY, target } = uvToClient(hit.uv); | |
| if (!prev || prev.target !== target) { | |
| if (prev) { | |
| dispatchPointer(prev.target, 'pointerleave', prev.x, prev.y); | |
| dispatchPointer(prev.target, 'pointerout', prev.x, prev.y); | |
| } | |
| dispatchPointer(target, 'pointerover', clientX, clientY); | |
| dispatchPointer(target, 'pointerenter', clientX, clientY); | |
| } | |
| dispatchPointer(target, 'pointermove', clientX, clientY); | |
| hoverState.set(controller, { target, x: clientX, y: clientY }); | |
| if (laser) laser.material.color.setHex(0x4caf50); | |
| } | |
| function setupDesktopPointerInteraction() { | |
| const cast = (e) => { | |
| if (renderer.xr.isPresenting) return null; | |
| const ndc = new THREE.Vector2( | |
| (e.clientX / window.innerWidth) * 2 - 1, | |
| -(e.clientY / window.innerHeight) * 2 + 1 | |
| ); | |
| raycaster.setFromCamera(ndc, camera); | |
| return raycaster.intersectObject(mesh, false)[0] || null; | |
| }; | |
| renderer.domElement.addEventListener('pointermove', (e) => { | |
| const hit = cast(e); | |
| if (hit && hit.uv) { | |
| const { clientX, clientY, target } = uvToClient(hit.uv); | |
| dispatchPointer(target, 'pointermove', clientX, clientY); | |
| } | |
| }); | |
| renderer.domElement.addEventListener('pointerdown', (e) => { | |
| const hit = cast(e); | |
| if (hit && hit.uv) { | |
| const { clientX, clientY, target } = uvToClient(hit.uv); | |
| dispatchPointer(target, 'pointerdown', clientX, clientY); | |
| dispatchPointer(target, 'pointerup', clientX, clientY); | |
| dispatchPointer(target, 'click', clientX, clientY); | |
| } | |
| }); | |
| } | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| function animate(time) { | |
| if (!renderer.xr.isPresenting && mesh) { | |
| mesh.rotation.x = Math.sin(time * 0.0005) * 0.2; | |
| mesh.rotation.y = Math.cos(time * 0.0008) * 0.2; | |
| } | |
| if (renderer.xr.isPresenting) { | |
| if (controller1) updateHover(controller1); | |
| if (controller2) updateHover(controller2); | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment