Three.js基础---一文了解Three.js(2)

311 阅读45分钟

九、动画

注意: 这里对于动画如何创建,然后如何在动画中操作物体的这些操作在前面的内容中都已经说到过了,这里就不再重复了

(1)基础动画

==> THREE.Raycaster <==

说明: 这个东西可以理解为发射一条射线,然后看那个物体与这条射线相交,一般用来实现鼠标拾取物体、碰撞检测等

准备工作:

const object1 = new THREE.Mesh( 
    new THREE.SphereBufferGeometry(0.5, 16, 16), 
    new THREE.MeshBasicMaterial({ color: '#ff0000' }) 
)

object1.position.x = -2 

const object2 = new THREE.Mesh( 
    new THREE.SphereBufferGeometry(0.5, 16, 16),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)

const object3 = new THREE.Mesh(
    new THREE.SphereBufferGeometry(0.5, 16, 16),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)

object3.position.x = 2

image.png

<== 基础使用 ==>

创建: 由于光线投射是基于向量的数学运算进行的,为了确保计算的准确性和效率,这个方向向量必须是单位向量,也就是单位长度必须为1,也就是所谓的归一化,它的好处如下

  • 计算效率:在进行向量运算时,使用单位向量可以减少不必要的乘法和除法运算,从而提高计算效率

  • 避免误差:非单位向量在进行距离计算或点积运算时可能会引入额外的误差,而单位向量可以保证计算的精确性

  • 标准化: 归一化是一种常见的数学操作,它使得不同长度的向量可以在相同的尺度上进行比较和运算

// ...

// 实例化raycaster
const raycaster = new THREE.Raycaster() 

// 规定光线的发射位置
const rayOrigin = new THREE.Vector3(-3,0,0)
// 规定光线的发射方向(归一化)
const raydirection = new THREE.Vector3(10, 0, 0).normalize()

// 
raycaster.set(rayOrigin, raydirection)

// ...

normalize(): 将一个Vector进行归一处理

相交检测: 这里有两种方法,一个是检测单个对象的intersectObject,一个是检测多个对象的intersectObjects,它们的返回结果都是一个数组,数组中的每个对象就是与光线的每一次相交的信息

// ...

const intersect = raycaster.intersectObject(object2)
console.log(intersect)

const intersects = raycaster.intersectObjects([object1, object2, object3])
console.log(intersects)

// ...

image.png

注意: 即使只测试一个对象,相交的结果也始终是一个数组。这是因为射线可以多次穿过同一个对象。想象一个甜甜圈。射线将穿过环的第一部分,然后穿过中间的孔,然后再次穿过环的第二部分

image.png

相交结果: 返回的数组中的每个项目都包含许多有用的信息,如何使用这些数据取决于你。如果你想改变物体的颜色,你可以更新object的材质。如果你想在撞击点显示爆炸,你可以在point位置创建这个爆炸等等

  • distance:射线起点与碰撞点之间的距离
  • face:射线击中了几何体的哪个面
  • faceIndex:该面的索引
  • object:碰撞涉及的物体
  • point:碰撞在 3D 空间中的精确位置的Vector3
  • uv:该几何体的 UV 坐标

image.png

在动画中测试:

// ...

const clock = new THREE.Clock();

const tick = () => {
    const elapsedTime = clock.getElapsedTime();

    // 给物体设置动画
    object1.position.y = Math.sin(elapsedTime * 0.3) * 1.5;
    object2.position.y = Math.sin(elapsedTime * 0.8) * 1.5;
    object3.position.y = Math.sin(elapsedTime * 1.4) * 1.5;

    const objectToTest = [object1, object2, object3];
    const intersects = raycaster.intersectObjects(objectToTest);

    // 先重置物体颜色为红色
    for (const object of objectToTest) {
        object.material.color.set('#ff0000');
    }

    // 如果有碰撞,将物体颜色设置为蓝色
    for (const intersect of intersects) {
        intersect.object.material.color.set('#0000ff');
    }

    // ...
}

tick()

光线检测动画.gif

<== 与鼠标结合 ==>

说明: 测试物体是否在鼠标后面,简单来说就是鼠标移动到哪个物体上面;这个需要使用setFromCamera这个方法,不过在使用这个方法的时候,需要处理一下鼠标的坐标,因为这个方法需要整个平面的坐标的取值要在[-1,1]之间,做起来就是屏幕左下角是(-1,-1),右上角是(1,1)

// ...

const mouse = new THREE.Vector2()

window.addEventListener('mousemove', (event) => {
    mouse.x = (event.clientX / sizes.width) * 2 - 1;
    mouse.y = -(event.clientY / sizes.height) * 2 + 1;
})

const tick = () => {
    // ...
    
    // 将射线导向正确的方向
    raycaster.setFromCamera(mouse, camera);

    // ...
}
// ...

与鼠标结合的动画.gif

==> GSAP举例使用 <==

说明: GSAP是一个高性能的JavaScript动画库,用于创建复杂的动画效果。它提供了丰富的API和强大的功能,可以轻松实现平滑的动画过渡、时间轴控制、缓动效果等。

举例: 拥有仅由WebGL组成的体验非常好,但是这种体验也可以成为网站的一部分,比如使用Three.js作为HTML页面的背景。然后使相机平移以跟随滚动,这种体验可以在后台为页面添加一些美感,最后可以根据光标位置添加一个很酷的视差动画,以及达到响应部分时触发动画

准备工作:

<canvas class="webgl"></canvas>

<section class="section">
    <h1>My Portfolio</h1>
</section>

<section class="section">
    <h2>My projects</h2>
</section>

<section class="section">
    <h2>Contact me</h2>
</section>
* {
    margin: 0;
    padding: 0;
}

html {
    background: #1e1a20;
}

.webgl {
    position: fixed;
    top: 0;
    left: 0;
    outline: none;
}


.section {
    display: flex;
    align-items: center;
    height: 100vh;
    position: relative;
    font-family: 'Cabin', sans-serif;
    color: #ffeded;
    text-transform: uppercase;
    font-size: 7vmin;
    padding-left: 10%;
    padding-right: 10%;
}

section:nth-child(odd) {
    justify-content: flex-end;
}
// ...

// 导入dat.GUI库,用于创建图形用户界面
import * as dat from 'lil-gui' 

// 创建一个dat.GUI实例,用于调试参数
const gui = new dat.GUI() 

// 定义一个对象来存储可调整的参数
const parameters = { 
    // 初始材质颜色
    materialColor: '#ffeded' 
}

// 使用dat.GUI添加颜色选择器
gui 
    // 添加一个颜色选择器,用于调整材质颜色
    .addColor(parameters, 'materialColor') 
    // 当颜色改变时
    .onChange(() =>  {
        // 更新主材质颜色
        material.color.set(parameters.materialColor) 
    })

const scene = new THREE.Scene() 

const material = new THREE.MeshToonMaterial({
    color: parameters.materialColor
})

const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),
    material
)
const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),
    material
)
const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),
    material
)

scene.add(mesh1, mesh2, mesh3)

// 灯光
const directionalLight = new THREE.DirectionalLight('#ffffff', 1)
directionalLight.position.set(1, 1, 0)
scene.add(directionalLight)

// ...

滚动条动画演示.gif

<== 物体与相机的移动 ==>

移动物体: 由于上面三段文字之间的距离是相等的,那么这个距离可以设置为一个常量,然后每段文字移动0倍、1倍和2倍的距离就可以先将物体分开了

// ...

const objectsDistance = 4

mesh1.position.y = - objectsDistance * 0
mesh2.position.y = - objectsDistance * 1
mesh3.position.y = - objectsDistance * 2

// ...

image.png

移动相机: 这时候就需要让相机随着滚动而移动了,就需要用到window.scrollY这个值了,但是滚动条的1像素和threejs的1单位差距是蛮大的,所以需要处理这个值,由于每个部分的尺寸与视口完全相同。这意味着当我们滚动一个视口高度的距离时,相机应该到达下一个对象,也就是移动一个objectsDistance

// ...

// 水平移动一下,让物体能有与文字对齐
mesh1.position.x = 2
mesh2.position.x = - 2
mesh3.position.x = 2

let scrollY = window.scrollY

window.addEventListener('scroll', () => {
    scrollY = window.scrollY
})

const tick = () => {
    // ...
    
    // 这里负值是为了相机能够向下移动
    camera.position.y = - scrollY / sizes.height * objectsDistance

    // ...
}

tick()

// ...

位置动画.gif

<== 渐变纹理 ==>

说明: MeshToonMaterial材质将为亮处的部分使用一种颜色,为阴影处的部分使用一种较暗的颜色,所以可以通过提供渐变纹理来改善这一点,这里提供下面这个图像

3.jpg

// ...

