手写一个动态海洋和天空效果的vue hooks

背景

常规的后台管理系统登陆页面可能就只是一个简单的背景页面,这不太好看,接下来让我们来使用three.js来实现一个动态的海洋和天空效果当作背景,这样的效果总会让人眼前一亮,如下图所示。

代码实现

接下来,让我们用trae来编写实现这个功能吧。

1. 组合式 API 初始化

ts 复制代码
import { onMounted, onBeforeUnmount } from "vue";
import * as THREE from "three";
import { Water } from "three/examples/jsm/objects/Water.js";
import { Sky } from "three/examples/jsm/objects/Sky.js";
  • Vue 组合式 API :使用 onMountedonBeforeUnmount 来处理组件的生命周期。在组件挂载时初始化场景,卸载时清理资源。
  • Three.js 导入 :导入 THREE 来处理 3D 渲染,WaterSky 分别处理水面和天空的效果。

2. 初始化 Three.js 场景

ts 复制代码
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let water: any;
let sun: THREE.Vector3;
let sky: any;
let animationFrameId: number;
  • 变量声明 :在 useOcean 函数中声明了多个变量,用于保存 Three.js 的场景、相机、渲染器、以及水面和天空的实例。animationFrameId 用于控制动画帧的请求。
ts 复制代码
const initThree = () => {
  const container = document.getElementById(canvasId);
  if (!container) {
    console.warn(`Canvas element with id '${canvasId}' not found`);
    return;
  }

  scene = new THREE.Scene();
  camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 20000);
  camera.position.set(30, 30, 100);
  camera.lookAt(0, 0, 0);
  
  sun = new THREE.Vector3();
  
  renderer = new THREE.WebGLRenderer({ canvas: container, antialias: true, alpha: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 0.5;
}
  • 场景与相机初始化 :创建了一个 Three.js 场景,并使用 PerspectiveCamera 创建相机,设置了相机的位置和朝向。
  • 渲染器初始化 :创建了一个 WebGLRenderer,并设置了反走样(antialias)和透明背景(alpha)。同时设置了渲染器的大小和色调映射。

3. 创建水面效果

ts 复制代码
const waterGeometry = new THREE.PlaneGeometry(10000, 10000);
water = new Water(waterGeometry, {
  textureWidth: 512,
  textureHeight: 512,
  waterNormals: new THREE.TextureLoader().load(
    "https://threejs.org/examples/textures/waternormals.jpg",
    function (texture) {
      texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
    }
  ),
  sunDirection: new THREE.Vector3(),
  sunColor: 0xffffff,
  waterColor: 0x001e0f,
  distortionScale: 3.7,
  fog: scene.fog !== undefined,
});
water.rotation.x = -Math.PI / 2;
scene.add(water);
  • 水面几何体 :使用 THREE.PlaneGeometry 创建了一个大的平面,作为海面基础。
  • 水面着色器 :使用 Water 对象并传入配置项,设置水面波动、光照、颜色等属性。
  • 水面纹理:加载了一个水面法线贴图,并设置为重复模式。

4. 创建天空效果

ts 复制代码
sky = new Sky();
sky.scale.setScalar(10000);
scene.add(sky);

const skyUniforms = sky.material.uniforms;

skyUniforms["turbidity"].value = 10;
skyUniforms["rayleigh"].value = 2;
skyUniforms["mieCoefficient"].value = 0.005;
skyUniforms["mieDirectionalG"].value = 0.8;

const parameters = {
  elevation: 2,
  azimuth: 180,
};
  • 天空对象 :使用 Sky 对象创建了一个天空,并通过设置 scale 来放大天空的大小。
  • 天空着色器的配置 :调整了 turbidity(浑浊度)、rayleigh(瑞利散射)、mieCoefficient(米散射系数)等参数来改变天空的效果。

5. 更新太阳位置与场景环境

ts 复制代码
const pmremGenerator = new THREE.PMREMGenerator(renderer);
let renderTarget: THREE.WebGLRenderTarget;

function updateSun() {
  const phi = THREE.MathUtils.degToRad(90 - parameters.elevation);
  const theta = THREE.MathUtils.degToRad(parameters.azimuth);
  sun.setFromSphericalCoords(1, phi, theta);
  
  sky.material.uniforms["sunPosition"].value.copy(sun);
  water.material.uniforms["sunDirection"].value.copy(sun).normalize();
  
  if (renderTarget !== undefined) renderTarget.dispose();
  renderTarget = pmremGenerator.fromScene(sky as any);
  
  scene.environment = renderTarget.texture;
}

updateSun();
  • 太阳位置更新 :通过 elevationazimuth 参数计算太阳的位置,并将其应用于天空和水面材质的着色器中,使太阳的位置影响场景中的光照和水面反射。

6. 动画与渲染循环

ts 复制代码
const animate = () => {
  if (!scene || !camera || !renderer || !water) {
    return;
  }

  water.material.uniforms["time"].value += 1.0 / 60.0;

  renderer.render(scene, camera);
  animationFrameId = requestAnimationFrame(animate);
};
  • 水面动画 :通过每帧更新水面着色器的 time 值,触发水面动画效果。
  • 渲染循环 :使用 requestAnimationFrame 实现每一帧的渲染。

7. 处理窗口大小变化

ts 复制代码
const handleResize = () => {
  if (camera && renderer) {
    try {
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(window.innerWidth, window.innerHeight);
    } catch (error) {
      console.error("Error during resize:", error);
    }
  }
};
  • 响应窗口变化 :当窗口大小变化时,更新相机的 aspect 比例并重新调整渲染器的大小,确保渲染效果不变形。

8. 资源清理

ts 复制代码
const cleanup = () => {
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
    animationFrameId = 0;
  }

  if (renderer) {
    renderer.dispose();
  }

  if (scene) {
    while (scene.children.length > 0) {
      scene.remove(scene.children[0]);
    }
  }
};
  • 清理动画和资源:当组件卸载时,清除动画帧和渲染器,移除场景中的所有对象,防止内存泄漏。

