在空间计算时代,传统的 2D 菜单已经无法满足用户对沉浸式体验的需求。作为一名 AR 开发者,我一直在思考如何利用 Rokid 智能眼镜的空间计算能力,创造出更符合 3D 交互习惯的用户界面。本文将带你从零开始,使用 JSAR 框架开发一个功能完整的 3D 交互式菜单系统。

JSAR(JavaScript Spatial Application Runtime)是 Rokid 推出的开源空间应用运行时环境。它允许开发者使用熟悉的 Web 技术栈(JavaScript + 类 HTML 标记语言 XSML)来开发 AR/XR 应用,大大降低了空间计算开发的门槛。
菜单是几乎所有应用的核心组件,掌握 3D 菜单的开发技巧,能让我们更好地理解空间 UI 设计的原则。通过这个项目,你将学会:
让我们开始吧!
在开始构建完整的菜单系统之前,我们需要先掌握 3D 按钮的创建方法。一个好的 3D 按钮应该具有清晰的视觉反馈、易于识别的文本标签,以及流畅的交互动画。
首先创建基本的 3D 场景:
const scene = spatialDocument.scene;
const camera = new BABYLON.ArcRotateCamera(
'camera',
Math.PI / 2,
Math.PI / 2.5,
15,
new BABYLON.Vector3(0, 0, 0),
scene
);
camera.attachControl(true);
const light = new BABYLON.HemisphericLight(
'light',
new BABYLON.Vector3(0, 1, 0),
scene
);
light.intensity = 0.8;这里使用了 ArcRotateCamera,这是一种围绕目标点旋转的相机,非常适合展示 3D 对象。
const button = BABYLON.MeshBuilder.CreateBox('button', {
width: 3,
height: 1,
depth: 0.2
}, scene);
const material = new BABYLON.StandardMaterial('buttonMat', scene);
material.diffuseColor = new BABYLON.Color3(0.2, 0.6, 1);
material.emissiveColor = new BABYLON.Color3(0.1, 0.3, 0.5);
button.material = material;我们使用扁平的立方体作为按钮基础,emissiveColor 让按钮具有自发光效果,在深色背景下更加醒目。

仅有几何体还不够,我们需要在按钮上显示文字。这里使用 JSAR 的 DynamicTexture 功能:
const textPlane = BABYLON.MeshBuilder.CreatePlane('textPlane', {
width: 2.8,
height: 0.8
}, scene);
textPlane.position = new BABYLON.Vector3(0, 0, -0.11);
textPlane.parent = button;
const texture = new BABYLON.DynamicTexture('buttonText', 512, scene, true);
const ctx = texture.getContext();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 80px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('点击我', 256, 256);
texture.update();
const textMaterial = new BABYLON.StandardMaterial('textMat', scene);
textMaterial.diffuseTexture = texture;
textMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1);
textPlane.material = textMaterial;DynamicTexture 就像一个 Canvas,我们可以在上面绘制任何内容,然后将其作为纹理应用到 3D 平面上。

静态的按钮缺乏吸引力,让我们添加一个简单的旋转动画:
scene.registerBeforeRender(() => {
button.rotation.y += 0.01;
});
最后,添加键盘交互:
spatialDocument.addEventListener('keydown', (event) => {
if (event.key === ' ' || event.key === 'Enter') {
button.material.diffuseColor = new BABYLON.Color3(0.2, 1, 0.3);
console.log('按钮被点击了!');
setTimeout(() => {
button.material.diffuseColor = new BABYLON.Color3(0.2, 0.6, 1);
}, 300);
}
});按下空格或回车键时,按钮会短暂变成绿色,提供清晰的视觉反馈。

