24

9. 动画

在本章之前,所有画面都是静止的,本章将介绍如果使用Three.js进行动态画面的渲染。此外,将会介绍一个Three.js作者写的另外一个库,用来观测每秒帧数(FPS)。

9.1 实现动画效果

9.1.1 动画原理

  • 在这里,我们将动态画面简称为动画(animation)。正如动画片的原理一样,动画的本质是利用了人眼的视觉暂留特性,快速地变换画面,从而产生物体在运动的假象。而对于Three.js程序而言,动画的实现也是通过在每秒钟多次重绘画面实现的。
  • 为了衡量画面切换速度,引入了每秒帧数FPS(Frames Per Second)的概念,是指每秒画面重绘的次数。FPS越,则动画效果越平滑,当FPS小于20时,一般就能明显感受到画面的卡滞现象。
  • 那么FPS是不是越大越好呢?其实也未必。当FPS足够大(比如达到60),再增加帧数人眼也不会感受到明显的变化,反而相应地就要消耗更多资源(比如电影的胶片就需要更长了,或是电脑刷新画面需要消耗计算资源等等)。因此,选择一个适中的FPS即可。
  • NTSC标准的电视FPS是30,PAL标准的电视FPS是25,电影的FPS标准为24。而对于Three.js动画而言,一般FPS在3060之间都是可取的。

9.1.2 setInterval方法

如果要设置特定的FPS(虽然严格来说,即使使用这种方法,JavaScript也不能保证帧数精确性),可以使用JavaScript DOM定义的方法:

setInterval(fn,mesc)
  • 其中,fn是每过msec毫秒执行的函数,如果将fn定义为重绘画面的函数,就能实现动画效果。setInterval函数返回一个变量timer,如果需要停止重绘,需要使用clearInterval方法,并传入该变量timer,具体的做法为:
  • 1、首先,在init函数中定义每20毫秒执行draw函数的setInterval,返回值记录在全局变量timer中:
timer = setInterval(draw,20);
  • 2、在draw函数中,我们首先设定在每帧中的变化(毕竟,如果每帧都是相同的,即使重绘再多次,还是不会有动画的效果),这里我们让场景中的长方体绕y轴转动。然后,执行渲染:
function draw() {
    // 每过20ms 就会执行一次这个函数,rotation.y就会加0.01
    // 转完360度就会进行取余,所以就会一直转下去
    mesh.rotation.y = (mesh.rotation.y + 0.01) % (Math.PI * 2);
    renderer.render(scene, camera);
}
  • 这样,每20毫秒就会调用一次draw函数,改变长方体的旋转值,然后进行重绘。最终得到的效果就是FPS50的旋转长方体。
  • 3、我们在HTML中添加两个按钮,一个是按下后停止动画,另一个是按下后继续动画:
<button id="stopBtn" onclick="stop()">Stop</button> 
<button id="startBtn" onclick="start()">Start</button>
  • 4、对应的stopstart函数为:
function stop() {
    if (timer !== null) {
        clearInterval(timer);
        timer = null;
    }
}

function start() {
    if (timer == null) {
        clearInterval(timer);
        timer = setInterval(draw, 20);
    }
}
  • 完整代码:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>动画效果</title>
    <script type="text/javascript" src="js/three.js"></script>

    <script type="text/javascript">
        var scene = null;
        var camera = null;
        var renderer = null;

        var mesh = null;
        var timer = null;

        function init() {
            renderer = new THREE.WebGLRenderer({
                canvas: document.getElementById('mainCanvas')
            });
            renderer.setClearColor(0x000000);
            scene = new THREE.Scene();

            camera = new THREE.OrthographicCamera(-5, 5, 3.75, -3.75, 0.1, 100);
            camera.position.set(5, 5, 20);
            camera.lookAt(new THREE.Vector3(0, 0, 0));
            scene.add(camera);

            mesh = new THREE.Mesh(new THREE.CubeGeometry(1, 2, 3),
                new THREE.MeshLambertMaterial({
                    color: 0xffff00
                }));
            scene.add(mesh);

            var light = new THREE.DirectionalLight(0xffffff);
            light.position.set(20, 10, 5);
            scene.add(light);

            timer = setInterval(draw, 20);
        }

        function draw() {
            mesh.rotation.y = (mesh.rotation.y + 0.01) % (Math.PI * 2);
            renderer.render(scene, camera);
        }

        function stop() {
            if (timer !== null) {
                clearInterval(timer);
                timer = null;
            }
        }

        function start() {
            if (timer == null) {
                clearInterval(timer);
                timer = setInterval(draw, 20);

            }
        }
    </script>
</head>

<body onload="init()">
    <canvas id="mainCanvas" width="800px" height="600px"></canvas>
    <button id="stopBtn" onclick="stop()">Stop</button>
    <button id="startBtn" onclick="start()">Start</button>
</body>

</html>
  • 效果图:

image

9.1.3 requestAnimationFrame方法

大多数时候,我们并不在意多久重绘一次,这时候就适合用requestAnimationFrame方法了。它告诉浏览器在合适的时候调用指定函数,通常可能达到60FPS

  • requestAnimationFrame同样有对应的cancelAnimationFrame取消动画:
function stop() {
    if (timer !== null) {
        cancelAnimationFrame(timer);
        timer = null;
    }
}
  • setInterval不同的是,由于requestAnimationFrame只请求一帧画面,因此,除了在init函数中需要调用,在被其调用的函数中需要再次调用requestAnimationFrame
