{T}

骨骼动画与变形动画

深入探讨 Three.js 中的骨骼动画(Skeletal Animation)和变形动画(Morph Target Animation)技术。

概述

骨骼动画和变形动画是角色动画和复杂物体动画的两种主要技术。骨骼动画通过骨骼结构控制网格变形,变形动画通过在预定义的形态之间插值实现动画。

架构概览

plaintext
高级动画技术架构

├── 骨骼动画(Skeletal Animation)
│   ├── 核心组件
│   │   ├── Bone(骨骼)
│   │   ├── Skeleton(骨骼系统)
│   │   └── SkinnedMesh(蒙皮网格)
│   │
│   ├── 数据绑定
│   │   ├── skinIndex(骨骼索引)
│   │   └── skinWeight(骨骼权重)
│   │
│   └── 应用场景
│       ├── 角色动画
│       ├── 机械臂
│       └── 生物运动

└── 变形动画(Morph Target Animation)
    ├── 核心组件
    │   ├── morphAttributes(变形属性)
    │   └── morphTargetInfluences(变形权重)

    ├── 实现方式
    │   └── 顶点位置插值

    └── 应用场景
        ├── 表情动画
        ├── 形态变化
        └── 嘴型同步

技术对比

特性骨骼动画变形动画
实现原理骨骼驱动网格变形顶点位置插值
内存占用低(骨骼数据小)高(存储多个形态)
动画复杂度高(支持复杂骨骼)中(预定义形态)
实时控制强(程序化控制骨骼)弱(基于权重混合)
适用场景角色肢体运动表情、形态变化
学习曲线陡峭平缓

骨骼动画基础

骨骼系统结构

plaintext
骨骼层级结构示意
 
        根骨骼 (Root)

        ┌───┴───┐
        │       │
      脊柱    臀部
        │       │
      ┌─┴─┐   ┌┴┐
      │   │   │ │
    颈部 腰部 左腿 右腿
      │           │
    头部       ┌─┴─┐
              │   │
            小腿 小腿

            脚部

骨骼系统核心类

Bone 类

方法/属性说明
constructor()创建骨骼实例
isBone只读,标识为骨骼对象
name骨骼名称,用于动画绑定
parent父骨骼引用
children子骨骼数组
position/rotation/scale相对于父骨骼的变换
getWorldPosition(target)获取世界坐标位置
getWorldQuaternion(target)获取世界四元数旋转

Skeleton 类

方法/属性说明
constructor(bones, boneInverses)创建骨骼系统
bones骨骼数组
boneInverses骨骼逆矩阵数组
boneMatrices骨骼矩阵纹理数据
boneTexture骨骼矩阵纹理
update()更新骨骼矩阵
pose()设置为绑定姿势
getBoneByName(name)按名称获取骨骼

SkinnedMesh 类

方法/属性说明
constructor(geometry, material)创建蒙皮网格
bind(skeleton, bindMatrix)绑定骨骼系统
normalizeSkinWeights()归一化骨骼权重
pose()应用绑定姿势
skeleton骨骼系统引用
bindMode绑定模式
bindMatrix绑定矩阵
bindMatrixInverse绑定逆矩阵

创建骨骼系统

javascript
import * as THREE from 'three';
 
// 创建骨骼
const bone1 = new THREE.Bone();
const bone2 = new THREE.Bone();
const bone3 = new THREE.Bone();
 
// 建立骨骼层级
bone1.add(bone2);
bone2.add(bone3);
 
// 设置骨骼位置
bone1.position.set(0, 0, 0);
bone2.position.set(0, 1, 0);  // 相对于父骨骼
bone3.position.set(0, 1, 0);
 
// 创建骨骼数据
const bones = [bone1, bone2, bone3];
const skeleton = new THREE.Skeleton(bones);
 
// 绑定骨骼到网格
geometry.userData.bones = bones;
geometry.userData.skeleton = skeleton;

SkinnedMesh 创建流程

plaintext
创建蒙皮网格流程
 
1. 创建几何体


2. 创建骨骼层级


3. 计算骨骼权重 ──────────────────┐
   (skinIndex, skinWeight)        │
        │                         │
        ▼                         │
