Three.js 学习 - 第一个3D场景

Q: Three.js 是什么?我为什么要学习它?

A: Three.js 是一个很强大的 WebGL 工具库,我之前使用做地图前端项目,就是要出炫酷的效果,用过一些,但总是调 API ,没有自己系统学习过,所以这次打算系统学习一下并记录一下个人的工作流

前言

这一篇其实最主要的还是熟悉一下 Three.js 的开发流程和基本的使用。

  • 如何在项目中使用 Three.js
  • 一个最简单的 3D 场景案例
  • 几个简单的概念名词

如何在项目中使用 Three.js

这里其实不用概述,跟大部分的开源库一样,都有 CDN 和 npm 两种方式。

1. 使用 CDN

官方文档: https://threejs.org/docs/index.html#manual/zh/introduction/Installation

          <!-- 
  使用 importmap 的方式引入 three.js 
  记得更新版本号
  版本号地址:https://www.npmjs.com/package/three?activeTab=versions
-->
<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
    }
  }
</script>

        
HTML

2. 使用 npm

          # 安装 three.js 和 @types/three
npm install three @types/three

        
BASH

案例分析

1. 最简案例

代码如下:

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

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>1. 一个最简单的 3D 场景</title>

  <script type="importmap">
    {
      "imports": {
        "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
        "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
      }
    }
  </script>

  <style>
    html {
      box-sizing: border-box;
      font-family: 'Nunito Sans', sans-serif;
      font-size: 62.5%;
    }

    html body {
      font-size: 1.6rem;
      margin: 0;
      overflow: hidden;
    }

    ul {
      list-style: none;
    }

    canvas {
      width: 100%;
      height: 100%;
    }
  </style>
</head>

<body>

  <script type="module">
    import * as THREE from 'three';

    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 geometry = new THREE.BoxGeometry(1, 1, 1);
    const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
    const cube = new THREE.Mesh(geometry, material);
    scene.add(cube);

    camera.position.z = 5;

    function animate() {
      requestAnimationFrame(animate);

      cube.rotation.x += 0.01;
      cube.rotation.y += 0.01;

      renderer.render(scene, camera);
    }

    animate();
  </script>

</body>

</html>

        
HTML

这里其实最重要的就是: 场景(Scene)、相机(Camera)、渲染器(Renderer)。而渲染器 Renderer 是用来渲染场景的,也是最重要的。

而为什么这样说呢?(ο´・д・)?? 向下看 ⬇️

代码中,先创建了场景、相机、渲染器。但是这三个并没有联系在一起,而是通过先通过设置渲染器的大小,将渲染器添加到 body 中。这时候渲染器就起了作用,页面中能看到一个黑色的画布了。

此时,再通过 animate 函数,renderer.render(scene, camera); 通过利用渲染器,将场景和相机联系在一起,这样其实就达到了,有个场景客观存在,且有个相机可以将其看到,最后再通过渲染器,将这一起给渲染到画布中。 而早在第一步已经将渲染器添加到 DOM 中了,画布中自然能够看到。

所以才会说,Three.js 最基础最核心的三个概念就是:场景、相机、渲染器。因为这三个概念是渲染 3D 场景的必要条件。当然要 3D 场景更加好看,还需要一些其他的概念。

上面的代码中就是添加了一个立方体,然后通过设置坐标的改变,来达到动画的效果。

2. Three.js 的 shader模板

来分析一下这个模板。

HTML 和 CSS 代码:

          <link
    href="https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,wght@0,300;0,400;0,600;0,700;1,400&display=swap"
    rel="stylesheet"
/>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/three.min.js"></script>