function draw() {
    mesh.rotation.y = (mesh.rotation.y + 0.01) % (Math.PI * 2);
    renderer.render(scene, camera);
    timer = requestAnimationFrame(draw);
}
  • 因为requestAnimationFrame较为“年轻”,因而一些老的浏览器使用的是试验期的名字:mozRequestAnimationFramewebkitRequestAnimationFramemsRequestAnimationFrame,为了支持这些浏览器,我们最好在调用之前,先判断是否定义了requestAnimationFrame以及上述函数:
var requestAnimationFrame = window.requestAnimationFrame 
        || window.mozRequestAnimationFrame
        || window.webkitRequestAnimationFrame
        || window.msRequestAnimationFrame;
window.requestAnimationFrame = requestAnimationFrame;
  • 完整代码:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>动画效果</title>
    <script type="text/javascript" src="js/three.js"></script>

    <script type="text/javascript">
        var requestAnimationFrame = window.requestAnimationFrame ||
            window.mozRequestAnimationFrame ||
            window.webkitRequestAnimationFrame ||
            window.msRequestAnimationFrame;
        window.requestAnimationFrame = requestAnimationFrame;

        var scene = null;
        var camera = null;
        var renderer = null;

        var mesh = null;
        var timer = null;

        function init() {
            renderer = new THREE.WebGLRenderer({
                canvas: document.getElementById('mainCanvas')
            });
            renderer.setClearColor(0x000000);
            scene = new THREE.Scene();

            camera = new THREE.OrthographicCamera(-5, 5, 3.75, -3.75, 0.1, 100);
            camera.position.set(5, 5, 20);
            camera.lookAt(new THREE.Vector3(0, 0, 0));
            scene.add(camera);

            mesh = new THREE.Mesh(new THREE.CubeGeometry(1, 2, 3),
                new THREE.MeshLambertMaterial({
                    color: 0xffff00
                }));
            scene.add(mesh);

            var light = new THREE.DirectionalLight(0xffffff);
            light.position.set(20, 10, 5);
            scene.add(light);

            id = requestAnimationFrame(draw);
        }

        function draw() {
            mesh.rotation.y = (mesh.rotation.y + 0.01) % (Math.PI * 2);
            renderer.render(scene, camera);
            timer = requestAnimationFrame(draw);
        }

        function stop() {
            if (timer !== null) {
                cancelAnimationFrame(timer);
                timer = null;
            }
        }

        function start() {
            if (timer == null) {
                timer = requestAnimationFrame(draw);
            }
        }
    </script>
</head>

<body onload="init()">
    <canvas id="mainCanvas" width="800px" height="600px"></canvas>
    <button id="stopBtn" onclick="stop()">Stop</button>
    <button id="startBtn" onclick="start()">Start</button>
</body>

</html>

setInterval和requestAnimationFrame的区别:

  • setInterval方法与requestAnimationFrame方法的区别较为微妙。一方面,最明显的差别表现在setInterval可以手动设定FPS,而requestAnimationFrame则会自动设定FPS;但另一方面,即使是setInterval也不能保证按照给定的FPS执行,在浏览器处理繁忙时,很可能低于设定值。当浏览器达不到设定的调用周期时,requestAnimationFrame采用跳过某些帧的方式来表现动画,虽然会有卡滞的效果但是整体速度不会拖慢,而setInterval会因此使整个程序放慢运行,但是每一帧都会绘制出来;
  • 总而言之,requestAnimationFrame适用于对于时间较为敏感的环境(但是动画逻辑更加复杂),而setInterval则可在保证程序的运算不至于导致延迟的情况下提供更加简洁的逻辑(无需自行处理时间)。

9.2 使用stat.js记录FPS

stat.js是Three.js的作者Mr.Doob的另一个有用的JavaScript库。很多情况下,我们希望知道实时的FPS信息,从而更好地监测动画效果。这时候,stat.js就能提供一个很好的帮助,它占据屏幕中的一小块位置(如左上角),效果为:image,单击后显示每帧渲染时间:image

<script type="text/javascript" src="stat.js"></script>
  • 在页面初始化的时候,对其初始化并将其添加至屏幕一角。这里,我们以左上角为例:
var stat = null;

function init() {
    stat = new Stats();
    stat.domElement.style.position = 'absolute';
    stat.domElement.style.left = '0px';
    stat.domElement.style.top = '0px';
    document.body.appendChild(stat.domElement);

    // Three.js init ...
}
  • 然后,在上一节介绍的动画重绘函数draw中调用stat.begin();stat.end();分别表示一帧的开始与结束:
