用 JSAR 让天气"活"起来:我的 AR 天气可视化之旅

75 阅读18分钟

用 JSAR 让天气"活"起来:我的 AR 天气可视化之旅

用 JSAR 让天气"活"起来:我的 AR 天气可视化之旅

开篇碎碎念

作为一个经常看天气预报的人,我一直觉得传统天气 APP 虽然功能齐全,但总缺了点什么——那些数字和小图标确实能传达信息,但总感觉不够直观。

直到我接触了 Rokid 的 JSAR 框架,一个想法突然冒出来:能不能把天气数据变成眼前可以"看到"、"感受到"的 3D 场景?

于是就有了这个项目。现在我可以在虚拟空间里"站在"雨中、"走进"雪天,温度用颜色告诉我冷暖,湿度用环形光晕表现浓淡。这是一种全新的天气数据呈现方式。

关于 Rokid AR 眼镜

Rokid 是国内领先的 AR(增强现实)硬件厂商,其 AR 眼镜系列产品能够将虚拟内容叠加在真实世界之上。配合 Rokid 自研的 JSAR(JavaScript Spatial Application Runtime)框架,开发者可以使用熟悉的 Web 技术栈(JavaScript + Babylon.js)快速构建空间计算应用,无需学习复杂的 Unity 或 Unreal 引擎。

JSAR 的最大优势在于降低了 AR 开发门槛:传统 AR 开发需要掌握 C#、C++ 等语言,而 JSAR 让前端开发者也能轻松上手。更重要的是,开发过程中即使没有 AR 设备,也能通过浏览器预览和调试,极大提升了开发效率。

image.png Rokid AR 眼镜 - 图片来源:Rokid 官网

image.png


灵感来源:为什么要重新发明天气 APP

传统天气应用的局限

传统天气应用主要通过数字和图标呈现信息:温度、湿度、风速等都是抽象的数值。虽然这些数据准确,但缺乏视觉反馈,不够直观。

如果天气可以"看见"呢?

我想要的天气预报是这样的:

🌡️ 温度 → 一根会变色的柱子,从蓝到红
💧 湿度 → 空气中的雾气浓度
💨 风速 → 真的看到风在吹
🌧️ 下雨 → 雨滴从天上落下来

Rokid AR + JSAR = 梦想成真!


技术选型:为什么是 JSAR?

老实说,刚开始我想用 Unity 做。但学了两天就放弃了——太复杂了!光是搭建 AR 环境就要配置一堆东西。

然后发现了 JSAR 这个宝藏框架:

优点一:Web 技术栈

不用学 C#,JavaScript 就行。我本来就是前端开发,直接上手。

优点二:Babylon.js 加持

内置了强大的 3D 引擎,粒子系统、光照、材质,应有尽有。

优点三:开发效率高

写个.xsml 文件,刷新浏览器就能看效果。不像 Unity 每次都要 Build 半天。

优点四:为 Rokid 定制

专门为 Rokid AR 眼镜优化的,性能有保证。

最重要的是:无脑上手,30 分钟就能跑出第一个 Demo


动手实践:从零到完整系统

我的开发思路是"先跑起来,再慢慢完善"。所以把整个过程拆成了 5 个小步骤,每一步都能看到成果。这样不会中途放弃(笑)。

第一步:搭个舞台(10 分钟)

最基础的东西:地面、天空、灯光。就像舞台布景一样。

<xsml version="1.0">
  <head>
    <title>步骤1:基础场景</title>
    <script>
      console.log('🌍 3D天气可视化系统 - 步骤1:基础场景');

      try {
        const scene = spatialDocument.scene;

        // 清理现有对象
        scene.meshes.forEach(mesh => mesh.dispose());
        scene.lights.forEach(light => light.dispose());

        // 设置相机 - 第一人称视角,平视
        if (scene.activeCamera) {
          scene.activeCamera.position = new BABYLON.Vector3(0, 1.6, -12);
          scene.activeCamera.setTarget(new BABYLON.Vector3(0, 0.6, 0));
          console.log('📷 相机设置完成:第一人称视角');
        }

        // 添加环境光
        const hemiLight = new BABYLON.HemisphericLight(
          'hemiLight',
          new BABYLON.Vector3(0, 1, 0),
          scene
        );
        hemiLight.intensity = 0.6;

        // 添加方向光(太阳光)
        const sunLight = new BABYLON.DirectionalLight(
          'sunLight',
          new BABYLON.Vector3(-1, -2, -1),
          scene
        );
        sunLight.intensity = 0.8;
        sunLight.position = new BABYLON.Vector3(20, 40, 20);

        console.log('💡 光源设置完成');

        // 创建地面
        const ground = BABYLON.MeshBuilder.CreateGround('ground', {
          width: 50,
          height: 50
        }, scene);

        const groundMaterial = new BABYLON.StandardMaterial('groundMat', scene);
        groundMaterial.diffuseColor = new BABYLON.Color3(0.3, 0.5, 0.3);
        groundMaterial.specularColor = new BABYLON.Color3(0.1, 0.1, 0.1);
        ground.material = groundMaterial;

        console.log('🏞️ 地面创建完成');

        // 设置背景色(替代天空盒,避免遮挡视线)
        scene.clearColor = new BABYLON.Color4(0.5, 0.7, 1, 1);

        console.log('☁️ 背景色设置完成');

        // 添加参考立方体(场景中心,视线高度)
        const centerCube = BABYLON.MeshBuilder.CreateBox('centerCube', {
          size: 1
        }, scene);
        centerCube.position.y = 0.6;

        const cubeMaterial = new BABYLON.StandardMaterial('cubeMat', scene);
        cubeMaterial.diffuseColor = new BABYLON.Color3(0.8, 0.8, 0.8);
        cubeMaterial.emissiveColor = new BABYLON.Color3(0.3, 0.3, 0.3);
        centerCube.material = cubeMaterial;

        console.log('📦 参考立方体创建完成(视线高度)');

        console.log('');
        console.log('✅ 基础场景搭建完成!');
        console.log('💡 下一步:添加天气数据显示(step2-weather-data.xsml)');

      } catch (error) {
        console.error('💥 场景创建失败:', error);
      }
    </script>
  </head>
  <space></space>
</xsml>

打开 VSCode,创建文件,粘贴代码,保存。然后用 JSAR 插件打开,就能看到一个简单的 3D 场景了。

image.png

第一步效果:绿色地面 + 蓝色天空

收获

  • 了解了 XSML 的基本结构
  • 知道怎么设置相机和光源
  • 学会了创建基础几何体

代码在文末完整提供,这里先讲思路。


第二步:把数据画出来(20 分钟)

有了舞台,该上演员了。我选了三种最直观的数据呈现方式:

🌡️ 温度 = 彩色柱子
  • 零下:蓝色(冰冷)
  • 0-15°C:青色(寒冷)
  • 15-25°C:绿色(舒适)
  • 25-35°C:黄色(温热)
  • 35°C 以上:红色(炎热)

柱子的高度也代表温度,一眼就能看出今天多热。

💧 湿度 = 旋转的环

湿度越高,环越亮、越不透明。就像空气中的水汽凝结成了光环。

💨 风速 = 飞舞的箭头

风速大,箭头就多;风速小,箭头就少。而且会左右飘动,模拟风吹的感觉。

