如何基于Three.js在VR环境中创建可交互虚拟键盘?
Three.js VR虚拟键盘实现问题与解决方案
我正在用Three.js开发VR项目,需要实现一个用户可在VR环境内交互的虚拟键盘,尝试了多种方案但始终无法正常实现,具体问题及优化方案如下:
已尝试的实现细节
- 面板与按钮设置:为键盘底座创建面板,将按钮作为独立PlaneGeometry网格添加到面板上,尝试用TextGeometry在每个按钮上渲染文本
- 字体加载:使用Three.js的FontLoader加载按钮标签字体,但文本的定位与缩放存在问题
- 交互性:不清楚如何让按钮具备交互性,尤其是如何检测VR环境中用户的“按键”操作
简化版代码
import { System } from 'three/addons/libs/ecsy.module.js'; import * as THREE from 'three'; import { Panel } from '../components/Panel.js'; import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'; import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'; export class PanelSystem extends System { init({ panel }) { this.panel = panel; this.panelGroup = new THREE.Group(); if (this.panel.length > 0 && this.panel[0].scene) { this.panel[0].scene.add(this.panelGroup); } this.panelMeshes = []; this.createPanel(); this.createKeyboard(); } execute() { if (this.panel.length > 0) { this.panelGroup.rotation.set( this.rotation.x * (Math.PI / 180), this.rotation.y * (Math.PI / 180), this.rotation.z * (Math.PI / 180) ); } this.renderer.render(this.scene, this.camera); } createPanel() { let panel, xOffset; this.panel.forEach(p => { this.font = p.font !== undefined ? p.font : Panel.schema.font.default; this.layers = p.layers !== undefined ? p.layers : Panel.schema.layers.default; this.width = p.width !== undefined ? p.width : Panel.schema.width.default; this.height = p.height !== undefined ? p.height : Panel.schema.height.default; this.color = p.color !== undefined ? p.color : Panel.schema.color.default; this.scene = p.scene || this.scene; this.renderer = p.renderer || this.renderer; this.camera = p.camera || this.camera; this.isSide = p.isSide !== undefined ? p.isSide : Panel.schema.isSide.default; this.padding = p.padding !== undefined ? p.padding : Panel.schema.padding.default; this.position = p.position || Panel.schema.position.default; this.rotation = p.rotation || Panel.schema.rotation.default; }); for (let i = 0; i < this.layers; i++) { const geometry = new THREE.PlaneGeometry(this.width, this.height); const side = this.isSide === true ? THREE.DoubleSide : THREE.FrontSide; const material = new THREE.MeshBasicMaterial({ color: this.color, side: side }); panel = new THREE.Mesh(geometry, material); if (i === 0) { xOffset = 0; panel.position.set(this.position.x + xOffset, this.position.y, this.position.z - 0.08); } else { if (i % 2 !== 0) { xOffset = - (this.width + this.padding); panel.rotation.set(0, (15 * (Math.PI / 180)), 0); panel.position.set(this.position.x + xOffset, this.position.y, this.position.z); } else { xOffset = (this.width + this.padding); panel.rotation.set(0, -(15 * (Math.PI / 180)), 0); panel.position.set(this.position.x + xOffset, this.position.y, this.position.z); } } this.panelGroup.add(panel); this.panelMeshes.push( { panel, scene: this.scene, camera: this.camera, renderer: this.renderer } ); } } createKeyboard() { // keyboard base const geometry = new THREE.PlaneGeometry(1, 0.3); const side = this.isSide === true ? THREE.DoubleSide : THREE.FrontSide; const material = new THREE.MeshBasicMaterial({ color: this.color, side: side }); this.keyboard = new THREE.Mesh(geometry, material); this.keyboard.position.set(this.position.x, this.position.y - 0.3, this.position.z + 0.1); // Posisi relatif terhadap panel this.keyboard.rotation.set(15, 0, 0); this.panelGroup.add(this.keyboard); const loader = new FontLoader(); loader.load('/fonts/Roboto_Italic.json', (font) => { fetch(this.font) .then(response => response.json()) .then(data => { data.keyboard.forEach(row => { this.createButton(row, font) }) }).catch(error => console.error('Error fetching JSON:', error)) }) } createButton(keyboard, font) { const xStart = -0.4375; // Minimum x position const yStart = -0.1; // Maximum y position const xSpacing = 0.1; // Horizontal spacing between keys const ySpacing = -0.1; // Vertical spacing between rows let currentY = yStart; let currentX = xStart; keyboard.keys.forEach((key, index) => { const geometry = new THREE.PlaneGeometry(0.1, 0.1); const material = new THREE.MeshBasicMaterial({ color: 0xffffff }); const button = new THREE.Mesh(geometry, material); if (index === 0) { currentX = xStart; // Reset x position for each row } else { currentX += xSpacing; } button.position.set(currentX, currentY, 0); this.keyboard.add(button); const textGeometry = new TextGeometry(key, { font: font, size: 0.05, height: 0.01 }); const textMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 }); const textMesh = new THREE.Mesh(textGeometry, textMaterial); textMesh.position.set(currentX - 0.045, currentY - 0.03, 0.01); this.keyboard.add(textMesh); }); currentY += ySpacing; } } PanelSystem.queries = { panel: { components: [Panel] } };
问题修复与优化方案
1. 文本定位与缩放问题修复
- TextGeometry创建后调用
textGeometry.center(),自动让文本居中,无需手动计算偏移量 - 调整文本尺寸与按钮比例,比如将文本size设为0.04、height设为0.005,避免超出按钮范围
- 将文本作为按钮的子元素添加,按钮移动时文本自动跟随,简化位置管理:
// 修改createButton中的文本部分 const textGeometry = new TextGeometry(key, { font: font, size: 0.04, height: 0.005 }); textGeometry.center(); // 居中处理 const textMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 }); const textMesh = new THREE.Mesh(textGeometry, textMaterial); textMesh.position.set(0, 0, 0.01); // 仅设置Z轴让文本浮在按钮上方 button.add(textMesh); // 添加为按钮子元素
2. 键盘布局优化
- 将
currentY += ySpacing移至keyboard.keys.forEach循环外部,确保每行Y轴位置正确偏移 - 调整行间距为-0.12,让行与行之间的距离更贴近真实键盘
- 针对特殊键(如Shift、回车)单独设置宽度,比如Shift键使用
new THREE.PlaneGeometry(0.2, 0.1),优化布局合理性
3. VR交互实现
- 使用
Raycaster结合VR控制器检测按钮点击:- 初始化VR控制器后,在execute方法中创建射线,对键盘按钮进行相交检测
- 给每个按钮添加
userData.key = key属性,方便获取按键值 - 检测控制器触发键(如Trigger)的按下状态,触发对应按键逻辑
- 核心代码片段:
// 在init方法中初始化VR控制器(示例) this.controllers = []; const controller = this.renderer.xr.getController(0); this.panelGroup.add(controller); this.controllers.push(controller); // 监听控制器触发键状态 controller.addEventListener('selectstart', () => { controller.userData.triggerPressed = true; }); controller.addEventListener('selectend', () => { controller.userData.triggerPressed = false; }); // 在execute方法中添加交互检测 const raycaster = new THREE.Raycaster(); this.controllers.forEach(controller => { raycaster.setFromCamera({ x: 0, y: 0 }, controller); const intersects = raycaster.intersectObjects(this.keyboard.children.filter(child => child.type === 'Mesh')); if (intersects.length > 0 && controller.userData.triggerPressed) { const clickedKey = intersects[0].object.userData.key; console.log('触发按键:', clickedKey); // 此处添加输入逻辑,比如更新输入框内容 } });
4. 视觉反馈优化
- 给按钮添加常态与按下态两种材质,交互时切换:
// 修改createButton中的按钮材质创建 const normalMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); const pressedMaterial = new THREE.MeshBasicMaterial({ color: 0xcccccc }); const button = new THREE.Mesh(geometry, normalMaterial); button.userData = { key: key, normalMaterial, pressedMaterial }; // 在交互检测中切换材质 if (intersects.length > 0) { const button = intersects[0].object; button.material = controller.userData.triggerPressed ? button.userData.pressedMaterial : button.userData.normalMaterial; } else { // 重置所有按钮材质为常态 this.keyboard.children.forEach(child => { if (child.userData.normalMaterial) { child.material = child.userData.normalMaterial; } }); }
内容的提问来源于stack exchange,提问作者Wildy Simanjuntak
相关产品推荐
相关产品推荐

