| |
| <!doctype html> |
| <html lang="fa"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover" /> |
| <title>WebXR AR Demo — Floating Marker</title> |
| <style> |
| :root{ |
| --bg:#0f1724; |
| --card:#0b1220; |
| --accent:#4dd0e1; |
| --muted:#9aa6b2; |
| --glass: rgba(255,255,255,0.04); |
| --radius: 12px; |
| } |
| html,body{height:100%;margin:0;background:linear-gradient(180deg,#071124,#0f1724);font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial;} |
| #app{height:100%;display:flex;flex-direction:column;align-items:stretch;} |
| #overlayUI{position:fixed;left:12px;right:12px;top:12px;display:flex;justify-content:space-between;gap:12px;z-index:50} |
| .card{backdrop-filter:blur(6px);background:var(--glass);padding:8px;border-radius:999px;display:flex;gap:8px;align-items:center;} |
| button{background:transparent;border:1px solid rgba(255,255,255,0.06);color:white;padding:8px 12px;border-radius:10px;font-weight:600} |
| button.primary{background:linear-gradient(90deg,var(--accent),#7bdff0);color:#052026;border:0} |
| #hint{position:fixed;left:12px;bottom:16px;right:12px;color:var(--muted);font-size:13px;text-align:center;z-index:40} |
| #message{position:fixed;left:50%;transform:translateX(-50%);top:60px;background:rgba(0,0,0,0.5);padding:8px 12px;border-radius:10px;color:white;z-index:60} |
| canvas{touch-action:none} |
| |
| #infoCard{position:fixed;right:12px;top:70px;background:linear-gradient(180deg,#071a23,#0b1620);padding:12px;border-radius:12px;color:var(--muted);font-size:13px;z-index:60;max-width:260px} |
| #logo{display:flex;gap:8px;align-items:center} |
| #logo .dot{width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)} |
| </style> |
| </head> |
| <body> |
| <div id="app"> |
| <div id="overlayUI"> |
| <div class="card"> |
| <div id="logo"><div class="dot"></div><div style="font-weight:700">AR-Demo</div></div> |
| </div> |
| <div class="card" id="controls"> |
| <button id="btnStart" class="primary">start ar</button> |
| <button id="btnReset">reset</button> |
| <button id="btnFallback">demo fallback</button> |
| </div> |
| </div> |
|
|
| <div id="message" style="display:none"></div> |
| <div id="infoCard"> |
| <div style="font-weight:700;color:white">Floating Marker — demo</div> |
| <div style="margin-top:8px">نحوهٔ کار: روی start ar بزن. سپس صفحهٔ مرورگر اجازهٔ دسترسی به دوربین را میپرسد. گوشی را حرکت بده تا شیء در فضا دیده شود. با تپ روی شیء رنگ عوض میشود.</div> |
| </div> |
|
|
| <div id="container"></div> |
| <div id="hint">best on Chrome (Android). requires https and camera permission.</div> |
| </div> |
|
|
| |
| <script type="module"> |
| |
| import * as THREE from 'https://unpkg.com/three@0.152.2/build/three.module.js'; |
| import { ARButton } from 'https://unpkg.com/three@0.152.2/examples/jsm/webxr/ARButton.js'; |
| import { XRControllerModelFactory } from 'https://unpkg.com/three@0.152.2/examples/jsm/webxr/XRControllerModelFactory.js'; |
| |
| |
| const btnStart = document.getElementById('btnStart'); |
| const btnReset = document.getElementById('btnReset'); |
| const btnFallback = document.getElementById('btnFallback'); |
| const message = document.getElementById('message'); |
| const container = document.getElementById('container'); |
| |
| |
| let camera, scene, renderer; |
| let reticle = null; |
| let markerGroup = null; |
| let clock = new THREE.Clock(); |
| let xrSessionActive = false; |
| let currentColorIndex = 0; |
| const colors = [0x4dd0e1, 0x7b61ff, 0xff7ab6, 0x6ee07a]; |
| |
| |
| function showMsg(text, timeout=2500){ |
| message.style.display='block'; |
| message.textContent = text; |
| clearTimeout(message._t); |
| message._t = setTimeout(()=> message.style.display='none', timeout); |
| } |
| |
| |
| function initThree(){ |
| |
| renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); |
| renderer.setPixelRatio(window.devicePixelRatio); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| renderer.xr.enabled = true; |
| container.appendChild(renderer.domElement); |
| |
| |
| scene = new THREE.Scene(); |
| camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.01, 100); |
| |
| |
| const ambient = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6); |
| scene.add(ambient); |
| const dir = new THREE.DirectionalLight(0xffffff, 0.8); |
| dir.position.set(0.5, 1, 0.2); |
| scene.add(dir); |
| |
| |
| markerGroup = new THREE.Group(); |
| markerGroup.visible = false; |
| |
| |
| const sphereGeo = new THREE.SphereGeometry(0.12, 32, 24); |
| const sphereMat = new THREE.MeshStandardMaterial({ metalness:0.3, roughness:0.2, emissive:colors[0], emissiveIntensity:0.2 }); |
| const sphere = new THREE.Mesh(sphereGeo, sphereMat); |
| sphere.name = 'markerSphere'; |
| |
| |
| const torusGeo = new THREE.TorusGeometry(0.18, 0.02, 16, 100); |
| const torusMat = new THREE.MeshBasicMaterial({ color: colors[0], transparent:true, opacity:0.9 }); |
| const halo = new THREE.Mesh(torusGeo, torusMat); |
| halo.rotation.x = Math.PI / 2; |
| halo.position.y = -0.02; |
| |
| |
| const labelCanvas = document.createElement('canvas'); |
| labelCanvas.width = 512; labelCanvas.height = 128; |
| const ctx = labelCanvas.getContext('2d'); |
| function updateLabel(text){ |
| ctx.clearRect(0,0,labelCanvas.width,labelCanvas.height); |
| ctx.fillStyle = 'rgba(5,32,38,0.9)'; |
| ctx.fillRect(0,0,labelCanvas.width,labelCanvas.height); |
| ctx.font = 'bold 46px system-ui'; |
| ctx.fillStyle = 'white'; |
| ctx.textAlign = 'center'; |
| ctx.fillText(text, labelCanvas.width/2, 76); |
| labelTexture.needsUpdate = true; |
| } |
| const labelTexture = new THREE.CanvasTexture(labelCanvas); |
| const labelMat = new THREE.MeshBasicMaterial({ map: labelTexture, transparent:false }); |
| const labelGeo = new THREE.PlaneGeometry(0.6, 0.15); |
| const labelMesh = new THREE.Mesh(labelGeo, labelMat); |
| labelMesh.position.y = 0.28; |
| labelMesh.lookAt(0,0,0); |
| |
| updateLabel('ورودی اصلی'); |
| |
| |
| markerGroup.add(sphere); |
| markerGroup.add(halo); |
| markerGroup.add(labelMesh); |
| scene.add(markerGroup); |
| |
| |
| const ringGeo = new THREE.RingGeometry(0.07, 0.09, 32); |
| const ringMat = new THREE.MeshBasicMaterial({ color:0xffffff, side:THREE.DoubleSide, transparent:true, opacity:0.85 }); |
| reticle = new THREE.Mesh(ringGeo, ringMat); |
| reticle.rotation.x = -Math.PI / 2; |
| reticle.visible = false; |
| scene.add(reticle); |
| |
| window.addEventListener('resize', onWindowResize); |
| } |
| |
| function onWindowResize(){ |
| if (camera){ |
| camera.aspect = window.innerWidth / window.innerHeight; |
| camera.updateProjectionMatrix(); |
| } |
| if (renderer) renderer.setSize(window.innerWidth, window.innerHeight); |
| } |
| |
| |
| function placeMarkerAt(pos, quaternion){ |
| markerGroup.position.copy(pos); |
| if (quaternion) markerGroup.quaternion.copy(quaternion); |
| markerGroup.visible = true; |
| showMsg('marker placed'); |
| } |
| |
| |
| function cycleMarkerColor(){ |
| currentColorIndex = (currentColorIndex + 1) % colors.length; |
| const sphere = markerGroup.getObjectByName('markerSphere'); |
| sphere.material.emissive.setHex(colors[currentColorIndex]); |
| markerGroup.children.forEach(child=>{ |
| if (child.material && child.material.color){ |
| child.material.color.setHex(colors[currentColorIndex]); |
| } |
| }); |
| } |
| |
| |
| function animateMarker(delta){ |
| if (!markerGroup.visible) return; |
| const t = performance.now() * 0.001; |
| markerGroup.position.y += Math.sin(t*1.5) * 0.0008; |
| |
| const halo = markerGroup.children.find(c=>c.geometry && c.geometry.type === 'TorusGeometry'); |
| if (halo) halo.rotation.z += delta * 0.6; |
| |
| const sphere = markerGroup.getObjectByName('markerSphere'); |
| sphere.material.emissiveIntensity = 0.2 + Math.abs(Math.sin(t*2)) * 0.5; |
| } |
| |
| |
| async function startARSession(){ |
| if (!renderer) initThree(); |
| |
| |
| const arButton = ARButton.createButton(renderer, { requiredFeatures: ['hit-test'] }); |
| |
| arButton.style.display='none'; |
| document.body.appendChild(arButton); |
| try { |
| const xrStarted = await new Promise((resolve, reject)=>{ |
| |
| renderer.xr.addEventListener('sessionstart', ()=> resolve(true)); |
| renderer.xr.addEventListener('sessionend', ()=> resolve(false)); |
| arButton.click(); |
| }); |
| if (!xrStarted) { |
| showMsg('xr session not started'); |
| return; |
| } |
| } catch(e){ |
| console.error(e); |
| showMsg('failed to start xr: ' + (e && e.message ? e.message : e)); |
| return; |
| } |
| |
| |
| renderer.setAnimationLoop(render); |
| |
| |
| const session = renderer.xr.getSession(); |
| xrSessionActive = true; |
| |
| |
| let viewerSpace = await session.requestReferenceSpace('viewer').catch(()=>null); |
| if (viewerSpace){ |
| const hitTestSource = await session.requestHitTestSource({ space: viewerSpace }).catch(()=>null); |
| |
| session.requestAnimationFrame(function onXRFrame(time, frame){ |
| |
| }); |
| |
| session._hitTestSource = hitTestSource; |
| } else { |
| |
| showMsg('hit-test not available, using fixed distance placement'); |
| } |
| |
| showMsg('webxr session started', 2000); |
| |
| |
| const controller = renderer.xr.getController(0); |
| controller.addEventListener('select', onSelect); |
| scene.add(controller); |
| } |
| |
| |
| function onSelect(){ |
| |
| if (reticle && reticle.visible){ |
| const pos = new THREE.Vector3(); |
| pos.setFromMatrixPosition(reticle.matrix); |
| |
| const quat = new THREE.Quaternion(); |
| quat.setFromRotationMatrix(reticle.matrix); |
| placeMarkerAt(pos, quat); |
| return; |
| } |
| |
| const cam = renderer.xr.getCamera(camera); |
| const dir = new THREE.Vector3(0,0,-1).applyQuaternion(cam.quaternion); |
| const pos = new THREE.Vector3().copy(cam.position).add(dir.multiplyScalar(1.5)); |
| placeMarkerAt(pos, null); |
| } |
| |
| |
| function render(timestamp, frame){ |
| const delta = clock.getDelta(); |
| |
| |
| if (frame){ |
| const session = renderer.xr.getSession(); |
| if (session && session._hitTestSource){ |
| const hitTestResults = frame.getHitTestResults(session._hitTestSource); |
| if (hitTestResults && hitTestResults.length > 0){ |
| const hit = hitTestResults[0]; |
| const pose = hit.getPose(renderer.xr.getReferenceSpace()); |
| reticle.visible = true; |
| reticle.matrix.fromArray(pose.transform.matrix); |
| reticle.matrix.decompose(reticle.position, reticle.quaternion, reticle.scale); |
| } else { |
| reticle.visible = false; |
| } |
| } else { |
| |
| const cam = renderer.xr.getCamera(camera); |
| reticle.visible = true; |
| const dir = new THREE.Vector3(0,0,-1).applyQuaternion(cam.quaternion); |
| reticle.position.copy(cam.position).add(dir.multiplyScalar(1.6)); |
| reticle.quaternion.copy(cam.quaternion); |
| } |
| } else { |
| |
| } |
| |
| |
| animateMarker(delta); |
| |
| |
| if (markerGroup.visible){ |
| const label = markerGroup.children.find(c=>c.geometry && c.geometry.type === 'PlaneGeometry'); |
| if (label) label.lookAt(renderer.xr.isPresenting ? renderer.xr.getCamera(camera).position : camera.position); |
| } |
| |
| renderer.render(scene, camera); |
| } |
| |
| |
| function resetMarker(){ |
| if (markerGroup) markerGroup.visible=false; |
| showMsg('reset'); |
| } |
| |
| |
| async function startFallbackDemo(){ |
| |
| if (!renderer) initThree(); |
| |
| if (renderer.xr && renderer.xr.getSession()){ |
| try { await renderer.xr.getSession().end(); } catch(e){} |
| } |
| |
| const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio:false }); |
| const video = document.createElement('video'); |
| video.autoplay = true; video.playsInline = true; video.muted = true; |
| video.srcObject = stream; |
| await video.play(); |
| |
| |
| const videoTex = new THREE.VideoTexture(video); |
| videoTex.minFilter = THREE.LinearFilter; videoTex.magFilter = THREE.LinearFilter; videoTex.format = THREE.RGBFormat; |
| |
| const bgMat = new THREE.MeshBasicMaterial({ map: videoTex }); |
| const bgGeo = new THREE.PlaneGeometry(2, 2 * (window.innerHeight / window.innerWidth)); |
| const bgMesh = new THREE.Mesh(bgGeo, bgMat); |
| bgMesh.position.set(0,0,-1.5); |
| scene.add(bgMesh); |
| |
| |
| showMsg('demo mode: tap screen to place floating marker'); |
| |
| |
| renderer.domElement.style.touchAction = 'auto'; |
| renderer.domElement.addEventListener('pointerdown', (e)=>{ |
| |
| const pos = new THREE.Vector3(0,0,-1).applyQuaternion(camera.quaternion).multiplyScalar(1.4).add(camera.position); |
| placeMarkerAt(pos); |
| }); |
| |
| |
| renderer.setAnimationLoop(()=> { |
| const delta = clock.getDelta(); |
| animateMarker(delta); |
| if (markerGroup.visible){ |
| const label = markerGroup.children.find(c=>c.geometry && c.geometry.type === 'PlaneGeometry'); |
| if (label) label.lookAt(camera.position); |
| } |
| renderer.render(scene,camera); |
| }); |
| } |
| |
| |
| btnStart.addEventListener('click', async ()=>{ |
| if (!navigator.xr){ |
| showMsg('webxr not available on this browser'); |
| return; |
| } |
| |
| if (!renderer) initThree(); |
| try { |
| await startARSession(); |
| } catch(e){ |
| console.error(e); |
| showMsg('could not start webxr: ' + (e.message || e)); |
| } |
| }); |
| |
| btnReset.addEventListener('click', ()=> resetMarker()); |
| btnFallback.addEventListener('click', ()=> { |
| if (!renderer) initThree(); |
| startFallbackDemo().catch(err=> { |
| console.error(err); |
| showMsg('fallback failed: ' + (err && err.message ? err.message : err)); |
| }); |
| }); |
| |
| |
| |
| function setupPointerInteraction(){ |
| const raycaster = new THREE.Raycaster(); |
| const pointer = new THREE.Vector2(); |
| renderer.domElement.addEventListener('pointerdown', (ev)=>{ |
| |
| pointer.x = (ev.clientX / window.innerWidth) * 2 - 1; |
| pointer.y = - (ev.clientY / window.innerHeight) * 2 + 1; |
| raycaster.setFromCamera(pointer, camera); |
| const intersects = raycaster.intersectObjects(markerGroup.children, true); |
| if (intersects.length > 0){ |
| cycleMarkerColor(); |
| } |
| }); |
| } |
| |
| |
| initThree(); |
| setupPointerInteraction(); |
| showMsg('ready — use start ar or demo fallback'); |
| |
| </script> |
| </body> |
| </html> |
|
|