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}><meshgeometry={nodes.polySurface944_tree_body_0.geometry}material={materials.PaletteMaterial001}/><meshgeometry={nodes.polySurface945_tree1_0.geometry}material={materials.PaletteMaterial001}/><meshgeometry={nodes.polySurface946_tree2_0.geometry}material={materials.PaletteMaterial001}/><meshgeometry={nodes.polySurface947_tree1_0.geometry}material={materials.PaletteMaterial001}/><meshgeometry={nodes.polySurface948_tree_body_0.geometry}material={materials.PaletteMaterial001}/><meshgeometry={nodes.polySurface949_tree_body_0.geometry}material={materials.PaletteMaterial001}/><meshgeometry={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 = () => {// 初始化屏幕縮放比例,初始值設為 nulllet 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]// 判斷當前窗口寬度是否小于 768pxif (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 渲染畫布,寬度和高度占滿整個屏幕,背景透明,并設置相機的近裁剪面和遠裁剪面 */}<CanvasclassName='w-full h-screen bg-transparent'camera={{ near:0.1, far:1000 }}>{/* 使用 Suspense 組件處理異步加載,當 Island 組件未加載完成時,顯示 Loader 組件 */}<Suspense fallback={<Loader/>}>{/* 添加定向光,為場景提供有方向的光照 */}<directionalLight/>{/* 添加環境光,為場景提供全局均勻的光照 */}<ambientLight />{/* 添加點光源,從一個點向四周發射光線 */}<pointLight />{/* 添加聚光燈,發射出類似圓錐形的光線 */}<spotLight />{/* 添加半球光,模擬天空和地面的光照效果 */}<hemisphereLight />{/* 渲染 Island 組件,設置其位置、縮放和旋轉屬性 */}<Islandposition={islandPosition}scale={islandScale}rotation={islandRotation}/></Suspense></Canvas></section>)
}// 導出 Home 組件,供其他文件引入使用
export default Home
4.安裝依賴運行
npm install @react-spring/three
npm run dev