上手 Rokid JSAR:新手也能快速入门的 AR 开发之旅

🌹🌹期待您的关注 🌹🌹

❀ Rokid JSAR

前言:作为 Rokid 针对轻量化 AR 场景推出的 JavaScript 开发方案,JSAR 最大的优势在于 "低门槛":无需厚重的原生开发功底,只需掌握基础的前端知识,就能基于 Web 环境搭建 AR 交互功能;更重要的是,它对 Rokid AR 眼镜等硬件设备的适配做了深度优化,省去了开发者反复调试设备兼容性的麻烦。

这篇文章就想记录下这次 Rokid JSAR 开发初体验的全过程,如需了解更多详细的过程可前往

JSAR


初始JSAR


首先让我们先来了解一下什么是JSAR,JSAR 基于 Web 技术,能让开发者借助熟悉的前端工具链(比如 npm、模块打包器等)来构建 AR 应用,并且对于 Rokid 自家的 AR 眼镜等设备做了深度适配,无需额外操心硬件兼容问题。JSAR 也可以运行基于 Web APIs 实现的 JavaScript / TypeScript 代码,同时也可以运行 WebAssembly。做到空间开发。同时关于更多的JSAR的介绍Rokid官方网址上也有介绍

Rokid 与 JSAR的深度绑定


Rokid JSAR 并非孤立的 AR 开发工具,其核心功能的设计与落地,始终围绕 Rokid 从硬件设备到软件生态的全链路布局展开。这种 "技术适配硬件、生态支撑开发" 的绑定关系,既让 JSAR 具备了差异化的功能优势,也成为 Rokid 构建 AR 开发者生态的核心纽带。

  • Rokid 的 AR 硬件矩阵是 JSAR 核心功能得以实现的 "物理载体",JSAR 几乎所有空间交互能力都依赖于设备端的硬件支撑,两者形成了 "软件调用能力、硬件提供动力" 的协同模式。
  • 在 Rokid 的应用生态中,JSAR 承担着 "空间小程序开发入口" 的核心角色,其功能设计完全服务于 Rokid 对 "轻量化 AR 应用生态" 的布局。

JSAR的强大功能


在深入体验 Rokid JSAR 的开发过程中,其功能体系的 "专业性" 与 "易用性" 形成的奇妙平衡让人印象深刻。不同于简单的 AR 效果插件,JSAR 构建了一套覆盖从场景搭建到部署运维的完整功能矩阵,既封装了 AR 开发的复杂底层逻辑,又保留了前端开发者熟悉的技术范式,让轻量化开发也能实现专业级 AR 效果。同时JSAR 创新性地将 Web 交互逻辑延伸至 3D 空间,通过 JSAR-DOM 构建了一套 "空间文档对象模型",让前端开发者能沿用熟悉的方式处理虚拟交互。

配置开发环境


关于开发环境的搭建,我这里根据官方文档的搭建过程分为了3个步骤

1. 安装 Visual Studio Code 2. 安装 Node.js 3. 安装 JSAR DevTools

安装 Visual Studio Code


首先我们进入 Visual Studio Code 的官方网站,同时留意好软件的版本,我们所需的版本必须大于等于1.80.0,这个很重要,我们进入网站直接下载最新版即可!

安装 Node.js


我们同样的点击进入 Node.js 的下载网站,直接点击 Get 即可,在安装完成后,我们可以打开终端去查看安装完成之后的Node.js和npm的版本

bash 复制代码
node -v
npm -v

安装 JSAR DevTools


安装 JSAR DevTools 有两种方法,我这里更推荐直接使用 Visual Studio Code 去拓展中直接下载,这种直接明了,直接安装即可

JSAR项目开发流程


项目初始化


根据官方文档解析,项目初始化一共有两种方法:

1. 通过 npm 创建 2. 通过 GitHub Template 创建

通过 npm 创建


使用 npm 快速创建,只需要打开终端输入:

bash 复制代码
npm init @yodaos-jsar/widget

输入完成后等待,按照提示进行操作,等待工具自动拉取最新的项目模板M-CreativeLab/template-for-jsar-widget来初始化,同时会获得一个packge.json,初始化成功后,我们需要进入对应目录安装依赖

bash 复制代码
npm install  # 提供代码补全

通过 GitHub Template 创建


使用 GitHub Template 创建,我们需要进入M-CreativeLab/template-for-jsar-widget 使用模板创建一个新的项目,填写对用信息,创建完成后,同样是一个JSAR空间小程序项目

以下是官方提供的两个参考示例

