上手 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 事件就跟网页按钮一样简单,整个体验下来是非常不错的!点赞!

相关推荐
右子2 小时前
HTML Canvas API 技术简述与关系性指南
前端·javascript·canvas
Lotzinfly2 小时前
10个JavaScript浏览器API奇淫技巧你需要掌握😏😏😏
前端·javascript·面试
合肥烂南瓜2 小时前
浏览器的事件循环EventLoop
前端·面试
golang学习记2 小时前
从0死磕全栈之Next.js after 函数详解:在响应完成后执行异步任务
前端
TeleostNaCl2 小时前
实战 | 使用 Chrome 开发者工具修改网页源码跳过前端校验
前端·chrome·经验分享·后端·js
阿星AI工作室3 小时前
1分钟搞定高级感PPT演示!Obsidian+Excalidraw神级玩法,手残党亲测有效
前端
liangshanbo12153 小时前
React 19 新特性:原生支持在组件中渲染 <meta> 与 <link>
前端·javascript·react.js
浩男孩3 小时前
🍀发现个有趣的工具可以用来随机头像🚀🚀
前端
前端 贾公子3 小时前
《Vuejs设计与实现》第 18 章(同构渲染)(下)
前端·javascript·html