<xsml version="1.0">
  <head>
    <title>步骤2:天气数据显示</title>
    <script>
      console.log('🌍 3D天气可视化系统 - 步骤2:数据显示');

      try {
        const scene = spatialDocument.scene;

        // 场景设置
        scene.meshes.forEach(mesh => mesh.dispose());
        scene.lights.forEach(light => light.dispose());

        if (scene.activeCamera) {
          scene.activeCamera.position = new BABYLON.Vector3(0, 1.6, -12);
          scene.activeCamera.setTarget(new BABYLON.Vector3(0, 0.6, 0));
          console.log('📷 相机设置完成:第一人称视角');
        }

        const hemiLight = new BABYLON.HemisphericLight('hemiLight',
          new BABYLON.Vector3(0, 1, 0), scene);
        hemiLight.intensity = 0.6;

        const sunLight = new BABYLON.DirectionalLight('sunLight',
          new BABYLON.Vector3(-1, -2, -1), scene);
        sunLight.intensity = 0.8;

        const ground = BABYLON.MeshBuilder.CreateGround('ground', {
          width: 50, height: 50
        }, scene);
        const groundMaterial = new BABYLON.StandardMaterial('groundMat', scene);
        groundMaterial.diffuseColor = new BABYLON.Color3(0.3, 0.5, 0.3);
        ground.material = groundMaterial;

        // 设置背景色
        scene.clearColor = new BABYLON.Color4(0.5, 0.7, 1, 1);

        console.log('🏞️ 基础场景完成');

        // 天气数据
        const weatherData = {
          city: '北京',
          weather: '晴天',
          temperature: 25,
          humidity: 60,
          windSpeed: 12
        };

        // 温度颜色映射
        function getTemperatureColor(temp) {
          if (temp < 0) return new BABYLON.Color3(0, 0.5, 1); // 蓝色
          if (temp < 15) return new BABYLON.Color3(0, 1, 1); // 青色
          if (temp < 25) return new BABYLON.Color3(0, 1, 0); // 绿色
          if (temp < 35) return new BABYLON.Color3(1, 1, 0); // 黄色
          return new BABYLON.Color3(1, 0.3, 0); // 红色
        }

        // 创建温度柱体
        const tempHeight = weatherData.temperature / 5;
        const tempBar = BABYLON.MeshBuilder.CreateCylinder('tempBar', {
          height: tempHeight,
          diameter: 1.5
        }, scene);
        tempBar.position = new BABYLON.Vector3(-4, 1.6, 0);

        const tempMaterial = new BABYLON.StandardMaterial('tempMat', scene);
        tempMaterial.diffuseColor = getTemperatureColor(weatherData.temperature);
        tempMaterial.emissiveColor = tempMaterial.diffuseColor.scale(0.3);
        tempBar.material = tempMaterial;

        console.log('🌡️ 温度柱体创建完成');

        // 创建湿度环
        const humidityRing = BABYLON.MeshBuilder.CreateTorus('humidityRing', {
          diameter: 3,
          thickness: 0.4,
          tessellation: 32
        }, scene);
        humidityRing.position = new BABYLON.Vector3(0, 1.6, 0);
        humidityRing.rotation.x = Math.PI / 2;

        const humidityMaterial = new BABYLON.StandardMaterial('humidityMat', scene);
        const humidityRatio = weatherData.humidity / 100;
        humidityMaterial.diffuseColor = new BABYLON.Color3(
          0.2,
          0.5 + humidityRatio * 0.5,
          1
        );
        humidityMaterial.emissiveColor = humidityMaterial.diffuseColor.scale(0.4);
        humidityMaterial.alpha = 0.3 + humidityRatio * 0.5;
        humidityRing.material = humidityMaterial;

        // 湿度环旋转动画
        scene.registerBeforeRender(() => {
          humidityRing.rotation.z += 0.01;
        });

        console.log('💧 湿度环创建完成');

        // 创建风速箭头
        const arrowCount = Math.floor(weatherData.windSpeed / 3);
        const arrows = [];

        for (let i = 0; i < arrowCount; i++) {
          const arrow = BABYLON.MeshBuilder.CreateCylinder(`arrow${i}`, {
            height: 2,
            diameterTop: 0,
            diameterBottom: 0.3
          }, scene);

          arrow.position = new BABYLON.Vector3(4, 1.6 + i * 0.5 - (arrowCount * 0.25), 0);
          arrow.rotation.z = -Math.PI / 2;

          const arrowMaterial = new BABYLON.StandardMaterial(`arrowMat${i}`, scene);
          arrowMaterial.diffuseColor = new BABYLON.Color3(1, 1, 1);
          arrowMaterial.emissiveColor = new BABYLON.Color3(0.5, 0.5, 0.5);
          arrow.material = arrowMaterial;

          arrows.push(arrow);
        }

        // 风速箭头移动动画
        let windOffset = 0;
        scene.registerBeforeRender(() => {
          windOffset += 0.05;
          arrows.forEach((arrow, i) => {
            arrow.position.x = 4 + Math.sin(windOffset + i * 0.6) * 1.5;
          });
        });

        console.log(`💨 风速箭头创建完成 (${arrowCount}个)`);

        // 打印天气数据
        console.log('');
        console.log('📊 当前天气数据:');
        console.log(`   城市:${weatherData.city}`);
        console.log(`   天气:${weatherData.weather}`);
        console.log(`   温度:${weatherData.temperature}°C`);
        console.log(`   湿度:${weatherData.humidity}%`);
        console.log(`   风速:${weatherData.windSpeed} km/h`);
        console.log('');
        console.log('✅ 天气数据可视化完成!');
        console.log('💡 下一步:添加天气粒子效果(step3-weather-effects.xsml)');

      } catch (error) {
        console.error('💥 创建失败:', error);
      }
    </script>
  </head>
  <space></space>
</xsml>

image.png

三种数据可视化元素

遇到的坑

一开始温度柱子是白色的,怎么调都不对。后来发现是忘了设置 emissiveColor(自发光)。Babylon.js 里,如果想让物体在暗处也有颜色,必须加自发光。

material.diffuseColor = color;        // 基础颜色
material.emissiveColor = color.scale(0.3); // 自发光(重要!)

第三步:让天气"动"起来(30 分钟)

数据有了,但还是静态的。真正的挑战来了:粒子系统

🌧️ 下雨效果

关键参数:

  • 粒子数量:3000 个(太少不逼真,太多卡)
  • 方向:向下(Y 轴负方向)
  • 重力:-9.8(模拟真实重力)
  • 速度:快(20-25)
  • 颜色:淡蓝色半透明