const textureLoader = new THREE.TextureLoader()
const gradientTexture = textureLoader.load('textures/gradients/3.jpg')

const material = new THREE.MeshToonMaterial({
    color: parameters.materialColor,
    gradientMap: gradientTexture
})

// ...

image.png

注意: 这不是我们需要的卡通效果,这是因为纹理是一个非常小的图像,由 3 个像素组成,从暗到亮。默认情况下,WebGL 不会选择纹理上最近的像素,而是尝试插入像素。这通常对于我们的体验来说是一个好主意,但在这种情况下,它会产生渐变效果而不是卡通效果。为了解决这个问题,我们需要将magFilter纹理的设置为,THREE.NearestFilter以便使用最近的像素,而不使用相邻像素进行插值

// ...

gradientTexture.magFilter = THREE.NearestFilter

// ...

image.png

<== 视差动画 ==>

说明: 视差是通过不同观察点观察同一物体的行为,这是我们眼睛的自然反应,也是我们感受事物深度的方式;为了让体验感更好,可以使用视差效果,让摄像头根据鼠标的移动来操作物体水平和垂直移动。这将创造一种自然的互动,并帮助用户感受到深度

基础版本:

// ...

const cursor = {}
cursor.x = 0
cursor.y = 0

window.addEventListener('mousemove', (event) => {
    // 将值限制在[-0.5,0.5]之间可以在屏幕上让物体
    //上下左右移动了
    cursor.x = event.clientX / sizes.width - 0.5
    cursor.y = event.clientY / sizes.height - 0.5
})

const tick = () => {
    // ...
    
    // 负值是解决x和y轴在方向上似乎不同步
    const parallaxX = cursor.x
    const parallaxY = -cursor.y
    camera.position.x = parallaxX
    camera.position.y = parallaxY

    // ...
}

tick()

// ...

视差效果.gif

注意: 这里发现滚动条失效了,这是因为更新了camera.position.y两次,第二次更新将取代第一次更新,解决这个问题,我们将把相机放在一个group中,并将视差应用于该组而不是相机本身

// ...

const cameraGroup = new THREE.Group()
scene.add(cameraGroup)

const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 6
cameraGroup.add(camera)


const tick = () => {
    // ...

    const parallaxX = cursor.x
    const parallaxY = - cursor.y    
    cameraGroup.position.x = parallaxX
    cameraGroup.position.y = parallaxY

    // ...
}

tick()

把相机放在group中.gif

缓冲效果: 这种动画是没延迟的,也就是鼠标移动之后物体立马发生了变化,不过现实中是不可能存在这样的动画的,所以可以设置相机每次移动的距离都是目的地与实际位置之间距离的十分之一,以此得到缓冲的效果

// ...

cameraGroup.position.x = (parallaxX - cameraGroup.position.x) * 0.1
cameraGroup.position.y = (parallaxY - cameraGroup.position.y) * 0.1

// ...

缓冲.gif

注意: 不过这种方式会在高频屏幕上得到不一样的结果,这是因为tick函数调用频繁的程度不同,为了在所有设备上获得相同的结果,需要利用每帧之间的时间,这样得到的结果就都是相同的了

// ...

// 创建一个时钟对象来跟踪时间
const clock = new THREE.Clock()
// 初始化上一帧的时间
let previousTime = 0

// 定义动画循环函数
const tick = () => {
    // 获取自上一帧以来的时间
    const elapsedTime = clock.getElapsedTime()
    // 计算时间差
    const deltaTime = elapsedTime - previousTime
    // 更新上一帧的时间
    previousTime = elapsedTime

    // ...
    
    cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime

    // ...
}

tick()
<== 添加粒子 ==>

说明: 让体验更具沉浸感并帮助用户感受到深度的一个好方法是添加粒子,它能很好的让用户感知一些变化

// ...

const sectionMeshes = [mesh1, mesh2, mesh3]

// 定义粒子的数量
const particlesCount = 200
// 创建一个用于存储粒子位置的数组
const positions = new Float32Array(particlesCount * 3)

// 循环为每个粒子设置随机位置
for (let i = 0; i < particlesCount; i++) {
    // 让粒子能够在x轴方向进行扩散
    positions[i * 3 + 0] = (Math.random() - 0.5) * 10
    // 让粒子能够从每个物体的上半部分往下进行扩散
    positions[i * 3 + 1] = objectsDistance * 0.5 - Math.random() * objectsDistance * sectionMeshes.length
    // 让粒子能够在z轴方向进行扩散
    positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}

// 创建一个BufferGeometry来存储粒子的位置信息
const particlesGeometry = new THREE.BufferGeometry()
// 将位置数组设置为BufferAttribute,并添加到BufferGeometry中
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

// 创建一个PointsMaterial,设置粒子的颜色和大小
const particlesMaterial = new THREE.PointsMaterial({
    color: parameters.materialColor, // 粒子颜色
    sizeAttenuation: textureLoader, // 大小衰减,这里应该是一个错误,应为布尔值或函数
    size: 0.03 // 粒子的大小
})

// 使用上面定义的几何体和材质创建Points对象
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
// 将粒子系统添加到场景中
scene.add(particles)

// ...

粒子.gif

<== GSAP的使用 ==>

举例: 到达哪一个物体,让这个物体旋转一下

说明: 由于每个部分恰好是视口的一个高度,那么就可以使用 Math.round(window.scrollY / sizes.height)来得到现在展示的是那一部分了,然后使用GSAP动画库进行动画的操作就可以了

// ...

const sectionMeshes = [mesh1, mesh2, mesh3]

// 初始化滚动Y值为当前窗口的滚动位置
let scrollY = window.scrollY
// 初始化当前章节索引
let currentSection = 0

// 监听窗口滚动事件
window.addEventListener('scroll', () => {
    // 更新滚动Y值
    scrollY = window.scrollY
    // 计算新的章节索引
    const newSection = Math.round(scrollY / sizes.height)

    // 如果新的章节索引与当前章节索引不同
    if (newSection != currentSection) {
        // 更新当前章节索引
        currentSection = newSection

        // 使用gsap动画库来更新当前章节网格的旋转
        gsap.to(
            sectionMeshes[currentSection].rotation, {
                // 动画持续时间为1.5秒
                duration: 1.5,
                // 使用power2.inOut缓动函数
                ease: 'power2.inOut',
                // 更新旋转的X、Y、Z轴角度
                x: '+=6',
                y: '+=3',
                z: '+=1.5'
            }
        )
    }
})

// ...

gsap演示.gif

十、纹理

(1)纹理的加载

==> 纹理的加载与应用 <==

使用: 对于纹理的加载需要使用load方法,这个方法返回一个Texture类,在加载好之后使用材质的map属性将纹理与材质结合起来,以下面的图片纹理为例

minecraft.png

// ...

const textureLoader = new THREE.TextureLoader()
// 加载颜色纹理,并设置纹理参数
const colorTexture = textureLoader.load('/textures/minecraft.png')

const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshBasicMaterial({
    map: colorTexture
})
const mesh = new THREE.Mesh(geometry, material)

// ...

纹理的基本加载.gif

  • 加载的图片格式可以是PNG、GIF或者是JPEG;对于图片的大小来说,为了达到最好的效果,推荐使用长宽大小为2的次方的正方形图片

  • 如果要加载的纹理较大,而程序在纹理加载完成之前开始渲染场景,则会在最开始的瞬间看到场景中的一些物体表面没有贴图,此时可以使用load方法的回调函数完成异步加载,这个函数第一个参数是加载的图片路径,然后依次是onLoadFunction在纹理加载完成时被调用;onProgressFunction可以随时汇报加载进度;onErrorFunction在纹理加载或解析出故障时被调用;

  • 对于回调函数来说,如果只写第一个,那么后面两个可以不用使用占位的东西,如果写了第二个或者第三个,那么前面没写的回调函数可以使用undefined占位

// ...

const colorTexture = textureLoader.load(
    '/textures/minecraft.png',
    () => {
        console.log('textureLoader: 颜色纹理加载完成')
    },
    undefined, // onProgress回调未提供
    () => {
        console.log('textureLoader: 颜色纹理加载错误')
    }
)

// ...

image.png

==> 放大器与缩小器 <==

