Three.js 学习 - 第一个3D场景
Q: Three.js 是什么?我为什么要学习它?
A: Three.js 是一个很强大的 WebGL 工具库,我之前使用做地图前端项目,就是要出炫酷的效果,用过一些,但总是调 API ,没有自己系统学习过,所以这次打算系统学习一下并记录一下个人的工作流。
- Three.js 中文文档:https://threejs.org/docs/index.html#manual/zh/introduction/Installation
- 炫酷的下雨场景:https://unseen.co/labs/webgl-rain/
- 众多 Shader 案例:https://tympanus.net/codrops/demos/
前言
这一篇其实最主要的还是熟悉一下 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>
2. 使用 npm
# 安装 three.js 和 @types/three
npm install three @types/three
案例分析
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>
这里其实最重要的就是: 场景(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>
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);
}
`
上面这个代码的注释其实也很详细了,这里就不再赘述了。也能够看出来,这里的核心目的就是为了写 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>