| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>Reachy Mini 3D Stage</title> |
| <style> |
| :root { color-scheme: dark; } |
| html, body { margin: 0; height: 100%; overflow: hidden; background: #080b14; } |
| #scene { position: fixed; inset: 0; } |
| #scene canvas { display: block; } |
| |
| #scan { |
| position: fixed; inset: 0; pointer-events: none; z-index: 2; opacity: 0.16; |
| background: repeating-linear-gradient(180deg, rgba(255,255,255,0.08) 0 1px, transparent 1px 3px); |
| mix-blend-mode: overlay; |
| } |
| #vignette { |
| position: fixed; inset: 0; pointer-events: none; z-index: 2; |
| box-shadow: inset 0 0 130px 36px rgba(3,2,8,0.72); |
| } |
| |
| .hud { |
| position: fixed; z-index: 3; pointer-events: none; |
| font-family: ui-monospace, "Cascadia Code", monospace; font-size: 10px; letter-spacing: .22em; |
| color: rgba(127,233,255,0.55); text-transform: uppercase; text-shadow: 0 0 10px rgba(34,227,255,0.45); |
| } |
| .hud.tl { top: 12px; left: 14px; } |
| .hud.br { bottom: 12px; right: 14px; } |
| .hud .kanji { font-family: "Noto Sans JP", system-ui, sans-serif; letter-spacing: .14em; } |
| #loading { |
| position: fixed; inset: 0; display: grid; place-items: center; z-index: 4; |
| font-family: ui-monospace, "Cascadia Code", monospace; letter-spacing: .18em; |
| color: #7fe9ff; text-transform: uppercase; font-size: 12px; |
| text-shadow: 0 0 12px rgba(34,227,255,0.7); transition: opacity .5s; |
| } |
| </style> |
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/" |
| } |
| } |
| </script> |
| </head> |
| <body> |
| <div id="scene"></div> |
| <div id="scan"></div> |
| <div id="vignette"></div> |
| <div class="hud tl"><span class="kanji">通訳</span> · reachy.link</div> |
| <div class="hud br">tracking · <span class="kanji">音声</span></div> |
| <div id="loading">booting reachy…</div> |
|
|
| <script type="module"> |
| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| |
| const mount = document.getElementById('scene'); |
| const loading = document.getElementById('loading'); |
| const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); |
| const lerp = (a, b, t) => a + (b - a) * t; |
| |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| renderer.outputColorSpace = THREE.SRGBColorSpace; |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; |
| renderer.toneMappingExposure = 1.15; |
| mount.appendChild(renderer.domElement); |
| |
| const scene = new THREE.Scene(); |
| |
| |
| function gradientBg() { |
| const c = document.createElement('canvas'); c.width = 4; c.height = 256; |
| const g = c.getContext('2d'); |
| const grd = g.createLinearGradient(0, 0, 0, 256); |
| grd.addColorStop(0.0, '#11172a'); |
| grd.addColorStop(0.55, '#0b0f1a'); |
| grd.addColorStop(1.0, '#070912'); |
| g.fillStyle = grd; g.fillRect(0, 0, 4, 256); |
| const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; |
| } |
| scene.background = gradientBg(); |
| scene.fog = new THREE.Fog(0x0a0e18, 6.5, 17); |
| |
| const camera = new THREE.PerspectiveCamera(34, window.innerWidth / window.innerHeight, 0.1, 100); |
| camera.position.set(0.1, 1.55, 4.7); |
| |
| const controls = new OrbitControls(camera, renderer.domElement); |
| controls.target.set(0, 1.28, 0); |
| controls.enablePan = false; |
| controls.enableZoom = false; |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.08; |
| controls.minPolarAngle = Math.PI * 0.28; |
| controls.maxPolarAngle = Math.PI * 0.60; |
| controls.autoRotate = false; |
| |
| |
| scene.add(new THREE.HemisphereLight(0xbfd8ff, 0x140a22, 0.55)); |
| const key = new THREE.DirectionalLight(0xfff3e0, 1.5); key.position.set(2.6, 4.2, 3.2); scene.add(key); |
| const fill = new THREE.DirectionalLight(0x9fc4ff, 0.5); fill.position.set(-3.0, 1.6, 2.0); scene.add(fill); |
| const rimViolet = new THREE.PointLight(0x8b5cf6, 1.5, 18); rimViolet.position.set(-2.6, 2.4, -2.4); scene.add(rimViolet); |
| const rimIndigo = new THREE.PointLight(0x6366f1, 1.4, 18); rimIndigo.position.set(2.8, 1.8, -2.2); scene.add(rimIndigo); |
| |
| |
| const grid = new THREE.GridHelper(46, 92, 0x818cf8, 0x2a2c66); |
| grid.material.transparent = true; grid.material.opacity = 0.22; grid.position.y = 0.001; |
| scene.add(grid); |
| |
| |
| const PCOUNT = 700; |
| const pPos = new Float32Array(PCOUNT * 3); |
| for (let i = 0; i < PCOUNT; i++) { |
| pPos[i*3+0] = (Math.random() - 0.5) * 16; |
| pPos[i*3+1] = Math.random() * 7.5; |
| pPos[i*3+2] = (Math.random() - 0.5) * 14 - 2; |
| } |
| const pGeo = new THREE.BufferGeometry(); |
| pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); |
| const particles = new THREE.Points(pGeo, new THREE.PointsMaterial({ |
| color: 0x9fb4ff, size: 0.035, transparent: true, opacity: 0.7, |
| depthWrite: false, blending: THREE.AdditiveBlending, |
| })); |
| scene.add(particles); |
| |
| |
| function glowTexture(rgb) { |
| const c = document.createElement('canvas'); c.width = c.height = 128; |
| const g = c.getContext('2d'); |
| const grd = g.createRadialGradient(64, 64, 0, 64, 64, 64); |
| grd.addColorStop(0, `rgba(${rgb},1)`); |
| grd.addColorStop(0.25, `rgba(${rgb},0.85)`); |
| grd.addColorStop(1, `rgba(${rgb},0)`); |
| g.fillStyle = grd; g.fillRect(0, 0, 128, 128); |
| const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; |
| } |
| function makeGlow(rgb, size) { |
| const s = new THREE.Sprite(new THREE.SpriteMaterial({ |
| map: glowTexture(rgb), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, |
| })); |
| s.scale.set(size, size, 1); return s; |
| } |
| |
| const reachy = new THREE.Group(); |
| scene.add(reachy); |
| |
| |
| const baseMat = new THREE.MeshStandardMaterial({ color: 0x201a2c, metalness: 0.5, roughness: 0.45 }); |
| const base = new THREE.Mesh(new THREE.CylinderGeometry(0.82, 0.92, 0.20, 48), baseMat); |
| base.position.y = 0.10; reachy.add(base); |
| const baseTrim = new THREE.Mesh(new THREE.TorusGeometry(0.83, 0.035, 16, 60), |
| new THREE.MeshStandardMaterial({ color: 0x818cf8, emissive: 0x4338ca, emissiveIntensity: 1.1, metalness: 0.3, roughness: 0.3 })); |
| baseTrim.rotation.x = Math.PI / 2; baseTrim.position.y = 0.205; reachy.add(baseTrim); |
| |
| |
| const bodyProfile = [ |
| [0.60, 0.20], [0.72, 0.30], [0.76, 0.55], [0.74, 0.86], [0.64, 1.06], [0.50, 1.16], [0.34, 1.20], |
| ].map(p => new THREE.Vector2(p[0], p[1])); |
| const bodyMat = new THREE.MeshStandardMaterial({ color: 0xf1e7d2, metalness: 0.12, roughness: 0.55 }); |
| const body = new THREE.Mesh(new THREE.LatheGeometry(bodyProfile, 64), bodyMat); |
| reachy.add(body); |
| |
| const collar = new THREE.Mesh(new THREE.TorusGeometry(0.40, 0.05, 16, 48), |
| new THREE.MeshStandardMaterial({ color: 0x6d5cd6, emissive: 0x4c3aa8, emissiveIntensity: 0.5, roughness: 0.5 })); |
| collar.rotation.x = Math.PI / 2; collar.position.y = 1.16; reachy.add(collar); |
| |
| |
| const head = new THREE.Group(); head.position.set(0, 1.55, 0); reachy.add(head); |
| const skull = new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 40), |
| new THREE.MeshStandardMaterial({ color: 0xf4ecdb, metalness: 0.1, roughness: 0.5 })); |
| skull.scale.set(1.0, 0.86, 0.92); head.add(skull); |
| |
| |
| const visor = new THREE.Mesh(new THREE.SphereGeometry(0.42, 48, 40, 0, Math.PI * 2, 0, Math.PI * 0.62), |
| new THREE.MeshStandardMaterial({ color: 0x14121d, metalness: 0.6, roughness: 0.25 })); |
| visor.rotation.x = Math.PI * 0.5; visor.position.set(0, -0.02, 0.16); visor.scale.set(1.0, 0.78, 0.7); head.add(visor); |
| |
| |
| const eyeMat = new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0x22e3ff, emissiveIntensity: 2.2, roughness: 0.2 }); |
| const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.105, 28, 24), eyeMat); |
| const eyeR = new THREE.Mesh(new THREE.SphereGeometry(0.105, 28, 24), eyeMat.clone()); |
| const EYE_BASE = { lx: -0.18, rx: 0.18, y: 0.02, z: 0.40 }; |
| eyeL.position.set(EYE_BASE.lx, EYE_BASE.y, EYE_BASE.z); eyeR.position.set(EYE_BASE.rx, EYE_BASE.y, EYE_BASE.z); |
| head.add(eyeL, eyeR); |
| const eyeGlowL = makeGlow('120,235,255', 0.5); eyeGlowL.position.copy(eyeL.position); |
| const eyeGlowR = makeGlow('120,235,255', 0.5); eyeGlowR.position.copy(eyeR.position); |
| head.add(eyeGlowL, eyeGlowR); |
| |
| |
| const mouth = new THREE.Mesh(new THREE.CapsuleGeometry(0.05, 0.16, 4, 12), |
| new THREE.MeshStandardMaterial({ color: 0x241a2e, emissive: 0x9b8cff, emissiveIntensity: 0.5, roughness: 0.5 })); |
| mouth.rotation.z = Math.PI / 2; mouth.position.set(0, -0.20, 0.42); head.add(mouth); |
| |
| |
| function makeAntenna(side) { |
| const g = new THREE.Group(); |
| const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.022, 0.03, 0.52, 16), |
| new THREE.MeshStandardMaterial({ color: 0x2a2436, metalness: 0.6, roughness: 0.35 })); |
| stalk.position.y = 0.26; g.add(stalk); |
| const tipColor = side < 0 ? 0x818cf8 : 0xa78bfa; |
| const tip = new THREE.Mesh(new THREE.SphereGeometry(0.075, 24, 20), |
| new THREE.MeshStandardMaterial({ color: 0xf2f0ff, emissive: tipColor, emissiveIntensity: 1.8, roughness: 0.3 })); |
| tip.position.y = 0.54; g.add(tip); |
| const tipGlow = makeGlow(side < 0 ? '129,140,248' : '167,139,250', 0.42); |
| tipGlow.position.copy(tip.position); g.add(tipGlow); |
| g.position.set(side * 0.22, 0.42, -0.02); |
| g.rotation.z = -side * 0.18; |
| return g; |
| } |
| const antL = makeAntenna(-1), antR = makeAntenna(1); |
| head.add(antL, antR); |
| |
| |
| const ring = new THREE.Mesh(new THREE.TorusGeometry(1.05, 0.02, 16, 80), |
| new THREE.MeshBasicMaterial({ color: 0x22e3ff, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending })); |
| ring.rotation.x = Math.PI / 2; ring.position.y = 0.04; reachy.add(ring); |
| |
| |
| |
| |
| let composer = null; |
| try { |
| const { EffectComposer } = await import('three/addons/postprocessing/EffectComposer.js'); |
| const { RenderPass } = await import('three/addons/postprocessing/RenderPass.js'); |
| const { UnrealBloomPass } = await import('three/addons/postprocessing/UnrealBloomPass.js'); |
| composer = new EffectComposer(renderer); |
| composer.addPass(new RenderPass(scene, camera)); |
| const bloom = new UnrealBloomPass( |
| new THREE.Vector2(window.innerWidth, window.innerHeight), 0.85, 0.6, 0.18); |
| composer.addPass(bloom); |
| } catch (err) { |
| console.warn('Reachy bloom unavailable, falling back to direct render:', err && err.message); |
| composer = null; |
| } |
| |
| |
| |
| |
| const pointer = { x: 0, y: 0, tx: 0, ty: 0, lastMove: -10 }; |
| const MAX_YAW = 0.55, MAX_PITCH = 0.26; |
| function setPointer(nx, ny) { |
| pointer.tx = clamp(nx, -1.4, 1.4); |
| pointer.ty = clamp(ny, -1.4, 1.4); |
| pointer.lastMove = clock.getElapsedTime(); |
| } |
| |
| window.addEventListener('mousemove', (e) => { |
| setPointer((e.clientX / window.innerWidth) * 2 - 1, (e.clientY / window.innerHeight) * 2 - 1); |
| }); |
| |
| |
| |
| |
| const PALETTE = { |
| cyan: new THREE.Color(0x4dd6ff), lime: new THREE.Color(0x6ee7b7), |
| amber: new THREE.Color(0xfbbf24), magenta: new THREE.Color(0x9b8cff), |
| }; |
| const STATES = { |
| idle: { rotate: 0.9, antenna: 0.10, bob: 0.6, eye: 'cyan', eyeI: 1.9, mouth: 0.0, ring: 0.45 }, |
| listening: { rotate: 0.25, antenna: 0.55, bob: 0.9, eye: 'cyan', eyeI: 2.8, mouth: 0.1, ring: 1.0 }, |
| thinking: { rotate: 0.5, antenna: 0.22, bob: 0.4, eye: 'magenta', eyeI: 2.0, mouth: 0.0, ring: 0.6, tilt: 1 }, |
| speaking: { rotate: 0.6, antenna: 0.40, bob: 1.4, eye: 'cyan', eyeI: 3.0, mouth: 1.0, ring: 0.9, nod: 1 }, |
| text_only: { rotate: 0.5, antenna: 0.12, bob: 0.5, eye: 'amber', eyeI: 2.0, mouth: 0.2, ring: 0.55 }, |
| success: { rotate: 1.1, antenna: 0.9, bob: 1.0, eye: 'lime', eyeI: 3.0, mouth: 0.4, ring: 1.0, hop: 1 }, |
| encourage: { rotate: 0.5, antenna: 0.25, bob: 0.5, eye: 'amber', eyeI: 2.2, mouth: 0.1, ring: 0.6, nod: 1 }, |
| nodding: { rotate: 0.6, antenna: 0.20, bob: 0.6, eye: 'cyan', eyeI: 2.2, mouth: 0.0, ring: 0.6, nod: 1 }, |
| }; |
| let target = STATES.idle; |
| const cur = { rotate: 0.9, antenna: 0.10, bob: 0.6, eyeI: 1.9, mouth: 0, ring: 0.45, tilt: 0, nod: 0, hop: 0 }; |
| const eyeColor = new THREE.Color(0x22e3ff); |
| |
| function setState(name) { |
| target = STATES[name] || STATES.idle; |
| } |
| window.addEventListener('message', (e) => { |
| const d = e.data || {}; |
| if (d.type === 'reachy-state' && typeof d.state === 'string') setState(d.state); |
| else if (d.type === 'reachy-pointer' && typeof d.x === 'number') setPointer(d.x, d.y); |
| }); |
| |
| if (location.hash.includes('state=')) setState(location.hash.split('state=')[1]); |
| |
| const clock = new THREE.Clock(); |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| const t = clock.getElapsedTime(); |
| const k = 0.06; |
| cur.rotate = lerp(cur.rotate, target.rotate, k); |
| cur.antenna = lerp(cur.antenna, target.antenna, k); |
| cur.bob = lerp(cur.bob, target.bob, k); |
| cur.eyeI = lerp(cur.eyeI, target.eyeI, k); |
| cur.mouth = lerp(cur.mouth, target.mouth, k); |
| cur.ring = lerp(cur.ring, target.ring, k); |
| cur.tilt = lerp(cur.tilt, target.tilt ? 1 : 0, k); |
| cur.nod = lerp(cur.nod, target.nod ? 1 : 0, k); |
| cur.hop = lerp(cur.hop, target.hop ? 1 : 0, k); |
| |
| |
| if (t - pointer.lastMove > 2.2) { |
| pointer.tx = Math.sin(t * 0.45) * 0.5; |
| pointer.ty = Math.sin(t * 0.33) * 0.22 - 0.05; |
| } |
| pointer.x = lerp(pointer.x, pointer.tx, 0.08); |
| pointer.y = lerp(pointer.y, pointer.ty, 0.08); |
| const prox = Math.max(0, 1 - Math.hypot(pointer.x, pointer.y)); |
| |
| |
| head.position.y = 1.55 + Math.sin(t * 1.8) * 0.02 * cur.bob + cur.hop * Math.abs(Math.sin(t * 4.0)) * 0.05; |
| head.rotation.y = pointer.x * MAX_YAW; |
| const nodPitch = -cur.nod * Math.max(0, Math.sin(t * 3.2)) * 0.18; |
| head.rotation.x = nodPitch + pointer.y * MAX_PITCH; |
| head.rotation.z = Math.sin(t * 1.2) * 0.12 * cur.tilt; |
| reachy.position.y = cur.hop * Math.abs(Math.sin(t * 4.0)) * 0.04; |
| reachy.rotation.y = pointer.x * 0.12; |
| |
| |
| const aAmp = cur.antenna + prox * 0.22; |
| antL.rotation.z = 0.18 + Math.sin(t * 5.0) * aAmp; |
| antR.rotation.z = -0.18 - Math.sin(t * 5.0 + 0.6) * aAmp; |
| antL.rotation.x = Math.sin(t * 3.3) * aAmp * 0.4; |
| antR.rotation.x = Math.sin(t * 3.3 + 1.0) * aAmp * 0.4; |
| |
| |
| const tgtCol = PALETTE[target.eye] || PALETTE.cyan; |
| eyeColor.lerp(tgtCol, 0.05); |
| const blink = (Math.sin(t * 0.7) > 0.985) ? 0.15 : 1.0; |
| const ex = pointer.x * 0.03, ey = -pointer.y * 0.02; |
| eyeL.position.set(EYE_BASE.lx + ex, EYE_BASE.y + ey, EYE_BASE.z); |
| eyeR.position.set(EYE_BASE.rx + ex, EYE_BASE.y + ey, EYE_BASE.z); |
| eyeGlowL.position.copy(eyeL.position); eyeGlowR.position.copy(eyeR.position); |
| [eyeL, eyeR].forEach(e => { e.material.emissive.copy(eyeColor); e.material.emissiveIntensity = cur.eyeI * blink; }); |
| [eyeGlowL, eyeGlowR].forEach(s => { s.material.opacity = 0.5 * blink; }); |
| eyeL.scale.y = eyeR.scale.y = blink < 1 ? 0.2 : 1; |
| |
| |
| const talk = Math.max(0, Math.sin(t * 11.0)); |
| mouth.scale.set(1 + cur.mouth * talk * 5.0, 1 + cur.mouth * 0.15, 1); |
| |
| |
| const rp = 0.85 + 0.18 * Math.sin(t * 2.4); |
| ring.scale.set(rp, rp, 1); |
| ring.material.opacity = 0.18 + (cur.ring + prox * 0.4) * (0.35 + 0.2 * Math.sin(t * 2.4)); |
| ring.material.color.copy(eyeColor); |
| |
| |
| particles.rotation.y += 0.0008; |
| particles.position.x = lerp(particles.position.x, -pointer.x * 0.35, 0.05); |
| particles.position.y = lerp(particles.position.y, -pointer.y * 0.20, 0.05); |
| |
| controls.update(); |
| if (composer) composer.render(); else renderer.render(scene, camera); |
| } |
| |
| function onResize() { |
| const w = window.innerWidth, h = window.innerHeight; |
| camera.aspect = w / h; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(w, h); |
| if (composer) composer.setSize(w, h); |
| } |
| window.addEventListener('resize', onResize); |
| |
| loading.style.opacity = '0'; |
| setTimeout(() => loading.remove(), 600); |
| animate(); |
| </script> |
| </body> |
| </html> |
|
|