说明: 由于纹理通常需要放大或缩小,所以纹理上的像素不会一对一地映射到面的像素上。为此,WebGL和Three.js提供了各种不同的选项,你可以设置magFilter属性来指定纹理如何放大,设置minFilter属性来指定纹理如何缩小;通过设置这两个属性可以让纹理更加清晰

  • THREE.NearestFilter: 表示最邻近过滤,它将纹理上最近的像素颜色应用于面上,在放大时会导致方块化,在缩小时会丢失很多细节

  • THREE.NearestMipmapNearestFilter: 选择最邻近的mip层,并执行最邻近过滤,虽然放大时仍会存在方块化,但是缩小的时候会好很多

  • THREE.NearestMipmapLinearFilter: 选择最邻近的两个mip层,并分别在这两个mip层上运行最邻近过滤获取两个中间值,最后将这两个中间值传到线性过滤中得到最终值

  • THREE.LinearFilter: 表示线性过滤,它最终颜色是由周围四个像素值决定的,这样虽然在缩小的时候仍会丢失一些细节,但是在放大时会平滑很多,方块化也出现的比较少

  • THREE.LinearMipmapNearestFilter: 选择最邻近的mip层,并执行线性过滤

  • THREE.LinearMipmapLinearFilter: 选择最邻近的两个mip层,并分别在这两个mip层上运行线性过滤获取两个中间值,最后将这两个中间值传到线性过滤中得到最终值

// ...

colorTexture.minFilter = THREE.NearestFilter;
colorTexture.magFilter = THREE.NearestFilter;

// ...

应用过滤器之后的纹理.gif

  • 对于minFilter属性来说它可以设置的属性值有上面的六个,默认值是THREE.LinearMipmapLinearFilter

  • 但是对于magFilter来说可以设置的值只有THREE.NearestFilter和THREE.LinearFilter(默认值)

(2)纹理贴图

==> 凹凸贴图 <==

说明: 凹凸贴图通常是一张灰度图像,其中不同的灰度级别代表了模型表面不同的高度变化。在渲染过程中,这些高度变化会影响光线与表面的交互方式,从而在视觉上产生凹凸的效果;其作用是在不改变模型实际几何形状的情况下,通过改变表面的光照反射方式来模拟物体表面的凹凸细节。这种技术可以增加模型的视觉复杂度和真实感,而无需增加额外的多边形数量,从而节省计算资源

优点:

  • 增加模型的细节和真实感

  • 在不增加多边形数量的情况下提高视觉效果

  • 节省计算资源,因为不需要创建额外的几何体

  • 改善光照效果,使物体表面的光照反应更加自然和逼真

所用图片:

stone.jpg

stone-bump.jpg

使用效果:

// ...

const colorTexture = textureLoader.load('/textures/stone.jpg')
const bumpMap = textureLoader.load('/textures/stone-bump.jpg')

const geometry = new THREE.BoxGeometry(1, 1, 1)

// 材质
const material1 = new THREE.MeshStandardMaterial({
    map: colorTexture,
})
const material2 = new THREE.MeshStandardMaterial({
    map: colorTexture,
    bumpMap: bumpMap,
})

// 网格
const mesh1 = new THREE.Mesh(geometry, material1)
scene.add(mesh1)

const mesh2 = new THREE.Mesh(geometry, material2)
mesh2.position.x = 1.5
scene.add(mesh2)

// 创建光线让材质可见
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(1, 1, 1)
scene.add(directionalLight);

// ...

凹凸纹理的正常使用.gif

注意:

  • 这种贴图并不是所有的材质都可以使用,这种材质需要光线的支持才可以

  • 而且想看到这种贴图带来的效果,则需要确保场景中有足够强的光源,并且光源的位置和角度能够有效地照射到物体上,以便能够显示出凹凸细节,一般都是使用定向光

// ...

// 如果使用环境光,则看不见这种效果
const light = new THREE.AmbientLight(0xffffff)
scene.add(light)

// ...

看不出效果的环境光.gif

==> 法线贴图 <==

说明: 这是一种纹理贴图,用于模拟物体表面的微小凹凸细节,以增强模型的视觉真实感。与凹凸贴图不同,法线贴图不是通过灰度值来表示高度变化,而是通过RGB颜色通道来编码表面法线的方向,通常,红色通道代表X轴方向,绿色通道代表Y轴方向,蓝色通道代表Z轴方向。这样,通过改变法线贴图中的颜色值,就可以模拟出物体表面的凹凸效果

优点:

  • 在不增加多边形数量的情况下,模拟物体表面的凹凸细节

  • 提高模型的视觉真实感和细节丰富度

  • 优化性能,因为不需要创建额外的几何体

  • 增强光照效果,使物体表面的光照反应更加自然和逼真

所用图片:

plaster.jpg

plaster-normal.jpg

// ...

const colorTexture = textureLoader.load('/textures/plaster.jpg')
const normalMap = textureLoader.load('/textures/plaster-normal.jpg')

// ...

const material2 = new THREE.MeshStandardMaterial({
    map: colorTexture,
    normalMap: normalMap,
})

// ...

法线贴图的使用.gif

  • 使用法向贴图的最大问题是它们很难创建,需要使用比如Blender 和Photoshop这样的特殊工具。这些工具可以将高分辨率的效果图或者纹理作为输入来创建法向贴图

  • 法线贴图对材质的影响程度可以通过设置normalScale属性来完成,它的取值是一个Vector2,其默认值是(1,1),越大影响越深,不过推荐的取值范围是0-1,如果设置的值为负数,那么高度就会反转

==> 位移贴图 <==

说明: 它通过在渲染过程中对模型表面的顶点进行位移操作,来模拟物体表面的凹凸细节。与凹凸贴图和法线贴图不同,位移贴图会实际改变模型的几何形状,从而产生更加真实的立体效果;位移贴图通常是一张灰度图像,其中不同的灰度级别代表了模型表面不同的高度变化。在渲染过程中,这些高度变化会被转换为顶点的位移,从而改变模型的实际形状。位移贴图的主要优点是它能够产生非常真实的立体效果,尤其是对于那些需要高度关注细节的场景,如建筑、地形等。

优点:

  • 通过改变模型表面的顶点位置,模拟物体表面的凹凸细节

  • 产生更加真实的立体效果,尤其适用于高度关注细节的场景

所用图片:

w_c.jpg

w_d.png

// ...

const colorTexture = textureLoader.load('/textures/w_c.jpg')
const displacementMap = textureLoader.load('/textures/w_d.png')

// ...

const material2 = new THREE.MeshStandardMaterial({
    map: colorTexture,
    displacementMap
})

// ...

位移贴图.gif

  • 使用位移贴图可能会增加渲染的计算负担,因为需要处理额外的顶点位移操作;除了给displacementMap属性指定纹理对象之外, displacementScale和displacementOffset两个属性也可以用来控制顶点的移位程度

  • 模型必须具有大量顶点,否则其顶点移位效果看起来会与移位贴图并不相像。这是因为顶点过少的模型没有足够的顶点可以移动

==> 环境光遮挡贴图 <==

说明: 这种贴图通常以灰度图的形式存在,其中黑色区域表示遮挡最严重的地方,即间接光照最少的区域,这些区域在最终渲染中会显得更暗,白色区域表示遮挡最少的地方,即间接光照最多的区域,这些区域会显得更亮;其作用是指出模型上哪些部分处于阴影中,或者由于几何形状的原因,应该从环境光中接受较少的光照。它通过模拟光线在不同表面间的遮蔽效果,增强场景的真实感和细节

优点:

  • 通过模拟物体间的遮挡效果,环境光遮挡贴图能够使模型的光照和阴影看起来更加自然和真实

  • 环境光遮挡贴图可以在预处理阶段计算好遮挡信息,从而在实时渲染时减少计算量,提高渲染速度

  • 相比于完全动态计算光照,使用环境光遮挡贴图可以减少对复杂光照模型和计算资源的需求

  • 环境光遮挡贴图可以强调模型表面的细节,尤其是那些由于遮挡而产生的微妙光照变化

// ...

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const textureLoader = new THREE.TextureLoader()
const loader = new GLTFLoader();

loader.load('/models/Duck/glTF/Duck.gltf', (gltf) => {
    const model = gltf.scene

    const material = new THREE.MeshStandardMaterial({
        // 使用的环境光遮挡贴图图片
        aoMap: textureLoader.load("/textures/ambient.png"),
        // 这里添加了紫色,如果不添加下面看到的模型就是黑白的
        color: 0xff00ff,
      });

      model.traverse(function (child) {        
        if (child.isMesh) {
            child.material = material;
        }
    });

    scene.add(model);

})

// 灯光
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(300, 100, 100)
scene.add(directionalLight);

const ambientLight = new THREE.AmbientLight(0xffffff, 0.2)
scene.add(ambientLight)

// ...

环境光遮挡贴图.gif

  • 这种贴图本身是就是黑白的,如果需要添加自己的颜色,可以使用color属性进行添加

==> 光照贴图 <==

说明: 用于模拟静态光照效果的纹理。它通过在纹理图像中预先计算并存储光照信息,简单来说就是可以没有物体然后制造物体的假阴影,然后在渲染时直接应用这些信息,从而减少实时计算的负担

