1.模型素材
在Sketchfab上下载狐狸岛模型,然后转换为素材资源asset,嫌麻烦直接在网盘链接下载素材,
- Fox’s islands
- https://sketchfab.com/3d-models/foxs-islands-163b68e09fcc47618450150be7785907
- https://gltf.pmnd.rs/
素材夸克网盘:
链接:https://pan.quark.cn/s/f02d30f07286
提取码:Yn3k
在 vite.config.js 或 vite.config.ts 文件里添加 assetsInclude 配置项,让 Vite 把 .glb 文件当作静态资源处理。
vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
assetsInclude: ['**/*.glb']
})
2.小岛代码
src下创建文件夹models,models下创建Island.jsx
Island.jsx
/**
* IMPORTANT: 将 glTF 模型加载到 Three.js 场景中是一项复杂的工作。
* 在我们能够配置或动画化模型的网格之前,需要遍历模型网格的每个部分并单独保存。
*
* 但幸运的是,有一个工具可以将 gltf 或 glb 文件转换为 jsx 组件。
* 对于这个模型,请访问 https://gltf.pmnd.rs/
* 获取代码,然后添加其余内容。
* 你不必从零开始编写所有代码
*/
// 从 @react-spring/three 库导入 a 组件,用于创建动画效果
import { a } from "@react-spring/three";
// 从 react 库导入 useEffect 和 useRef 钩子,useEffect 用于处理副作用,useRef 用于创建可变引用
import { useEffect, useRef } from "react";
// 从 @react-three/drei 库导入 useGLTF 钩子,用于加载 glTF 模型
import { useGLTF } from "@react-three/drei";
// 从 @react-three/fiber 库导入 useFrame 和 useThree 钩子,useFrame 用于在每一帧更新时执行代码,useThree 用于获取 Three.js 上下文
import { useFrame, useThree } from "@react-three/fiber";
// 导入岛屿模型的 glb 文件
import islandScene from "../assets/3d/island.glb";
/**
* Island 组件,用于渲染 3D 岛屿模型,并处理模型的旋转交互和阶段设置。
* @param {Object} props - 组件的属性对象
* @param {boolean} props.isRotating - 指示岛屿是否正在旋转的状态
* @param {Function} props.setIsRotating - 用于设置岛屿旋转状态的函数
* @param {Function} props.setCurrentStage - 用于设置当前阶段的函数
* @param {*} props.currentFocusPoint - 当前焦点点
* @returns {JSX.Element} 渲染的 3D 岛屿模型元素
*/
export function Island({
isRotating,
setIsRotating,
setCurrentStage,
currentFocusPoint,
...props
}) {
// 创建一个 ref 用于引用岛屿模型
const islandRef = useRef();
// 使用 useThree 钩子获取 Three.js 渲染器和视口信息
const { gl, viewport } = useThree();
// 使用 useGLTF 钩子加载岛屿模型,获取模型的节点和材质
const { nodes, materials } = useGLTF(islandScene);
// 创建一个 ref 用于存储上一次鼠标的 x 坐标
const lastX = useRef(0);
// 创建一个 ref 用于存储旋转速度
const rotationSpeed = useRef(0);
// 定义阻尼因子,用于控制旋转减速效果
const dampingFactor = 0.95;
// 处理指针(鼠标或触摸)按下事件
const handlePointerDown = (event) => {
// 阻止事件冒泡和默认行为
event.stopPropagation();
event.preventDefault();
// 设置岛屿为旋转状态
setIsRotating(true);
// 根据事件类型(触摸或鼠标)获取当前指针的 x 坐标
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
// 存储当前指针的 x 坐标,供后续计算使用
lastX.current = clientX;
};
// 处理指针(鼠标或触摸)抬起事件
const handlePointerUp = (event) => {
// 阻止事件冒泡和默认行为
event.stopPropagation();
event.preventDefault();
// 设置岛屿为停止旋转状态
setIsRotating(false);
};
// 处理指针(鼠标或触摸)移动事件
const handlePointerMove = (event) => {
// 阻止事件冒泡和默认行为
event.stopPropagation();
event.preventDefault();
if (isRotating) {
// 如果岛屿正在旋转,根据事件类型(触摸或鼠标)获取当前指针的 x 坐标
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
// 计算指针在水平方向上的移动距离,相对于视口宽度的比例
const delta = (clientX - lastX.current) / viewport.width;
// 根据指针移动距离更新岛屿的旋转角度
islandRef.current.rotation.y += delta * 0.01 * Math.PI;
// 更新上一次指针的 x 坐标
lastX.current = clientX;
// 更新旋转速度
rotationSpeed.current = delta * 0.01 * Math.PI;
}
};
// 处理键盘按下事件
const handleKeyDown = (event) => {
if (event.key === "ArrowLeft") {
// 如果按下左箭头键,且岛屿未旋转,则设置为旋转状态
if (!isRotating) setIsRotating(true);
// 向左旋转岛屿
islandRef.current.rotation.y += 0.005 * Math.PI;
// 设置旋转速度
rotationSpeed.current = 0.007;
} else if (event.key === "ArrowRight") {
// 如果按下右箭头键,且岛屿未旋转,则设置为旋转状态
if (!isRotating) setIsRotating(true);
// 向右旋转岛屿
islandRef.current.rotation.y -= 0.005 * Math.PI;
// 设置旋转速度
rotationSpeed.current = -0.007;
}
};
// 处理键盘抬起事件
const handleKeyUp = (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
// 如果松开左箭头键或右箭头键,设置岛屿为停止旋转状态
setIsRotating(false);
}
};
// 处理触摸开始事件,用于移动设备
const handleTouchStart = (e) => {
// 阻止事件冒泡和默认行为
e.stopPropagation();
e.preventDefault();
// 设置岛屿为旋转状态
setIsRotating(true);
// 获取触摸点的 x 坐标
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
// 存储当前触摸点的 x 坐标
lastX.current = clientX;
}
// 处理触摸结束事件,用于移动设备
const handleTouchEnd = (e) => {
// 阻止事件冒泡和默认行为
e.stopPropagation();
e.preventDefault();
// 设置岛屿为停止旋转状态
setIsRotating(false);
}
// 处理触摸移动事件,用于移动设备
const handleTouchMove = (e) => {
// 阻止事件冒泡和默认行为
e.stopPropagation();
e.preventDefault();
if (isRotating) {
// 如果岛屿正在旋转,获取触摸点的 x 坐标
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
// 计算触摸点在水平方向上的移动距离,相对于视口宽度的比例
const delta = (clientX - lastX.current) / viewport.width;
// 根据触摸移动距离更新岛屿的旋转角度
islandRef.current.rotation.y += delta * 0.01 * Math.PI;
// 更新上一次触摸点的 x 坐标
lastX.current = clientX;
// 更新旋转速度
rotationSpeed.current = delta * 0.01 * Math.PI;
}
}
// 使用 useEffect 钩子添加和移除事件监听器
useEffect(() => {
// 获取 Three.js 渲染器的画布元素
const canvas = gl.domElement;
// 添加指针按下、抬起、移动事件监听器
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointerup", handlePointerUp);
canvas.addEventListener("pointermove", handlePointerMove);
// 添加键盘按下、抬起事件监听器
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
// 添加触摸开始、结束、移动事件监听器
canvas.addEventListener("touchstart", handleTouchStart);
canvas.addEventListener("touchend", handleTouchEnd);
canvas.addEventListener("touchmove", handleTouchMove);
// 组件卸载时移除事件监听器,避免内存泄漏
return () => {
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("pointerup", handlePointerUp);
canvas.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
canvas.removeEventListener("touchstart", handleTouchStart);
canvas.removeEventListener("touchend", handleTouchEnd);
canvas.removeEventListener("touchmove", handleTouchMove);
};
}, [gl, handlePointerDown, handlePointerUp, handlePointerMove]);
// 使用 useFrame 钩子在每一帧更新时执行代码
useFrame(() => {
// 如果岛屿未旋转,应用阻尼效果使旋转逐渐减速
if (!isRotating) {
// 应用阻尼因子,降低旋转速度
rotationSpeed.current *= dampingFactor;
// 当旋转速度非常小时,停止旋转
if (Math.abs(rotationSpeed.current) < 0.001) {
rotationSpeed.current = 0;
}
// 根据旋转速度更新岛屿的旋转角度
islandRef.current.rotation.y += rotationSpeed.current;
} else {
// 当岛屿正在旋转时,根据岛屿的朝向确定当前阶段
const rotation = islandRef.current.rotation.y;
/**
* 对旋转值进行归一化处理,确保其保持在 [0, 2 * Math.PI] 范围内。
* 目的是保证旋转值在特定范围内,避免出现非常大或负的旋转值导致的潜在问题。
* 以下是这段代码的分步解释:
* 1. rotation % (2 * Math.PI) 计算旋转值除以 2 * Math.PI 的余数。
* 这实际上会在旋转值达到一整圈(360 度)时将其环绕,使其保持在 0 到 2 * Math.PI 的范围内。
* 2. (rotation % (2 * Math.PI)) + 2 * Math.PI 将步骤 1 的结果加上 2 * Math.PI。
* 这样做是为了确保即使在步骤 1 的取模运算后结果为负,该值仍然为正且在 0 到 2 * Math.PI 的范围内。
* 3. 最后,((rotation % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI) 对步骤 2 得到的值再次应用取模运算。
* 这一步保证了该值始终保持在 0 到 2 * Math.PI 的范围内,这在弧度制中相当于一整圈。
*/
const normalizedRotation =
((rotation % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
// 根据岛屿的朝向设置当前阶段
switch (true) {
case normalizedRotation >= 5.45 && normalizedRotation <= 5.85:
setCurrentStage(4);
break;
case normalizedRotation >= 0.85 && normalizedRotation <= 1.3:
setCurrentStage(3);
break;
case normalizedRotation >= 2.4 && normalizedRotation <= 2.6:
setCurrentStage(2);
break;
case normalizedRotation >= 4.25 && normalizedRotation <= 4.75:
setCurrentStage(1);
break;
default:
setCurrentStage(null);
}
}
});
return (
// {岛屿 3D 模型来源: https://sketchfab.com/3d-models/foxs-islands-163b68e09fcc47618450150be7785907}
// 使用 a.group 组件包裹岛屿模型,支持动画效果
<a.group ref={islandRef} {...props}>
<mesh
geometry={nodes.polySurface944_tree_body_0.geometry}
material={materials.PaletteMaterial001}
/>
<mesh
geometry={nodes.polySurface945_tree1_0.geometry}
material={materials.PaletteMaterial001}
/>
<mesh
geometry={nodes.polySurface946_tree2_0.geometry}
material={materials.PaletteMaterial001}
/>
<mesh
geometry={nodes.polySurface947_tree1_0.geometry}
material={materials.PaletteMaterial001}
/>
<mesh
geometry={nodes.polySurface948_tree_body_0.geometry}
material={materials.PaletteMaterial001}
/>
<mesh
geometry={nodes.polySurface949_tree_body_0.geometry}
material={materials.PaletteMaterial001}
/>
<mesh
geometry={nodes.pCube11_rocks1_0.geometry}
material={materials.PaletteMaterial001}
/>
</a.group>
);
}
// 导出 Island 组件作为默认导出,方便其他文件引入使用
export default Island
3.主页代码
Home.jsx
// 导入 React 库和 Suspense 组件,Suspense 用于处理异步组件加载
// 当异步组件还未加载完成时,可显示一个 fallback 组件
import React, { Suspense } from 'react'
// 从 @react-three/fiber 库中导入 Canvas 组件,用于创建 Three.js 渲染上下文,
// 借助该组件能在 React 应用里渲染 3D 场景
import { Canvas } from '@react-three/fiber'
// 从 ../components/Loader 路径导入 Loader 组件,该组件会在异步加载时显示加载状态
import Loader from '../components/Loader'
// 从 ../models/Island 路径导入 Island 组件,此组件用于渲染 3D 岛屿模型
import { Island } from "../models/Island"
// <div className='absolute top-28 left-0 right-0 z-10 flex items-center justify-center'>
// 弹出窗口
// </div>
/**
* Home 组件,作为应用的主页组件。
* 该组件会依据屏幕尺寸对 Island 组件的缩放、位置和旋转进行调整,
* 并且在 Canvas 中渲染 Island 组件,同时处理异步加载状态。
* @returns {JSX.Element} 渲染后的 JSX 元素
*/
const Home = () => {
/**
* 根据屏幕尺寸调整 Island 组件的缩放、位置和旋转。
* @returns {Array} 包含屏幕缩放比例、位置和旋转值的数组
*/
const adjustIslandForScreenSize = () => {
// 初始化屏幕缩放比例,初始值设为 null
let screenScale = null
// 初始化 Island 组件的位置,默认值为 [0, -6.5, -43]
let screenPosition = [0, -6.5, -43]
// 初始化 Island 组件的旋转值,默认值为 [0.1, 4.7, 0]
let rotation = [0.1, 4.7, 0]
// 判断当前窗口宽度是否小于 768px
if (window.innerWidth < 768) {
// 若窗口宽度小于 768px,将屏幕缩放比例设置为 [0.9, 0.9, 0.9]
screenScale = [0.9, 0.9, 0.9];
} else {
// 若窗口宽度大于等于 768px,将屏幕缩放比例设置为 [1, 1, 1]
screenScale = [1, 1, 1];
}
// 返回包含屏幕缩放比例、位置和旋转值的数组
return [screenScale, screenPosition, rotation];
}
// 调用 adjustIslandForScreenSize 函数,获取调整后的岛屿缩放、位置和旋转参数
const [islandScale, islandPosition, islandRotation] = adjustIslandForScreenSize();
return (
// 创建一个 section 元素,宽度和高度占满整个屏幕,且采用相对定位
<section className='w-full h-screen relative'>
{/* 创建 Three.js 渲染画布,宽度和高度占满整个屏幕,背景透明,
并设置相机的近裁剪面和远裁剪面 */}
<Canvas
className='w-full h-screen bg-transparent'
camera={{ near:0.1, far:1000 }}
>
{/* 使用 Suspense 组件处理异步加载,当 Island 组件未加载完成时,显示 Loader 组件 */}
<Suspense fallback={<Loader/>}>
{/* 添加定向光,为场景提供有方向的光照 */}
<directionalLight/>
{/* 添加环境光,为场景提供全局均匀的光照 */}
<ambientLight />
{/* 添加点光源,从一个点向四周发射光线 */}
<pointLight />
{/* 添加聚光灯,发射出类似圆锥形的光线 */}
<spotLight />
{/* 添加半球光,模拟天空和地面的光照效果 */}
<hemisphereLight />
{/* 渲染 Island 组件,设置其位置、缩放和旋转属性 */}
<Island
position={islandPosition}
scale={islandScale}
rotation={islandRotation}
/>
</Suspense>
</Canvas>
</section>
)
}
// 导出 Home 组件,供其他文件引入使用
export default Home
4.安装依赖运行
npm install @react-spring/three
npm run dev