同时官方指出如果通过 GitHub 项目创建的 JSAR 空间小程序,推荐大家给项目添加 "jsar-widget" 的主题(Topic),这样可以方便 JSAR 开发团队以及社区在 GitHub #jsar-widget 上发现我们的项目

项目结构解析


1. packge.json 2. main.xsml

在 Rokid JSAR 开发中,packge.json 和 main.xsml 是项目的 "基石文件"------ 前者负责管理项目依赖与构建配置,是前端工程化的核心;后者作为空间场景的入口文件,定义了 AR 交互的核心结构。两者分工明确又相互配合,共同支撑起 JSAR 项目的运行与开发。

  • packge.json
javascript 复制代码
{
  "name": "jsar-Rokid", // 名称
  "displayName": "Display Name",
  "version": "1.0.0",  // 版本
  "description": "The template widget", // 描述
  "main": "main.xsml",  // 指向 xsml 入口
  "dependencies": {
    "three": "^0.180.0" // 类型支持
  }
}

这些配置会被 JSAR 构建工具和设备运行时读取,用于优化项目的兼容性与运行效率

  • main.xsml main.xsml是 JSAR 项目的空间场景入口文件,相当于普通前端项目的 index.html,但核心作用是 "定义 3D 空间结构与虚实交互的初始状态" main.xsml的 标签负责引入外部资源(JS 脚本、CSS 样式、3D 模型),并建立 "空间元素" 与 "交互逻辑" 的关联:
    • 引入的 index.js 脚本中,可通过 JSAR-DOM API 操作 main.xsml 中定义的 carmodel infopanel 等元素;

项目运行预览


vscode本地预览 在vscode中提供了本地预览场景,我们只需要打开main.xsml文件,点击右上角的【场景视图】按钮

在打开场景视图后,有两个功能按钮:

    • 重置位置,将场景视图的位置重置到原点
    • 刷新,重新加载场景视图 可以在需要时,点击这两个按钮。另外,当我们通过编辑器修改了项目文件时,场景视图会自动刷新。

Web 浏览器 其实JSAR 提供了一个在浏览器中即可打开的场景视图,来运行你的项目,但是需要在本地启动一个 http 服务

bash 复制代码
npm install serve -g

安装serve 后,进入你的项目目录,运行:

bash 复制代码
serve -p 8080 --cors
bash 复制代码
https://m-creativelab.github.io/jsar-dom/?url=http://你的IP:端口/main.xsml

进入之后,点击「Enter AR」按钮,即可在浏览器中模拟 Rokid 设备的 AR 沉浸式体验。

Rokid JSAR 开发初体验


抱着 "试试看" 的心态,我从环境搭建开始,一步步尝试加载 3D 模型、实现基础交互,过程中既有 "原来 AR 开发可以这么轻量" 的惊喜,也踩过几个新手常遇的小坑。

  • lib中存放了 TypeScript源码,核心渲染
  • model中存放了 3d模型 红色小汽车
  • main.html:前端页面,整合入口与脚本加载

核心代码展示


javascript 复制代码
// 简单的 three.js 初始化与 glTF (GLB) 加载器
// 目标:作为项目的最小可用实现 --- 若要实际运行,请先 `npm install` three

import type {
  WebGLRenderer,
  Scene,
  PerspectiveCamera,
  OrbitControls,
  Object3D,
  HemisphereLight,
  DirectionalLight,
  GridHelper,
  Vector3,
  Box3
} from 'three';
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader';

export type InitOptions = {
  canvas?: HTMLCanvasElement | string;
  modelUrl?: string;
  background?: string;
};

let THREE: typeof import('three') | null = null;
let renderer: WebGLRenderer | null = null;
let scene: Scene | null = null;
let camera: PerspectiveCamera | null = null;
let controls: OrbitControls | null = null;
let model: Object3D | null = null;  // 保存当前加载的模型引用
let hemiLight: HemisphereLight | null = null;
let dirLight: DirectionalLight | null = null;
let gridHelper: GridHelper | null = null;

/**
 * 初始化Three.js场景、相机和渲染器
 */