<xsml version="1.0">
  <head>
    <title>步骤3:天气粒子效果</title>
    <script>
      console.log('🌍 3D天气可视化系统 - 步骤3:粒子效果');

      try {
        const scene = spatialDocument.scene;

        // 基础场景设置
        scene.meshes.forEach(mesh => mesh.dispose());
        scene.lights.forEach(light => light.dispose());

        if (scene.activeCamera) {
          scene.activeCamera.position = new BABYLON.Vector3(0, 1.6, -12);
          scene.activeCamera.setTarget(new BABYLON.Vector3(0, 0.6, 0));
          console.log('📷 相机设置完成:第一人称视角');
        }

        const hemiLight = new BABYLON.HemisphericLight('hemiLight',
          new BABYLON.Vector3(0, 1, 0), scene);
        hemiLight.intensity = 0.6;

        const sunLight = new BABYLON.DirectionalLight('sunLight',
          new BABYLON.Vector3(-1, -2, -1), scene);
        sunLight.intensity = 0.8;
        sunLight.position = new BABYLON.Vector3(20, 40, 20);

        const ground = BABYLON.MeshBuilder.CreateGround('ground', {
          width: 50, height: 50
        }, scene);
        const groundMaterial = new BABYLON.StandardMaterial('groundMat', scene);
        groundMaterial.diffuseColor = new BABYLON.Color3(0.3, 0.5, 0.3);
        ground.material = groundMaterial;

        // 设置背景色
        scene.clearColor = new BABYLON.Color4(0.5, 0.7, 1, 1);

        console.log('🏞️ 基础场景完成');

        // 全局粒子系统引用
        let currentParticleSystem = null;

        // 1. 雨天粒子系统
        function createRainEffect() {
          const rainSystem = new BABYLON.ParticleSystem('rain', 3000, scene);

          rainSystem.particleTexture = new BABYLON.Texture(
            'https://playground.babylonjs.com/textures/flare.png',
            scene
          );

          rainSystem.emitter = new BABYLON.Vector3(0, 15, 0);
          rainSystem.minEmitBox = new BABYLON.Vector3(-15, 0, -15);
          rainSystem.maxEmitBox = new BABYLON.Vector3(15, 0, 15);

          rainSystem.color1 = new BABYLON.Color4(0.7, 0.7, 1, 0.8);
          rainSystem.color2 = new BABYLON.Color4(0.5, 0.5, 0.8, 0.6);
          rainSystem.colorDead = new BABYLON.Color4(0.3, 0.3, 0.5, 0);

          rainSystem.minSize = 0.05;
          rainSystem.maxSize = 0.15;

          rainSystem.minLifeTime = 1;
          rainSystem.maxLifeTime = 2;

          rainSystem.emitRate = 1000;

          rainSystem.direction1 = new BABYLON.Vector3(0, -20, 0);
          rainSystem.direction2 = new BABYLON.Vector3(0, -25, 0);

          rainSystem.gravity = new BABYLON.Vector3(0, -9.8, 0);

          rainSystem.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;

          rainSystem.start();
          return rainSystem;
        }

        // 2. 雪天粒子系统
        function createSnowEffect() {
          const snowSystem = new BABYLON.ParticleSystem('snow', 2000, scene);

          snowSystem.particleTexture = new BABYLON.Texture(
            'https://playground.babylonjs.com/textures/flare.png',
            scene
          );

          snowSystem.emitter = new BABYLON.Vector3(0, 15, 0);
          snowSystem.minEmitBox = new BABYLON.Vector3(-15, 0, -15);
          snowSystem.maxEmitBox = new BABYLON.Vector3(15, 0, 15);

          snowSystem.color1 = new BABYLON.Color4(1, 1, 1, 1);
          snowSystem.color2 = new BABYLON.Color4(0.9, 0.9, 1, 0.8);
          snowSystem.colorDead = new BABYLON.Color4(0.7, 0.7, 0.8, 0);

          snowSystem.minSize = 0.1;
          snowSystem.maxSize = 0.3;

          snowSystem.minLifeTime = 3;
          snowSystem.maxLifeTime = 5;

          snowSystem.emitRate = 500;

          snowSystem.direction1 = new BABYLON.Vector3(-2, -5, -2);
          snowSystem.direction2 = new BABYLON.Vector3(2, -8, 2);

          snowSystem.gravity = new BABYLON.Vector3(0, -2, 0);

          snowSystem.blendMode = BABYLON.ParticleSystem.BLENDMODE_STANDARD;

          snowSystem.start();
          return snowSystem;
        }

        // 3. 雾气粒子系统
        function createFogEffect() {
          const fogSystem = new BABYLON.ParticleSystem('fog', 1000, scene);

          fogSystem.particleTexture = new BABYLON.Texture(
            'https://playground.babylonjs.com/textures/cloud.png',
            scene
          );

          fogSystem.emitter = new BABYLON.Vector3(0, 2, 0);
          fogSystem.minEmitBox = new BABYLON.Vector3(-10, -1, -10);
          fogSystem.maxEmitBox = new BABYLON.Vector3(10, 1, 10);

          fogSystem.color1 = new BABYLON.Color4(0.7, 0.7, 0.7, 0.3);
          fogSystem.color2 = new BABYLON.Color4(0.8, 0.8, 0.8, 0.2);
          fogSystem.colorDead = new BABYLON.Color4(0.6, 0.6, 0.6, 0);

          fogSystem.minSize = 3;
          fogSystem.maxSize = 6;

          fogSystem.minLifeTime = 10;
          fogSystem.maxLifeTime = 15;

          fogSystem.emitRate = 50;

          fogSystem.direction1 = new BABYLON.Vector3(-1, 0.5, -1);
          fogSystem.direction2 = new BABYLON.Vector3(1, 1, 1);

          fogSystem.gravity = new BABYLON.Vector3(0, 0, 0);

          fogSystem.blendMode = BABYLON.ParticleSystem.BLENDMODE_STANDARD;

          fogSystem.start();
          return fogSystem;
        }

        // 切换天气效果函数
        window.setWeatherEffect = function(type) {
          if (currentParticleSystem) {
            currentParticleSystem.stop();
            currentParticleSystem.dispose();
            currentParticleSystem = null;
          }

          switch(type) {
            case 'rain':
              currentParticleSystem = createRainEffect();
              console.log('🌧️ 雨天效果已启动');
              break;
            case 'snow':
              currentParticleSystem = createSnowEffect();
              console.log('❄️ 雪天效果已启动');
              break;
            case 'fog':
              currentParticleSystem = createFogEffect();
              console.log('🌫️ 雾天效果已启动');
              break;
            case 'clear':
              console.log('☀️ 晴天效果(无粒子)');
              break;
            default:
              console.log('⚠️ 未知天气类型');
          }
        };

        // 默认显示雨天效果
        setWeatherEffect('rain');

        console.log('');
        console.log('✅ 天气粒子系统创建完成!');
        console.log('');
        console.log('🎮 控制命令:');
        console.log('   setWeatherEffect("rain")  - 雨天');
        console.log('   setWeatherEffect("snow")  - 雪天');
        console.log('   setWeatherEffect("fog")   - 雾天');
        console.log('   setWeatherEffect("clear") - 晴天');
        console.log('');
        console.log('💡 下一步:多城市数据(step4-multi-cities.xsml)');

      } catch (error) {
        console.error('💥 创建失败:', error);
      }
    </script>
  </head>
  <space></space>
</xsml>

image.png 雨天效果:3000 个雨滴

❄️ 下雪效果

和雨的区别:

  • 粒子更大(雪花比雨滴大)
  • 速度更慢(飘落而不是砸落)
  • 横向漂移(风吹的效果)

image.png

雪天效果:雪花飘飘

调试心得

粒子系统最难的是调参数。我试了至少 20 种组合才找到满意的效果。建议:

  • 先从官方 Demo 抄参数
  • 一个一个改,看效果
  • 记录下好的组合

第四步:加入多城市(15 分钟)

一个城市太单调,我加了 5 个有代表性的:

城市
天气
为什么选它
北京
晴天
首都,经常蓝天
上海
雨天
魔都,梅雨季
哈尔滨
雪天
冰城,必须雪
成都
多云
阴天之都
广州
雾霾
南方潮湿
<xsml version="1.0">
  <head>
    <title>步骤4:多城市数据</title>
    <script>
      console.log('🌍 3D天气可视化系统 - 步骤4:多城市');

      try {
        const scene = spatialDocument.scene;

        // 基础场景
        scene.meshes.forEach(mesh => mesh.dispose());
        scene.lights.forEach(light => light.dispose());

        // 相机位置调整 - 第一人称视角
        if (scene.activeCamera) {
          scene.activeCamera.position = new BABYLON.Vector3(0, 1.6, -12);
          scene.activeCamera.setTarget(new BABYLON.Vector3(0, 0.6, 0));
          console.log('📷 相机设置完成:第一人称视角');
        }

        const hemiLight = new BABYLON.HemisphericLight('hemiLight',
          new BABYLON.Vector3(0, 1, 0), scene);
        hemiLight.intensity = 0.6;

        const sunLight = new BABYLON.DirectionalLight('sunLight',
          new BABYLON.Vector3(-1, -2, -1), scene);
        sunLight.intensity = 0.8;

        const ground = BABYLON.MeshBuilder.CreateGround('ground', {
          width: 50, height: 50
        }, scene);
        const groundMaterial = new BABYLON.StandardMaterial('groundMat', scene);
        groundMaterial.diffuseColor = new BABYLON.Color3(0.3, 0.5, 0.3);
        ground.material = groundMaterial;

        // 设置背景色
        scene.clearColor = new BABYLON.Color4(0.5, 0.7, 1, 1);

        console.log('🏞️ 基础场景完成');

        // 城市天气数据库
        const citiesData = [
          {
            name: '北京',
            weather: 'sunny',
            weatherName: '晴天',
            temperature: 25,
            humidity: 45,
            windSpeed: 12
          },
          {
            name: '上海',
            weather: 'rainy',
            weatherName: '雨天',
            temperature: 18,
            humidity: 85,
            windSpeed: 20
          },
          {
            name: '哈尔滨',
            weather: 'snow',
            weatherName: '雪天',
            temperature: -5,
            humidity: 70,
            windSpeed: 8
          },
          {
            name: '成都',
            weather: 'cloudy',
            weatherName: '多云',
            temperature: 22,
            humidity: 65,
            windSpeed: 6
          },
          {
            name: '广州',
            weather: 'foggy',
            weatherName: '雾霾',
            temperature: 28,
            humidity: 90,
            windSpeed: 4
          }
        ];

        let currentCityIndex = 0;

        // 可视化元素引用
        let tempBar = null;
        let humidityRing = null;
        let windArrows = [];
        let particleSystem = null;

        // 温度颜色映射
        function getTemperatureColor(temp) {
          if (temp < 0) return new BABYLON.Color3(0, 0.5, 1);
          if (temp < 15) return new BABYLON.Color3(0, 1, 1);
          if (temp < 25) return new BABYLON.Color3(0, 1, 0);
          if (temp < 35) return new BABYLON.Color3(1, 1, 0);
          return new BABYLON.Color3(1, 0.3, 0);
        }

        // 创建温度柱体
        function createTempBar(temp) {
          if (tempBar) tempBar.dispose();

          const height = Math.abs(temp) / 5 + 1;
          tempBar = BABYLON.MeshBuilder.CreateCylinder('tempBar', {
            height: height,
            diameter: 1.5
          }, scene);
          tempBar.position = new BABYLON.Vector3(-4, 1.6, 0);

          const material = new BABYLON.StandardMaterial('tempMat', scene);
          material.diffuseColor = getTemperatureColor(temp);
          material.emissiveColor = material.diffuseColor.scale(0.4);
          tempBar.material = material;
        }

        // 创建湿度环
        function createHumidityRing(humidity) {
          if (humidityRing) humidityRing.dispose();

          humidityRing = BABYLON.MeshBuilder.CreateTorus('humidityRing', {
            diameter: 3,
            thickness: 0.4
          }, scene);
          humidityRing.position = new BABYLON.Vector3(0, 1.6, 0);
          humidityRing.rotation.x = Math.PI / 2;

          const material = new BABYLON.StandardMaterial('humidityMat', scene);
          const ratio = humidity / 100;
          material.diffuseColor = new BABYLON.Color3(0.2, 0.5 + ratio * 0.5, 1);
          material.emissiveColor = material.diffuseColor.scale(0.5);
          material.alpha = 0.4 + ratio * 0.4;
          humidityRing.material = material;
        }

        // 创建风速箭头
        function createWindArrows(windSpeed) {
          windArrows.forEach(arrow => arrow.dispose());
          windArrows = [];

          const count = Math.floor(windSpeed / 4) + 1;
          for (let i = 0; i < count; i++) {
            const arrow = BABYLON.MeshBuilder.CreateCylinder(`arrow${i}`, {
              height: 2,
              diameterTop: 0,
              diameterBottom: 0.4
            }, scene);

            arrow.position = new BABYLON.Vector3(4, 1.6 + i * 0.5 - (count * 0.25), 0);
            arrow.rotation.z = -Math.PI / 2;

            const material = new BABYLON.StandardMaterial(`arrowMat${i}`, scene);
            material.diffuseColor = new BABYLON.Color3(1, 1, 1);
            material.emissiveColor = new BABYLON.Color3(0.6, 0.6, 0.6);
            arrow.material = material;

            windArrows.push(arrow);
          }
        }

        // 创建天气粒子效果
        function createWeatherEffect(weatherType) {
          if (particleSystem) {
            particleSystem.stop();
            particleSystem.dispose();
            particleSystem = null;
          }

          if (weatherType === 'rainy') {
            particleSystem = new BABYLON.ParticleSystem('rain', 3000, scene);
            particleSystem.particleTexture = new BABYLON.Texture(
              'https://playground.babylonjs.com/textures/flare.png', scene);
            particleSystem.emitter = new BABYLON.Vector3(0, 15, 0);
            particleSystem.minEmitBox = new BABYLON.Vector3(-15, 0, -15);
            particleSystem.maxEmitBox = new BABYLON.Vector3(15, 0, 15);
            particleSystem.color1 = new BABYLON.Color4(0.7, 0.7, 1, 0.8);
            particleSystem.color2 = new BABYLON.Color4(0.5, 0.5, 0.8, 0.6);
            particleSystem.minSize = 0.05;
            particleSystem.maxSize = 0.15;
            particleSystem.minLifeTime = 1;
            particleSystem.maxLifeTime = 2;
            particleSystem.emitRate = 1000;
            particleSystem.direction1 = new BABYLON.Vector3(0, -20, 0);
            particleSystem.direction2 = new BABYLON.Vector3(0, -25, 0);
            particleSystem.gravity = new BABYLON.Vector3(0, -9.8, 0);
            particleSystem.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
            particleSystem.start();
          } else if (weatherType === 'snow') {
            particleSystem = new BABYLON.ParticleSystem('snow', 2000, scene);
            particleSystem.particleTexture = new BABYLON.Texture(
              'https://playground.babylonjs.com/textures/flare.png', scene);
            particleSystem.emitter = new BABYLON.Vector3(0, 15, 0);
            particleSystem.minEmitBox = new BABYLON.Vector3(-15, 0, -15);
            particleSystem.maxEmitBox = new BABYLON.Vector3(15, 0, 15);
            particleSystem.color1 = new BABYLON.Color4(1, 1, 1, 1);
            particleSystem.color2 = new BABYLON.Color4(0.9, 0.9, 1, 0.8);
            particleSystem.minSize = 0.1;
            particleSystem.maxSize = 0.3;
            particleSystem.minLifeTime = 3;
            particleSystem.maxLifeTime = 5;
            particleSystem.emitRate = 500;
            particleSystem.direction1 = new BABYLON.Vector3(-2, -5, -2);
            particleSystem.direction2 = new BABYLON.Vector3(2, -8, 2);
            particleSystem.gravity = new BABYLON.Vector3(0, -2, 0);
            particleSystem.start();
          }
        }

        // 更新场景显示
        function updateScene() {
          const cityData = citiesData[currentCityIndex];

          createTempBar(cityData.temperature);
          createHumidityRing(cityData.humidity);
          createWindArrows(cityData.windSpeed);
          createWeatherEffect(cityData.weather);

          console.log('');
          console.log('📊 当前城市天气:');
          console.log(`   🏙️ 城市:${cityData.name}`);
          console.log(`   🌤️ 天气:${cityData.weatherName}`);
          console.log(`   🌡️ 温度:${cityData.temperature}°C`);
          console.log(`   💧 湿度:${cityData.humidity}%`);
          console.log(`   💨 风速:${cityData.windSpeed} km/h`);
          console.log('');
        }

        // 切换到下一个城市
        window.nextCity = function() {
          currentCityIndex = (currentCityIndex + 1) % citiesData.length;
          updateScene();
        };

        // 切换到上一个城市
        window.previousCity = function() {
          currentCityIndex = (currentCityIndex - 1 + citiesData.length) % citiesData.length;
          updateScene();
        };

        // 切换到指定城市
        window.goToCity = function(index) {
          if (index >= 0 && index < citiesData.length) {
            currentCityIndex = index;
            updateScene();
          } else {
            console.log('⚠️ 城市索引超出范围');
          }
        };

        // 动画循环
        let windOffset = 0;
        scene.registerBeforeRender(() => {
          if (humidityRing) {
            humidityRing.rotation.z += 0.01;
          }

          windOffset += 0.05;
          windArrows.forEach((arrow, i) => {
            arrow.position.x = 4 + Math.sin(windOffset + i * 0.6) * 1.5;
          });
        });

        // 初始化显示第一个城市
        updateScene();

        console.log('✅ 多城市天气系统创建完成!');
        console.log('');
        console.log('🎮 控制命令:');
        console.log('   nextCity()      - 下一个城市');
        console.log('   previousCity()  - 上一个城市');
        console.log('   goToCity(0)     - 跳转到指定城市(0-4)');
        console.log('');
        console.log(`📍 城市列表:`);
        citiesData.forEach((city, i) => {
          console.log(`   ${i}: ${city.name} (${city.weatherName})`);
        });
        console.log('');
        console.log('💡 下一步:键盘控制(step5-keyboard-control.xsml)');

      } catch (error) {
        console.error('💥 创建失败:', error);
      }
    </script>
  </head>
  <space></space>
</xsml>