4. 创建材质(skinning: true)     │
        │                         │
        ▼                         │
5. 创建 SkinnedMesh               │
        │                         │
        ▼                         │
6. 添加根骨骼到 Mesh ◄────────────┘


7. 绑定 Skeleton


8. 完成蒙皮网格

SkinnedMesh 完整示例

javascript
// 创建骨骼动画网格
const geometry = new THREE.CylinderGeometry(0.1, 0.1, 2, 8, 10, false);
 
// 创建骨骼
const bones = [];
const boneCount = 5;
 
for (let i = 0; i < boneCount; i++) {
  const bone = new THREE.Bone();
  bone.position.y = i === 0 ? 0 : 0.4;
  bones.push(bone);
  
  if (i > 0) {
    bones[i - 1].add(bone);
  }
}
 
const skeleton = new THREE.Skeleton(bones);
 
// 创建蒙皮网格
const material = new THREE.MeshStandardMaterial({
  color: 0x00ff00,
  skinning: true  // 启用蒙皮
});
 
const mesh = new THREE.SkinnedMesh(geometry, material);
mesh.add(bones[0]);  // 添加根骨骼
mesh.bind(skeleton);  // 绑定骨骼
 
scene.add(mesh);

骨骼权重详解

plaintext
骨骼权重计算示意
 
顶点 V 的位置计算:
FinalPosition = Σ (Weight_i × BoneMatrix_i × VertexPosition)
 
示例:顶点受 2 根骨骼影响
┌──────────────────────────────────────┐
│  骨骼索引: [1, 2, 0, 0]             │
│  骨骼权重: [0.7, 0.3, 0, 0]         │
│                                      │
│  最终位置 = 0.7 × Bone1 × V +        │
│             0.3 × Bone2 × V          │
└──────────────────────────────────────┘
 
注意:
- 每个顶点最多受 4 根骨骼影响
- 权重总和应为 1.0

设置骨骼权重

javascript
// 设置骨骼权重(皮肤权重)
const position = geometry.attributes.position;
const skinIndices = [];
const skinWeights = [];
 
for (let i = 0; i < position.count; i++) {
  const y = position.getY(i);
  
  // 计算受影响的骨骼
  const boneIndex = Math.floor((y + 1) * 2);
  const boneWeight = ((y + 1) * 2) % 1;
  
  // 每个顶点最多受 4 个骨骼影响
  skinIndices.push(boneIndex, boneIndex + 1, 0, 0);
  skinWeights.push(1 - boneWeight, boneWeight, 0, 0);
}
 
// 设置属性
geometry.setAttribute(
  'skinIndex',
  new THREE.Uint16BufferAttribute(skinIndices, 4)
);
geometry.setAttribute(
  'skinWeight',
  new THREE.Float32BufferAttribute(skinWeights, 4)
);
TIP

骨骼权重决定了顶点受骨骼影响的程度。平滑的权重分配可以产生自然的变形效果。

手动控制骨骼

javascript
function animate() {
  requestAnimationFrame(animate);
  
  const time = clock.getElapsedTime();
  
  // 旋转骨骼
  bones[1].rotation.z = Math.sin(time) * 0.5;
  bones[2].rotation.z = Math.cos(time * 1.5) * 0.3;
  
  renderer.render(scene, camera);
}

从模型加载骨骼动画

GLTF 骨骼模型

javascript
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
 
const loader = new GLTFLoader();
 
loader.load('character.glb', (gltf) => {
  const model = gltf.scene;
  scene.add(model);
  
  // 获取骨骼信息
  const skinnedMesh = model.getObjectByProperty('type', 'SkinnedMesh');
  const skeleton = skinnedMesh.skeleton;
  
  // 遍历骨骼
  skeleton.bones.forEach(bone => {
    console.log('骨骼:', bone.name);
  });
  
  // 播放动画
  const mixer = new THREE.AnimationMixer(model);
  const actions = gltf.animations.map(clip => mixer.clipAction(clip));
  
  actions[0].play();
  
  model.userData.mixer = mixer;
});

骨骼动画控制

javascript
// 获取特定骨骼
function findBone(root, name) {
  let result = null;
  
  root.traverse((child) => {
    if (child.isBone && child.name === name) {
      result = child;
    }
  });
  
  return result;
}
 