export async function init(opts: InitOptions = {}) {
  // 清理可能存在的旧实例
  dispose();

  try {
    // 动态导入three.js核心库
    THREE = await import('three');
  } catch (error) {
    console.warn('Three.js 未安装。请运行 `npm install three` 以启用3D预览功能。', error);
    return;
  }

  const { canvas, background = '#222' } = opts;
  const canvasEl = getCanvasElement(canvas);

  // 创建渲染器
  renderer = new THREE.WebGLRenderer({ 
    canvas: canvasEl, 
    antialias: true,
    powerPreference: 'high-performance'
  });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); // 限制最大像素比以提高性能
  renderer.setClearColor(background);

  // 创建场景
  scene = new THREE.Scene();

  // 创建相机
  camera = new THREE.PerspectiveCamera(
    45, 
    window.innerWidth / window.innerHeight, 
    0.1, 
    1000
  );
  camera.position.set(0, 1.5, 3);

  // 添加光源
  addLights();

  // 添加网格辅助线
  gridHelper = new THREE.GridHelper(10, 10);
  scene.add(gridHelper);

  // 添加窗口大小调整事件监听
  window.addEventListener('resize', onResize);

  // 初始化控制器
  await initControls();

  // 启动动画循环
  animate();

  // 如果提供了模型URL,则加载模型
  if (opts.modelUrl) {
    try {
      await loadModelFromUrl(opts.modelUrl);
    } catch (error) {
      console.error('模型加载失败:', error);
    }
  }
}

/**
 * 从URL加载GLTF/GLB模型
 */
export async function loadModelFromUrl(
  url: string, 
  onProgress?: (percent: number) => void, 
  onError?: (err: Error) => void
): Promise<GLTF> {
  if (!THREE) {
    throw new Error('Three.js 尚未初始化,请先调用 init() 方法');
  }

  try {
    // 动态导入GLTFLoader
    const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
    const loader = new GLTFLoader();

    return new Promise((resolve, reject) => {
      loader.load(
        url,
        (gltf) => {
          // 移除现有模型
          if (model && scene) {
            scene.remove(model);
          }

          // 添加新模型到场景
          scene?.add(gltf.scene);
          model = gltf.scene;

          // 处理模型位置和相机视角
          setupModelAndCamera(gltf.scene);

          resolve(gltf);
        },
        (progressEvent) => {
          if (onProgress && progressEvent.lengthComputable) {
            const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
            onProgress(percent);
          }
        },
        (error) => {
          const err = error instanceof Error ? error : new Error(String(error));
          onError?.(err);
          reject(err);
        }
      );
    });
  } catch (error) {
    const err = error instanceof Error ? error : new Error(String(error));
    onError?.(err);
    throw err;
  }
}

/**
 * 动画循环
 */
function animate() {
  requestAnimationFrame(animate);
  
  // 更新控制器
  controls?.update();

  // 模型浮动动画
  if (model) {
    const bobbingHeight = 0.1;
    const baseY = model.userData.baseY || 0;
    model.position.y = baseY + Math.sin(Date.now() * 0.005) * bobbingHeight;
  }

  // 渲染场景
  if (renderer && scene && camera) {
    renderer.render(scene, camera);
  }
}

/**
 * 窗口大小调整处理
 */
function onResize() {
  if (!camera || !renderer) return;
  
  const width = window.innerWidth;
  const height = window.innerHeight;
  
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height);
}

/**
 * 初始化轨道控制器
 */
async function initControls() {
  if (!THREE || !camera || !renderer) return;

  try {
    const { OrbitControls } = await import('three/examples/jsm/controls/OrbitControls.js');
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.05;
    controls.screenSpacePanning = false;
    controls.minDistance = 0.1;
    controls.maxDistance = 500;
  } catch (error) {
    console.warn('无法加载OrbitControls,交互控制将不可用', error);
    controls = null;
  }
}

/**
 * 添加场景光源
 */
function addLights() {
  if (!THREE || !scene) return;

  // 半球光
  hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1.0);
  scene.add(hemiLight);

  // 方向光
  dirLight = new THREE.DirectionalLight(0xffffff, 1);
  dirLight.position.set(5, 10, 7.5);
  scene.add(dirLight);
}

/**
 * 设置模型位置和相机视角
 */
function setupModelAndCamera(model: Object3D) {
  if (!THREE || !camera) return;

  // 计算模型包围盒
  const box = new THREE.Box3().setFromObject(model);
  const center = box.getCenter(new THREE.Vector3());
  const size = box.getSize(new THREE.Vector3());
  const radius = size.length() * 0.5;

  // 将模型居中到原点
  model.position.sub(center);

  // 调整模型位置使其底部贴合地面
  const bbox = new THREE.Box3().setFromObject(model);
  const minY = bbox.min.y;
  if (minY < 0) {
    const baseY = -minY;
    model.position.y = baseY;
    model.userData.baseY = baseY; // 保存基础高度供动画使用
  }

  // 调整相机位置以完整显示模型
  const fov = camera.fov * (Math.PI / 180); // 转换为弧度
  const distance = radius / Math.sin(fov / 2) || radius * 2;
  
  // 放置相机到模型前上方
  camera.position.set(0, radius * 0.6, distance * 1.2);
  camera.lookAt(new THREE.Vector3(0, 0, 0));
  camera.updateProjectionMatrix();
}

