|
|
|
|
|
<!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> |
|
|
|