function draw() {
    stat.begin();

    mesh.rotation.y = (mesh.rotation.y + 0.01) % (Math.PI * 2);
    renderer.render(scene, camera);

    stat.end();
}
  • 完整代码:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>stats</title>
    <script type="text/javascript" src="js/three.js"></script>
    <script type="text/javascript" src="Stats.js"></script>

    <script type="text/javascript">
        var requestAnimationFrame = window.requestAnimationFrame ||
            window.mozRequestAnimationFrame ||
            window.webkitRequestAnimationFrame ||
            window.msRequestAnimationFrame;
        window.requestAnimationFrame = requestAnimationFrame;

        var scene = null;
        var camera = null;
        var renderer = null;

        var mesh = null;
        var id = null;

        var stat = null;

        function init() {
            stat = new Stats();
            stat.domElement.style.position = 'absolute';
            stat.domElement.style.left = '0px';
            stat.domElement.style.top = '0px';
            document.body.appendChild(stat.domElement);

            renderer = new THREE.WebGLRenderer({
                canvas: document.getElementById('mainCanvas')
            });
            renderer.setClearColor(0x000000);
            scene = new THREE.Scene();

            camera = new THREE.OrthographicCamera(-5, 5, 3.75, -3.75, 0.1, 100);
            camera.position.set(5, 5, 20);
            camera.lookAt(new THREE.Vector3(0, 0, 0));
            scene.add(camera);

            mesh = new THREE.Mesh(new THREE.CubeGeometry(1, 2, 3),
                new THREE.MeshLambertMaterial({
                    color: 0xffff00
                }));
            scene.add(mesh);

            var light = new THREE.DirectionalLight(0xffffff);
            light.position.set(20, 10, 5);
            scene.add(light);

            timer = requestAnimationFrame(draw);
        }

        function draw() {
            stat.begin();

            mesh.rotation.y = (mesh.rotation.y + 0.01) % (Math.PI * 2);
            renderer.render(scene, camera);
            timer = requestAnimationFrame(draw);

            stat.end();
        }

        function stop() {
            if (timer !== null) {
                cancelAnimationFrame(timer);
                timer = null;
            }
        }
    </script>
</head>

<body onload="init()">
    <canvas id="mainCanvas" width="800px" height="600px"></canvas>
    <button id="stopBtn" onclick="stop()">Stop</button>

</body>

</html>

9.3 弹球案例

本节我们将使用一个弹球的例子来完整地学习使用动画效果。

  • 1、首先,我们把通用的框架部分写好,按照之前的方法实现动画重绘函数,并加入stat.js库:
var requestAnimationFrame = window.requestAnimationFrame 
        || window.mozRequestAnimationFrame
        || window.webkitRequestAnimationFrame
        || window.msRequestAnimationFrame;
window.requestAnimationFrame = requestAnimationFrame;

var stat;
var renderer;
var scene;
var camera;
var light;

function init() {
    stat = new Stats();
    stat.domElement.style.position = 'absolute';
    stat.domElement.style.left= '0px';
    stat.domElement.style.top = '0px';
    document.body.appendChild(stat.domElement);

    renderer = new THREE.WebGLRenderer({
        canvas: document.getElementById('mainCanvas')
    });
    scene = new THREE.Scene();

    timer = requestAnimationFrame(draw);
}

function draw() {
    stat.begin();

    renderer.render(scene, camera);

    timer = requestAnimationFrame(draw);

    stat.end();
}

function stop() {
    if (timer !== null) {
        cancelAnimationFrame(timer);
        timer = null;
    }
}
  • 2、然后,为了实现弹球弹动的效果,我们创建一个球体作为弹球模型,创建一个平面作为弹球反弹的平面。为了在draw函数中改变弹球的位置,我们可以声明一个全局变量ballMesh,以及弹球半径ballRadius
var ballMesh;
var ballRadius = 0.5;
  • 3、在init函数中添加球体平面,使弹球位于平面上,平面采用棋盘格图像作材质:
// 加载贴图
texture = THREE.ImageUtils.loadTexture('images/chess.png', {}, function() {
    renderer.render(scene, camera);
});
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(4, 4);

// 平面模型
var plane = new THREE.Mesh(new THREE.PlaneGeometry(8, 8),
    new THREE.MeshLambertMaterial({
        map: texture
    }));
// 沿x轴旋转-90度
plane.rotation.x = Math.PI / -2;
scene.add(plane);

// 球模型
ballMesh = new THREE.Mesh(new THREE.SphereGeometry(ballRadius, 40, 16),
    new THREE.MeshLambertMaterial({
        color: 0xffff00
    }));
  • 4、为了记录弹球的状态,我们至少需要位置速度加速度三个矢量,为了简单起见,这里弹球只做竖直方向上的自由落体运动,因此位置、速度、加速度只要各用一个变量表示。其中,位置就是ballMesh.position.y,不需要额外的变量,因此我们在全局声明速度v加速度a
var v = 0;
var a = -0.01;
  • 这里,a = -0.01代表每帧小球向y方向负方向移动0.01个单位。
  • 5、一开始,弹球从高度为maxHeight(自己定义的一个高度)处自由下落,掉落到平面上时会反弹,并且速度有损耗。当速度很小的时候,弹球会在平面上作振幅微小的抖动,所以,当速度足够小时,我们需要让弹球停止跳动。因此,定义一个全局变量表示是否在运动,初始值为false
var isMoving = false;
  • 6、在HTML中定义一个按钮,点击按钮时,弹球从最高处下落:
<button id="dropBtn" onclick="drop()">Drop</button>

<script>
    function drop() {
        isMoving = true;
        ballMesh.position.y = maxHeight;
        v = 0;
    }
</script>
  • 7、下面就是最关键的函数了,在draw函数中,需要判断当前的isMoving值,并且更新小球的速度和位置:
function draw() {
    stat.begin();
    if (isMoving) {
        ballMesh.position.y += v;
        // a= -0.01
        v += a;
        // 当小球从定义的高度落到小球停在平面时的高度的时候
        if (ballMesh.position.y <= ballRadius) {
            // 让小球弹起来
            v = -v * 0.9;
        }
        // 当小球的速度小于设定值的时候
        if (Math.abs(v) < 0.001) {
            // 让它停下来
            isMoving = false;
            ballMesh.position.y = ballRadius;
        }
    }
    renderer.render(scene, camera);
    requestAnimationFrame(draw);
    stat.end();
}
  • 完整代码:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>弹弹弹</title>
    <script type="text/javascript" src="js/three.js"></script>
    <script type="text/javascript" src="Stats.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
        }
        
        body {
            position: fixed;
        }
        
        button {
            position: fixed;
            left: 50%;
            margin-left: -50px;
            bottom: 20%;
            width: 100px;
            height: 30px;
            background-color: #d3d3d3;
            border: none;
            border-radius: 15px;
            outline: none;
            font-size: 18px;
            font-weight: 700;
            color: #333;
            box-shadow: -1px -1px 1px #fff, 1px 1px 1px #000;
        }
    </style>
    <script>
        var stat;
        var renderer;
        var scene;
        var camera;
        var light;
        var texture;
        var ballMesh;
        var ballRadius = 0.5;

        var isMoving = false;
        var maxHeight = 5;
        var v = 0;
        var a = -0.01;

        function init() {
            // 处理requireAnimationFrame兼容性
            var requestAnimationFrame = window.requestAnimationFrame ||
                window.mozRequestAnimationFrame ||
                window.webkitRequestAnimationFrame ||
                window.msRequestAnimationFrame;
            window.requestAnimationFrame = requestAnimationFrame;

            // FPS 插件
            stat = new Stats();
            stat.domElement.style.position = 'absolute';
            stat.domElement.style.left = '0px';
            stat.domElement.style.top = '0px';
            document.body.appendChild(stat.domElement);

            // 渲染器
            renderer = new THREE.WebGLRenderer({
                antialias: true
            });
            width = window.innerWidth;
            height = window.innerHeight;
            renderer.setSize(width, height);
            document.body.appendChild(renderer.domElement);
            renderer.setClearColor(0xd3d3d3);

            // 场景
            scene = new THREE.Scene();

            // 相机
            camera = new THREE.OrthographicCamera(width / -128, width / 128, height / 128, height / -128, 1, 1000);
            camera.position.set(10, 15, 25);
            camera.lookAt(new THREE.Vector3(0, 0, 0));
            scene.add(camera);

            // 添加光照
            light = new THREE.DirectionalLight(0xffffff);
            light.position.set(-10, 30, 25);
            scene.add(light);

            // 加载贴图
            texture = THREE.ImageUtils.loadTexture('images/chess.png', {}, function() {
                renderer.render(scene, camera);
            });
            texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
            texture.repeat.set(4, 4);

            // 平面模型
            var plane = new THREE.Mesh(new THREE.PlaneGeometry(8, 8),
                new THREE.MeshLambertMaterial({
                    map: texture
                }));
            // 沿x轴旋转-90度
            plane.rotation.x = Math.PI / -2;
            scene.add(plane);

            // 球模型
            ballMesh = new THREE.Mesh(new THREE.SphereGeometry(ballRadius, 40, 16),
                new THREE.MeshLambertMaterial({
                    color: 0xffff00
                }));
            // 设置球的位置
            ballMesh.position.y = ballRadius;
            scene.add(ballMesh);

            // 坐标轴
            /* drawAxes(scene);
            function drawAxes(scene) {
                // x-axis
                var xGeo = new THREE.Geometry();
                xGeo.vertices.push(new THREE.Vector3(0, 0, 0));
                xGeo.vertices.push(new THREE.Vector3(7, 0, 0));
                var xMat = new THREE.LineBasicMaterial({
                    color: 0xff0000
                });
                var xAxis = new THREE.Line(xGeo, xMat);
                scene.add(xAxis);

                // y-axis
                var yGeo = new THREE.Geometry();
                yGeo.vertices.push(new THREE.Vector3(0, 0, 0));
                yGeo.vertices.push(new THREE.Vector3(0, 7, 0));
                var yMat = new THREE.LineBasicMaterial({
                    color: 0x00ff00
                });
                var yAxis = new THREE.Line(yGeo, yMat);
                scene.add(yAxis);

                // z-axis
                var zGeo = new THREE.Geometry();
                zGeo.vertices.push(new THREE.Vector3(0, 0, 0));
                zGeo.vertices.push(new THREE.Vector3(0, 0, 7));
                var zMat = new THREE.LineBasicMaterial({
                    color: 0x00ccff
                });
                var zAxis = new THREE.Line(zGeo, zMat);
                scene.add(zAxis);

            } */
            requestAnimationFrame(draw);
        }

        // 计算球运动的速度和位置
        function draw() {
            stat.begin();
            if (isMoving) {
                ballMesh.position.y += v;
                // a= -0.01
                v += a;
                // 当小球从定义的高度落到小球停在平面时的高度的时候
                if (ballMesh.position.y <= ballRadius) {
                    // 让小球弹起来
                    v = -v * 0.9;
                }
                // 当小球的速度小于设定值的时候
                if (Math.abs(v) < 0.001) {
                    // 让它停下来
                    isMoving = false;
                    ballMesh.position.y = ballRadius;
                }
            }
            renderer.render(scene, camera);
            requestAnimationFrame(draw);
            stat.end();
        }
        // 触发函数
        function drop() {
            isMoving = true;
            // 小球起落位置
            ballMesh.position.y = maxHeight;
            // 加速度为0
            v = 0;
        }
    </script>