const armBone = findBone(model, 'leftArm');
 
// 控制骨骼
function animate() {
  requestAnimationFrame(animate);
  
  // 旋转手臂
  armBone.rotation.x = Math.sin(time) * 0.5;
  
  renderer.render(scene, camera);
}

骨骼辅助器

javascript
import { SkeletonHelper } from 'three/addons/helpers/SkeletonHelper.js';
 
// 创建骨骼辅助器
const skeletonHelper = new SkeletonHelper(model);
scene.add(skeletonHelper);
 
// 更新辅助器
function animate() {
  requestAnimationFrame(animate);
  
  skeletonHelper.update();
  
  renderer.render(scene, camera);
}

变形动画(Morph Targets)

原理图示

plaintext
变形动画原理
 
基础形态          目标形态 1         目标形态 2
   ○                 ◐                 ●
  /|\               / | \             / | \
   |                 |                 |
  / \               / \               / \
 
   │                 │                 │
   └─────────┬───────┴─────────────────┘


      morphTargetInfluences
      [0.3, 0.7]  权重数组


         混合结果

          /│\

          / \

Morph Targets API 参考

属性/方法说明
geometry.morphAttributes.position变形目标位置数组
geometry.morphAttributes.normal变形目标法线数组
mesh.morphTargetInfluences变形权重数组(0-1)
mesh.morphTargetDictionary变形目标名称到索引的映射
material.morphTargets是否启用变形目标
material.morphNormals是否启用变形法线

创建变形目标

javascript
// 基础几何体
const geometry = new THREE.BoxGeometry(2, 2, 2, 8, 8, 8);
 
// 创建变形目标
const morphTargets = [];
 
// 目标 1:膨胀
const morphTarget1 = new THREE.BoxGeometry(2.5, 2.5, 2.5, 8, 8, 8);
morphTargets.push({
  name: 'inflate',
  positions: morphTarget1.attributes.position.array
});
 
// 目标 2:扭曲
const morphTarget2 = geometry.clone();
const positions2 = morphTarget2.attributes.position;
for (let i = 0; i < positions2.count; i++) {
  const x = positions2.getX(i);
  const y = positions2.getY(i);
  const z = positions2.getZ(i);
  
  // 扭曲变形
  const angle = y * 0.5;
  positions2.setXYZ(
    i,
    x * Math.cos(angle) - z * Math.sin(angle),
    y,
    x * Math.sin(angle) + z * Math.cos(angle)
  );
}
morphTargets.push({
  name: 'twist',
  positions: morphTarget2.attributes.position.array
});
 
// 设置变形目标
geometry.morphAttributes.position = morphTargets.map(target =>
  new THREE.Float32BufferAttribute(target.positions, 3)
);
 
// 创建材质
const material = new THREE.MeshStandardMaterial({
  color: 0x00ff00,
  morphTargets: true
});
 
// 创建网格
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

控制变形权重

javascript
function animate() {
  requestAnimationFrame(animate);
  
  const time = clock.getElapsedTime();
  
  // 设置变形权重
  mesh.morphTargetInfluences[0] = (Math.sin(time) + 1) / 2;  // 膨胀
  mesh.morphTargetInfluences[1] = (Math.cos(time) + 1) / 2;  // 扭曲
  
  renderer.render(scene, camera);
}
WARNING

变形权重的总和不一定需要为 1,可以同时激活多个变形目标产生叠加效果。

变形动画示例:表情动画

javascript
// 表情动画
function createFace() {
  const geometry = new THREE.SphereGeometry(1, 32, 32);
  
  // 正常表情
  const normalPositions = geometry.attributes.position.array.slice();
  
  // 微笑表情
  const smilePositions = geometry.attributes.position.array.slice();
  // 修改嘴角位置...
  
  // 惊讶表情
  const surprisePositions = geometry.attributes.position.array.slice();
  // 修改嘴和眼睛位置...
  
  geometry.morphAttributes.position = [
    new THREE.Float32BufferAttribute(normalPositions, 3),
    new THREE.Float32BufferAttribute(smilePositions, 3),
    new THREE.Float32BufferAttribute(surprisePositions, 3)
  ];
  
  // 设置变形目标名称
  geometry.morphTargetsRelative = false;
  
  const material = new THREE.MeshStandardMaterial({
    color: 0xffcc99,
    morphTargets: true
  });
  
  const mesh = new THREE.Mesh(geometry, material);
  
  // 控制表情
  mesh.morphTargetInfluences = [1, 0, 0];  // 正常
  
  return mesh;
}
 