image.png

北京晴天:明亮干燥

image.png

哈尔滨雪天:冰天雪地


第五步:键盘控制(关键!)

这一步最重要!因为我暂时没有 Rokid 眼镜,全靠键盘调试。

我设计了非常方便的快捷键:

← →  或  A D  → 切换城市
1 2 3 4 5     → 直接跳转
H             → 显示帮助
<xsml version="1.0">
  <head>
    <title>步骤5:键盘控制</title>
    <script>
      console.log('🌍 3D天气可视化系统 - 步骤5:键盘控制');
      console.log('💡 点击页面后按键盘操作:← → 切换城市,1-5 直接跳转,H 帮助');

      try {
        const scene = spatialDocument.scene;

        // 基础场景
        scene.meshes.forEach(mesh => mesh.dispose());
        scene.lights.forEach(light => light.dispose());

        // 调整相机位置 - 第一人称视角
        if (scene.activeCamera) {
          scene.activeCamera.position = new BABYLON.Vector3(0, 1.6, -12);
          scene.activeCamera.setTarget(new BABYLON.Vector3(0, 0.6, 0));
          console.log('📷 相机设置完成:第一人称视角');
        }

        const hemiLight = new BABYLON.HemisphericLight('hemiLight',
          new BABYLON.Vector3(0, 1, 0), scene);
        hemiLight.intensity = 0.6;

        const sunLight = new BABYLON.DirectionalLight('sunLight',
          new BABYLON.Vector3(-1, -2, -1), scene);
        sunLight.intensity = 0.8;

        const ground = BABYLON.MeshBuilder.CreateGround('ground', {
          width: 50, height: 50
        }, scene);
        const groundMaterial = new BABYLON.StandardMaterial('groundMat', scene);
        groundMaterial.diffuseColor = new BABYLON.Color3(0.3, 0.5, 0.3);
        ground.material = groundMaterial;

        // 设置背景色
        scene.clearColor = new BABYLON.Color4(0.5, 0.7, 1, 1);

        // 城市数据
        const citiesData = [
          { name: '北京', weather: 'sunny', weatherName: '晴天', temperature: 25, humidity: 45, windSpeed: 12 },
          { name: '上海', weather: 'rainy', weatherName: '雨天', temperature: 18, humidity: 85, windSpeed: 20 },
          { name: '哈尔滨', weather: 'snow', weatherName: '雪天', temperature: -5, humidity: 70, windSpeed: 8 },
          { name: '成都', weather: 'cloudy', weatherName: '多云', temperature: 22, humidity: 65, windSpeed: 6 },
          { name: '广州', weather: 'foggy', weatherName: '雾霾', temperature: 28, humidity: 90, windSpeed: 4 }
        ];

        let currentCityIndex = 0;
        let tempBar = null;
        let humidityRing = null;
        let windArrows = [];
        let particleSystem = null;

        // 工具函数
        function getTemperatureColor(temp) {
          if (temp < 0) return new BABYLON.Color3(0, 0.5, 1);
          if (temp < 15) return new BABYLON.Color3(0, 1, 1);
          if (temp < 25) return new BABYLON.Color3(0, 1, 0);
          if (temp < 35) return new BABYLON.Color3(1, 1, 0);
          return new BABYLON.Color3(1, 0.3, 0);
        }

        function createTempBar(temp) {
          if (tempBar) tempBar.dispose();
          const height = Math.abs(temp) / 5 + 1;
          tempBar = BABYLON.MeshBuilder.CreateCylinder('tempBar', {
            height: height, diameter: 1.5
          }, scene);
          tempBar.position = new BABYLON.Vector3(-4, 1.6, 0);
          const material = new BABYLON.StandardMaterial('tempMat', scene);
          material.diffuseColor = getTemperatureColor(temp);
          material.emissiveColor = material.diffuseColor.scale(0.4);
          tempBar.material = material;
        }

        function createHumidityRing(humidity) {
          if (humidityRing) humidityRing.dispose();
          humidityRing = BABYLON.MeshBuilder.CreateTorus('humidityRing', {
            diameter: 3, thickness: 0.4
          }, scene);
          humidityRing.position = new BABYLON.Vector3(0, 1.6, 0);
          humidityRing.rotation.x = Math.PI / 2;
          const material = new BABYLON.StandardMaterial('humidityMat', scene);
          const ratio = humidity / 100;
          material.diffuseColor = new BABYLON.Color3(0.2, 0.5 + ratio * 0.5, 1);
          material.emissiveColor = material.diffuseColor.scale(0.5);
          material.alpha = 0.4 + ratio * 0.4;
          humidityRing.material = material;
        }

        function createWindArrows(windSpeed) {
          windArrows.forEach(arrow => arrow.dispose());
          windArrows = [];
          const count = Math.floor(windSpeed / 4) + 1;
          for (let i = 0; i < count; i++) {
            const arrow = BABYLON.MeshBuilder.CreateCylinder(`arrow${i}`, {
              height: 2, diameterTop: 0, diameterBottom: 0.4
            }, scene);
            arrow.position = new BABYLON.Vector3(4, 1.6 + i * 0.5 - (count * 0.25), 0);
            arrow.rotation.z = -Math.PI / 2;
            const material = new BABYLON.StandardMaterial(`arrowMat${i}`, scene);
            material.diffuseColor = new BABYLON.Color3(1, 1, 1);
            material.emissiveColor = new BABYLON.Color3(0.6, 0.6, 0.6);
            arrow.material = material;
            windArrows.push(arrow);
          }
        }

        function createWeatherEffect(weatherType) {
          if (particleSystem) {
            particleSystem.stop();
            particleSystem.dispose();
            particleSystem = null;
          }

          if (weatherType === 'rainy') {
            particleSystem = new BABYLON.ParticleSystem('rain', 3000, scene);
            particleSystem.particleTexture = new BABYLON.Texture(
              'https://playground.babylonjs.com/textures/flare.png', scene);
            particleSystem.emitter = new BABYLON.Vector3(0, 15, 0);
            particleSystem.minEmitBox = new BABYLON.Vector3(-15, 0, -15);
            particleSystem.maxEmitBox = new BABYLON.Vector3(15, 0, 15);
            particleSystem.color1 = new BABYLON.Color4(0.7, 0.7, 1, 0.8);
            particleSystem.color2 = new BABYLON.Color4(0.5, 0.5, 0.8, 0.6);
            particleSystem.minSize = 0.05;
            particleSystem.maxSize = 0.15;
            particleSystem.minLifeTime = 1;
            particleSystem.maxLifeTime = 2;
            particleSystem.emitRate = 1000;
            particleSystem.direction1 = new BABYLON.Vector3(0, -20, 0);
            particleSystem.direction2 = new BABYLON.Vector3(0, -25, 0);
            particleSystem.gravity = new BABYLON.Vector3(0, -9.8, 0);
            particleSystem.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
            particleSystem.start();
          } else if (weatherType === 'snow') {
            particleSystem = new BABYLON.ParticleSystem('snow', 2000, scene);
            particleSystem.particleTexture = new BABYLON.Texture(
              'https://playground.babylonjs.com/textures/flare.png', scene);
            particleSystem.emitter = new BABYLON.Vector3(0, 15, 0);
            particleSystem.minEmitBox = new BABYLON.Vector3(-15, 0, -15);
            particleSystem.maxEmitBox = new BABYLON.Vector3(15, 0, 15);
            particleSystem.color1 = new BABYLON.Color4(1, 1, 1, 1);
            particleSystem.color2 = new BABYLON.Color4(0.9, 0.9, 1, 0.8);
            particleSystem.minSize = 0.1;
            particleSystem.maxSize = 0.3;
            particleSystem.minLifeTime = 3;
            particleSystem.maxLifeTime = 5;
            particleSystem.emitRate = 500;
            particleSystem.direction1 = new BABYLON.Vector3(-2, -5, -2);
            particleSystem.direction2 = new BABYLON.Vector3(2, -8, 2);
            particleSystem.gravity = new BABYLON.Vector3(0, -2, 0);
            particleSystem.start();
          }
        }

        function updateScene() {
          const cityData = citiesData[currentCityIndex];
          createTempBar(cityData.temperature);
          createHumidityRing(cityData.humidity);
          createWindArrows(cityData.windSpeed);
          createWeatherEffect(cityData.weather);

          console.log('');
          console.log(`📍 ${cityData.name} - ${cityData.weatherName}`);
          console.log(`   🌡️ ${cityData.temperature}°C  💧 ${cityData.humidity}%  💨 ${cityData.windSpeed}km/h`);
        }

        // 键盘控制系统
        window.addEventListener('keydown', (event) => {
          console.log('🎹 按键:', event.key);

          switch(event.key) {
            case 'ArrowLeft':
            case 'a':
            case 'A':
              currentCityIndex = (currentCityIndex - 1 + citiesData.length) % citiesData.length;
              updateScene();
              console.log('⬅️ 上一个城市');
              break;

            case 'ArrowRight':
            case 'd':
            case 'D':
              currentCityIndex = (currentCityIndex + 1) % citiesData.length;
              updateScene();
              console.log('➡️ 下一个城市');
              break;

            case '1':
              currentCityIndex = 0;
              updateScene();
              break;
            case '2':
              currentCityIndex = 1;
              updateScene();
              break;
            case '3':
              currentCityIndex = 2;
              updateScene();
              break;
            case '4':
              currentCityIndex = 3;
              updateScene();
              break;
            case '5':
              currentCityIndex = 4;
              updateScene();
              break;

            case 'h':
            case 'H':
              console.log('');
              console.log('⌨️ 键盘控制说明:');
              console.log('   ← / A      - 上一个城市');
              console.log('   → / D      - 下一个城市');
              console.log('   1-5        - 直接跳转到城市');
              console.log('   H          - 显示此帮助');
              console.log('');
              break;
          }
        });

        // 动画循环
        let windOffset = 0;
        scene.registerBeforeRender(() => {
          if (humidityRing) {
            humidityRing.rotation.z += 0.01;
          }
          windOffset += 0.05;
          windArrows.forEach((arrow, i) => {
            arrow.position.x = 4 + Math.sin(windOffset + i * 0.6) * 1.5;
          });
        });

        // 初始化
        updateScene();

        console.log('✅ 键盘控制系统创建完成!');
        console.log('');
        console.log('⌨️ 键盘控制说明:');
        console.log('   ← / A      - 上一个城市');
        console.log('   → / D      - 下一个城市');
        console.log('   1-5        - 直接跳转到城市');
        console.log('   H          - 显示帮助');
        console.log('');
        console.log('📍 城市列表:');
        citiesData.forEach((city, i) => {
          console.log(`   ${i + 1}: ${city.name} (${city.weatherName})`);
        });
        console.log('');
        console.log('💡 如果按键没反应,请点击页面任意位置获取焦点!');

      } catch (error) {
        console.error('💥 创建失败:', error);
      }
    </script>
  </head>
  <space></space>