9. 生命周期钩子

ts 复制代码
onMounted(() => {
  initThree();
  window.addEventListener("resize", handleResize);
});

onBeforeUnmount(() => {
  window.removeEventListener("resize", handleResize);
  cleanup();
});
  • 生命周期钩子:在组件挂载时初始化 Three.js 场景,并在卸载时清理资源。

完整源码

完整源码如下:

ts 复制代码
import { onMounted, onBeforeUnmount } from "vue";
import * as THREE from "three";

// 导入海洋着色器
import { Water } from "three/examples/jsm/objects/Water.js";
import { Sky } from "three/examples/jsm/objects/Sky.js";

export function useOcean(canvasId: string) {
  // Three.js 相关变量
  let scene: THREE.Scene;
  let camera: THREE.PerspectiveCamera;
  let renderer: THREE.WebGLRenderer;
  let water: any;
  let sun: THREE.Vector3;
  let sky: any;
  let animationFrameId: number;

  // 初始化Three.js场景
  const initThree = () => {
    const container = document.getElementById(canvasId);
    if (!container) {
      console.warn(`Canvas element with id '${canvasId}' not found`);
      return;
    }

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

    // 创建相机
    camera = new THREE.PerspectiveCamera(
      60,
      window.innerWidth / window.innerHeight,
      1,
      20000
    );
    camera.position.set(30, 30, 100);
    camera.lookAt(0, 0, 0);

    // 创建太阳光源
    sun = new THREE.Vector3();

    // 创建渲染器
    renderer = new THREE.WebGLRenderer({
      canvas: container,
      antialias: true,
      alpha: true,
    });
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 0.5;

    // 创建水面
    const waterGeometry = new THREE.PlaneGeometry(10000, 10000);
    water = new Water(waterGeometry, {
      textureWidth: 512,
      textureHeight: 512,
      waterNormals: new THREE.TextureLoader().load(
        "https://threejs.org/examples/textures/waternormals.jpg",
        function (texture) {
          texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
        }
      ),
      sunDirection: new THREE.Vector3(),
      sunColor: 0xffffff,
      waterColor: 0x001e0f,
      distortionScale: 3.7,
      fog: scene.fog !== undefined,
    });
    water.rotation.x = -Math.PI / 2;
    scene.add(water);

    // 创建天空
    sky = new Sky();
    sky.scale.setScalar(10000);
    scene.add(sky);

    const skyUniforms = sky.material.uniforms;

    skyUniforms["turbidity"].value = 10;
    skyUniforms["rayleigh"].value = 2;
    skyUniforms["mieCoefficient"].value = 0.005;
    skyUniforms["mieDirectionalG"].value = 0.8;

    const parameters = {
      elevation: 2,
      azimuth: 180,
    };

    const pmremGenerator = new THREE.PMREMGenerator(renderer);
    let renderTarget: THREE.WebGLRenderTarget;

    function updateSun() {
      const phi = THREE.MathUtils.degToRad(90 - parameters.elevation);
      const theta = THREE.MathUtils.degToRad(parameters.azimuth);

      sun.setFromSphericalCoords(1, phi, theta);

      sky.material.uniforms["sunPosition"].value.copy(sun);
      water.material.uniforms["sunDirection"].value.copy(sun).normalize();

      if (renderTarget !== undefined) renderTarget.dispose();

      renderTarget = pmremGenerator.fromScene(sky as any);

      scene.environment = renderTarget.texture;
    }

    updateSun();

    // 添加环境光
    const ambient = new THREE.AmbientLight(0x555555);
    scene.add(ambient);

    animate();
  };

  // 动画循环
  const animate = () => {
    if (!scene || !camera || !renderer || !water) {
      return;
    }

    // 更新水面动画
    water.material.uniforms["time"].value += 1.0 / 60.0;

    renderer.render(scene, camera);
    animationFrameId = requestAnimationFrame(animate);
  };

  // 处理窗口大小变化
  const handleResize = () => {
    if (camera && renderer) {
      try {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
      } catch (error) {
        console.error("Error during resize:", error);
      }
    }
  };

  // 清理资源
  const cleanup = () => {
    if (animationFrameId) {
      cancelAnimationFrame(animationFrameId);
      animationFrameId = 0;
    }

    if (renderer) {
      renderer.dispose();
    }

    // 清理场景中的对象
    if (scene) {
      while (scene.children.length > 0) {
        scene.remove(scene.children[0]);
      }
    }
  };

  // 生命周期钩子
  onMounted(() => {
    initThree();
    window.addEventListener("resize", handleResize);
  });

  onBeforeUnmount(() => {
    window.removeEventListener("resize", handleResize);
    cleanup();
  });

  return {
    // 如果需要暴露更多方法或属性,可以在这里添加
  };
}