/**
 * 获取Canvas元素
 */
function getCanvasElement(canvas?: HTMLCanvasElement | string): HTMLCanvasElement | undefined {
  if (!canvas) return undefined;
  
  if (typeof canvas === 'string') {
    const element = document.querySelector<HTMLCanvasElement>(canvas);
    if (!element) {
      console.warn(`未找到选择器为 "${canvas}" 的Canvas元素`);
      return undefined;
    }
    return element;
  }
  
  return canvas;
}

/**
 * 清理Three.js资源
 */
export function dispose() {
  // 移除事件监听
  window.removeEventListener('resize', onResize);

  // 清理控制器
  if (controls) {
    controls.dispose();
    controls = null;
  }

  // 清理场景中的对象
  if (scene) {
    scene.remove(hemiLight as Object3D);
    scene.remove(dirLight as Object3D);
    scene.remove(gridHelper as Object3D);
    scene.remove(model as Object3D);
  }

  // 清理渲染器
  if (renderer) {
    try {
      renderer.dispose();
      // 强制释放WebGL上下文(如果支持)
      if (typeof renderer.forceContextLoss === 'function') {
        renderer.forceContextLoss();
      }
    } catch (error) {
      console.warn('渲染器清理过程中发生错误:', error);
    }
    renderer = null;
  }

  // 重置所有变量
  scene = null;
  camera = null;
  model = null;
  hemiLight = null;
  dirLight = null;
  gridHelper = null;
  THREE = null;
}
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>3D 模型展示</title>
    <!-- 引入 Tailwind CSS -->
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- 引入 Font Awesome 图标 -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <!-- Import map: 映射裸模块名到本地模块,便于直接在浏览器中使用 node_modules 的 ES module -->
    <script type="importmap">
        {
            "imports": {
                "three": "/node_modules/three/build/three.module.js",
                "three/": "/node_modules/three/"
            }
        }
    </script>
    <style type="text/tailwindcss">
        @layer utilities {
            .content-auto {
                content-visibility: auto;
            }
            .progress-transition {
                transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            }
        }
    </style>
</head>
<body class="bg-gray-900 text-white">
    <div id="app" class="flex flex-col h-screen">
        <!-- 控制栏 -->
        <div id="controls" class="bg-gray-800 px-4 py-3 flex items-center justify-between shadow-lg">
            <div class="flex items-center space-x-3">
                <button id="loadBtn" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 flex items-center">
                    <i class="fas fa-download mr-2"></i> 加载示例模型
                </button>
                <input 
                    id="modelUrl" 
                    placeholder="输入 glb URL 或留空使用 ./model/red_car.glb" 
                    class="bg-gray-700 text-white border border-gray-600 rounded-md px-3 py-2 w-64 focus:outline-none focus:ring-2 focus:ring-blue-500"
                />
            </div>
            <div class="flex items-center space-x-4">
                <span id="status" class="text-blue-300 font-medium">就绪</span>
                <div id="progressBar" class="w-48 h-2 bg-gray-700 rounded-full overflow-hidden">
                    <div id="progressFill" class="h-full bg-blue-500 progress-transition" style="width: 0%"></div>
                </div>
            </div>
        </div>

        <!-- 错误提示 -->
        <div id="errorMsg" class="bg-red-900 text-red-300 px-4 py-2 hidden">加载出错</div>

        <!-- 模型展示区域 -->
        <div id="viewer" class="flex-1 relative">
            <canvas id="glCanvas" class="w-full h-full"></canvas>
            <!-- 加载中遮罩(可选,可根据需要显示) -->
            <div id="loadingOverlay" class="absolute inset-0 bg-gray-900/70 flex items-center justify-center hidden">
                <div class="text-center">
                    <div class="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
                    <p class="mt-3 text-blue-300">模型加载中...</p>
                </div>
            </div>
        </div>
    </div>

    <script type="module">
        // 导入Three.js初始化和模型加载函数
        import { init, loadModelFromUrl } from './lib/index.js';

        // 初始化渲染器并挂载到canvas元素
        init({ 
            canvas: '#glCanvas', 
            background: '#0b1220' 
        });

        // 获取DOM元素引用
        const statusEl = document.getElementById('status');
        const progressFill = document.getElementById('progressFill');
        const errorEl = document.getElementById('errorMsg');
        const modelUrlInput = document.getElementById('modelUrl');
        const loadingOverlay = document.getElementById('loadingOverlay');

        // 加载按钮点击事件处理
        document.getElementById('loadBtn').addEventListener('click', async () => {
            // 获取模型URL,使用默认值如果输入为空
            const url = modelUrlInput.value.trim() || './model/red_car.glb';
            
            // 显示加载遮罩
            loadingOverlay.classList.remove('hidden');
            
            // 重置UI状态
            resetUI();
            
            try {
                // 加载模型并处理进度更新
                await loadModelFromUrl(
                    url, 
                    handleProgress,
                    handleError
                );
                
                // 加载成功更新UI
                statusEl.textContent = '加载完成';
                progressFill.style.width = '100%';
                // 隐藏加载遮罩
                setTimeout(() => {
                    loadingOverlay.classList.add('hidden');
                }, 300);
            } catch (error) {
                // 捕获异常并显示错误信息
                console.error('模型加载失败:', error);
                handleError(error);
                // 隐藏加载遮罩
                loadingOverlay.classList.add('hidden');
            }
        });

        /**
         * 重置UI状态到初始状态
         */
        function resetUI() {
            errorEl.classList.add('hidden');
            errorEl.textContent = '';
            statusEl.textContent = '开始加载...';
            progressFill.style.width = '0%';
        }

        /**
         * 处理加载进度更新
         * @param {number} percent - 加载进度百分比
         */
        function handleProgress(percent) {
            statusEl.textContent = `加载中 ${percent}%`;
            progressFill.style.width = `${percent}%`;
        }

        /**
         * 处理加载错误
         * @param {Error} error - 错误对象
         */
        function handleError(error) {
            errorEl.classList.remove('hidden');
            errorEl.textContent = `加载出错: ${error.message || String(error)}`;
            statusEl.textContent = '加载错误';
        }
    </script>