</xsml>

关键设计:所有元素都放在视线高度(y=1.6),模拟戴上 AR 眼镜平视前方的效果!

  • 左侧(x=-4):温度柱
  • 中间(x=0):湿度环
  • 右侧(x=4):风速箭头

这样即使没有 AR 眼镜,也能体验到真实的第一人称视角。

image.png

按键即可切换,超方便

调试技巧

  • 每次按键都会在控制台打印 🎹 按键: xxx,能看到是否接收到
  • 每次切换都打印城市信息,方便观察
  • 用 Emoji 让日志更好看(🌧️ 🌡️ 💧)
  • 按 H 随时查看帮助
  • 如果没反应,先点击页面!

完整版:集大成者

把所有功能整合到一起,再加点细节优化:

优化 1:根据天气调整光照

if (weather === 'rainy') {
  // 雨天:暗一点
  hemiLight.intensity = 0.3;
} else if (weather === 'sunny') {
  // 晴天:亮一点
  hemiLight.intensity = 0.8;
}

优化 2:动画更流畅

// 温度柱微微脉动
const pulse = Math.sin(time) * 0.03 + 1;
tempBar.scaling.x = pulse;

优化 3:清理内存

// 切换城市前销毁旧对象
if (oldParticleSystem) {
  oldParticleSystem.dispose(); // 很重要!
}

代码文件:weather-system.xsml