适用场景:

  • 对于不会移动或改变形状的物体,如建筑物的墙壁、地面等,可以使用光照贴图来模拟全局光照效果

  • 光照贴图可以在加载时预计算光照效果,从而减少实时渲染时的计算负担。这对于性能要求较高的场景(如大型游戏或实时渲染应用)非常重要

  • 光照贴图允许您通过简单的纹理编辑来调整光照效果,而无需修改场景中的光源或材质设置

使用图片:

lightmap.png

floor-wood.jpg

// ...

const textureLoader = new THREE.TextureLoader();
const woodTexture = textureLoader.load('/textures/floor-wood.jpg');
const lightMapTexture = textureLoader.load('/textures/lightmap.png');

// 创建几何体
const geometry = new THREE.PlaneGeometry(10, 10);

// 设置 UV2 属性
geometry.setAttribute('uv2', new THREE.BufferAttribute(geometry.attributes.uv.array, 2));

// 创建材质
const material = new THREE.MeshBasicMaterial({
    map: woodTexture,
    lightMap: lightMapTexture,
});

// 创建网格
const plane = new THREE.Mesh(geometry, material);
plane.rotation.x = -Math.PI * 0.5;
scene.add(plane);

// ...

image.png

注意: 这里比较重要的是设置几何体的UV2属性,因为这样渲染器能够将光照信息应用到模型上,如果不提供,你会看到加载出来的材质是一片白色的

==> 金属光泽度贴图和粗糙度贴图 <==

说明: 前面介绍过THREE.MeshStandardMaterial这种材质,其中,更改它的metalness属性可生成闪亮的金属质感表面,通过roughness属性调节粗糙度属性来生成木质或者塑料质感的表面,通过这两个属性可以生成大部分所需的表面质感;如果想简单一些,可以使用metalnessMap金属光泽度贴图和roughnessMap粗糙度贴图一样能够完成效果,这两种贴图都是灰色的

注意:

  • 对于金属度而言,金属成分越大的材质表面反射的光线越多,因此可见的范围可能会相对减少。这是因为高反射率的金属表面会将大部分光线反射回去,而不是让光线穿透或散射到周围环境中。然而,这并不意味着金属成分越大的材质在光线照射下就完全不可见。实际上,金属表面仍然会有一些光线被散射和透射,只是相对于非金属表面,可见范围可能会减小。此外,金属表面的颜色和光泽也会影响其在光线照射下的可见性。总之,金属成分越大的材质在光线照射下可见范围可能会相对减少,但并非完全不可见。

  • 对于粗糙度来说,如果粗糙度越大,材质表面会显得越不光滑,高光反射会扩散到更大的区域,而不是集中在一个小点上,其次粗糙表面会使光线向各个方向散射,而不是像光滑表面那样主要向一个方向反射

使用图片:

color.jpg

// ...

// 光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

const light = new THREE.PointLight(0xffffff, 0.5);
light.position.x = 2;
light.position.y = 3;
light.position.z = 4;
scene.add(light);

// 纹理
const texture = textureLoader.load('/textures/door/color.jpg');

// 几何体
const geometry = new THREE.SphereGeometry();

// 材质
const material = new THREE.MeshStandardMaterial({
    map: texture,
    metalness: 0,
    roughness: 0,
});

// 调试工具
const gui = new dat.GUI()

gui.add(material, 'metalness').min(0).max(1).step(0.01)
gui.add(material, 'roughness').min(0).max(1).step(0.01)

// ...

金属和粗糙度贴图.gif

==> alpha贴图 <==

说明: 它通常是一张灰度图,其中每个像素的灰度值代表了对应位置的透明度。黑色表示完全透明,白色表示完全不透明,而介于两者之间的灰色则表示不同程度的半透明,通过这种贴图可以创建出更加复杂和真实的透明效果,比如树叶的半透明、皮肤的光泽感等

使用图片:

partial-transparency.png

// ...

// ...

const material = new THREE.MeshStandardMaterial({
    alphaMap: texture,
    alphaTest: 1,
    // 使用透明度就需要将这个属性设置为true
    transparent: true,
    side: THREE.DoubleSide
});

// ...

alpha贴图.gif

  • alphaTest: 用于控制材质的透明度测试。它是一个介于0到1之间的值,用于确定像素是否应该被渲染;如果为0这意味着不进行透明度测试,所有像素都会被渲染,无论它们的透明度值是多少,如果为1就只有完全不透明的像素才会被渲染

==> 自发光贴图 <==

说明: 用于控制材质的自发光效果。它通常是一张彩色图像,其中每个像素的颜色值代表了对应位置的自发光颜色和强度。在Three.js中,emissiveMap属性用于将这种贴图应用到材质上。通过使用自发光贴图,可以创建出物体表面发光的效果,比如电子屏幕、霓虹灯、生物体的荧光等;自发光贴图通常与emissive属性一起使用,emissive属性定义了材质的基础自发光颜色,而emissiveMap则提供了更细致的控制,允许你为材质的不同部分指定不同的自发光颜色和强度。

使用图片:

lava.png

lava-normals.png

lava-smoothness.png

// ...

// 光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.1);

const light = new THREE.PointLight(0xffffff, 0.1);
light.position.x = 2;
light.position.y = 3;
light.position.z = 4;

scene.add(light, ambientLight);

// ...

const geometry = new THREE.BoxGeometry();

// 创建材质
const material = new THREE.MeshStandardMaterial({
    emissive: 0xffffff,
    emissiveMap: textureLoader.load('/textures/lava.png'),
    normalMap: textureLoader.load('/textures/lava-normals.png'),
    metalnessMap: textureLoader.load('/textures/lava-smoothness.png'),
    metalness: 1,
    roughness: 0.4,
    normalScale: new THREE.Vector2(4,4)
});

// ...

自发光贴图.gif

==> 高光贴图 <==

说明: 它用于控制材质的高光反射效果。它通常是一张灰度图,其中每个像素的灰度值代表了对应位置的高光反射强度。黑色表示没有高光反射,白色表示最强的高光反射,而介于两者之间的灰色则表示不同程度的高光反射,通过使用高光贴图,可以创建出物体表面不同区域具有不同高光反射效果的效果,比如金属表面的光泽变化、木质表面的自然反光等

使用图片:

EarthNormal.png

EarthSpec.png

Earth.png

// ...

// 光线
const light = new THREE.PointLight(0xffffff, 1);
light.position.x = 2;
light.position.y = 3;
light.position.z = 4;

scene.add(light);

// ...

const material = new THREE.MeshPhongMaterial({
    map: textureLoader.load('/textures/Earth.png'),
    normalMap: textureLoader.load('/textures/EarthNormal.png'),
    specularMap: textureLoader.load('/textures/EarthSpec.png'),
    normalScale: new THREE.Vector2(6,6)
});

// ...

高光贴图.gif

==> 环境贴图 <==

说明: 环境贴图可以用作背景、反射、照明等等,这都是环境贴图的主要用途

  • 对于这里使用的模型,可以点击此处来寻找下载

  • 对于这里使用的纹理,可以点击此处来寻找下载

  • 对于这里将纹理制作成环境贴图,可以点击此处使用这个工具完成

  • 使用人工智能生成环境贴图可以点击此处去尝试一下

准备工作:

// ...

import {
    GLTFLoader
} from 'three/addons/loaders/GLTFLoader.js';

// 用来导入gltf模型
const gltfloader = new GLTFLoader()
// 用来加载立方体纹理
const cubeTextureLoader = new THREE.CubeTextureLoader()

// 制作好的六张环境贴图图片
const environmentMap = cubeTextureLoader.load([
    '/environmentMaps/0/px.png',
    '/environmentMaps/0/nx.png',
    '/environmentMaps/0/py.png',
    '/environmentMaps/0/ny.png',
    '/environmentMaps/0/pz.png',
    '/environmentMaps/0/nz.png'
])

// 加载模型,由于模型很小所以要放大一下
gltfloader.load('/models/FlightHelmet/glTF/FlightHelmet.gltf', (gltf) => {
    gltf.scene.scale.set(10,10,10)
    scene.add(gltf.scene)
})

// ...
  • 在使用cubeTextureLoader加载图片的时候,加载的顺序按照正x、负x、正y、负y、正z和负z的顺序进行加载,正用n表示,负用n表示;顺序不要错了,避免贴图不正确或者效果出不来
<== 环境贴图作为背景 ==>
// ...

scene.background = environmentMap

// ...

环境贴图作为背景.gif

<== 环境贴图照亮模型 ==>

说明: 如果是单个或者是少量的模型,可以使用material.envMap来完成,但是数量多起来就不好操作了,此时有一种快捷的方式就是简单的在场景的environment属性上设置环境贴图就好,后面要更改其强度,可以直接使用environmentIntensity属性完成,这样就简单许多

// ...

scene.environment = environmentMap
gui.add(scene, 'environmentIntensity', 0, 10, 0.01)