</head>

<body onload="init();">
    <button id="dropBtn" onclick="drop();">Drop</button>
</body>

</html>
  • 效果图:

image

10. 外部模型

前面我们了解到,使用Three.js创建常见几何体是十分方便的,但是对于人或者动物这样非常复杂的模型使用几何体组合就非常麻烦了。因此,Three.js允许用户导入由3ds Max等工具制作的三维模型,并添加到场景中。

  • 本章以3ds Max为例,介绍如何导入外部模型。

10.1 支持格式

Three.js有一系列导入外部文件的辅助函数,是在three.js之外的,使用前需要额外下载,在https://github.com/mrdoob/three.js/tree/master/examples/js/loaders可以找到,选择对应的模型加载器,系在下来。

  • *.obj是最常用的模型格式,导入*.obj文件需要OBJLoader.js;导入带*.mtl材质的*.obj文件需要MTLLoader.js以及OBJMTLLoader.js。另有PLYLoader.jsSTLLoader.js等分别对应不同格式的加载器,可以根据模型格式自行选择。
  • 目前,支持的模型格式有:

    • *.obj
    • *.obj, *.mtl
    • *.dae
    • *.ctm
    • *.ply
    • *.stl
    • *.wrl
    • *.vtk

10.2 无材质的模型

本节中,我们将将导出的没有材质的模型使用Three.js导入场景中。

<script type="text/javascript" src="OBJLoader.js"></script>
  • 然后,我们需要准备一个*.obj模型,在init函数中,创建loader变量,用于导入模型:
var loader = new THREE.OBJLoader();
  • loader导入模型的时候,接受两个参数,第一个表示模型路径,第二个表示完成导入后的回调函数,一般我们需要在这个回调函数中将导入的模型添加到场景中。
loader.load('../lib/port.obj', function(obj) {
    //储存到全局变量中
    mesh = obj; 
    scene.add(obj);
});
  • 可以看到一个没有材质的茶壶

image

  • 我们在重绘函数中让茶壶旋转:
function draw() {
    renderer.render(scene, camera);

    mesh.rotation.y += 0.01;
    if (mesh.rotation.y > Math.PI * 2) {
        mesh.rotation.y -= Math.PI * 2;
    }
}
  • 可以看到在某些角度时,好像有些面片没有被绘制出来,因而后方的茶嘴似乎穿越到前方了:

image

  • 这是由于默认的情况下,只有正面的面片被绘制,而如果需要双面绘制,需要这样设置:
var loader = new THREE.OBJLoader();
loader.load('port.obj', function(obj) {
    obj.traverse(function(child) {
        if (child instanceof THREE.Mesh) {
            child.material.side = THREE.DoubleSide;
        }
    });

    mesh = obj;
    scene.add(obj);
});
  • 完整代码:
<!DOCTYPE html>
<html lang="en">

<head>
    <script type="text/javascript" src="js/three.js"></script>
    <script type="text/javascript" src="OBJLoader.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
        }
        
        body {
            position: fixed;
        }
    </style>
    <script type="text/javascript">
        var scene = null;
        var camera = null;
        var renderer = null;

        var mesh = null;
        var id = null;

        function init() {
            renderer = new THREE.WebGLRenderer();
            renderer.setSize(800, 600);
            document.body.appendChild(renderer.domElement);
            renderer.setClearColor(0x000000);
            scene = new THREE.Scene();

            camera = new THREE.OrthographicCamera(-8, 8, 6, -6, 0.1, 100);
            camera.position.set(15, 25, 25);
            camera.lookAt(new THREE.Vector3(0, 2, 0));
            scene.add(camera);

            var loader = new THREE.OBJLoader();
            loader.load('port.obj', function(obj) {
                obj.traverse(function(child) {
                    if (child instanceof THREE.Mesh) {
                        child.material.side = THREE.DoubleSide;
                    }
                });

                mesh = obj;
                scene.add(obj);
            });

            var light = new THREE.DirectionalLight(0xffffff);
            light.position.set(20, 10, 5);
            scene.add(light);

            id = setInterval(draw, 20);
        }

        function draw() {
            renderer.render(scene, camera);

            mesh.rotation.y += 0.01;
            if (mesh.rotation.y > Math.PI * 2) {
                mesh.rotation.y -= Math.PI * 2;
            }
        }
    </script>
</head>

<body onload="init()">

</body>

</html>
  • 效果图:

image

10.3 有材质的模型

模型的材质可以有两种定义方式,一种是在代码中导入模型后设置材质,另一种是在建模软件中导出材质信息。下面,我们将分别介绍这两种方法。

10.3.1 代码中设置材质

这种方法与上一节类似,不同之处在于回调函数中设置模型的材质:

var loader = new THREE.OBJLoader();
loader.load('port.obj', function(obj) {
    obj.traverse(function(child) {
        if (child instanceof THREE.Mesh) {
            /* 修改这里以下的代码 */
            child.material = new THREE.MeshLambertMaterial({
                color: 0xffff00,
                side: THREE.DoubleSide
            });
            /* 修改这里以上的代码 */
        }
    });

    mesh = obj;
    scene.add(obj);
});
  • 效果图:

image

10.3.2 建模软件中设置材质

导出3D模型的时候,选择导出port.obj模型文件以及port.mtl材质文件。

  • 现在,我们不再使用OBJLoader.js,而是使用MTLLoader.jsOBJMTLLoader.js,并且要按该顺序引用:
<script type="text/javascript" src="MTLLoader.js"></script>
<script type="text/javascript" src="OBJMTLLoader.js"></script>
  • 调用的方法也略有不同:
var mtlLoader = new THREE.MTLLoader();
mtlLoader.setPath('');
mtlLoader.load('port.mtl', function(materials) {
    materials.preload();
    // model loader
    var objLoader = new THREE.OBJLoader();
    objLoader.setMaterials(materials);
    objLoader.setPath('');
    objLoader.load('port.obj', function(object) {
        object.position.y = -95;
        // if has object, add to scene
        if (object.children.length > 0) {
            scene.add(object.children[0]);
        }
    });
});
  • 完整代码:

<html>

<head>
    <script type="text/javascript" src="js/three.js"></script>
    <script type="text/javascript" src="MTLLoader.js"></script>
    <script type="text/javascript" src="OBJLoader.js"></script>

    <script type="text/javascript">
        var scene = null;
        var camera = null;
        var renderer = null;

        var mesh = null;
        var id = null;

        function init() {
            renderer = new THREE.WebGLRenderer();
            renderer.setSize(800, 600);
            document.body.appendChild(renderer.domElement);
            renderer.setClearColor(0x000000);
            scene = new THREE.Scene();

            camera = new THREE.OrthographicCamera(-8, 8, 6, -6, 0.1, 100);
            camera.position.set(15, 25, 25);
            camera.lookAt(new THREE.Vector3(0, 2, 0));
            scene.add(camera);

            // material loader
            var mtlLoader = new THREE.MTLLoader();
            mtlLoader.setPath('');
            mtlLoader.load('port.mtl', function(materials) {
                materials.preload();
                // model loader
                var objLoader = new THREE.OBJLoader();
                objLoader.setMaterials(materials);
                objLoader.setPath('');
                objLoader.load('port.obj', function(object) {
                    object.position.y = -95;
                    // if has object, add to scene
                    if (object.children.length > 0) {
                        scene.add(object.children[0]);
                    }

                });
                mesh = materials;
                console.log(mesh);
            });
            var light = new THREE.DirectionalLight(0xffffff);
            light.position.set(20, 10, 5);
            scene.add(light);

            id = setInterval(draw, 20);
        }

        function draw() {
            renderer.render(scene, camera);
        }
    </script>
</head>

<body onload="init()">
</body>

</html>
  • 导出时自带的效果图:

image

11. 光与影

图像渲染的丰富效果很大程度上也要归功于光与影的利用。真实世界中的光影效果非常复杂,但是其本质—光的传播原理却又是非常单一的,这便是自然界繁简相成的又一例证。为了使计算机模拟丰富的光照效果,人们提出了几种不同的光源模型(环境光平行光点光源聚光灯等),在不同场合下组合利用,将能达到很好的光照效果。

  • 在Three.js中,光源与阴影的创建和使用是十分方便的。在学会了如何控制光影的基本方法之后,如果能将其灵活应用,将能使场景的渲染效果更加丰富逼真。在本章中,我们将探讨四种常用的光源(环境光、点光源、平行光、聚光灯)和阴影带来的效果,以及如何去创建使用光影。

11.1 环境光(AmbientLight)

环境光是指场景整体的光照效果,是由于场景内若干光源的多次反射形成的亮度一致的效果,通常用来为整个场景指定一个基础亮度。因此,环境光没有明确的光源位置,在各处形成的亮度也是一致的。

  • 在设置环境光时,只需指定光的颜色:
var light = new THREE.AmbientLight(hex);
scene.add(light);
  • 其中hex是十六进制的RGB颜色信息,如红色表示为0xff0000
  • 但是,如果此时场景中没有物体,只添加了这个环境光,那么渲染的结果仍然是一片黑。所以,我们添加两个长方体看下效果:
// 创建一个绿色的正方体
var greenCube = new THREE.Mesh(new THREE.CubeGeometry(2, 2, 2),
        new THREE.MeshLambertMaterial({color: 0x00ff00}));
greenCube.position.x = 3;
scene.add(greenCube);

// 创建一个白色的正方体
var whiteCube = new THREE.Mesh(new THREE.CubeGeometry(2, 2, 2),
        new THREE.MeshLambertMaterial({color: 0xffffff}));
whiteCube.position.x = -3;
scene.add(whiteCube);
  • 效果如图:

image

  • 如果想让环境光暗些,可以将其设置为new THREE.AmbientLight(0xcccccc)等,效果为:

image

11.2 点光源(PointLight)

点光源是不计光源大小,可以看作一个点发出的光源。点光源照到不同物体表面的亮度是线性递减的,因此,离点光源距离越的物体会显得越

  • 点光源的构造函数是:
THREE.PointLight(hex, intensity, distance);
  • 其中,hex是光源十六进制的颜色值;intensity是亮度,缺省值为1,表示100%亮度;distance是光源最远照射到的距离,缺省值为0
  • 创建点光源并将其添加到场景中的完整做法是:
var light = new THREE.PointLight(0xffffff, 2, 100);
light.position.set(0, 1.5, 2);
scene.add(light);
  • 效果图:

image

  • 注意,这里光在每个面上的亮度是不同的,对于每个三角面片,将根据三个顶点的亮度进行插值。

11.3 平行光(DirectionalLight)