// 切换表情
function setExpression(mesh, expression) {
  // 表情:'normal', 'smile', 'surprise'
  const index = ['normal', 'smile', 'surprise'].indexOf(expression);
  
  mesh.morphTargetInfluences = [0, 0, 0];
  mesh.morphTargetInfluences[index] = 1;
}

变形动画关键帧

javascript
// 创建变形动画关键帧
const morphTrack = new THREE.NumberKeyframeTrack(
  '.morphTargetInfluences[0]',  // 属性路径
  [0, 1, 2, 3],                 // 时间
  [0, 1, 1, 0]                  // 值
);
 
const clip = new THREE.AnimationClip('morph', 3, [morphTrack]);
 
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip);
action.play();

组合骨骼和变形

javascript
// 骨骼动画 + 变形动画
const geometry = new THREE.SphereGeometry(1, 32, 32);
 
// 设置骨骼
// ...
 
// 设置变形目标
geometry.morphAttributes.position = [
  // ...
];
 
const material = new THREE.MeshStandardMaterial({
  color: 0x00ff00,
  skinning: true,      // 启用骨骼
  morphTargets: true   // 启用变形
});
 
const mesh = new THREE.SkinnedMesh(geometry, material);
plaintext
骨骼 + 变形组合应用
 
角色模型
├── 骨骼动画
│   ├── 肢体运动
│   ├── 走路/跑步
│   └── 动作切换

└── 变形动画
    ├── 面部表情
    ├── 眨眼
    └── 嘴型同步
 
组合效果:身体动作 + 面部表情

从模型加载变形动画

javascript
loader.load('model.glb', (gltf) => {
  const model = gltf.scene;
  scene.add(model);
  
  // 获取变形目标
  model.traverse((child) => {
    if (child.isMesh && child.geometry.morphAttributes.position) {
      console.log('变形目标数量:', child.geometry.morphAttributes.position.length);
      console.log('变形权重:', child.morphTargetInfluences);
      console.log('变形字典:', child.morphTargetDictionary);
    }
  });
  
  // 播放变形动画
  const mixer = new THREE.AnimationMixer(model);
  gltf.animations.forEach(clip => {
    const action = mixer.clipAction(clip);
    action.play();
  });
  
  model.userData.mixer = mixer;
});

骨骼动画高级应用

逆运动学(IK)

plaintext
IK 原理示意
 
目标位置 (Target)


      │   ┌───┐
      │   │   │
      └──►│   │ 骨骼链
          │   │
          └───┘
          
算法:从末端骨骼开始,逐个调整角度
使末端指向目标位置
javascript
// 简单的 IK 求解(CCD-IK)
function solveIK(joints, target, iterations = 10) {
  for (let i = 0; i < iterations; i++) {
    // 从末端到根部遍历
    for (let j = joints.length - 1; j >= 0; j--) {
      const joint = joints[j];
      const endEffector = joints[joints.length - 1].getWorldPosition(new THREE.Vector3());
      
      // 计算方向
      const toTarget = new THREE.Vector3().subVectors(target, joint.getWorldPosition(new THREE.Vector3()));
      const toEnd = new THREE.Vector3().subVectors(endEffector, joint.getWorldPosition(new THREE.Vector3()));
      
      // 旋转骨骼
      const quaternion = new THREE.Quaternion().setFromUnitVectors(
        toEnd.normalize(),
        toTarget.normalize()
      );
      
      joint.quaternion.premultiply(quaternion);
      joint.updateMatrixWorld(true);
    }
  }
}
 
// 使用 IK
const bones = [bone1, bone2, bone3];
const target = new THREE.Vector3(2, 2, 0);
 
solveIK(bones, target);

骨骼物理

