康心伴Logo
康心伴WellAlly
前端开发

使用 React Three Fiber 构建健身应用的3D人体解剖模型

5 分钟阅读

使用 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

参考资料

#

文章标签

react
three.js
3d可视化
健身应用
解剖学

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