使用 React Three Fiber 构建健身应用的3D人体解剖模型
概述
3D 人体解剖模型为健身应用提供了直观的可视化体验,帮助用户理解肌肉群的锻炼方式和运动轨迹。本文将介绍如何使用 React Three Fiber (R3F) 构建一个交互式的3D解剖模型展示组件。
技术栈介绍
React Three Fiber
React Three Fiber 是 Three.js 的 React 渲染器:
- 声明式 API:使用 React 组件构建 3D 场景
- Hooks 支持:利用 React 生态系统
- 易于集成:与现有 React 应用无缝集成
- 性能优化:内置渲染优化机制
安装依赖
code
npm install three @react-three/fiber @react-three/drei
# 或
yarn add three @react-three/fiber @react-three/drei
Code collapsed
基础场景设置
创建 Canvas 容器
code
// components/3d/AnatomyCanvas.tsx
import React, { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, PerspectiveCamera, Environment, ContactShadows } from '@react-three/drei';
interface AnatomyCanvasProps {
children: React.ReactNode;
className?: string;
}
export const AnatomyCanvas: React.FC<AnatomyCanvasProps> = ({ children, className }) => {
return (
<div className={`w-full h-[600px] rounded-lg overflow-hidden ${className || ''}`}>
<Canvas
shadows
gl={{ antialias: true, alpha: true }}
className: "bg-gradient-to-b from-gray-50 to-gray-100"
>
<Suspense fallback={<CanvasLoader />}>
{/* 灯光设置 */}
<ambientLight intensity={0.5} />
<directionalLight
position={[10, 10, 5]}
intensity={1}
castShadow
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
/>
<spotLight
position={[-10, -10, -5]}
intensity={0.5}
angle={0.3}
penumbra={1}
/>
{/* 相机 */}
<PerspectiveCamera makeDefault position={[0, 1.5, 3]} fov={50} />
{/* 环境和阴影 */}
<Environment preset: "studio" />
<ContactShadows
position={[0, -0.5, 0]}
opacity={0.4}
scale={10}
blur={2}
far={4}
/>
{/* 内容 */}
{children}
{/* 轨道控制 */}
<OrbitControls
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={2}
maxDistance={6}
maxPolarAngle={Math.PI / 2}
/>
</Suspense>
</Canvas>
</div>
);
};
// 加载动画组件
const CanvasLoader: React.FC = () => {
return (
<mesh>
<sphereGeometry args={[0.1, 16, 16]} />
<meshStandardMaterial color: "#3b82f6" />
</mesh>
);
};
Code collapsed
创建肌肉群组件
基础肌肉模型
code
// components/3d/MuscleGroup.tsx
import React, { useRef, useState } from 'react';
import { useFrame } from '@react-three/fiber';
import { Mesh, Vector3 } from 'three';
import { Text } from '@react-three/drei';
export enum MuscleGroup {
CHEST = 'chest',
BACK = 'back',
SHOULDERS = 'shoulders',
BICEPS = 'biceps',
TRICEPS = 'triceps',
ABS = 'abs',
QUADS = 'quads',
HAMSTRINGS = 'hamstrings',
CALVES = 'calves'
}
interface MuscleProps {
position: Vector3;
rotation?: [number, number, number];
scale?: Vector3;
group: MuscleGroup;
isActive?: boolean;
onClick?: (group: MuscleGroup) => void;
color?: string;
}
export const MuscleGroup3D: React.FC<MuscleProps> = ({
position,
rotation = [0, 0, 0],
scale = [1, 1, 1],
group,
isActive = false,
onClick,
color = '#3b82f6'
}) => {
const meshRef = useRef<Mesh>(null);
const [hovered, setHovered] = useState(false);
// 悬停动画
useFrame((state) => {
if (meshRef.current && hovered) {
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime) * 0.1;
}
});
const handleClick = (event: ThreeEvent<MouseEvent>) => {
event.stopPropagation();
onClick?.(group);
};
return (
<group position={position} rotation={rotation} scale={scale}>
<mesh
ref={meshRef}
onClick={handleClick}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
castShadow
>
{/* 简化的肌肉形状 - 实际应用中应使用精确的3D模型 */}
<capsuleGeometry args={[0.15, 0.3, 4, 8]} />
<meshStandardMaterial
color={isActive ? '#10b981' : hovered ? '#60a5fa' : color}
emissive={isActive ? '#10b981' : hovered ? '#3b82f6' : '#1e40af'}
emissiveIntensity={isActive ? 0.5 : hovered ? 0.3 : 0.1}
transparent
opacity={0.9}
roughness={0.3}
metalness={0.1}
/>
</mesh>
{/* 标签 */}
{(hovered || isActive) && (
<Text
position={[0, 0.3, 0]}
fontSize={0.08}
color: "#1f2937"
anchorX: "center"
anchorY: "middle"
>
{group}
</Text>
)}
</group>
);
};
Code collapsed
完整的人体模型组件
code
// components/3d/HumanBody.tsx
import React, { useState } from 'react';
import { Group } from '@react-three/fiber';
import { MuscleGroup3D, MuscleGroup } from './MuscleGroup';
import * as THREE from 'three';
interface HumanBodyProps {
onMuscleSelect?: (group: MuscleGroup) => void;
activeMuscles?: MuscleGroup[];
}
export const HumanBody: React.FC<HumanBodyProps> = ({
onMuscleSelect,
activeMuscles = []
}) => {
const [selectedMuscle, setSelectedMuscle] = useState<MuscleGroup | null>(null);
const handleMuscleClick = (group: MuscleGroup) => {
setSelectedMuscle(group);
onMuscleSelect?.(group);
};
const isActive = (group: MuscleGroup) =>
activeMuscles.includes(group) || selectedMuscle === group;
return (
<group>
{/* 躯干 */}
<MuscleGroup3D
group={MuscleGroup.CHEST}
position={[0, 0.4, 0.15]}
scale={[0.5, 0.4, 0.2]}
isActive={isActive(MuscleGroup.CHEST)}
onClick={handleMuscleClick}
/>
<MuscleGroup3D
group={MuscleGroup.BACK}
position={[0, 0.4, -0.15]}
scale={[0.5, 0.4, 0.2]}
isActive={isActive(MuscleGroup.BACK)}
onClick={handleMuscleClick}
color: "#6366f1"
/>
<MuscleGroup3D
group={MuscleGroup.ABS}
position={[0, 0.1, 0.15]}
scale={[0.35, 0.35, 0.15]}
isActive={isActive(MuscleGroup.ABS)}
onClick={handleMuscleClick}
color: "#8b5cf6"
/>
{/* 肩膀 */}
<MuscleGroup3D
group={MuscleGroup.SHOULDERS}
position={[-0.3, 0.7, 0]}
rotation={[0, 0, Math.PI / 4]}
isActive={isActive(MuscleGroup.SHOULDERS)}
onClick={handleMuscleClick}
color: "#f59e0b"
/>
<MuscleGroup3D
group={MuscleGroup.SHOULDERS}
position={[0.3, 0.7, 0]}
rotation={[0, 0, -Math.PI / 4]}
isActive={isActive(MuscleGroup.SHOULDERS)}
onClick={handleMuscleClick}
color: "#f59e0b"
/>
{/* 手臂 - 肱二头肌 */}
<MuscleGroup3D
group={MuscleGroup.BICEPS}
position={[-0.5, 0.5, 0]}
isActive={isActive(MuscleGroup.BICEPS)}
onClick={handleMuscleClick}
color: "#ec4899"
/>
<MuscleGroup3D
group={MuscleGroup.BICEPS}
position={[0.5, 0.5, 0]}
isActive={isActive(MuscleGroup.BICEPS)}
onClick={handleMuscleClick}
color: "#ec4899"
/>
{/* 手臂 - 肱三头肌 */}
<MuscleGroup3D
group={MuscleGroup.TRICEPS}
position={[-0.5, 0.5, -0.1]}
isActive={isActive(MuscleGroup.TRICEPS)}
onClick={handleMuscleClick}
color: "#14b8a6"
/>
<MuscleGroup3D
group={MuscleGroup.TRICEPS}
position={[0.5, 0.5, -0.1]}
isActive={isActive(MuscleGroup.TRICEPS)}
onClick={handleMuscleClick}
color: "#14b8a6"
/>
{/* 腿部 - 股四头肌 */}
<MuscleGroup3D
group={MuscleGroup.QUADS}
position={[-0.15, -0.4, 0.1]}
scale={[0.8, 1, 0.8]}
isActive={isActive(MuscleGroup.QUADS)}
onClick={handleMuscleClick}
color: "#f97316"
/>
<MuscleGroup3D
group={MuscleGroup.QUADS}
position={[0.15, -0.4, 0.1]}
scale={[0.8, 1, 0.8]}
isActive={isActive(MuscleGroup.QUADS)}
onClick={handleMuscleClick}
color: "#f97316"
/>
{/* 腿部 - 腘绳肌 */}
<MuscleGroup3D
group={MuscleGroup.HAMSTRINGS}
position={[-0.15, -0.4, -0.1]}
scale={[0.8, 1, 0.8]}
isActive={isActive(MuscleGroup.HAMSTRINGS)}
onClick={handleMuscleClick}
color: "#a855f7"
/>
<MuscleGroup3D
group={MuscleGroup.HAMSTRINGS}
position={[0.15, -0.4, -0.1]}
scale={[0.8, 1, 0.8]}
isActive={isActive(MuscleGroup.HAMSTRINGS)}
onClick={handleMuscleClick}
color: "#a855f7"
/>
{/* 小腿 */}
<MuscleGroup3D
group={MuscleGroup.CALVES}
position={[-0.15, -1.1, 0]}
scale={[0.6, 0.8, 0.6]}
isActive={isActive(MuscleGroup.CALVES)}
onClick={handleMuscleClick}
color: "#06b6d4"
/>
<MuscleGroup3D
group={MuscleGroup.CALVES}
position={[0.15, -1.1, 0]}
scale={[0.6, 0.8, 0.6]}
isActive={isActive(MuscleGroup.CALVES)}
onClick={handleMuscleClick}
color: "#06b6d4"
/>
</group>
);
};
Code collapsed
健身应用集成
锻炼展示页面
code
// app/[locale]/(app)/workout/[id]/page.tsx
import React, { useState } from 'react';
import { AnatomyCanvas } from '@/components/3d/AnatomyCanvas';
import { HumanBody } from '@/components/3d/HumanBody';
import { MuscleGroup } from '@/components/3d/MuscleGroup';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
interface Exercise {
id: string;
name: string;
description: string;
primaryMuscles: MuscleGroup[];
secondaryMuscles: MuscleGroup[];
instructions: string[];
tips: string[];
}
const EXERCISES: Record<string, Exercise> = {
'bench-press': {
id: 'bench-press',
name: '卧推',
description: '锻炼胸部肌肉的经典复合动作',
primaryMuscles: [MuscleGroup.CHEST, MuscleGroup.TRICEPS, MuscleGroup.SHOULDERS],
secondaryMuscles: [MuscleGroup.BICEPS],
instructions: [
'仰卧在卧推凳上,双脚平放地面',
'握住杠铃,略宽于肩',
'慢慢将杠铃降至胸部',
'用力将杠铃推回起始位置'
],
tips: [
'保持肩胛骨收紧',
'避免背部过度拱起',
'杠铃下落时吸气,推起时呼气'
]
}
};
export default function ExercisePage({ params }: { params: { id: string } }) {
const [selectedMuscle, setSelectedMuscle] = useState<MuscleGroup | null>(null);
const exercise = EXERCISES[params.id];
if (!exercise) {
return <div>练习不存在</div>;
}
return (
<div className: "container mx-auto px-4 py-8">
<div className: "grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 3D 模型 */}
<div className: "space-y-4">
<h1 className: "text-3xl font-bold">{exercise.name}</h1>
<p className: "text-gray-600">{exercise.description}</p>
<AnatomyCanvas>
<HumanBody
activeMuscles={exercise.primaryMuscles}
onMuscleSelect={setSelectedMuscle}
/>
</AnatomyCanvas>
{/* 肌肉群信息 */}
{selectedMuscle && (
<Card>
<CardHeader>
<CardTitle>肌肉信息</CardTitle>
</CardHeader>
<CardContent>
<div className: "space-y-2">
<div className: "flex items-center gap-2">
<Badge variant: "outline">
{exercise.primaryMuscles.includes(selectedMuscle) ? '主' : '辅'}
</Badge>
<span>{selectedMuscle}</span>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* 练习详情 */}
<div className: "space-y-6">
{/* 目标肌肉 */}
<Card>
<CardHeader>
<CardTitle>目标肌肉</CardTitle>
</CardHeader>
<CardContent>
<div className: "space-y-3">
<div>
<h4 className: "font-semibold mb-2">主要肌肉</h4>
<div className: "flex flex-wrap gap-2">
{exercise.primaryMuscles.map(muscle => (
<Badge key={muscle} variant: "default">
{muscle}
</Badge>
))}
</div>
</div>
{exercise.secondaryMuscles.length > 0 && (
<div>
<h4 className: "font-semibold mb-2">辅助肌肉</h4>
<div className: "flex flex-wrap gap-2">
{exercise.secondaryMuscles.map(muscle => (
<Badge key={muscle} variant: "secondary">
{muscle}
</Badge>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* 动作要领 */}
<Card>
<CardHeader>
<CardTitle>动作要领</CardTitle>
<CardDescription>按照以下步骤正确完成动作</CardDescription>
</CardHeader>
<CardContent>
<ol className: "space-y-3">
{exercise.instructions.map((step, index) => (
<li key={index} className: "flex gap-3">
<span className: "flex-shrink-0 w-6 h-6 rounded-full bg-blue-500 text-white text-sm flex items-center justify-center">
{index + 1}
</span>
<span className: "text-gray-700">{step}</span>
</li>
))}
</ol>
</CardContent>
</Card>
{/* 训练技巧 */}
<Card>
<CardHeader>
<CardTitle>训练技巧</CardTitle>
</CardHeader>
<CardContent>
<ul className: "space-y-2">
{exercise.tips.map((tip, index) => (
<li key={index} className: "flex gap-2">
<span className: "text-blue-500">•</span>
<span className: "text-gray-700">{tip}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Button size: "lg" className: "w-full">
开始训练
</Button>
</div>
</div>
</div>
);
}
Code collapsed
加载和优化3D模型
使用 GLTFLoader 加载外部模型
code
// components/3d/ModelLoader.tsx
import React, { useEffect, useState } from 'react';
import { useGLTF, useAnimations } from '@react-three/drei';
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
type GLTFResult = GLTF & {
nodes: Record<string, THREE.Mesh>;
materials: Record<string, THREE.Material>;
};
interface ModelLoaderProps {
url: string;
position?: [number, number, number];
scale?: number;
}
export const ModelLoader: React.FC<ModelLoaderProps> = ({
url,
position = [0, 0, 0],
scale = 1
}) => {
const gltf = useGLTF(url) as GLTFResult;
const { actions } = useAnimations(gltf.animations, gltf.scene);
useEffect(() => {
// 如果有动画,播放第一个动画
if (actions && Object.keys(actions).length > 0) {
const firstAnimation = Object.values(actions)[0];
firstAnimation?.play();
}
}, [actions]);
return (
<primitive
object={gltf.scene}
position={position}
scale={scale}
dispose={null}
/>
);
};
// 预加载模型以提高性能
useGLTF.preload('/models/human-anatomy.glb');
Code collapsed
性能优化
1. 使用实例化渲染
code
import { Instances, Instance } from '@react-three/drei';
export const MusclesInstance: React.FC = () => {
return (
<Instances limit={10}>
<capsuleGeometry args={[0.15, 0.3, 4, 8]} />
<meshStandardMaterial />
{muscleData.map((data, i) => (
<Instance
key={i}
position={data.position}
rotation={data.rotation}
scale={data.scale}
color={data.color}
/>
))}
</Instances>
);
};
Code collapsed
2. 懒加载模型
code
import { lazy, Suspense } from 'react';
const HumanBody = lazy(() => import('@/components/3d/HumanBody'));
export default function WorkoutPage() {
return (
<Suspense fallback={<ModelLoader />}>
<HumanBody />
</Suspense>
);
}
Code collapsed