我们都知道,太阳光常常被看作平行光,这是因为相对地球上物体的尺度而言,太阳离我们的距离足够远。对于任意平行的平面,平行光照射的亮度都是相同的,而与平面所在位置无关。

  • 平行光的构造函数是:
THREE.DirectionalLight(hex, intensity)
  • 其中,hex是光源十六进制的颜色值;intensity是亮度,缺省值为1,表示100%亮度。
  • 此外,对于平行光而言,设置光源位置尤为重要。
var light = new THREE.DirectionalLight();
light.position.set(2, 5, 3);
scene.add(light);
  • 注意,这里设置光源位置并不意味着所有光从(2, 5, 3)点射出(如果是的话,就成了点光源),而是意味着,平行光将以矢量(-2, -5, -3)的方向照射到所有平面。因此,平面亮度与平面的位置无关,而只与平面的法向量相关。只要平面是平行的,那么得到的光照也一定是相同的。
  • 示例代码:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="js/three.js"></script>
    <script>
        var stat;
        var renderer;
        var scene;
        var camera;

        function init() {

            // 渲染器
            renderer = new THREE.WebGLRenderer({
                antialias: true
            });
            renderer.setSize(800, 600);
            document.body.appendChild(renderer.domElement);
            renderer.setClearColor(0x000000);

            // 场景
            scene = new THREE.Scene();

            // 相机
            var camera = new THREE.OrthographicCamera(-5, 5, 3.75, -3.75, 0.1, 100);
            camera.position.set(5, 15, 25);
            camera.lookAt(new THREE.Vector3(0, 0, 0));
            scene.add(camera);

            // 平行光
            var light = new THREE.DirectionalLight();
            light.position.set(2, 5, 3);
            scene.add(light);
            
            // 右侧正方体
            var rightCube = new THREE.Mesh(new THREE.CubeGeometry(1, 1, 1),
                new THREE.MeshLambertMaterial({
                    color: 0x00ff00
                }));
            rightCube.position.x = 1;
            rightCube.position.y = -1;
            scene.add(rightCube);
            
            // 左侧正方体
            var leftCube = new THREE.Mesh(new THREE.CubeGeometry(1, 1, 1),
                new THREE.MeshLambertMaterial({
                    color: 0x00ff00
                }));
            leftCube.position.x = -1;
            scene.add(leftCube);

            // 渲染
            renderer.render(scene, camera);
        }
    </script>
</head>

<body onload="init();">

</body>

</html>
  • 效果图:

image

11.4 聚光灯(SpotLight)

可以看出,聚光灯是一种特殊的点光源,它能够朝着一个方向投射光线。聚光灯投射出的是类似圆锥形的光线,这与我们现实中看到的聚光灯是一致的。

  • 其构造函数为:
THREE.SpotLight(hex, intensity, distance, angle, exponent)
  • 相比点光源,多了angleexponent两个参数。angle是聚光灯的张角,缺省值是Math.PI / 3,最大值是Math.PI / 2exponent是光强在偏离target的衰减指数(target需要在之后定义,缺省值为(0, 0, 0)),缺省值是10
  • 在调用构造函数之后,除了设置光源本身的位置,一般还需要设置target
light.position.set(x1, y1, z1);
light.target.position.set(x2, y2, z2);
  • 除了设置light.target.position的方法外,如果想让聚光灯跟着某一物体移动(就像真的聚光灯!),可以target指定为该物体
var cube = new THREE.Mesh(new THREE.CubeGeometry(1, 1, 1),
                    new THREE.MeshLambertMaterial({color: 0x00ff00}));

var light = new THREE.SpotLight(0xffff00, 1, 100, Math.PI / 6, 25);
light.target = cube;
  • 示例代码:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="js/three.js"></script>
    <script type="text/javascript">
        var scene = null;
        var camera = null;
        var renderer = null;

        var cube = null;
        var alpha = 0;

        function init() {
            renderer = new THREE.WebGLRenderer();
            renderer.setSize(800, 600);

            document.body.appendChild(renderer.domElement);

            scene = new THREE.Scene();

            camera = new THREE.OrthographicCamera(-5, 5, 3.75, -3.75, 0.1, 100);
            camera.position.set(5, 15, 25);
            camera.lookAt(new THREE.Vector3(0, 0, 0));
            scene.add(camera);

            // 平面
            var plane = new THREE.Mesh(new THREE.PlaneGeometry(8, 8, 16, 16),
                new THREE.MeshLambertMaterial({
                    color: 0xcccccc
                }));
            plane.rotation.x = -Math.PI / 2;
            plane.position.y = -1;
            plane.receiveShadow = true;
            scene.add(plane);

            // 立方体
            cube = new THREE.Mesh(new THREE.CubeGeometry(1, 1, 1),
                new THREE.MeshLambertMaterial({
                    color: 0x00ff00,
                }));
            cube.position.x = 2;
            scene.add(cube);

            // 聚光灯
            var light = new THREE.SpotLight(0xffff00, 1, 100, Math.PI / 6, 25);
            light.position.set(2, 5, 3);
            light.target = cube;
            scene.add(light);

            // 环境光
            var ambient = new THREE.AmbientLight(0x666666);
            scene.add(ambient);

            requestAnimationFrame(draw);
        }

        function draw() {
            alpha += 0.01;
            if (alpha > Math.PI * 2) {
                alpha -= Math.PI * 2;
            }

            cube.position.set(2 * Math.cos(alpha), 0, 2 * Math.sin(alpha));

            renderer.render(scene, camera);

            requestAnimationFrame(draw);
        }
    </script>