</body>
</html>
xml 复制代码
{
  "name": "jsar-Rokid",
  "displayName": "Display Name",
  "version": "1.0.0",
  "description": "The template widget",
  "main": "main.xsml",
  "scripts": {
    "build": "tsc",
    "start": "npx http-server -c-1 ./ -p 8080"  // 新增start,默认使用http启动端口8080
  },
  "files": [
    "icon.png",
    "main.xsml",
    "lib/*.ts",
    "model/red_car.glb"
  ],
  "icon3d": {
    "base": "./model/red_car.glb"
  },
  "author": "",
  "license": "Apache-2.0",
  "devDependencies": {},
  "dependencies": {
    "three": "^0.180.0"
  }
}

<xsml version="1.0">
  <head>
    <title>JSAR Widget</title>
    <link id="model" rel="mesh" type="octstream/glb" href="./model/red_car.glb" />
    <script src="./lib/main.ts"></script>
  </head>
  <space>
    <mesh id="model" ref="model" selector="__root__" />
  </space>
</xsml>

项目运行浏览


  • 本地浏览

  • Web 运行

  1. 安装依赖:npm install
  2. 编译TypeScript:npx tsc --project tsconfig.json
  3. 启动服务器:npm start
  4. 访问 Web 页面:http://127.0.0.1:8080/main.html

点击"加载示例模型"按钮,或输入自定义glb模型URL加载。

说真的,接触 JSAR 之前我还怕 AR 开发会很复杂,结果上手后发现,它完全是照着 "让前端少走弯路" 来设计的,优点真的很实在,开发效率也是真的高!加载 3D 模型?一行 标签就搞定,不用自己写模型解析逻辑;做空间交互?绑定个 click 事件就跟网页按钮一样简单,整个体验下来是非常不错的!点赞!

相关推荐
wearegogog12337 分钟前
基于 MATLAB 的卡尔曼滤波器实现,用于消除噪声并估算信号
前端·算法·matlab
Drawing stars44 分钟前
JAVA后端 前端 大模型应用 学习路线
java·前端·学习
品克缤1 小时前
Element UI MessageBox 增加第三个按钮(DOM Hack 方案)
前端·javascript·vue.js
小二·1 小时前
Python Web 开发进阶实战:性能压测与调优 —— Locust + Prometheus + Grafana 构建高并发可观测系统
前端·python·prometheus
小沐°1 小时前
vue-设置不同环境的打包和运行
前端·javascript·vue.js
qq_419854052 小时前
CSS动效
前端·javascript·css
烛阴2 小时前
3D字体TextGeometry
前端·webgl·three.js
桜吹雪2 小时前
markstream-vue实战踩坑笔记
前端
C_心欲无痕2 小时前
nginx - 实现域名跳转的几种方式
运维·前端·nginx
花哥码天下3 小时前
恢复网站console.log的脚本
前端·javascript·vue.js