You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何基于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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 18:09:53