<xsml version="1.0">
  <head>
    <title>3D天气可视化系统 - 完整版</title>
    <script>
      console.log('🌍 3D天气可视化系统 - 完整版');
      console.log('💡 提示:点击页面后按键盘 ← → 切换城市,按 1-5 直接跳转,按 H 查看帮助');

      try {
        const scene = spatialDocument.scene;

        // 清理场景
        scene.meshes.forEach(mesh => mesh.dispose());
        scene.lights.forEach(light => light.dispose());

        // 设置相机 - 第一人称视角,平视
        if (scene.activeCamera) {
          scene.activeCamera.position = new BABYLON.Vector3(0, 1.6, -12);
          scene.activeCamera.setTarget(new BABYLON.Vector3(0, 0.6, 0));
          console.log('📷 相机设置完成:第一人称视角 - 位置(0, 1.6, -12) 目标(0, 1.6, 0)');
        }

        // 光源系统
        const hemiLight = new BABYLON.HemisphericLight('hemiLight',
          new BABYLON.Vector3(0, 1, 0), scene);
        hemiLight.intensity = 0.6;

        const sunLight = new BABYLON.DirectionalLight('sunLight',
          new BABYLON.Vector3(-1, -2, -1), scene);
        sunLight.intensity = 0.8;
        sunLight.position = new BABYLON.Vector3(20, 40, 20);

        console.log('💡 光源系统完成');

        // 地面
        const ground = BABYLON.MeshBuilder.CreateGround('ground', {
          width: 50,
          height: 50
        }, scene);
        const groundMaterial = new BABYLON.StandardMaterial('groundMat', scene);
        groundMaterial.diffuseColor = new BABYLON.Color3(0.3, 0.5, 0.3);
        groundMaterial.specularColor = new BABYLON.Color3(0.1, 0.1, 0.1);
        ground.material = groundMaterial;

        // 天空盒 - 暂时禁用,避免遮挡视线
        // const skybox = BABYLON.MeshBuilder.CreateBox('skybox', {
        //   size: 1000
        // }, scene);
        // const skyboxMaterial = new BABYLON.StandardMaterial('skyboxMat', scene);
        // skyboxMaterial.backFaceCulling = false;
        // skyboxMaterial.diffuseColor = new BABYLON.Color3(0, 0, 0);
        // skyboxMaterial.specularColor = new BABYLON.Color3(0, 0, 0);
        // skyboxMaterial.emissiveColor = new BABYLON.Color3(0.5, 0.7, 1);
        // skyboxMaterial.disableLighting = true;
        // skybox.material = skyboxMaterial;

        // 设置场景背景色代替天空盒
        scene.clearColor = new BABYLON.Color4(0.5, 0.7, 1, 1);

        console.log('🏞️ 场景环境完成');

        // 城市天气数据库
        const citiesData = [
          {
            name: '北京',
            weather: 'sunny',
            weatherName: '晴天',
            temperature: 25,
            humidity: 45,
            windSpeed: 12,
            icon: '☀️'
          },
          {
            name: '上海',
            weather: 'rainy',
            weatherName: '雨天',
            temperature: 18,
            humidity: 85,
            windSpeed: 20,
            icon: '🌧️'
          },
          {
            name: '哈尔滨',
            weather: 'snow',
            weatherName: '雪天',
            temperature: -5,
            humidity: 70,
            windSpeed: 8,
            icon: '❄️'
          },
          {
            name: '成都',
            weather: 'cloudy',
            weatherName: '多云',
            temperature: 22,
            humidity: 65,
            windSpeed: 6,
            icon: '☁️'
          },
          {
            name: '广州',
            weather: 'foggy',
            weatherName: '雾霾',
            temperature: 28,
            humidity: 90,
            windSpeed: 4,
            icon: '🌫️'
          }
        ];

        let currentCityIndex = 0;

        // 可视化元素引用
        let tempBar = null;
        let humidityRing = null;
        let windArrows = [];
        let particleSystem = null;

        // 温度颜色映射函数
        function getTemperatureColor(temp) {
          if (temp < 0) return new BABYLON.Color3(0, 0.5, 1);
          if (temp < 10) return new BABYLON.Color3(0, 0.8, 1);
          if (temp < 20) return new BABYLON.Color3(0, 1, 0.5);
          if (temp < 30) return new BABYLON.Color3(0, 1, 0);
          if (temp < 35) return new BABYLON.Color3(1, 1, 0);
          return new BABYLON.Color3(1, 0.3, 0);
        }

        // 创建温度柱体
        function createTempBar(temp) {
          if (tempBar) tempBar.dispose();

          const absTemp = Math.abs(temp);
          const height = absTemp / 4 + 1;

          tempBar = BABYLON.MeshBuilder.CreateCylinder('tempBar', {
            height: height,
            diameter: 1.5,
            tessellation: 32
          }, scene);
          // 抬高到视线高度 (y=1.6 是人眼高度)
          tempBar.position = new BABYLON.Vector3(-4, 1.6, 0);

          const material = new BABYLON.StandardMaterial('tempMat', scene);
          material.diffuseColor = getTemperatureColor(temp);
          material.emissiveColor = material.diffuseColor.scale(0.4);
          material.specularColor = new BABYLON.Color3(0.3, 0.3, 0.3);
          tempBar.material = material;

          console.log(`   🌡️ 温度柱已创建:高度=${height.toFixed(1)}, 位置=(-6, ${(height/2).toFixed(1)}, 0)`);
        }

        // 创建湿度环
        function createHumidityRing(humidity) {
          if (humidityRing) humidityRing.dispose();

          humidityRing = BABYLON.MeshBuilder.CreateTorus('humidityRing', {
            diameter: 3,
            thickness: 0.4,
            tessellation: 64
          }, scene);
          humidityRing.position = new BABYLON.Vector3(0, 1.6, 0);
          humidityRing.rotation.x = Math.PI / 2;

          const material = new BABYLON.StandardMaterial('humidityMat', scene);
          const ratio = humidity / 100;
          material.diffuseColor = new BABYLON.Color3(0.2, 0.5 + ratio * 0.5, 1);
          material.emissiveColor = material.diffuseColor.scale(0.5);
          material.alpha = 0.4 + ratio * 0.4;
          humidityRing.material = material;

          console.log(`   💧 湿度环已创建:直径=4, 位置=(0, 3, 0), 透明度=${material.alpha.toFixed(2)}`);
        }

        // 创建风速箭头
        function createWindArrows(windSpeed) {
          windArrows.forEach(arrow => arrow.dispose());
          windArrows = [];

          const count = Math.max(1, Math.floor(windSpeed / 4));
          for (let i = 0; i < count; i++) {
            const arrow = BABYLON.MeshBuilder.CreateCylinder(`arrow${i}`, {
              height: 2,
              diameterTop: 0,
              diameterBottom: 0.4
            }, scene);

            arrow.position = new BABYLON.Vector3(4, 1.6 + i * 0.5 - (count * 0.25), 0);
            arrow.rotation.z = -Math.PI / 2;

            const material = new BABYLON.StandardMaterial(`arrowMat${i}`, scene);
            material.diffuseColor = new BABYLON.Color3(1, 1, 1);
            material.emissiveColor = new BABYLON.Color3(0.6, 0.6, 0.6);
            arrow.material = material;

            windArrows.push(arrow);
          }
          console.log(`   💨 风速箭头已创建:${count}个, 位置=(6, 1.5~${(1.5 + (count-1) * 1).toFixed(1)}, 0)`);
        }

        // 创建天气粒子效果
        function createWeatherEffect(weatherType) {
          if (particleSystem) {
            particleSystem.stop();
            particleSystem.dispose();
            particleSystem = null;
          }

          switch(weatherType) {
            case 'rainy':
              particleSystem = new BABYLON.ParticleSystem('rain', 3500, scene);
              particleSystem.particleTexture = new BABYLON.Texture(
                'https://playground.babylonjs.com/textures/flare.png', scene);
              particleSystem.emitter = new BABYLON.Vector3(0, 15, 0);
              particleSystem.minEmitBox = new BABYLON.Vector3(-15, 0, -15);
              particleSystem.maxEmitBox = new BABYLON.Vector3(15, 0, 15);
              particleSystem.color1 = new BABYLON.Color4(0.7, 0.7, 1, 0.9);
              particleSystem.color2 = new BABYLON.Color4(0.5, 0.5, 0.9, 0.7);
              particleSystem.colorDead = new BABYLON.Color4(0.3, 0.3, 0.6, 0);
              particleSystem.minSize = 0.05;
              particleSystem.maxSize = 0.18;
              particleSystem.minLifeTime = 0.8;
              particleSystem.maxLifeTime = 1.8;
              particleSystem.emitRate = 1200;
              particleSystem.direction1 = new BABYLON.Vector3(-1, -22, -1);
              particleSystem.direction2 = new BABYLON.Vector3(1, -28, 1);
              particleSystem.gravity = new BABYLON.Vector3(0, -12, 0);
              particleSystem.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
              particleSystem.start();
              console.log('🌧️ 雨天效果');
              break;

            case 'snow':
              particleSystem = new BABYLON.ParticleSystem('snow', 2500, scene);
              particleSystem.particleTexture = new BABYLON.Texture(
                'https://playground.babylonjs.com/textures/flare.png', scene);
              particleSystem.emitter = new BABYLON.Vector3(0, 15, 0);
              particleSystem.minEmitBox = new BABYLON.Vector3(-15, 0, -15);
              particleSystem.maxEmitBox = new BABYLON.Vector3(15, 0, 15);
              particleSystem.color1 = new BABYLON.Color4(1, 1, 1, 1);
              particleSystem.color2 = new BABYLON.Color4(0.95, 0.95, 1, 0.9);
              particleSystem.colorDead = new BABYLON.Color4(0.8, 0.8, 0.9, 0);
              particleSystem.minSize = 0.12;
              particleSystem.maxSize = 0.35;
              particleSystem.minLifeTime = 4;
              particleSystem.maxLifeTime = 6;
              particleSystem.emitRate = 600;
              particleSystem.direction1 = new BABYLON.Vector3(-2, -4, -2);
              particleSystem.direction2 = new BABYLON.Vector3(2, -7, 2);
              particleSystem.gravity = new BABYLON.Vector3(0, -2.5, 0);
              particleSystem.blendMode = BABYLON.ParticleSystem.BLENDMODE_STANDARD;
              particleSystem.start();
              console.log('❄️ 雪天效果');
              break;

            case 'foggy':
            case 'cloudy':
              particleSystem = new BABYLON.ParticleSystem('fog', 800, scene);
              particleSystem.particleTexture = new BABYLON.Texture(
                'https://playground.babylonjs.com/textures/cloud.png', scene);
              particleSystem.emitter = new BABYLON.Vector3(0, 2, 0);
              particleSystem.minEmitBox = new BABYLON.Vector3(-10, -1, -10);
              particleSystem.maxEmitBox = new BABYLON.Vector3(10, 1, 10);
              particleSystem.color1 = new BABYLON.Color4(0.75, 0.75, 0.75, 0.35);
              particleSystem.color2 = new BABYLON.Color4(0.85, 0.85, 0.85, 0.25);
              particleSystem.colorDead = new BABYLON.Color4(0.7, 0.7, 0.7, 0);
              particleSystem.minSize = 3;
              particleSystem.maxSize = 6;
              particleSystem.minLifeTime = 12;
              particleSystem.maxLifeTime = 18;
              particleSystem.emitRate = 40;
              particleSystem.direction1 = new BABYLON.Vector3(-1.5, 0.5, -1.5);
              particleSystem.direction2 = new BABYLON.Vector3(1.5, 1.5, 1.5);
              particleSystem.gravity = new BABYLON.Vector3(0, 0, 0);
              particleSystem.blendMode = BABYLON.ParticleSystem.BLENDMODE_STANDARD;
              particleSystem.start();
              console.log('🌫️ 雾天/多云效果');
              break;

            case 'sunny':
            default:
              console.log('☀️ 晴天(无粒子)');
              break;
          }
        }

        // 更新整个场景
        function updateScene() {
          const cityData = citiesData[currentCityIndex];

          createTempBar(cityData.temperature);
          createHumidityRing(cityData.humidity);
          createWindArrows(cityData.windSpeed);
          createWeatherEffect(cityData.weather);

          // 根据天气调整光照和背景色
          if (cityData.weather === 'rainy' || cityData.weather === 'foggy') {
            hemiLight.intensity = 0.3;
            sunLight.intensity = 0.4;
            scene.clearColor = new BABYLON.Color4(0.3, 0.4, 0.5, 1);
          } else if (cityData.weather === 'snow') {
            hemiLight.intensity = 0.5;
            sunLight.intensity = 0.5;
            scene.clearColor = new BABYLON.Color4(0.6, 0.7, 0.8, 1);
          } else {
            hemiLight.intensity = 0.6;
            sunLight.intensity = 0.8;
            scene.clearColor = new BABYLON.Color4(0.5, 0.7, 1, 1);
          }

          console.log('');
          console.log(`${cityData.icon} ${cityData.name} - ${cityData.weatherName}`);
          console.log(`   🌡️ 温度: ${cityData.temperature}°C`);
          console.log(`   💧 湿度: ${cityData.humidity}%`);
          console.log(`   💨 风速: ${cityData.windSpeed} km/h`);
          console.log('');
        }

        // 键盘控制
        window.addEventListener('keydown', (event) => {
          switch(event.key) {
            case 'ArrowLeft':
            case 'a':
            case 'A':
              currentCityIndex = (currentCityIndex - 1 + citiesData.length) % citiesData.length;
              updateScene();
              console.log('⬅️ 切换到上一个城市');
              break;

            case 'ArrowRight':
            case 'd':
            case 'D':
              currentCityIndex = (currentCityIndex + 1) % citiesData.length;
              updateScene();
              console.log('➡️ 切换到下一个城市');
              break;

            case '1':
              currentCityIndex = 0;
              updateScene();
              break;
            case '2':
              currentCityIndex = 1;
              updateScene();
              break;
            case '3':
              currentCityIndex = 2;
              updateScene();
              break;
            case '4':
              currentCityIndex = 3;
              updateScene();
              break;
            case '5':
              currentCityIndex = 4;
              updateScene();
              break;

            case 'h':
            case 'H':
              console.log('');
              console.log('⌨️ 键盘控制:');
              console.log('   ← / →      - 切换城市');
              console.log('   A / D      - 切换城市');
              console.log('   1-5        - 直接跳转');
              console.log('   H          - 显示帮助');
              console.log('');
              break;
          }
        });

        // 动画循环
        let windOffset = 0;
        let rotationSpeed = 0;

        scene.registerBeforeRender(() => {
          // 湿度环旋转
          if (humidityRing) {
            rotationSpeed += 0.0001;
            humidityRing.rotation.z += 0.008 + rotationSpeed;
          }

          // 风速箭头移动
          windOffset += 0.06;
          windArrows.forEach((arrow, i) => {
            arrow.position.x = 4 + Math.sin(windOffset + i * 0.6) * 1.5;
          });

          // 温度柱体微脉动
          if (tempBar) {
            const pulse = Math.sin(Date.now() * 0.001) * 0.03 + 1;
            tempBar.scaling.x = pulse;
            tempBar.scaling.z = pulse;
          }
        });

        // 初始化
        updateScene();

        console.log('');
        console.log('✨ 3D天气可视化系统创建完成!');
        console.log('');
        console.log('⌨️ 键盘控制:');
        console.log('   ← / → (或 A / D)  - 切换城市');
        console.log('   1-5               - 直接跳转到城市');
        console.log('   H                 - 显示帮助');
        console.log('');
        console.log('📍 城市列表:');
        citiesData.forEach((city, i) => {
          console.log(`   ${i + 1}. ${city.icon} ${city.name} - ${city.weatherName}`);
        });
        console.log('');
        console.log('💡 如果按键没反应,请先点击页面任意位置!');

      } catch (error) {
        console.error('💥 系统创建失败:', error);
        console.error(error.stack);
      }
    </script>
  </head>
  <space>
    <!-- 3D天气可视化系统 -->
  </space>