javascript
// 简单的骨骼弹簧物理
const bonePhysics = [];
 
bones.forEach(bone => {
  bonePhysics.push({
    velocity: new THREE.Vector3(),
    spring: 0.1,
    damping: 0.8
  });
});
 
function updateBonePhysics(delta) {
  bones.forEach((bone, i) => {
    const physics = bonePhysics[i];
    
    // 弹簧力
    const targetRotation = new THREE.Euler(0, 0, Math.sin(time + i) * 0.5);
    const springForce = new THREE.Vector3(
      targetRotation.x - bone.rotation.x,
      targetRotation.y - bone.rotation.y,
      targetRotation.z - bone.rotation.z
    ).multiplyScalar(physics.spring);
    
    // 更新速度
    physics.velocity.add(springForce);
    physics.velocity.multiplyScalar(physics.damping);
    
    // 应用旋转
    bone.rotation.x += physics.velocity.x;
    bone.rotation.y += physics.velocity.y;
    bone.rotation.z += physics.velocity.z;
  });
}

性能优化

骨骼数量建议

平台建议骨骼数说明
移动端低端≤ 20基础角色
移动端高端20-50复杂角色
桌面端50-100高精度角色
高端 PC> 100电影级模型
javascript
// 骨骼 LOD
function updateSkeletonLOD(camera, models) {
  models.forEach(model => {
    const distance = camera.position.distanceTo(model.position);
    
    if (distance < 10) {
      // 高细节动画
      model.activeAction = model.highDetailAction;
    } else if (distance < 30) {
      // 中等细节
      model.activeAction = model.mediumDetailAction;
    } else {
      // 低细节
      model.activeAction = model.lowDetailAction;
    }
  });
}

变形目标数量建议

平台建议变形目标数说明
移动端4-8基础表情
桌面端8-16复杂表情
高端 PC> 16电影级表情

内存优化

javascript
// 减少变形目标内存占用
// 1. 使用 morphTargetsRelative(增量模式)
geometry.morphTargetsRelative = true;
 
// 2. 只存储与基础形态不同的顶点
// 3. 使用压缩格式存储变形数据

常见问题解答(FAQ)

Q: 骨骼动画不生效,网格没有变形?

A: 检查以下几点:

javascript
// 1. 材质必须启用 skinning
const material = new THREE.MeshStandardMaterial({
  skinning: true  // 必须为 true
});
 
// 2. 使用 SkinnedMesh 而非 Mesh
const mesh = new THREE.SkinnedMesh(geometry, material);
 
// 3. 正确绑定骨骼
mesh.add(bones[0]);  // 添加根骨骼
mesh.bind(skeleton); // 绑定骨骼系统
 
// 4. 确保有骨骼权重数据
console.log(geometry.attributes.skinIndex);
console.log(geometry.attributes.skinWeight);

Q: 骨骼变形出现"糖果纸"扭曲效果?

A: 这通常是因为骨骼权重分配不当。确保:

  1. 权重在关节处平滑过渡
  2. 权重总和为 1
  3. 使用 normalizeSkinWeights() 方法
javascript
// 归一化骨骼权重
mesh.normalizeSkinWeights();

Q: 变形目标没有效果?

A: 检查以下几点:

javascript
// 1. 材质启用 morphTargets
const material = new THREE.MeshStandardMaterial({
  morphTargets: true  // 必须为 true
});
 
// 2. 变形目标已正确设置
console.log(geometry.morphAttributes.position);  // 应该有数据
 
// 3. morphTargetInfluences 已初始化
console.log(mesh.morphTargetInfluences);  // 应该有数据
 
// 4. 顶点数量匹配
const baseCount = geometry.attributes.position.count;
const morphCount = geometry.morphAttributes.position[0].count;
console.log(baseCount === morphCount);  // 应该为 true

Q: 如何调试骨骼动画?

A: 使用 SkeletonHelper 可视化骨骼:

javascript
import { SkeletonHelper } from 'three/addons/helpers/SkeletonHelper.js';
 
const helper = new SkeletonHelper(model);
scene.add(helper);
 
// 在动画循环中更新
function animate() {
  helper.update();
  // ...
}
 
