Three.js物理效果实践

这段代码通过 Three.jscannon-es 库,创建了一个有趣的交互式 3D 物理模拟场景。当你在页面上点击鼠标时,会发射出一个小球,它会在重力作用下弹跳并与地面发生碰撞。

以下是这段代码的逐步解析,以及它如何协同工作来构建这个模拟场景:

一、设置 3D 场景基础

首先,代码导入了所需的库:

  • THREE:用于创建和渲染 3D 图形。

  • CANNON:一个轻量级的 JavaScript 物理引擎,用于处理碰撞、重力和运动。

  • OrbitControls:来自 Three.js 的一个辅助工具,让你能够通过鼠标拖动、缩放和旋转来控制相机视角。

接下来,代码设置了 Three.js 的核心组件:

  • scene(场景):3D 世界的容器,所有物体、灯光和相机都放在这里。

  • camera(相机):你观察世界的视点。这里使用 PerspectiveCamera 创建了一个透视相机,模拟了人眼的观察方式。

  • renderer(渲染器):负责将场景中的 3D 对象渲染到你的浏览器窗口中。它还启用了阴影贴图 (renderer.shadowMap.enabled = true;),让场景看起来更真实。

  • directionalLight(平行光):模拟了太阳光,它从上方照射,并启用了投射阴影。为了提高阴影质量,代码还调整了阴影的分辨率和相机范围。

import * as THREE from 'three';
import * as CANNON from 'cannon-es';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// 场景
const scene = new THREE.Scene();
//相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 100, 30);
camera.lookAt(0, 0, 0);
//渲染
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

二、创建地面

  • 视觉上的地面:使用 THREE.PlaneGeometryTHREE.MeshStandardMaterial 创建了一个 Three.js 平面。这个平面被旋转到水平,并设置了 receiveShadow = true,使其能够接收其他物体投射的阴影。

  • 物理上的地面:使用 CANNON.PlaneCANNON.Body 创建了一个物理世界的平面。它的 mass 被设置为 0,这意味着它是一个不可移动的静态物体。这个物理平面与视觉平面被旋转到相同的方向,从而在物理世界和视觉世界中对齐。

//加载器
const loader = new THREE.TextureLoader();
const texture = loader.load('./images/01.jpg');