</xsml>

image.png

最终完整版


在 Rokid 眼镜上是什么体验?

虽然我现在还没有实体眼镜(已经下单了!),但根据文档和社区反馈,应该是这样的:

沉浸感

戴上眼镜后,天气效果会叠加在真实世界上。比如:

  • 在办公室里,雨滴从天花板"落下来"
  • 在户外,虚拟的雪花和真实的风景融合
  • 温度柱就漂浮在你的桌子上

交互性

如果配合 Rokid 的手势识别:

  • 挥手切换城市
  • 捏合放大查看细节
  • 语音控制"切换到北京"

实用性

想象这些场景:

  • 早晨出门前:一眼看到今天的天气状况
  • 旅行规划:对比不同城市的天气
  • 新闻播报:主播身边展示立体天气
  • 教育场景:给孩子讲解气象知识

踩过的坑 & 解决方案

坑 1:粒子不显示

现象:代码没报错,但看不到粒子。

原因:纹理加载失败。

解决

// 加个加载监听
texture.onLoadObservable.add(() => {
  console.log('纹理加载成功!');
});

坑 2:键盘没反应

现象:按键没有任何响应。

原因:页面失去焦点了。

解决:点击一下页面,或者:

window.focus(); // 强制获取焦点

坑 3:切换城市时卡顿

现象:第一次很流畅,后面越来越卡。

原因:没销毁旧对象,越积越多。

解决

function cleanup() {
  if (oldMesh) oldMesh.dispose();
  if (oldParticle) oldParticle.dispose();
}

坑 4:温度柱颜色不对

现象:在黑暗环境下看不到颜色。

原因:只设置了 diffuseColor,没设置 emissiveColor。

解决

material.emissiveColor = color.scale(0.4); // 加自发光

性能优化心得

粒子数量控制

// 根据设备性能调整
const isMobile = /Mobile/.test(navigator.userAgent);
const count = isMobile ? 1500 : 3000;

按需加载

只在需要的时候创建粒子系统,不要一次性全创建。

材质复用

// 错误做法:每个箭头一个材质
for (let i = 0; i < 10; i++) {
  const mat = new Material(); // 浪费!
}

// 正确做法:共用一个材质
const sharedMat = new Material();
for (let i = 0; i < 10; i++) {
  arrow.material = sharedMat; // 高效!
}

总结与感想

技术收获

✅ 深入理解了 Babylon.js 粒子系统

✅ 掌握了 3D 数据可视化的设计思路

✅ 学会了 JSAR 的开发流程

✅ 熟悉了键盘交互的实现

开发体验

JSAR 真的很友好。作为一个前端开发者,我只用了一个周末就做出了这个项目。如果用 Unity,估计要学一个月。

粒子系统很好玩。调参数的过程就像画画,每改一个值都能看到不同的效果。虽然花时间,但很有成就感。

键盘控制是神器。没有 AR 设备也能完整开发,这点太重要了。不然每次调试都要戴眼镜,太累了。

给后来者的建议

  1. 从简单开始:先让代码跑起来,再慢慢加功能
  2. 多看文档:Babylon.js 文档很全,遇到问题先查文档
  3. 善用控制台:console.log 是最好的调试工具
  4. 参数多试:特别是粒子系统,没有完美公式,只能试
  5. 及时清理:记得 dispose(),不然内存泄漏

参考资料


最后的话

这个项目花了我两个周末的时间,但我觉得很值得。不仅学到了新技术,还做出了一个自己真正想用的东西。

Rokid + JSAR 给了 Web 开发者进入 AR 领域的钥匙。不需要学 C++、C#,不需要搞 Unity、Unreal,用你熟悉的 JavaScript 就能做出酷炫的 AR 应用。这才是降低门槛!

如果你也对 AR 开发感兴趣,强烈推荐试试 JSAR。真的没那么难,一个周末就能入门。

期待看到更多有创意的 JSAR 应用!