// ...

image.png

<== 背景模糊 ==>

说明: 一般在你使用的纹理的清晰度很低作为背景的时候,可以选择模糊一下,这样别人就看不出你的环境贴图的质量很差

// ...

// 调节背景的模糊程度
scene.backgroundBlurriness = 0.5
// 调节背景的亮度
scene.backgroundIntensity = 10

// ...

image.png

<== HDR环境贴图 ==>

说明: 这是一种高动态范围图像,用于存储比传统RGB图像更多亮度级别的信息

  • 优点: 可以捕捉到非常微妙的亮度和颜色变化,使得在3D渲染中能够更真实地模拟现实世界的光照效果

  • 缺点: 它的渲染和加载更加消耗性能,从文件大小上面看就大的多,推荐只对光照使用HDR环境贴图,这样即使你的分辨率很低,也看不出什么差别来

// ...

import {
    RGBELoader
} from 'three/examples/jsm/loaders/RGBELoader.js';

const rgbeLoader = new RGBELoader()

rgbeLoader.load('/environmentMaps/0/2k.hdr', (environmentMap) => {
    // 可以确保环境贴图正确地覆盖整个场景,并且物体表面能够根据
    // 环境贴图反映出周围环境的细节
    environmentMap.mapping = THREE.EquirectangularReflectionMapping

    scene.environment = environmentMap
    scene.background = environmentMap
})

// ...

HDR环境贴图.gif

<== GroundedSkybox ==>

说明: 前面看见模型都是飞起来的,想让模型在地面上,可以使用GroundedSkybox来解决,它的目的是创建一个地面投影的天空盒,它可以根据相机的高度和半径来调整大小,并且可以应用环境贴图

  • 在使用的时候需要将GroundedSkybox实例的position.y的值要比传递的高度值小一点点,否则就达不到效果

  • 其次这个效果并不适合所有的环境贴图,因为有的使用后贴图会变形啥的,看起来就怪怪的

// ...

import {
    GroundedSkybox
} from 'three/addons/objects/GroundedSkybox.js';

// ...

rgbeLoader.load('/environmentMaps/2/2k.hdr', (environmentMap) => {
    environmentMap.mapping = THREE.EquirectangularReflectionMapping
    scene.environment = environmentMap

    const skybox = new GroundedSkybox(environmentMap, 15, 100)
    // 注意设置position.y的值
    skybox.position.y = 14.99
    scene.add(skybox)
})

// ...

skybox.gif

<== 实时环境贴图 ==>

说明: 这是一种在实时渲染中使用的纹理,它能够根据观察者的视角动态地显示周围环境。这种贴图通常用于创建反射和折射效果,使得物体表面能够显示出周围环境的细节

准备工作:

// ...

import {
    GLTFLoader
} from 'three/addons/loaders/GLTFLoader.js';

const gltfloader = new GLTFLoader()
const textureLoader = new THREE.TextureLoader()


// 模型
gltfloader.load('/models/FlightHelmet/glTF/FlightHelmet.gltf', (gltf) => {
    gltf.scene.scale.set(10,10,10)
    scene.add(gltf.scene)
})


const environmentMap = textureLoader.load('/environmentMaps/blockadesLabsSkybox/interior_views_cozy_wood_cabin_with_cauldron_and_p.jpg')
environmentMap.mapping = THREE.EquirectangularReflectionMapping
// 解决图像与渲染的贴图产生的色差问题
environmentMap.colorSpace = THREE.SRGBColorSpace
scene.background = environmentMap


const holyDount = new THREE.Mesh(
    new THREE.TorusGeometry(8, 0.5),
    new THREE.MeshBasicMaterial({
        color: 'white'
    })
)
holyDount.position.y = 3.5
scene.add(holyDount)


// 这里让圆环旋转起来便于看到效果
const clock = new THREE.Clock()
const tick = () => {
    const elapsedTime = clock.getElapsedTime()

    if(holyDount) {
        holyDount.rotation.x = Math.sin(elapsedTime) * 2
    }

    // ...
}

tick()

// ...

实时环境贴图准备工作.gif

创建立方体渲染目标: 在进行实时环境贴图的时候需要使用THREE.WebGLCubeRenderTarget来创建一个立方体渲染目标,这个渲染目标会在运行时动态生成立方体贴图,而不是使用预先渲染好的立方体贴图,也就意味着可以根据需要实时更新贴图内容

  • 在实例化的过程中,有一个必传参数,就是贴图的分辨率,这个值必须是2的幂次方。
// ...

const cubeRenderTarget = new THREE.WebGLCubeRenderTarget(256, {
    // (这里使用的是hdr贴图)使用半精度类型可以提高性能,
    // 尤其是在处理高分辨率纹理或者大量纹理数据时
    type: THREE.HalfFloatType,
})

// 使用这个立方体贴图纹理作为场景的环境贴图。这意味着场景
// 中的物体将会根据这个贴图来反射和折射光线,从而在视觉上
// 融入到这个环境中
scene.environment = cubeRenderTarget.texture

// ...

使用立方体相机:

// ...

const cubeCamera = new THREE.CubeCamera(0.1, 100, cubeRenderTarget)

if(holyDount) {
    holyDount.rotation.x = Math.sin(elapsedTime) * 2

    // 更新相机
    cubeCamera.update(renderer, scene)
}

// ...

使用立方体相机.gif

十一、真实渲染

说明: 在计算机图形学中,通过模拟光线与物体相互作用的物理过程,生成逼真图像的技术。这种技术旨在使计算机生成的图像在视觉上尽可能接近人眼所看到的真实世界,能够以最佳的效果展示你的作品

(1)基础使用

准备工作:

// ...

import {
    GLTFLoader
} from 'three/examples/jsm/loaders/GLTFLoader.js'
import {
    RGBELoader
} from 'three/examples/jsm/loaders/RGBELoader.js'

const gltfLoader = new GLTFLoader()
const rgbeLoader = new RGBELoader()

const scene = new THREE.Scene()
// 设置环境贴图强度
scene.environmentIntensity = 1

// 加载hdr环境贴图
rgbeLoader.load('/environmentMaps/0/2k.hdr', (environmentMap) => {
    environmentMap.mapping = THREE.EquirectangularReflectionMapping

    scene.background = environmentMap
    scene.environment = environmentMap
})

// 加载模型
gltfLoader.load(
    '/models/FlightHelmet/glTF/FlightHelmet.gltf',
    (gltf) => {
        gltf.scene.scale.set(10, 10, 10)
        scene.add(gltf.scene)
    }
)

// ...

真实渲染准备工作.gif

==> 色调映射 <==

说明: 这是一种图像处理技术,用于将高动态范围(HDR)图像转换为低动态范围(LDR)图像,以便在标准显示设备上显示。其目的是在保留图像细节的同时,压缩图像的动态范围

取值:

  • THREE.NoToneMapping(默认):不进行色调映射,直接将HDR颜色值裁剪到LDR范围内;适用于不需要特殊色调映射处理的场景,或者当您希望手动控制色调映射过程时使用

  • THREE.LinearToneMapping: 线性色调映射,简单地将HDR颜色值线性缩放到LDR范围内;适用于需要简单线性变换的场景,例如当您希望保持图像的原始色彩平衡,但又需要将其转换为LDR格式时

  • THREE.ReinhardToneMapping:旨在保持图像的平均亮度,并增强对比度;适用于需要增强图像对比度和细节的场景,同时保持平均亮度不变,适合于数字艺术和游戏中的场景渲染。

  • THREE.CineonToneMapping: 专为电影行业设计,特别是当您希望模拟传统电影胶片的视觉效果时。

  • THREE.ACESFilmicToneMapping:一种更现代的算法,旨在提供更好的动态范围压缩,同时保持图像的自然外观;适用于高端的电影和游戏制作,它提供了更好的动态范围压缩,能够更好地保留图像的细节和自然感,同时避免过曝或欠曝

// ...

renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 3

// ...

色调映射.gif

  • toneMappingExposure: 控制色调映射过程中的曝光度,值越到大,其场景的亮度和对比度越高

==> 抗锯齿 <==

说明: 锯齿状边缘是由于像素是矩形的,而现实世界中的物体边缘往往是曲线或斜线,因此在将三维图形渲染到二维屏幕上时,GPU会进行一些计算,导致一些像素被舍弃掉,此时就会出现锯齿状的边缘

常用解决方案:

  • 超级采样(SSAA): 通过在比最终显示分辨率更高的分辨率下渲染图像,然后将其缩小到目标分辨率来实现。这种方法虽然可以有效地减少锯齿状边缘,提高图像质量,但是会增加渲染的计算量导致性能的下降

  • 多重采样(MSAA): 通过在单个像素区域内使用多个采样点来获取颜色和深度信息,然后将这些信息合并以生成最终的颜色值。这种方法可以在不增加渲染分辨率的情况下提高图像的平滑度和细节;这种同样会消耗性能但是比上面那种要好得多