<script>
    (window.onload = () => {
    // 1. 创建场景
        const scene = new THREE.Scene();
    // 2. 创建相机
        const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
    // 3. 创建渲染器
        const renderer = new THREE.WebGLRenderer();
    // 4. 设置渲染器大小为浏览器窗口大小
        renderer.setSize(window.innerWidth, window.innerHeight);
    // 5. 将渲染器添加到 body 中
        document.body.appendChild(renderer.domElement);

    // 6. 创建平面几何体
        const geometry = new THREE.PlaneGeometry(2, 2);
    // 7. 创建时钟
        const clock = new THREE.Clock();
    // 8. 创建 uniform 变量 (uniform 是 shader 中的变量,用于下面写 shader 代码)
        const uniforms = {
            u_time: { value: 0.0 },
            u_mouse: { value: { x: 0.0, y: 0.0 } },
            u_resolution: { value: { x: 0.0, y: 0.0 } },
        };

    // 9. 创建纹理
        if (typeof imageURL === 'string') {
            uniforms.u_texture = {
                type: 't',
                value: new THREE.TextureLoader().load(imageURL)
            };
        }

    // 10. 创建材质
        const material = new THREE.ShaderMaterial({
            uniforms,
            vertexShader,
            fragmentShader,
        });

    // 11. 创建平面
        const plane = new THREE.Mesh(geometry, material);
    // 12. 将平面添加到场景中
        scene.add(plane);

    // 13. 设置相机位置
        camera.position.z = 1;

    // 14. 监听窗口大小变化
        onWindowResize();

    // 15. 监听窗口大小变化
        window.addEventListener('resize', onWindowResize, false);
    // 16. 监听触摸事件
        renderer.domElement.addEventListener('touch', onMouseMove);
    // 17. 监听鼠标移动事件
        renderer.domElement.addEventListener('mousemove', onMouseMove);

    // 18. 开始动画
        animate();

    // 19. 监听触摸事件
        function onMouseMove(evt) {
            const { clientX, clientY } = evt.touches ? evt.touches[0] : evt;
            uniforms.u_mouse.value.x = clientX;
            uniforms.u_mouse.value.y = window.innerHeight - clientY;
        }

    // 20. 监听窗口大小变化
        function onWindowResize(event) {
            const aspectRatio = window.innerWidth / window.innerHeight;
            let width, height;
            if (aspectRatio >= 1) {
                width = 1;
                height = (window.innerHeight / window.innerWidth) * width;
            } else {
                width = aspectRatio;
                height = 1;
            }
            camera.left = -width;
            camera.right = width;
            camera.top = height;
            camera.bottom = -height;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);

            if (uniforms.u_resolution) {
                uniforms.u_resolution.value.x = window.innerWidth;
                uniforms.u_resolution.value.y = window.innerHeight;
            }
        }

    // 21. 开始动画
        function animate() {
            uniforms.u_time.value = clock.getElapsedTime();
            requestAnimationFrame(animate);
            renderer.render(scene, camera);
        }
    })();
</script>

<style>
    *,
    *::before,
    *::after {
        font-family: inherit;
        box-sizing: inherit;
        margin: 0;
        padding: 0;
    }

    html {
        box-sizing: border-box;
        font-family: 'Nunito Sans', sans-serif;
        font-size: 62.5%;
    }

    html body {
        font-size: 1.6rem;
        margin: 0;
        overflow: hidden;
    }

    ul {
        list-style: none;
    }

    a,
    a:link,
    a:visited {
        text-decoration: none;
    }

    canvas {
        width: 100%;
        height: 100%;
    }
</style>

        
HTML

JS 代码:

          // const imageURL = 'https://picsum.photos/512/512';
// create your own shader using the same template: https://codepen.io/pen?template=d9456708048f635904c9adce3bb7af0b
const vertexShader = `
varying vec2 v_uv;
varying vec3 v_position;

void main() {
    v_uv = uv;
    v_position = position;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`

const fragmentShader = `
uniform vec2 u_mouse;
uniform vec2 u_resolution;
uniform float u_time;
// uniform sampler2D u_texture;

varying vec2 v_uv;
varying vec3 v_position;

void main() {
    vec2 uv = gl_FragCoord.xy/u_resolution;
    vec3 col = 0.5 + 0.5 * cos(u_time + uv.xyx + vec3(0,2,4));
    gl_FragColor = vec4(col,1.0);
    // gl_FragColor = texture2D(u_texture, uv);
}
`

        
JS

上面这个代码的注释其实也很详细了,这里就不再赘述了。也能够看出来,这里的核心目的就是为了写 shader 代码。

3. 一个稍微好看一点的 3D 场景

这里的 HTML 代码其实 CodePen 中已经给出了,这里就不再赘述了。下面给出一个 Vue 组件版本,直接在项目中复制过去就可以使用了。

          <script setup lang="ts">
