场景结构
场景在 3D 引擎是一个图中节点的层次结构,其中每个节点代表了一个局部空间(local space)。
示例
假设我们需要做一个太阳系的例子。对于太阳来说,它只需要保持自身不动,那么他的child有水金地火木土星……这些child可能还有自己的卫星,比如月球绕着地球转。但是对于月球来说,它需要关注太阳在哪吗?当然不用。月球只需要关注它的parent地球,绕着地球转动即可。同样的假设月球上有个宇航员,他不需要关注太阳和地球怎么动,他的移动只需要相对于月球即可。
我们可以使用一个场景Scene来模仿上述的例子。
创建中心的太阳
// 要更新旋转角度的对象数组 const objects = []; // 一球多用 const radius = 1; // 设置球的半径 const widthSegments = 20; // 设置球的边数 const heightSegments = 20; // 设置球的边数 const sphereGeometry = new THREE.SphereGeometry( radius, widthSegments, heightSegments ); const sunMaterial = new THREE.MeshPhongMaterial({ emissive: 0xffff00 }); const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial); sunMesh.scale.set(5, 5, 5); // 扩大太阳的大小 scene.add(sunMesh); objects.push(sunMesh);放射属性(emissive)是基本上不受其他光照影响的固有颜色。光照会被添加到该颜色上。
要注意:要让太阳能够正确显示,我们需要按需设置camera,这决定了相机的可视范围。
// 创建透视摄像机 const fov = 75; const aspect = 2; // 相机默认值 const near = 0.1; const far = 100; const camera = new THREE.PerspectiveCamera(fov, aspect, near, far); camera.position.set(0, 50, 0); camera.up.set(0, 0, 1); camera.lookAt(0, 0, 0);我们要创建一个俯视图,所以把相机设置在了y轴+50的方向上,同时需要设置对于摄像机朝上的up位置(想象我们俯视(0,0,0)位置的太阳,我们相机的上方应该在z轴方向上),然后我们需要使用lookAt让其看向原点。
相当于三步:确定相机位置、上下和聚焦点
只有这三点都确定,我们才能完全确定一个相机的摆放方式。
注意我们需要调整far,沿用之前的far=5会让离相机远的object显示不出来。
创建完成后,我们想让太阳旋转起来,只需要调整objects数组,设定其旋转方式。
objects.forEach((obj) => { obj.rotation.y = time; });这个object的坐标应该是对于创建的3dObject,它会随着时间绕y轴旋转time的角度。
创建地球
// 创建地球 const earthMaterial = new THREE.MeshPhongMaterial({ color: 0x2233ff, emissive: 0x112244, }); const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial); earthMesh.position.x = 10; scene.add(earthMesh); objects.push(earthMesh);const widthSegments = 10; // 设置球的边数 const heightSegments = 10; // 设置球的边数我们可以适当设置球的边数,这样更容易看出来它在旋转。
在这种情况下,我们虽然可以看到地球正确绘制并自转了,但它并没有绕着太阳公转。
sunMesh.add(earthMesh);我们不应该把earth添加到场景scene中,而是直接添加给sun.
但是地球居然变得和太阳一样大,而且会飞出视野范围。
我们让
earthMesh成为sunMesh的一个子节点。sunMesh.scale.set(5, 5, 5)将其比例设置为 5x。这意味着sunMeshs的局部空间是 5 倍大。这表示地球现在大了 5 倍,它与太阳的距离 (earthMesh.position.x = 10) 也是 5 倍。为了解决这个问题,我们添加一个空的场景图节点。我们将把太阳和地球都作为该节点的子节点。
// 创建太阳系容器 const solarSystem = new THREE.Object3D(); scene.add(solarSystem); objects.push(solarSystem);这里我们创建了一个 [
Object3D]。像 [Mesh]一样,它也是场景图中的一个节点,但与 [Mesh] 不同的是,它没有材质(material)和几何体(geometry)。它只是代表一个局部空间。新的场景图变为:创建月球
延续同样的模式,我们再加一个月亮。
新的场景图应该像这样:
雨是我们再次添加了更多的隐形场景图节点。首先是一个名为earthOrbit的 [Object3D],并将新增earthMesh和moonOrbit都添加到其中。然后,我们把moonMesh添加到moonOrbit上。
轴可视化
[AxesHelper]。它画了 3 条线,分别代表本地的 X(红色), Y(绿色), 以及 Z(蓝色)轴。
// 为每个节点添加一个AxesHelper objects.forEach((node) => { const axes = new THREE.AxesHelper(); axes.material.depthTest = false; axes.renderOrder = 1; node.add(axes); });我们希望轴即使在球体内部也能出现。要做到这一点,我们将其材质(material)的depthTest属性设置为 false,这意味着它们不会检查其是否在其他东西后面进行绘制。我们还将它们的renderOrder属性设置为 1(默认值为 0),这样它们就会在所有球体之后被绘制。否则一个球体可能会画在它们上面,把它们遮住。
可能很难看到其中一些轴,因为有 2 对重叠的轴。sunMesh和solarSystem都在同一位置。同样地,earthMesh和earthOrbit也在同一位置。让我们添加一些简单的控制方法,让我们可以为每个节点打开/关闭它们。同时,我们还可以添加另一个名为 [GridHelper]的帮助工具。它可以在 X,Z 平面上创建一个 2D 网格。默认情况下,网格是 10x10 单位。
使用[lil-gui],这是一个在 three.js 项目中非常流行的 UI 库。lil-gui 会获取一个对象和该对象上的属性名,并根据属性的类型自动生成一个 UI 来操作该属性。
我们要为每个节点制作一个 [GridHelper] 和一个 [AxesHelper]。我们需要为每个节点添加一个标签,所以我们将删除旧的循环,转而调用一些函数为每个节点添加帮助程序。
需要注意的是,我们将 [AxesHelper]的renderOrder设置为 2,将[GridHelper]的设置为 1,这样轴就会在网格之后绘制。否则网格可能会覆盖轴。
场景图示例
在建立场景图之前,我们需要明确每个场景的具体架构。
在一个简单的游戏世界中,一辆汽车可能有这样的场景图。
如果你移动车体,所有的轮子都会随之移动。如果你想让车身和轮子分开弹跳,你可以将车身和轮子作为代表汽车框架的框架(frame)节点的子节点。
对于游戏世界中的人类:
对于一辆简单的坦克:
日地月代码示例
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <style> /* 居中显示并留白,背景和卡片式样式 */ html, body { height: 100%; margin: 0; background: #000000; display: flex; align-items: center; justify-content: center; } /* 画布占满容器 */ canvas { display: block; width: 98vw; height: 98vh; border-radius: 4px; background: #000; /* 可选,避免未渲染时看起来空白 */ } </style> <body> <canvas id="c"></canvas> <script type="module"> // 引入ThreeJs import * as THREE from 'three'; import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; // 渲染(WebGL渲染器) // 自己创建的canvas在使用时候会更灵活(直接对canvas进行修改) const canvas = document.querySelector('#c'); const renderer = new THREE.WebGLRenderer({antialias: true, canvas}); const gui = new GUI(); // 创建透视摄像机 const fov = 75; const aspect = 2; // 相机默认值 const near = 0.1; const far = 100; const camera = new THREE.PerspectiveCamera(fov, aspect, near, far); camera.position.set(0, 50, 0); camera.up.set(0, 0, 1); camera.lookAt(0, 0, 0); // camera.position.z = 4; // 设置相机位置 // 创建场景 const scene = new THREE.Scene(); scene.background = new THREE.Color(0xAAAAAA); // 创建灯光(三维效果会更明显) const color = 0xFFFFFF; const intensity = 3; const light = new THREE.DirectionalLight(color, intensity); light.position.set(-1, 2, 4); scene.add(light); // 要更新旋转角度的对象数组 const objects = []; // 一球多用 const radius = 1; // 设置球的半径 const widthSegments = 10; // 设置球的边数 const heightSegments = 10; // 设置球的边数 const sphereGeometry = new THREE.SphereGeometry( radius, widthSegments, heightSegments ); // 创建太阳系容器 const solarSystem = new THREE.Object3D(); scene.add(solarSystem); objects.push(solarSystem); // 创建太阳 const sunMaterial = new THREE.MeshPhongMaterial({ emissive: 0xffff00 }); const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial); sunMesh.scale.set(5, 5, 5); // 扩大太阳的大小 solarSystem.add(sunMesh); objects.push(sunMesh); // 创建地球轨道容器 const earthOrbit = new THREE.Object3D(); earthOrbit.position.x = 10; solarSystem.add(earthOrbit); objects.push(earthOrbit); // 创建地球 const earthMaterial = new THREE.MeshPhongMaterial({ color: 0x2233ff, emissive: 0x112244, }); const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial); earthOrbit.add(earthMesh); objects.push(earthMesh); // 创建月球轨道容器 const moonOrbit = new THREE.Object3D(); moonOrbit.position.x = 2; earthOrbit.add(moonOrbit); // 创建月球 const moonMaterial = new THREE.MeshPhongMaterial({color: 0x888888, emissive: 0x222222}); const moonMesh = new THREE.Mesh(sphereGeometry, moonMaterial); moonMesh.scale.set(.5, .5, .5); moonOrbit.add(moonMesh); objects.push(moonMesh); // 打开/关闭轴和网格的可见性 // lil-gui 要求一个返回类型为bool型的属性来创建一个复选框,所以我们为 visible属性 // 绑定了一个setter 和 getter。 从而让lil-gui去操作该属性. class AxisGridHelper { constructor(node, units = 10) { const axes = new THREE.AxesHelper(); axes.material.depthTest = false; axes.renderOrder = 2; // 在网格渲染之后再渲染 node.add(axes); const grid = new THREE.GridHelper(units, units); grid.material.depthTest = false; grid.renderOrder = 1; node.add(grid); this.grid = grid; this.axes = axes; this.visible = false; } get visible() { return this._visible; } set visible(v) { this._visible = v; this.grid.visible = v; this.axes.visible = v; } } function makeAxisGrid(node, label, units) { const helper = new AxisGridHelper(node, units); gui.add(helper, 'visible').name(label); } makeAxisGrid(solarSystem, 'solarSystem', 25); makeAxisGrid(sunMesh, 'sunMesh'); makeAxisGrid(earthOrbit, 'earthOrbit'); makeAxisGrid(earthMesh, 'earthMesh'); makeAxisGrid(moonOrbit, 'moonOrbit'); makeAxisGrid(moonMesh, 'moonMesh'); // 是否需要调整渲染器大小以适应显示尺寸 function resizeRendererToDisplaySize(renderer) { const canvas = renderer.domElement; const pixelRatio = window.devicePixelRatio; const width = Math.floor( canvas.clientWidth * pixelRatio ); const height = Math.floor( canvas.clientHeight * pixelRatio ); const needResize = canvas.width !== width || canvas.height !== height; if (needResize) { renderer.setSize(width, height, false); } return needResize; } function render(time) { time *= 0.001; // 将时间单位变为秒 // 调整渲染器大小以适应显示尺寸 if (resizeRendererToDisplaySize(renderer)) { const canvas = renderer.domElement; camera.aspect = canvas.clientWidth / canvas.clientHeight; // 更新相机宽高比 camera.updateProjectionMatrix(); // 更新投影矩阵 } objects.forEach((obj) => { obj.rotation.y = time; }); renderer.render(scene, camera); requestAnimationFrame(render); } requestAnimationFrame(render); </script> </body> </html>