</head>

<body onload="init();">

</body>

</html>
  • 效果图:

image

11.5 阴影

明暗是相对的,阴影的形成也就是因为比周围获得的光照更少。因此,要形成阴影,光源必不可少。

  • 在Three.js中,能形成阴影的光源只有THREE.DirectionalLightTHREE.SpotLight;而相对地,能表现阴影效果的材质只有THREE.LambertMaterialTHREE.PhongMaterial。因而在设置光源和材质的时候,一定要注意这一点。
  • 下面,我们以聚光灯为例,在之前的基础上增加阴影效果。
  • 首先,我们需要在初始化时,告诉渲染器渲染阴影:
renderer.shadowMapEnabled = true;
  • 然后,对于光源以及所有要产生阴影的物体调用:
// 上面的案例,产生阴影的物体是正方体
cube.castShadow = true;
  • 对于接收阴影的物体调用:
// 接收阴影的物体是平面
plan.receiveShadow = true;
  • 比如场景中一个平面上有一个正方体,想要让聚光灯照射在正方体上,产生的阴影投射在平面上,那么就需要对聚光灯和正方体调用castShadow = true,对于平面调用receiveShadow = true
  • 以上就是产生阴影效果的必要步骤了,不过通常还需要设置光源的阴影相关属性,才能正确显示出阴影效果。
  • 对于聚光灯,需要设置shadowCameraNearshadowCameraFarshadowCameraFov三个值,类比我们在第二章学到的透视投影照相机,只有介于shadowCameraNearshadowCameraFar之间的物体将产生阴影,shadowCameraFov表示张角。
  • 对于平行光,需要设置shadowCameraNearshadowCameraFarshadowCameraLeftshadowCameraRightshadowCameraTop以及shadowCameraBottom六个值,相当于正交投影照相机的六个面。同样,只有在这六个面围成的长方体内的物体才会产生阴影效果。
  • 为了看到阴影照相机的位置,通常可以在调试时开启light.shadowCameraVisible = true
  • 如果想要修改阴影的深浅,可以通过设置shadowDarkness,该值的范围是01,越小越浅。
  • 另外,这里实现阴影效果的方法是Shadow Mapping,即阴影是作为渲染前计算好的贴图贴上去的,因而会受到贴图像素大小的限制。所以可以通过设置shadowMapWidthshadowMapHeight值控制贴图的大小,来改变阴影的精确度。
  • 而如果想实现软阴影的效果,可以通过renderer.shadowMapSoft = true;方便地实现。
  • 完整代码:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="js/three.js"></script>
    <script type="text/javascript">
        var scene = null;
        var camera = null;
        var renderer = null;

        var cube = null;
        var alpha = 0;

        function init() {
            renderer = new THREE.WebGLRenderer();
            renderer.setSize(800, 600);

            document.body.appendChild(renderer.domElement);
            renderer.shadowMapEnabled = true;


            scene = new THREE.Scene();

            camera = new THREE.OrthographicCamera(-5, 5, 3.75, -3.75, 0.1, 100);
            camera.position.set(5, 15, 25);
            camera.lookAt(new THREE.Vector3(0, 0, 0));
            scene.add(camera);

            var plane = new THREE.Mesh(new THREE.PlaneGeometry(8, 8, 16, 16),
                new THREE.MeshLambertMaterial({
                    color: 0xcccccc
                }));
            plane.rotation.x = -Math.PI / 2;
            plane.position.y = -1;
            plane.receiveShadow = true;
            scene.add(plane);

            cube = new THREE.Mesh(new THREE.CubeGeometry(1, 1, 1),
                new THREE.MeshLambertMaterial({
                    color: 0x00ff00
                }));
            cube.position.x = 2;
            cube.castShadow = true;
            scene.add(cube);

            var light = new THREE.SpotLight(0xffff00, 1, 100, Math.PI / 6, 25);
            light.position.set(2, 5, 3);
            light.target = cube;
            light.castShadow = true;

            light.shadowCameraNear = 2;
            light.shadowCameraFar = 10;
            light.shadowCameraFov = 30;

            light.shadowMapWidth = 1024;
            light.shadowMapHeight = 1024;
            light.shadowDarkness = 0.3;

            scene.add(light);

            // ambient light
            var ambient = new THREE.AmbientLight(0x666666);
            scene.add(ambient);

            requestAnimationFrame(draw);
        }

        function draw() {
            alpha += 0.01;
            if (alpha > Math.PI * 2) {
                alpha -= Math.PI * 2;
            }

            cube.position.set(2 * Math.cos(alpha), 0, 2 * Math.sin(alpha));

            renderer.render(scene, camera);

            requestAnimationFrame(draw);
        }
    </script>
</head>

<body onload="init();">

</body>

</html>
  • 效果图:

image

补充问题

<span id = "jump"></span>

本地服务器

  • 1、下载安装node.js,因为node.js自带npm
  • 2、打开电脑命令行工具,输入npm install -g live-server 全局安装
  • 3、在需要运行文件的文件夹下,按住shift键,点击鼠标右键在此处打开命令窗口
  • 4、输入live-server回车

nmp官方说明


深海丶Deepsea
3.9k 声望1.4k 粉丝

Trust yourself,You know more than you think you do.