import {
  AmbientLight,
  AxesHelper,
  Color,
  GridHelper,
  PCFSoftShadowMap,
  PerspectiveCamera,
  PointLight,
  Scene,
  WebGLRenderer,
  
} from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

const emit = defineEmits(['onload'])

const canvasContainer = ref<HTMLCanvasElement | null>(null)

let canvas: HTMLElement
let renderer: WebGLRenderer
let scene: Scene
let ambientLight: AmbientLight
let pointLight: PointLight
let camera: PerspectiveCamera
let axesHelper: AxesHelper
let cameraControls: OrbitControls

onMounted(() => {
  initThree()
  animate()
})

function initThree() {
  // ===== 🖼️ 画布, 渲染, & 场景 =====
  {
    canvas = canvasContainer.value!
    renderer = new WebGLRenderer({
      canvas,
      antialias: true, // 抗锯齿
      alpha: true, // 渲染器透明
      precision: 'highp', // 着色器开启高精度
    })
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    renderer.shadowMap.enabled = true
    renderer.shadowMap.type = PCFSoftShadowMap
    scene = new Scene()
  }

  // ===== 🎥 相机 =====
  {
    camera = new PerspectiveCamera(
      50,
      canvas.clientWidth / canvas.clientHeight,
      0.1,
      100,
    )
    camera.position.set(2, 2, 5)
  }

  // ===== 💡 灯光 =====
  {
    // 环境光
    ambientLight = new AmbientLight(0xffffff, 0.4)
    scene.add(ambientLight)
    // 点光源
    pointLight = new PointLight('white', 20, 100)
    pointLight.position.set(0, 2, 0)
    pointLight.castShadow = true
    pointLight.shadow.radius = 2
    pointLight.shadow.camera.near = 0.5
    pointLight.shadow.camera.far = 4000
    pointLight.shadow.mapSize.width = 2048
    pointLight.shadow.mapSize.height = 2048
    scene.add(pointLight)
  }

  // ===== 🕹️ 控制器 =====
  {
    cameraControls = new OrbitControls(camera, canvas)

    // 鼠标控制方式
    // cameraControls.mouseButtons = {
    //   LEFT: MOUSE.RIGHT, // 左键 -> 旋转
    //   RIGHT: MOUSE.LEFT, // 右键 -> 平移
    //   MIDDLE: MOUSE.MIDDLE, // 中键不变,保持缩放功能
    // }

    // 阻尼效果
    cameraControls.enableDamping = false
    // 自动旋转
    cameraControls.autoRotate = false
    cameraControls.update()
  }

  // ===== 🪄 网格 =====
  {
    axesHelper = new AxesHelper(4)
    axesHelper.visible = false
    scene.add(axesHelper)

    const gridHelper = new GridHelper(20, 20, 'teal', 'darkgray')
    gridHelper.position.y = -0.01
    scene.add(gridHelper)
  }

  emit('onload', {
    renderer,
    scene,
    camera,
    cameraControls,
    axesHelper,
    canvas,
  })
}
function animate() {
  requestAnimationFrame(animate)

  // 渲染器
  renderer.clear()
  renderer.clearDepth()

  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement
    camera.aspect = canvas.clientWidth / canvas.clientHeight
    camera.updateProjectionMatrix()
  }

  cameraControls.update()

  renderer.render(scene, camera)
}

function resizeRendererToDisplaySize(renderer: WebGLRenderer) {
  const canvas = renderer.domElement
  const width = canvas.clientWidth
  const height = canvas.clientHeight
  const needResize = canvas.width !== width || canvas.height !== height
  if (needResize) {
    renderer.setSize(width, height, false)
  }
  return needResize
}
</script>

<template>
  <canvas ref="canvasContainer" class="h-full w-full" />
</template>

<style scoped>
canvas {
  height: 100%;
  width: 100%;
  outline: none;

  background: rgb(34, 193, 195);
  background: linear-gradient(0deg,
      rgb(8, 163, 166) 0%,
      rgba(79, 166, 167, 0.849) 8%,
      rgba(61, 79, 94, 0.885) 40%,
      rgb(17, 19, 23));
}
</style>

        
VUE