使用示例:

html 复制代码
<canvas id="bg-canvas"></canvas>
ts 复制代码
useOcean('bg-canvas');

总结

以上我们就完成了一个动态的海洋和天空效果,它让我们的登陆页显得更加高大上档次,并且也展示了如何在 Vue 中集成复杂的 3D 渲染,同时确保了在窗口大小变化时的适配,以及在组件卸载时正确清理资源,通过合理的生命周期管理和资源清理,确保了程序的稳定性和性能。

相关推荐
爱吃鱼的锅包肉几秒前
Flutter路由模块化管理方案
前端·javascript·flutter
风清扬雨27 分钟前
Vue3具名插槽用法全解——从零到一的详细指南
前端·javascript·vue.js
大熊猫今天吃什么1 小时前
【一天一坑】空数组,使用 allMatch 默认返回true
前端·数据库
创码小奇客1 小时前
MCP:AI 集成的神奇钥匙 —— 从入门到高级玩法
ai编程·mcp·trae
!win !1 小时前
Tailwind CSS一些你需要记住的原子类
前端·tailwindcss
前端极客探险家1 小时前
打造一个 AI 面试助手:输入岗位 + 技术栈 → 自动生成面试问题 + 标准答案 + 技术考点图谱
前端·人工智能·面试·职场和发展·vue
橘子味的冰淇淋~2 小时前
【解决】Vue + Vite + TS 配置路径别名成功仍爆红
前端·javascript·vue.js
利刃之灵2 小时前
03-HTML常见元素
前端·html
kidding7232 小时前
gitee新的仓库,Vscode创建新的分支详细步骤
前端·gitee·在仓库创建新的分支
听风吹等浪起2 小时前
基于html实现的课题随机点名
前端·html