单个按钮已经完成,现在我们需要创建一个包含多个选项的菜单系统。
使用数组来管理菜单项:
const menuItems = [
{ id: 1, title: '立方体', color: new BABYLON.Color3(0.2, 0.6, 1) },
{ id: 2, title: '球体', color: new BABYLON.Color3(1, 0.3, 0.3) },
{ id: 3, title: '圆柱体', color: new BABYLON.Color3(0.3, 1, 0.3) },
{ id: 4, title: '八面体', color: new BABYLON.Color3(1, 0.8, 0.2) }
];每个菜单项都有独特的颜色,便于用户快速识别。
将按钮创建逻辑封装成函数:
function createMenuButton(item, index) {
const button = BABYLON.MeshBuilder.CreateBox('button' + item.id, {
width: 3,
height: 1,
depth: 0.2
}, scene);
// 水平排列
const spacing = 4;
button.position = new BABYLON.Vector3(
(index - 1.5) * spacing,
0,
0
);
const material = new BABYLON.StandardMaterial('buttonMat' + item.id, scene);
material.diffuseColor = item.color;
material.emissiveColor = item.color.scale(0.5);
button.material = material;
// ... 创建文字标签(省略重复代码)
return button;
}
menuItems.forEach((item, index) => {
const btn = createMenuButton(item, index);
buttons.push(btn);
});四个按钮以 (index - 1.5) * spacing 的方式水平居中排列。
添加选择指示器,让用户知道当前聚焦在哪个选项上:
function highlightButton(index) {
buttons.forEach((btn, i) => {
if (i === index) {
btn.scaling = new BABYLON.Vector3(1.2, 1.2, 1.2);
btn.material.emissiveColor = menuItems[i].color;
} else {
btn.scaling = new BABYLON.Vector3(1, 1, 1);
btn.material.emissiveColor = menuItems[i].color.scale(0.5);
}
});
}选中的按钮会放大 20% 并增强发光效果。
let selectedIndex = 0;
spatialDocument.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft' || event.key === 'a') {
selectedIndex = (selectedIndex - 1 + menuItems.length) % menuItems.length;
highlightButton(selectedIndex);
} else if (event.key === 'ArrowRight' || event.key === 'd') {
selectedIndex = (selectedIndex + 1) % menuItems.length;
highlightButton(selectedIndex);
}
});菜单的核心功能是导航到不同的内容页面。我们需要实现一个优雅的视图切换系统。
let currentView = 'menu'; // 'menu' 或 'detail'
let menuObjects = [];
let detailObjects = [];使用两个数组分别管理菜单视图和详情视图的所有对象,便于切换时清理。
function clearMenuView() {
menuObjects.forEach(obj => obj.dispose());
menuObjects = [];
}
function clearDetailView() {
detailObjects.forEach(obj => obj.dispose());
detailObjects = [];
}dispose() 方法会释放对象的所有资源,包括几何体、材质和纹理,这在频繁切换视图时非常重要。
function showDetailView(item) {
currentView = 'detail';
clearMenuView();
let displayObject;
const material = new BABYLON.StandardMaterial('displayMat', scene);
material.diffuseColor = item.color;
material.emissiveColor = item.color.scale(0.3);
if (item.title === '立方体') {
displayObject = BABYLON.MeshBuilder.CreateBox('display', { size: 4 }, scene);
} else if (item.title === '球体') {
displayObject = BABYLON.MeshBuilder.CreateSphere('display', { diameter: 4 }, scene);
}
// ... 其他形状
displayObject.material = material;
detailObjects.push(displayObject);
}根据选择的菜单项创建对应的 3D 对象。
const backText = BABYLON.MeshBuilder.CreatePlane('backText', {
width: 6,
height: 1
}, scene);
backText.position = new BABYLON.Vector3(0, -4, 0);
// 绘制"返回"提示文字
const backTexture = new BABYLON.DynamicTexture('backTexture', 512, scene, true);
const ctx = backTexture.getContext();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 50px Arial';
ctx.textAlign = 'center';
ctx.fillText('按 ESC 或 B 返回菜单', 256, 256);
backTexture.update();现在我们可以选择并点击选项,并且可以退出到菜单。

为了让用户更好地理解每个 3D 对象,我们需要添加一个信息展示面板。
const menuItems = [
{
id: 1,
title: '立方体',
subtitle: 'Cube',
description: '最基础的3D图形,具有6个面、8个顶点和12条边',
color: new BABYLON.Color3(0.2, 0.6, 1)
},
// ... 其他项
];这是本项目最复杂的部分,需要在 3D 空间中绘制格式化的文本信息:
function createInfoPanel(item) {
const panel = BABYLON.MeshBuilder.CreatePlane('infoPanel', {
width: 10,
height: 4
}, scene);
panel.position = new BABYLON.Vector3(0, 4.5, 0);
const texture = new BABYLON.DynamicTexture('infoTexture', {
width: 1024,
height: 512
}, scene, true);
const ctx = texture.getContext();
// 背景
ctx.fillStyle = 'rgba(0, 20, 40, 0.9)';
ctx.fillRect(0, 0, 1024, 512);
// 边框
ctx.strokeStyle = '#' + item.color.toHexString();
ctx.lineWidth = 8;
ctx.strokeRect(10, 10, 1004, 492);
// 标题
ctx.fillStyle = '#' + item.color.toHexString();
ctx.font = 'bold 80px Arial';
ctx.textAlign = 'center';
ctx.fillText(item.title, 512, 100);
// 副标题
ctx.fillStyle = '#ffffff';
ctx.font = '50px Arial';
ctx.fillText(item.subtitle, 512, 180);
// 描述(支持长文本换行)
ctx.fillStyle = '#cccccc';
ctx.font = '40px Arial';
// ... 文字换行逻辑
texture.update();
}
panel.renderingGroupId = 1;将信息面板设置为渲染组 1,确保它始终显示在其他对象前面。
为了提高效率,我们添加数字键直达功能:
spatialDocument.addEventListener('keydown', (event) => {
if (currentView === 'menu') {
if (event.key >= '1' && event.key <= '4') {
const index = parseInt(event.key) - 1;
showDetailView(menuItems[index]);
}
}
});现在用户可以直接按 1-4 快速跳转到对应页面。
function createHelpText() {
const helpText = BABYLON.MeshBuilder.CreatePlane('helpText', {
width: 12,
height: 1.5
}, scene);
helpText.position = new BABYLON.Vector3(0, -3, 0);
// 绘制提示文字
ctx.fillText('按数字键 1-4 直接选择 | 左右键切换 | 回车确认', 512, 128);
}虽然本教程使用键盘进行交互演示,但这个菜单系统可以轻松适配到 Rokid 智能眼镜上:
emissiveColor 确保在不同光照环境下都清晰可见dispose() 释放资源renderingGroupId 控制渲染顺序,避免不必要的深度排序本文所有代码均在 Windows 10 + VSCode + JSAR 扩展环境下测试通过
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。