//创建一个平面
const planeGeometry = new THREE.PlaneGeometry(100, 100, 100, 100);
const planeMaterial = new THREE.MeshStandardMaterial({
    map: texture
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.rotation.x = -Math.PI / 2;
scene.add(plane);


// 创建一个物理世界
const world = new CANNON.World();
world.gravity.set(0, -9.82, 0);

// 创建物理材质
const groundPhysMat = new CANNON.Material();
const ballPhysMat = new CANNON.Material();
// 配置接触材质
const contactMaterial = new CANNON.ContactMaterial(
    groundPhysMat,
    ballPhysMat,
    {
        restitution: 0.9,    // 弹性系数 0-1(1为完全弹性碰撞)
        friction: 0.8       // 摩擦系数
    }
);
world.addContactMaterial(contactMaterial);

// 创建物理地面
const planeShape = new CANNON.Plane()
const planeBody = new CANNON.Body({
    mass: 0,
    shape: planeShape,
    material: groundPhysMat
})
planeBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0) // make it face up
world.addBody(planeBody)

三、创建小球

定义一个 createSphere 函数,同时创建了 Three.js 的视觉小球和 cannon-es 的物理小球:

  • 视觉小球:一个 THREE.SphereGeometry 加上 THREE.MeshStandardMaterial,用于在场景中显示。

  • 物理小球:一个 CANNON.Sphere 加上 CANNON.Body。它的 mass 被设置为 0.5,这意味着它会受到重力和其他力的影响。

当你在页面上点击鼠标时,一个事件监听器会触发:

  1. Raycaster:Three.js 的一个工具,用于从相机位置发射一条射线,来确定鼠标点击了 3D 空间中的哪个位置和方向。

  2. createSphere 被调用:根据射线的信息,在点击的位置创建一个新的小球。

  3. applyLocalForce:在物理小球创建后,立即给它一个力,使其沿着你点击的方向飞出去。

const createSphere = (position, direction) => {
    //创建一个小球
    const radio = 10;
    const ballGeometry = new THREE.SphereGeometry(radio, 30, 30);
    const ballMaterial = new THREE.MeshStandardMaterial({
        map: texture
    });
    const ball = new THREE.Mesh(ballGeometry, ballMaterial);
    ball.position.copy(position);
    ball.castShadow = true;
    scene.add(ball);

    // 创建一个物理小球
    const ballShape = new CANNON.Sphere(radio);
    const ballBody = new CANNON.Body({
        mass: 0.5,
        position: ball.position,
        shape: ballShape,
        material: ballPhysMat
    });
    //给物理小球添加力
    ballBody.applyLocalForce(
        direction.scale(600),
        new CANNON.Vec3(0, 0, 0)
    );
    world.addBody(ballBody);

    spheres.push({
        three: ball,
        cannon: ballBody
    })
}
renderer.domElement.addEventListener('click', (event) => {
    //坐标归化
    const mouse = new THREE.Vector2();
    mouse.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
    mouse.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;

    const raycaster = new THREE.Raycaster();
    raycaster.setFromCamera(mouse, camera);

    const pos = new THREE.Vector3();
    pos.copy(raycaster.ray.direction);
    pos.add(raycaster.ray.origin);

    const direction = new CANNON.Vec3(
        raycaster.ray.direction.x,
        raycaster.ray.direction.y,
        raycaster.ray.direction.z
    );

    createSphere({
        x: pos.x,
        y: pos.y,
        z: pos.z
    }, direction)
})

四、物理世界与视觉世界的同步

这是整个模拟的核心部分。物理引擎和渲染引擎是独立运行的,你需要将它们的结果同步起来。

  • world.step(1 / 60):这是物理世界的关键步骤。它告诉 cannon-es 引擎,时间已经过去了 1/60 秒。在这个时间步长内,引擎会计算所有物体的运动、碰撞、重力等物理效果。

  • spheres.forEach(...):这段代码遍历了所有创建的小球。对于每个小球,它会将物理世界中的位置 (sphere.cannon.position) 和旋转 (sphere.cannon.quaternion) 拷贝给 Three.js 对应的视觉小球 (sphere.three.positionsphere.three.quaternion)。

  • animate() 函数:使用 requestAnimationFrame 循环调用 animate() 函数。在这个循环里,它会:

    • 更新相机控制器 (controls.update())。

    • 调用 update() 函数,从而同步物理和视觉世界。

    • 重新渲染场景 (renderer.render(scene, camera))。

// 物理世界和threejs世界同步
function update() {
    world.step(1 / 60);
    spheres.forEach(sphere => {
        sphere.three.position.copy(sphere.cannon.position);
        // 球面朝向滚动
        sphere.three.quaternion.copy(sphere.cannon.quaternion);
    })
}
//创建一个轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
function animate() {
    requestAnimationFrame(animate);
    controls.update();
    update()
    renderer.render(scene, camera);
}
animate();

五、碰撞效果

小球的弹跳效果是通过 CANNON.ContactMaterial 配置的。

  • groundPhysMatballPhysMat:分别定义了地面和小球的物理材质。

  • contactMaterial:这个对象定义了这两种材质相互接触时的行为。

    • restitution: 0.9:这是弹性系数0.9 表示碰撞后会损失一点能量,但小球会弹起得很高(1 是完全弹性碰撞,0 是完全非弹性碰撞,不会弹起)。

    • friction: 0.8:这是摩擦系数。它决定了小球在地面上滚动时受到的阻力。

// 创建物理材质
const groundPhysMat = new CANNON.Material();
const ballPhysMat = new CANNON.Material();
// 配置接触材质
const contactMaterial = new CANNON.ContactMaterial(
    groundPhysMat,
    ballPhysMat,
    {
        restitution: 0.9,    // 弹性系数 0-1(1为完全弹性碰撞)
        friction: 0.8       // 摩擦系数
    }
);
world.addContactMaterial(contactMaterial);

全部代码:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        body {
            margin: 0;
            padding: 0;
        }
    </style>
</head>

<body>
    <script type="module">
        import * as THREE from 'three';
        import * as CANNON from 'cannon-es';
        import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

        // 场景
        const scene = new THREE.Scene();
        //相机
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.set(0, 100, 30);
        camera.lookAt(0, 0, 0);
        //渲染
        const renderer = new THREE.WebGLRenderer();
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);
        renderer.shadowMap.enabled = true;

        // 从上方照射的白色平行光,强度为 0.5。
        const directionalLight = new THREE.DirectionalLight(0xffffff, 3);
        directionalLight.position.set(20, 20, 0);
        directionalLight.castShadow = true;
        // 增加阴影质量设置
        directionalLight.shadow.mapSize.width = 4096;  // 提高阴影分辨率
        directionalLight.shadow.mapSize.height = 4096;
        directionalLight.shadow.camera.near = 0.5;     // 调整阴影相机范围
        directionalLight.shadow.camera.far = 500;
        directionalLight.shadow.camera.left = -100;    // 扩展阴影计算区域
        directionalLight.shadow.camera.right = 100;
        directionalLight.shadow.camera.top = 100;
        directionalLight.shadow.camera.bottom = -100;

        scene.add(directionalLight);
        //加载器
        const loader = new THREE.TextureLoader();
        const texture = loader.load('./images/01.jpg');

        //创建一个平面
        const planeGeometry = new THREE.PlaneGeometry(100, 100, 100, 100);
        const planeMaterial = new THREE.MeshStandardMaterial({
            map: texture
        });
        const plane = new THREE.Mesh(planeGeometry, planeMaterial);
        plane.rotation.x = -Math.PI / 2;
        plane.receiveShadow = true;
        scene.add(plane);


        // 创建一个物理世界
        const world = new CANNON.World();
        world.gravity.set(0, -9.82, 0);

        // 创建物理材质
        const groundPhysMat = new CANNON.Material();
        const ballPhysMat = new CANNON.Material();
        // 配置接触材质
        const contactMaterial = new CANNON.ContactMaterial(
            groundPhysMat,
            ballPhysMat,
            {
                restitution: 0.9,    // 弹性系数 0-1(1为完全弹性碰撞)
                friction: 0.8       // 摩擦系数
            }
        );
        world.addContactMaterial(contactMaterial);

        // 创建物理地面
        const planeShape = new CANNON.Plane()
        const planeBody = new CANNON.Body({
            mass: 0,
            shape: planeShape,
            material: groundPhysMat
        })
        planeBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0) // make it face up
        world.addBody(planeBody)

        const spheres = []
        const createSphere = (position, direction) => {
            //创建一个小球
            const radio = 10;
            const ballGeometry = new THREE.SphereGeometry(radio, 30, 30);
            const ballMaterial = new THREE.MeshStandardMaterial({
                map: texture
            });
            const ball = new THREE.Mesh(ballGeometry, ballMaterial);
            ball.position.copy(position);
            ball.castShadow = true;
            scene.add(ball);

            // 创建一个物理小球
            const ballShape = new CANNON.Sphere(radio);
            const ballBody = new CANNON.Body({
                mass: 0.5,
                position: ball.position,
                shape: ballShape,
                material: ballPhysMat
            });
            //给物理小球添加力
            ballBody.applyLocalForce(
                direction.scale(600),
                new CANNON.Vec3(0, 0, 0)
            );
            world.addBody(ballBody);

            spheres.push({
                three: ball,
                cannon: ballBody
            })
        }
        renderer.domElement.addEventListener('click', (event) => {
            //坐标归化
            const mouse = new THREE.Vector2();
            mouse.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
            mouse.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;

            const raycaster = new THREE.Raycaster();
            raycaster.setFromCamera(mouse, camera);

            const pos = new THREE.Vector3();
            pos.copy(raycaster.ray.direction);
            pos.add(raycaster.ray.origin);

            const direction = new CANNON.Vec3(
                raycaster.ray.direction.x,
                raycaster.ray.direction.y,
                raycaster.ray.direction.z
            );

            createSphere({
                x: pos.x,
                y: pos.y,
                z: pos.z
            }, direction)
        })
        // 物理世界和threejs世界同步
        function update() {
            world.step(1 / 60);
            spheres.forEach(sphere => {
                sphere.three.position.copy(sphere.cannon.position);
                // 球面朝向滚动
                sphere.three.quaternion.copy(sphere.cannon.quaternion);
            })
        }
        //创建一个轨道控制器
        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;
        function animate() {
            requestAnimationFrame(animate);
            controls.update();
            update()
            renderer.render(scene, camera);
        }
        animate();

    </script>
</body>

</html>

效果展示:

compressed_C{14UA8GB8WJU2~L$4C`3HV

六、总结

这段代码通过结合 Three.js 和 cannon-es,成功地创建了一个实时的 3D 物理模拟场景。Three.js 负责所有你看得到的视觉效果,而 cannon-es 则在幕后处理所有复杂的物理计算。通过将这两个世界的位置和旋转同步起来,我们就看到了一个逼真且富有交互性的物理世界。

posted @ 2025-08-10 18:07  雪旭  阅读(54)  评论(0)    收藏  举报