Skip to content

58. 实战:双人斗舞

Published:

学完骨骼动画、css2d 标注后,我们来做一个双人斗舞的实战。

舞台上有两个人,面对面站着。

点击某个人的时候,会把相机切到那个人的角度,用 tweenjs 做相机动画。

在右边展示这个人的介绍信息,用 css2d 标签。

然后点击开始按钮,就开始跳舞,播放骨骼动画。

我们还可以用聚光灯、阴影增加舞台的感觉。

大概就是这样的场景,我们来实现一下:

npx create-vite two-dancer

image.png

进入项目,安装依赖:

npm install
npm install --save three
npm install --save-dev @types/three

改下 src/main.js

import './style.css';
import * as THREE from 'three';
import {
    OrbitControls
} from 'three/addons/controls/OrbitControls.js';
import stage from './stage.js';

const scene = new THREE.Scene();

scene.add(stage);

const directionLight = new THREE.DirectionalLight(0xffffff, 2);
directionLight.position.set(500, 400, 300);
scene.add(directionLight);

const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);

const width = window.innerWidth;
const height = window.innerHeight;

const helper = new THREE.AxesHelper(500);
scene.add(helper);

const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 10000);
camera.position.set(500, 600, 800);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height)

function render() {
    renderer.render(scene, camera);
    requestAnimationFrame(render);
}

render();

document.body.append(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);

window.onresize = function () {
  const width = window.innerWidth;
  const height = window.innerHeight;

  renderer.setSize(width,height);

  camera.aspect = width / height;
  camera.updateProjectionMatrix();
};

创建 Scene、Light、Camera、Renderer。

处理下 window.resize,resize 的时候重新设置宽高比。

改下 style.css

body {
  margin: 0;
}

然后创建 stage.js

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const loader = new GLTFLoader();

const stage = new THREE.Group();

loader.load("./stage.glb", function (gltf) {
    console.log(gltf);
    stage.add(gltf.scene);
});

export default stage;

我们这次直接加载一个房间的模型做舞台。

你可以从这里下载:

image.png

放在 public 目录下:

image.png

跑一下:

npm run dev

image.png

image.png

有点小,放大一下:

gltf.scene.scale.set(50,50,50);

image.png

2025-04-07 16.53.45.gif

然后把舞者的模型加载进来:

https://github.com/mrdoob/three.js/blob/e9144842962d46f0ab4a7049cc072ad201d9659d/examples/models/gltf/Michelle.glb

image.png

下载下来放到 public 目录下:

image.png

在代码里加载:

image.png

loader.load("./Michelle.glb", function (gltf) {
    stage.add(gltf.scene);
    gltf.scene.scale.set(300, 300, 300);
    gltf.scene.position.z = 500;
    gltf.scene.rotateY(Math.PI);
});

image.png

播放下跳舞的骨骼动画:

image.png

const mixer = new THREE.AnimationMixer(gltf.scene);
const clipAction = mixer.clipAction(gltf.animations[0]);
clipAction.play();

const clock = new THREE.Clock();
function render() {
    const delta = clock.getDelta();
    mixer.update(delta);

    requestAnimationFrame(render);
}

render();

2025-04-07 20.24.44.gif

然后我们再加载一个放对面,因为骨骼动画适合模型绑定的,不能直接 clone,我们重新加载一次:

封装个方法:

image.png

加载两次模型,传入 z、旋转角度:

loadDancer((dancer)=> {}, 200, Math.PI);
loadDancer((dancer) => {}, -200, 0);

function loadDancer(callback, z, angle) {
    loader.load("./Michelle.glb", function (gltf) {
        callback(gltf.scene);

        stage.add(gltf.scene);
        gltf.scene.scale.set(300, 300, 300);
        gltf.scene.position.z = z;
        gltf.scene.rotateY(angle);
    
        const mixer = new THREE.AnimationMixer(gltf.scene);
        const clipAction = mixer.clipAction(gltf.animations[0]);
        clipAction.play();

        const clock = new THREE.Clock();
        function render() {
            const delta = clock.getDelta();
            mixer.update(delta);
    
            requestAnimationFrame(render);
        }
    
        render();
    });
}

2025-04-07 20.45.01.gif

给第二个 dancer 改个颜色:

image.png

dancer.traverse(obj => {
    if(obj.isMesh) {
        obj.material = obj.material.clone();
        obj.material.color.set('orange');
    }
})

这里遍历找到 mesh,先复制一份材质,不然共用材质会相互影响,然后改下颜色。

2025-04-07 20.48.31.gif

然后我们加个后期效果:

image.png

image.png

import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { GlitchPass } from 'three/addons/postprocessing/GlitchPass.js';
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

const glitchPass = new GlitchPass();
composer.addPass(glitchPass);

function render() {
    composer.render();
    requestAnimationFrame(render);
}

这里用 RenderPass 和 GlitchPass 两个 Pass。GlitchPass 是闪屏的效果。

看一下:

2025-04-07 20.58.18.gif

可以看到,每隔一段时间有个闪屏效果。

但它会导致颜色变暗,后期通道那节讲过可以加伽马校正来修复颜色:

image.png

这里我们就不修复了,暗点效果更好。

我们可以把灯光调亮一下:

image.png

2025-04-07 21.00.12.gif

好多了。

去掉 AxesHelper,把平行光稍微调暗点,我们再加一个聚光灯:

image.png

const spotLight = new THREE.SpotLight('white', 5000000);
spotLight.angle = Math.PI / 6;
spotLight.position.set(0, 800, 0);
spotLight.lookAt(0, 0, 0);
scene.add(spotLight);

2025-04-07 21.23.18.gif

然后加一下聚光灯的阴影:

首先开启舞者的投射阴影属性:

image.png

gltf.scene.traverse(obj => {
    obj.castShadow = true;
});

然后开启舞台的接收阴影属性:

image.png

gltf.scene.traverse(obj => {
    obj.receiveShadow = true;
});

因为它们都有很多子对象,所以要遍历设置。

image.png

spotLight.castShadow = true;

const cameraHelper = new THREE.CameraHelper(spotLight.shadow.camera);
scene.add(cameraHelper);

开启聚光灯的投射阴影属性,然后用 CameraHelper 可视化一下阴影相机。

image.png

最后开启下 renderer 的阴影设置。

renderer.shadowMap.enabled = true;

看下效果:

image.png

可以看到,没有投射阴影,因为阴影相机的 far 不够大。

改一下:

image.png

spotLight.shadow.camera.far = 10000;

image.png

这样就好了:

2025-04-07 22.15.08.gif

案例代码上传了小册仓库

总结

这节我们实现了双人斗舞的一个场景。

主要用到了 gltf 模型的加载,骨骼动画的播放,聚光灯、阴影,后期处理这些基础知识。

下节我们继续给这个场景加一些交互。

评论