// 打印骨骼信息
model.traverse((child) => {
  if (child.isBone) {
    console.log(`骨骼: ${child.name}`, child.position, child.rotation);
  }
});

Q: 如何让角色看向特定方向?

A: 使用骨骼的 lookAt 方法:

javascript
// 获取头部骨骼
const headBone = model.getObjectByName('Head');
 
// 让头部看向目标
function lookAt(target) {
  // 获取骨骼在世界空间的位置
  const headWorldPos = new THREE.Vector3();
  headBone.getWorldPosition(headWorldPos);
  
  // 计算方向
  const direction = new THREE.Vector3().subVectors(target, headWorldPos);
  
  // 注意:lookAt 会在世界空间操作,需要转换到骨骼局部空间
  // 对于骨骼动画,通常需要更复杂的 IK 解决方案
}

Q: 变形动画和骨骼动画可以同时使用吗?

A: 可以。这是角色动画的常见做法:

javascript
const material = new THREE.MeshStandardMaterial({
  skinning: true,      // 骨骼
  morphTargets: true   // 变形
});
 
const mesh = new THREE.SkinnedMesh(geometry, material);
 
// 骨骼动画控制身体
bones[1].rotation.z = Math.sin(time) * 0.5;
 
// 变形动画控制表情
mesh.morphTargetInfluences[0] = (Math.sin(time) + 1) / 2;

完整示例

javascript
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
// 场景设置
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
 
const controls = new OrbitControls(camera, renderer.domElement);
camera.position.z = 5;
 
const clock = new THREE.Clock();
 
// 创建骨骼动画
function createSkinnedMesh() {
  // 几何体
  const geometry = new THREE.CylinderGeometry(0.1, 0.1, 4, 8, 20, false);
  
  // 创建骨骼
  const bones = [];
  const segmentHeight = 4 / 4;
  
  for (let i = 0; i < 5; i++) {
    const bone = new THREE.Bone();
    bone.position.y = i === 0 ? 0 : segmentHeight;
    bones.push(bone);
    
    if (i > 0) {
      bones[i - 1].add(bone);
    }
  }
  
  // 创建蒙皮网格
  const material = new THREE.MeshStandardMaterial({
    color: 0x00ff00,
    skinning: true
  });
  
  const mesh = new THREE.SkinnedMesh(geometry, material);
  mesh.add(bones[0]);
  
  const skeleton = new THREE.Skeleton(bones);
  mesh.bind(skeleton);
  
  return { mesh, bones };
}
 
const { mesh, bones } = createSkinnedMesh();
scene.add(mesh);
 
// 创建变形动画
function createMorphMesh() {
  const geometry = new THREE.SphereGeometry(1, 32, 32);
  
  // 变形目标 1:膨胀
  const inflateGeometry = new THREE.SphereGeometry(1.3, 32, 32);
  
  // 变形目标 2:扁平
  const flattenGeometry = geometry.clone();
  flattenGeometry.scale(1, 0.5, 1);
  
  geometry.morphAttributes.position = [
    new THREE.Float32BufferAttribute(inflateGeometry.attributes.position.array, 3),
    new THREE.Float32BufferAttribute(flattenGeometry.attributes.position.array, 3)
  ];
  
  const material = new THREE.MeshStandardMaterial({
    color: 0xff6600,
    morphTargets: true
  });
  
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.x = 3;
  
  return mesh;
}
 
const morphMesh = createMorphMesh();
scene.add(morphMesh);
 
// 光源
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);
 
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
 
// 动画循环
function animate() {
  requestAnimationFrame(animate);
  
  const time = clock.getElapsedTime();
  
  // 骨骼动画
  bones[1].rotation.z = Math.sin(time) * 0.5;
  bones[2].rotation.z = Math.cos(time * 1.5) * 0.3;
  bones[3].rotation.z = Math.sin(time * 2) * 0.2;
  
  // 变形动画
  morphMesh.morphTargetInfluences[0] = (Math.sin(time) + 1) / 2;
  morphMesh.morphTargetInfluences[1] = (Math.cos(time) + 1) / 2;
  
  controls.update();
  renderer.render(scene, camera);
}
 
animate();

相关链接