// ...

const renderer = new THREE.WebGLRenderer({
    canvas: canvas,
    antialias: true
})

// ...

==> 添加阴影 <==

说明: 阴影是真实渲染里面最重要的一部分,逼真的阴影能够显著提高场景的视觉真实感,使物体看起来更加立体和自然,同时也能帮助观察者理解场景中的空间布局和物体之间的相对位置

<== 添加灯光 ==>

说明: 由于环境贴图不能投射阴影,所以需要自己添加灯光使其与环境贴图的灯光相匹配,灯光的方向一般来自环境贴图最亮的部分,然后对于角度的调整,可以使用GUI调试工具完成

// ...

const directionalLight = new THREE.DirectionalLight('#ffffff', 1)
directionalLight.position.set(3, 7, 6)
scene.add(directionalLight)

gui.add(directionalLight, 'intensity').min(0).max(10).step(0.001).name('lightIntensity')
gui.add(directionalLight.position, 'x').min(- 5).max(5).step(0.001).name('lightX')
gui.add(directionalLight.position, 'y').min(- 5).max(5).step(0.001).name('lightY')
gui.add(directionalLight.position, 'z').min(- 5).max(5).step(0.001).name('lightZ')

// ...

image.png

<== 激活渲染器阴影 ==>

说明: 这里激活是方便后面添加灯光辅助器使用的,这里激活以后还是看不见阴影的,还需要在物体上激活才可以

// ...

// 灯光
directionalLight.castShadow = true

// 渲染器
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap

// ...
<== 设置灯光辅助器 ==>

说明: 设置这个是方便调整灯光的位置,使其达到自己满意的效果

// ...

const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)

// ...

image.png

调整灯光目标: 默认情况灯光是照射场景的中心位置的,但是这里想让它照射模型的中心位置,此时就需要设置灯光的target属性;在更新这个属性的时候有以下几点需要注意

  • 在threejs中,每次调整物体的变换,都会根据变换后的参数创建一个矩阵,这个矩阵会用于定位顶点和对象等,这个过程由threejs自动完成,完成的时机在渲染对象之前,所以对场景中不存在的东西进行变换,这个变换是不会被察觉到的,需要自己手动更新

  • 对于更新的方法可以手动调用updateWorldMatrix()方法或者直接将灯光的target属性直接添加到场景中

// ...

directionalLight.target.position.set(0, 4, 0)

// 方案一
// directionalLight.target.updateWorldMatrix()

// 方案二
// scene.add(directionalLight.target)

// ...

image.png

优化大小和距离:

// ...

directionalLight.shadow.camera.far = 15
directionalLight.shadow.mapSize.set(1024, 1024)

// ...

image.png

<== 激活物体阴影并调试 ==>
// ...

gltfLoader.load(
    '/models/FlightHelmet/glTF/FlightHelmet.gltf',
    (gltf) => {
        gltf.scene.scale.set(10, 10, 10)
        scene.add(gltf.scene)

        updateAllMaterials()
    }
)

// 更新网格上面的阴影
const updateAllMaterials = () => {
    scene.traverse((child) => {
        if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
            child.castShadow = true
            child.receiveShadow = true
        }
    })
}

// 根据调试工具得到最优的灯光位置
directionalLight.position.set(-4, 6.5, 2.5)

// ...

image.png

==> 颜色空间 <==

说明: 这是一种根据人眼优化颜色储存的方式,其常见的颜色空间有两种,分别是线性和非线性颜色空间,对于人的眼睛来说,能够看到的色彩在非线性颜色空间中,也就是下面提到的THREE.SRGBColorSpace;而纹理默认加载的时候使用的是线性颜色空间,所以在使用某些贴图的时候就需要更改纹理的颜色空间,不然就会像下面这样颜色差距过大

image.png

使用图片:

wood_cabinet_worn_long_arm_1k.jpg

wood_cabinet_worn_long_diff_1k.jpg

wood_cabinet_worn_long_nor_gl_1k.png

举例:

// ...

const floorColorTexture = textureLoader.load('/textures/wood_cabinet_worn_long/wood_cabinet_worn_long_diff_1k.jpg')
const floorNormalTexture = textureLoader.load('/textures/wood_cabinet_worn_long/wood_cabinet_worn_long_nor_gl_1k')
const floorAoRoughnessMetalnessTexture = textureLoader.load('/textures/wood_cabinet_worn_long/wood_cabinet_worn_long_arm_1k.jpg')

// 更改颜色空间使其颜色恢复正常
floorColorTexture.colorSpace = THREE.SRGBColorSpace

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(8,8),
    new THREE.MeshStandardMaterial({
        map: floorColorTexture,
        normalMap: floorNormalTexture,
        aoMap: floorAoRoughnessMetalnessTexture,
        roughnessMap: floorAoRoughnessMetalnessTexture,
        metalnessMap: floorAoRoughnessMetalnessTexture,
    })
)

floor.rotation.x = -Math.PI / 2
scene.add(floor)

// ...

image.png

十二、了解着色器

前言: 其实着色器从一开始就在使用了,比如创建threejs的内置材质的时候,这些材质是由着色器组成的;通过WebGL渲染器渲染的内容也是通过着色器实现的;由于着色器是WebGL的主要组件之一,所以如果你要自己启动WebGL,你就必须自己去写着色器,而在写的过程中,没有任何库给你帮助,只有原生的WebGL语言,可想而知其难度

说明: 着色器是用GLSL语言编写的程序,这个程序会做两件事,一件是定位几何体的每个顶点,另一件是给该几何图形的每个可见片段着色,每一件事都会交给一个着色器完成,这两个着色器分别称为顶点着色器片段着色器;这两个着色器在被使用的时候就会接收到大量的数据,例如顶点坐标、网格变换、有关相机及其视野的信息、颜色、纹理、灯光、雾等参数。然后,GPU 按照着色器指令处理所有这些数据,然后我们的几何图形出现在渲染中

  • 顶点着色器: 顶点着色器的目的是定位几何体的顶点。其理念是发送顶点位置、网格变换(如其位置、旋转和缩放)、相机信息(如其位置、旋转和视野)。然后,GPU 将按照顶点着色器中的指令处理所有这些信息,以便将顶点投影到将成为我们的渲染的 2D 空间上,一旦放置了顶点,GPU 就知道几何图形的哪些像素是可见的,并且可以继续执行片段着色器

  • 片段着色器: 片段着色器的目的是给几何体的每个可见片段着色。同时几何体的每个可见片段都将使用相同的片段着色器,因此片段着色器中最直接的指令是将所有片段涂成相同的颜色

准备工作:

// ...

const geometry = new THREE.PlaneGeometry(1, 1, 32, 32)

const material = new THREE.MeshBasicMaterial()

const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)

// ...

image.png

常用变量: 下面这些变量在整个着色器程序中都是可用的,并且在渲染过程中保持不变,因此这些变量可以在不修改着色器代码的情况下,通过改变这些变量的值来影响渲染效果

变量作用
uniform表示传递给着色器的常量数据,如果在定义材质的时候使用uniforms属性定义uniforms类型变量,那么着色器使用的时候需要同名才可以
varying用于在顶点着色器和片段着色器之间传递数据。它们在顶点着色器中被赋值,然后在片段着色器中被读取,允许开发者控制渲染过程中顶点和片段着色器之间的数据流
attribute用于接收从应用程序(如Three.js)传递过来的顶点数据(也就是这个类型的数据只能在顶点着色器中使用)。这些数据通常包括顶点的位置、法线、纹理坐标等。attribute变量必须在着色器中声明,并且必须与JavaScript中定义的属性相匹配,以便正确地将数据传递给着色器
precision决定浮点数的精度,高精度可能会影响性能,并且可能无法在设备上运行,但是低精度可能会因为缺乏精度而产生错误,所以一般使用mediump中等精度,不过具体使用可能会因为场景的不同而不同

(1)基础使用

说明: 这里通过实现下面这样的例子来帮助你了解着色器的使用

着色器的起步.gif

  • 需要注意下,着色器的文件后缀是glsl,这里由于掘金不支持这种,导致没有代码高亮,为了美观,也用js文件代替,但是如果你们需要使用,记得更改

==> 着色器材质与起步 <==

解释: 着色器使用的的材质有两种,一种是着色器材质ShaderMaterial,一种是原始着色器材质RawShaderMaterial,前者会自动将一些代码放进着色器中,要简单方便一些,所以为了学习和了解,这里使用原始着色器材质

<== 基础代码 ==>

说明: 在使用RawShaderMaterial这种材质的时候,如果你什么都不给它,你会发现在控制台是会报错的,就像下面这样,什么都不会渲染出来;如果你在里面写下下面这些代码,你就又得到了你的白色方块;

image.png

// ...

const material = new THREE.RawShaderMaterial({
    vertexShader: `
        uniform mat4 projectionMatrix;
        uniform mat4 viewMatrix;
        uniform mat4 modelMatrix;

        attribute vec3 position;

        void main()
        {
            gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
        }
    `,
    fragmentShader: `
        precision mediump float;

        void main()
        {
            gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
        }
    `
})

// ...

image.png

<== 文件拆分 ==>

文件命名: 推荐顶点着色器和片段着色器使用vertex.glslfragment.glsl这两个文件名,然后这样的两个文件放在一个文件夹中,这个就是自己的着色器文件了,最后所有的着色器文件放在shaders文件夹中统一管理

image.png

问题: 在拆分然后导入之后,你可能会发现有下面这样的报错,其根本原因是vite不能够解析这样的文件,需要在vite的配置文件中添加一个vite-plugin-glsl的插件,之后重启一下项目就可以了

image.png

// ...
import glsl from 'vite-plugin-glsl'

export default {
    // ...
    
    plugins: [
        glsl()
    ],
}

代码高亮: 这里如果你是vscode编辑器的话,可以使用Shader languages support for VS Code这个插件

image.png

<== 代码解释 ==>

vertex:

// 投影矩阵会将坐标转换成最终的屏幕坐标
uniform mat4 projectionMatrix;
// 相机矩阵,它是相对于相机进行操作的
uniform mat4 viewMatrix;
// 这个变量表示模型矩阵,它将相对于网格位置、旋转和
// 缩放对每个顶点应用变换
uniform mat4 modelMatrix;

// 这种变量的创建在前面使用粒子自定义物体哪里使用过,
// 就是自己创建一些顶点的position然后将这些position
// 添加到buffergeometry中得到自己自定义的物体,这里
// 的变量名需要和哪里的变量名一致
attribute vec3 position;

// main函数:这个函数会被自动调用并且不会返回任何值,
// 所以它返回的类型是void
void main() {
  // gl_Position是一个内置变量,它包含顶点在屏幕上面的位置,
  // 之后通过后面的公式可以将顶点定位在屏幕上正确的位置
  gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
  • 有一个更短的版本就是使用modelViewMatrix去替代modelMatrixviewMatrix,不过这样可操作的东西就变得更少了;如果需要更多的操作空间,可以将其拆分成下面这种,然后你就会发现你可以操作的东西变多了
// ...

void main() {
  vec4 modelPosition = modelMatrix * vec4(position, 1.0);
  vec4 viewPosition = viewMatrix * modelPosition;
  vec4 projectedPosition = projectionMatrix * viewPosition;

  gl_Position = projectedPosition;
}

fragment:

precision mediump float;

void main() {
  // gl_FragColor为每一个可见像素也就是每一个片段进行着色,
  // 其vec4对应的四个值可以理解为rgba的四个值,如果启用了
  // alpha通道,那么就需要将材质的transparent属性设置为true,
  // 否则就没有效果
  gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}

==> 形成并处理波浪 <==

<== 形成波浪 ==>

基础使用: 在数学中,正弦函数或者余弦函数的图像很类似波浪,那么此处就可以使用内置的sin()试试看,如果想让一个平面变成一个波浪的形态,那么更改平面的z值就好,但是在上面着色器的代码中,使用的modelPosition是一个vec4,因此只能取这个向量的一个值使用就好,在上面的效果图中可以看见,波浪的形成是在xy的方向上都存在,所以这里取值只能是modelPosition.x或者modelPosition.y两种,这样就得到下面的结果

// ...

main() {
    // ...
    
    modelPosition.z += sin(modelPosition.x * 10.0) * 0.1;
    modelPosition.z += sin(modelPosition.y * 10.0) * 0.1;
    
    // ...
}

image.png

  • 这里解释下上面sin()函数中的数字,根据正弦函数的性质,函数里面决定它的周期,值越大,周期越小,也就是波浪越多,外面的值决定它的最大最小值,也就是波峰和波谷的位置,这里如果不做修改就只会得到一个平面或者很锋利的波浪

image.png

image.png

<== 使用uniform参数 ==>

说明: 像上面这样写着色器里面的频率和峰值是不可以更改的,为了在javascript中能够控制,可以使用前面所说的uniform参数,这个参数在使用的时候需要在材质中使用一个uniforms的配置对象,然后以间值对的形式进行参数的传递,这里以传递频率为例

// ...

const material = new THREE.RawShaderMaterial({
    uniforms: {
        uFrequency: {
            // 这里直接使用vec2是为了少写一个参数,要方便一点
            value: new THREE.Vector2(10, 10)
        }
    }
})

// 后面也可以添加调试工具方便调试
gui.add(material.uniforms.uFrequency.value, 'x')
   .min(0)
   .max(20)
   .step(0.01)
   .name('frequencyX')
   
gui.add(material.uniforms.uFrequency.value, 'y')
   .min(0)
   .max(20)
   .step(0.01)
   .name('frequencyY')

// ...
// ...

uniform vec2 uFrequency;

void main() {
  modelPosition.z += sin(modelPosition.x * uFrequency.x) * 0.1;
  modelPosition.z += sin(modelPosition.y * uFrequency.y) * 0.1;

  // ...
}

image.png

<== 制作动画 ==>

说明: 这里动画使用clock.getElapsedTime()而不使用Data.now(),这是因为后面那个值太大了,避免在着色器的uniform属性是使用太大的数据,其次这里的动画主要是sin()的偏移量,也就是在sin()里面加上流逝的时间就好

// ...

const material = new THREE.RawShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms: {
        uFrequency: {
            value: new THREE.Vector2(10, 10)
        },
        uTime: {
            value: 0
        }
    }
})

const clock = new THREE.Clock()

const tick = () => {
    const elapsedTime = clock.getElapsedTime()

    material.uniforms.uTime.value = elapsedTime

    // ...
}

tick()

// ...
// ...

uniform float uTime;

void() {
      vec4 modelPosition = modelMatrix * vec4(position, 1.0);
      modelPosition.z += sin(modelPosition.x * uFrequency.x + uTime) * 0.1;
      modelPosition.z += sin(modelPosition.y * uFrequency.y + uTime) * 0.1;
      
      // ...
}

波浪动画.gif

<== 更改颜色 ==>

说明: 对于颜色的更改其步骤跟动画是一样的,只是颜色是在fragment着色器中使用的

// ...

const material = new THREE.RawShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms: {
        uFrequency: {
            value: new THREE.Vector2(10, 10)
        },
        uTime: {
            value: 0
        },
        uColor: {
            value: new Three.Color('orange')
        }
    }
})

// ...
precision mediump float;

uniform vec3 uColor;

void main() {
  gl_FragColor = vec4(uColor, 1.0);
}

image.png

==> 使用二维纹理 <==

说明: 在着色器中,纹理是应用在fragment着色器中的,此时,需要使用到texture2D,这是一个用于从二维纹理中采样的内置函数,它接受两个参数,一个是纹理,其类型是sampler2D;另一个是纹理坐标,用来选取颜色用的,一般使用物体的uv坐标来代替

// ...

const textureLoader = new THREE.TextureLoader()
const flagTexture = textureLoader.load('/textures/flag-french.jpg')

const material = new THREE.RawShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms: {
        uFrequency: {
            value: new THREE.Vector2(10, 10)
        },
        uTime: {
            value: 0
        },
        uColor: {
            value: new THREE.Color('orange')
        },
        uTexture: {
            value: flagTexture
        }
    }
})

// ...
// ...

attribute vec2 uv;

varying vec2 vUv;

void main() {
  // ...

  gl_Position = projectedPosition;
  vUv = uv;
}
// ...

uniform sampler2D uTexture;

varying vec2 vUv;

void main() {
  vec4 textureColor = texture2D(uTexture, vUv);

  gl_FragColor = textureColor;
}

使用纹理制作动画.gif

<== 增加细节 ==>

说明: 这里的纹理它的亮度变化不是很明显,可以使用波浪的z值来更改波浪的颜色,这样就能产生忽明忽暗的感觉了

// ...

varying float vElevation;

void main() {
  vec4 modelPosition = modelMatrix * vec4(position, 1.0);
  float elevation = sin(modelPosition.x * uFrequency.x + uTime) * 0.1;
  elevation += sin(modelPosition.y * uFrequency.y + uTime) * 0.1;

  modelPosition.z += elevation;

  gl_Position = projectedPosition;

  vElevation = elevation;
}

// ...
// ...

varying float vElevation;

void main() {
  vec4 textureColor = texture2D(uTexture, vUv);
  // 这里是方式所x的值过小,导致纹理很暗,所以处理一下
  textureColor.rgb *= vElevation * 2.0 + 0.5;
  gl_FragColor = textureColor;
}

纹理的最终使用.gif

十三、了解物理

说明: 物理效果可能是webGL中最棒的效果了,通过这个你能够让物体获得摩擦力、弹力、张力等逼真的物理效果,这些效果你可以通过自己的方法实现,但最方便的办法就是使用库了,这里会使用Cannon.js这个物理库,虽然它没有Ammo.js那样更新的平凡,但是要简单很多,可以让你入门要容易一点

  • 注意,Connon.js的网址是http开头的,不是https,不然你看到的界面也是不一样的

image.png

image.png

(1)基础使用

准备工作:

// ...

// 导入物理库
import CANNON from 'cannon'

// Mesh
const sphere = new THREE.Mesh(
    new THREE.SphereGeometry(0.5, 32, 32),
    new THREE.MeshStandardMaterial()
)
sphere.castShadow = true
sphere.position.y = 0.5
scene.add(sphere)

// Floor
const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(10, 10),
    new THREE.MeshStandardMaterial({
        color: '#777777',
    })
)
floor.receiveShadow = true
floor.rotation.x = - Math.PI * 0.5
scene.add(floor)

// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 2.1)
scene.add(ambientLight)

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6)
directionalLight.castShadow = true
directionalLight.position.set(5, 5, 5)
scene.add(directionalLight)

// ...

image.png

==> 物理世界 <==

理论: 为了实现物体的物理效果,那么就需要创建一个物理世界,这个世界是纯理论并且看不见的,但是在这个世界中遵循一切的物理规律,当我们创建 Three.js 网格时,我们也会在物理世界中创建该网格的一个版本。如果我们在 Three.js 中创建一个盒子,我们也会在物理世界中创建一个盒子。然后,在每一帧上,在渲染任何内容之前,我们告诉物理世界进行自我更新;我们获取物理对象的坐标(位置和旋转)并将它们应用到相应的threejs网格,这样就能看见threejs的物体拥有物理效果了

<== 创建物理世界 ==>
// ...

// 创建一个物理世界
const world = new CANNON.World()
// 设置重力,一般是9.82,根据实际的情况来确定
world.gravity.set(0,-9.82,0)

// ...
<== 创建物理物体 ==>

说明: 这里的物体可以跟threejs中的Mesh对象对比来理解,只不过这里的物理对象一般来说不需要材质,只需要一个形状就能够创建了

// ...

const sphereShape = new CANNON.Sphere(0.5)
const sphereBody = new CANNON.Body({
    // 添加质量
    mass: 1,
    // 给定起始位置
    position: new CANNON.Vec3(0, 3, 0),
    // 使用形状创建物理对象
    shape: sphereShape
})
world.addBody(sphereBody)

// ...
<== 更新物理世界 ==>

说明: 这里需要使用到step()来进行物理世界的更新,对于这个函数可以参考此处;为了能够让它工作,你需要提供固定的时间步骤、自上一步以来经过了多少时间,以及世界可以应用多少次迭代来赶上潜在的延迟这三个参数

  • 时间步骤: 对于这个参数你可以先简单理解为如果你想体验以60fps的速度运行,你可以将这个值设置为1/60

  • 经过时间: 可以从前一帧的clock.getElapsedTime()减去当前帧的clock.getElapsedTime()来获取

  • 迭代次数: 这个由你自己决定,如果运行流畅的话这个值就不是很重要了,这里将值设置为3

// ...

const clock = new THREE.Clock()
let oldElapsedTime = 0

const tick = () => {
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - oldElapsedTime
    oldElapsedTime = elapsedTime

    world.step(1 / 60, deltaTime, 3)

    sphere.position.copy(sphereBody.position)

    // ...
}

// ...

更新物理世界.gif

<== 创建物理地面 ==>

说明: 这里的地面指在物理空间中它不会移动,是一个静止的状态,这样就能够让其它的物体落在这个地面上而不会一直往下下坠了

// ...

// 这里只是换一种写法,这两种都可以的
const planeShape = new CANNON.Plane()
const planeBody = new CANNON.Body()
// 质量为0并不意味着是由空气之类的东西组成,而是高速Cannon.js这个对象是静止的,是不会移动的
planeBody.mass = 0
planeBody.addShape(planeShape)
world.addBody(planeBody)

// ...

物理地面的bug.gif

  • bug: 增加的物理地面之后,你会发现小球并没有垂直的下坠,而是在水平方向上移动,这是因为默认情况下,上面生成的物理平面是竖直放置的,那么它的y轴的方向就是水平方向,如果需要将小球变成垂直下坠,那么就需要绕x轴进行旋转来使其恢复正常;不过在物理世界中,旋转并不是使用的rotation属性,而是使用四元数属性quaternion

  • 四元数: 可以简单理解为前三个值通过一个vec3规定一条旋转轴,然后这三个值的±规定插入的旋转轴的方向;第四个值规定其旋转的角度

image.png

// ...

// 可以这么理解,现在是默认情况,在水平向右这是y轴正方向,
// 向屏幕里面是x轴正方向,垂直方向是z轴正方向,这里需要
// 将这个平面旋转Math.PI * 0.5的角度,下面这个向量是朝
// x轴的正方向的,你可以想象一下有一条旋转轴往屏幕里面
// 这个方向插入进来,那么此时你要完成旋转的任务,就只需要
// 逆时针旋转了,那么下面的-号就起作用了;插入和旋转的方向
// 需要相反,否则旋转的结果不对,你会看不见效果的
planeBody.quaternion.setFromAxisAngle(
    new CANNON.Vec3(1, 0, 0),
    -Math.PI * 0.5
)

// ...

修复bug.gif

==> 物理材质 <==

说明: 这里小球弹起来的效果还是不够明显,这是默认的行为,我们可以使用Cannon.js中的MaterialContactMaterial来改变这种情况;这里的Material只是一种参考命名,通过ContactMaterial将两个参考命名产生联系,然后把这个联系交给物理物体使用,之后当规定的材质接触之后,就会触发自己需要的物理效果

// ...

// 命名两种物理材质
const concreteMaterial = new CANNON.Material('concrete')
const plasticMaterial = new CANNON.Material('plastic')

// 将上面创建的两种材质通过ContactMaterial进行链接起来
const concretePlasticContactMaterial = new CANNON.ContactMaterial(
    // 前两个参数是命名的材质
    concreteMaterial,
    plasticMaterial,
    // 最后一个参数是一个配置对象
    {
        // 摩擦系数(默认0.3)
        friction: 0.1,
        // 恢复系数(默认0.3)
        restitution: 0.7
    }
)
// 将创建好的材质添加到物理世界中
world.addContactMaterial(concretePlasticContactMaterial)

// 在两个物体上使用自己定义的材质命名,这样当他们接触的时候就知道是
// 哪两种材质接触了
const sphereBody = new CANNON.Body({
    // ...
    material: plasticMaterial
})

// ...

const floorBody = new CANNON.Body()
floorBody.material = concreteMaterial

// ...

材质.gif

<== 默认材质 ==>

说明: 创建不同的材质以及将不同的材质组合起来可能会很复杂或者组织起来比较麻烦,有些使用可能只需要使用一种材质就可以满足情况,此时创建的材质组合也只有一种,如果需要这样做,可以像下面这样写

// ...

// 这里的效果跟上面的效果是一样的
const defaultMaterial = new CANNON.Material('default')
const defaultContactMaterial = new CANNON.ContactMaterial(
    defaultMaterial,
    defaultMaterial,
    {
        friction: 0.1,
        restitution: 0.7
    }
)

// 这里就给所有物理都应用了这种材质,就不需要给每一种材质
// 都去添加material属性了
world.defaultContactMaterial = defaultContactMaterial

// ...

==> 力的作用 <==

  • applyForce: 用于施加一个持续作用的力。力的大小和方向会随着每一帧的更新而持续作用在物体上,直到你停止施加这个力。这种方式适用于模拟如重力、风力等持续作用在物体上的力

  • applyLocalForce: 上一个方法是从外部对物体施加力的作用,这个是从内部对物体施加力的作用

  • applyImpulse: 用于施加一个瞬时的冲量。冲量是一个力和作用时间的乘积,它会在一个非常短的时间内(通常是模拟的一帧)改变物体的速度。这种方式适用于模拟碰撞、爆炸等瞬间发生的力

  • applyLocalImpulse: 上一个方法是从外部对物体施加力的作用,这个是从内部对物体施加力的作用

// ...

sphereBody.applyLocalForce(
    new CANNON.Vec3(150, 0, 0),
    new CANNON.Vec3(0, 0, 0)
)

// ...

力的施加.gif