three.js part4

创造鬼屋

创建房子

首先创建一个容器,以防万一我们想要移动或缩放整个物体,而不是将构成该房屋的每个对象都放入场景中:

// House container
const house = new THREE.Group()
scene.add(house)

然后可以用一个简单的立方体创建墙壁并将其添加到 house. 不要忘记在 y 轴线上向上移动墙壁;否则它将是地板内的一半:

const walls = new THREE.Mesh(
    new THREE.BoxBufferGeometry(4, 2.5, 4),
    new THREE.MeshStandardMaterial({ color: '#ac8e82' })
)
walls.position.y = 1.25
house.add(walls)

对于屋顶,我们想要制作一个金字塔形状。问题是 Three.js 没有这种几何形状。但是如果你从一个圆锥开始,将边数减少到 4,你会得到一个金字塔:

// Roof
const roof = new THREE.Mesh(
    new THREE.ConeBufferGeometry(3.5, 1, 4),
    new THREE.MeshStandardMaterial({ color: '#b35f45' })
)
roof.rotation.y = Math.PI * 0.25
roof.position.y = 2.5 + 0.5
house.add(roof)

使用一个简单的平面作为门:

// Door
const door = new THREE.Mesh(
    new THREE.PlaneBufferGeometry(2, 2),
    new THREE.MeshStandardMaterial({ color: '#aa7b7b' })
)
door.position.y = 1
door.position.z = 2 + 0.01 // 在 z 轴上移动门以将其粘在墙上
house.add(door)

添加一些灌木丛。我们不会为每个灌木创建一个几何体,而是只创建一个,并且所有网格都将共享它:

// Bushes
const bushGeometry = new THREE.SphereBufferGeometry(1, 16, 16)
const bushMaterial = new THREE.MeshStandardMaterial({ color: '#89c854' })

const bush1 = new THREE.Mesh(bushGeometry, bushMaterial)
bush1.scale.set(0.5, 0.5, 0.5)
bush1.position.set(0.8, 0.2, 2.2)

const bush2 = new THREE.Mesh(bushGeometry, bushMaterial)
bush2.scale.set(0.25, 0.25, 0.25)
bush2.position.set(1.4, 0.1, 2.1)

const bush3 = new THREE.Mesh(bushGeometry, bushMaterial)
bush3.scale.set(0.4, 0.4, 0.4)
bush3.position.set(- 0.8, 0.1, 2.2)

const bush4 = new THREE.Mesh(bushGeometry, bushMaterial)
bush4.scale.set(0.15, 0.15, 0.15)
bush4.position.set(- 1, 0.05, 2.6)

house.add(bush1, bush2, bush3, bush4)

在这里插入图片描述

坟墓

将按程序创建和放置它们,而不是手动放置每个坟墓。
这个想法是将坟墓随机放置在房子周围的一个圆圈上。
首先,让创建一个容器以防万一:

// Graves
const graves = new THREE.Group()
scene.add(graves)

类似前面使用一种几何图形和一种材质创建多个甜甜圈一样,将创建一个BoxBufferGeometry和一个MeshStandardMaterial 1这将在每个坟墓之间共享:

const graveGeometry = new THREE.BoxBufferGeometry(0.6, 0.8, 0.2)
const graveMaterial = new THREE.MeshStandardMaterial({ color: '#b2b6b1' })

最后,循环做一些数学运算来定位房子周围的一堆坟墓。
将在一个圆上创建一个随机角度。请记住,一整圈是 π 的 2 倍。然后我们将在 a sin(…) 和 a 上使用该角度cos(…)。当你有角度时,这就是你如何在一个圆上定位东西。最后,我们还将这些 sin(…)和 cos(…) 结果乘以一个随机值,因为我们不希望坟墓位于一个完美的圆上。

for(let i = 0; i < 50; i++)
{
    const angle = Math.random() * Math.PI * 2 // Random angle
    const radius = 3 + Math.random() * 6      // Random radius
    const x = Math.cos(angle) * radius        // Get the x position using cosinus
    const z = Math.sin(angle) * radius        // Get the z position using sinus

    // Create the mesh
    const grave = new THREE.Mesh(graveGeometry, graveMaterial)

    // Position
    grave.position.set(x, 0.3, z)                              

    // Rotation
    grave.rotation.z = (Math.random() - 0.5) * 0.4
    grave.rotation.y = (Math.random() - 0.5) * 0.4

    // Add to the graves container
    graves.add(grave)
}

在这里插入图片描述

首先,调暗环境光和月光并赋予它们更蓝的颜色:

const ambientLight = new THREE.AmbientLight('#b9d5ff', 0.12)
// ...
const moonLight = new THREE.DirectionalLight('#b9d5ff', 0.12)

在门上方添加一个温暖的PointLight 。可以将其添加到房屋中,而不是将其添加到场景中:

// Door light
const doorLight = new THREE.PointLight('#ff7d46', 1, 7)
doorLight.position.set(0, 2.2, 2.7)
house.add(doorLight)

在这里插入图片描述

多雾路段

在恐怖电影中,总是使用雾。好消息是 Three.js 已经通过Fog支持它。
第一个参数是 color,第二个参数是 near (离相机多远雾开始),第三个参数是 far (离相机多远雾完全不透明)。

要激活雾,请将 fog 属性添加到 scene:

/**
 * Fog
 */
const fog = new THREE.Fog('#262837', 1, 15)
scene.fog = fog

可以看到坟墓和黑色背景之间的清晰切割。
为了解决这个问题,必须改变清晰的颜色 renderer 并使用与雾相同的颜色。在实例化后执行此操作 renderer:

renderer.setClearColor('#262837')

纹理

为了更加真实,我们可以添加纹理

加载所有的门纹理:

const doorColorTexture = textureLoader.load('/textures/door/color.jpg')
const doorAlphaTexture = textureLoader.load('/textures/door/alpha.jpg')
const doorAmbientOcclusionTexture = textureLoader.load('/textures/door/ambientOcclusion.jpg')
const doorHeightTexture = textureLoader.load('/textures/door/height.jpg')
const doorNormalTexture = textureLoader.load('/textures/door/normal.jpg')
const doorMetalnessTexture = textureLoader.load('/textures/door/metalness.jpg')
const doorRoughnessTexture = textureLoader.load('/textures/door/roughness.jpg')

然后可以将所有这些纹理应用到门材质上。不要忘记向PlaneBufferGeometry添加更多细分,所以 displacementMap 有一些顶点要移动。此外,将aoMap属性添加到几何体中。
可以使用以下命令访问门的几何形状 mesh.geometry:

const door = new THREE.Mesh(
    new THREE.PlaneBufferGeometry(2, 2, 100, 100),
    new THREE.MeshStandardMaterial({
        map: doorColorTexture,
        transparent: true,
        alphaMap: doorAlphaTexture,
        aoMap: doorAmbientOcclusionTexture,
        displacementMap: doorHeightTexture,
        displacementScale: 0.1,
        normalMap: doorNormalTexture,
        metalnessMap: doorMetalnessTexture,
        roughnessMap: doorRoughnessTexture
    })
)
door.geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(door.geometry.attributes.uv.array, 2))

在这里插入图片描述
门有点太小了。可以简单地增加PlaneBufferGeometry 1尺寸:

// ...
    new THREE.PlaneBufferGeometry(2.2, 2.2, 100, 100),
// ...

墙壁

/static/textures/bricks/ 让我们使用文件夹上的纹理对墙壁做同样的事情 。我们没有门那么多的纹理,但这不是问题。我们不需要 alpha 纹理,而且墙里面没有金属,所以我们也不需要金属纹理。

加载纹理:

const bricksColorTexture = textureLoader.load('/textures/bricks/color.jpg')
const bricksAmbientOcclusionTexture = textureLoader.load('/textures/bricks/ambientOcclusion.jpg')
const bricksNormalTexture = textureLoader.load('/textures/bricks/normal.jpg')
const bricksRoughnessTexture = textureLoader.load('/textures/bricks/roughness.jpg')

然后我们可以更新我们的MeshStandardMaterial 1为墙壁。不要忘记删除 color 并添加 uv2 环境光遮蔽的属性。

const walls = new THREE.Mesh(
    new THREE.BoxBufferGeometry(4, 2.5, 4),
    new THREE.MeshStandardMaterial({
        map: bricksColorTexture,
        aoMap: bricksAmbientOcclusionTexture,
        normalMap: bricksNormalTexture,
        roughnessMap: bricksRoughnessTexture
    })
)
walls.geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(walls.geometry.attributes.uv.array, 2))

在这里插入图片描述

地上

与墙壁的交易相同。草纹理位于 /static/textures/grass/ 文件夹中。

加载纹理:

const grassColorTexture = textureLoader.load('/textures/grass/color.jpg')
const grassAmbientOcclusionTexture = textureLoader.load('/textures/grass/ambientOcclusion.jpg')
const grassNormalTexture = textureLoader.load('/textures/grass/normal.jpg')
const grassRoughnessTexture = textureLoader.load('/textures/grass/roughness.jpg')

更新MeshStandardMaterial 1并且不要忘记删除 color 并添加 uv2 环境光遮蔽的属性:

const floor = new THREE.Mesh(
    new THREE.PlaneBufferGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        map: grassColorTexture,
        aoMap: grassAmbientOcclusionTexture,
        normalMap: grassNormalTexture,
        roughnessMap: grassRoughnessTexture
    })
)
floor.geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(floor.geometry.attributes.uv.array, 2))

纹理太大。为了解决这个问题,我们可以简单地使用属性重复每个草纹理 repeat :

grassColorTexture.repeat.set(8, 8)
grassAmbientOcclusionTexture.repeat.set(8, 8)
grassNormalTexture.repeat.set(8, 8)
grassRoughnessTexture.repeat.set(8, 8)

纹理太大。为了解决这个问题,我们可以简单地使用属性重复每个草纹理 repeat :

grassColorTexture.repeat.set(8, 8)
grassAmbientOcclusionTexture.repeat.set(8, 8)
grassNormalTexture.repeat.set(8, 8)
grassRoughnessTexture.repeat.set(8, 8)

在这里插入图片描述

鬼魂

将使用漂浮在房子周围并穿过地面和坟墓的简单灯光。

/**
 * Ghosts
 */
const ghost1 = new THREE.PointLight('#ff00ff', 2, 3)
scene.add(ghost1)

const ghost2 = new THREE.PointLight('#00ffff', 2, 3)
scene.add(ghost2)

const ghost3 = new THREE.PointLight('#ffff00', 2, 3)
scene.add(ghost3)

现在可以使用一些带有大量三角函数的数学来为它们制作动画:

const clock = new THREE.Clock()

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

    // Ghosts
    const ghost1Angle = elapsedTime * 0.5
    ghost1.position.x = Math.cos(ghost1Angle) * 4
    ghost1.position.z = Math.sin(ghost1Angle) * 4
    ghost1.position.y = Math.sin(elapsedTime * 3)

    const ghost2Angle = - elapsedTime * 0.32
    ghost2.position.x = Math.cos(ghost2Angle) * 5
    ghost2.position.z = Math.sin(ghost2Angle) * 5
    ghost2.position.y = Math.sin(elapsedTime * 4) + Math.sin(elapsedTime * 2.5)

    const ghost3Angle = - elapsedTime * 0.18
    ghost3.position.x = Math.cos(ghost3Angle) * (7 + Math.sin(elapsedTime * 0.32))
    ghost3.position.z = Math.sin(ghost3Angle) * (7 + Math.sin(elapsedTime * 0.5))
    ghost3.position.y = Math.sin(elapsedTime * 4) + Math.sin(elapsedTime * 2.5)

    // ...

阴影

最后,为了增加真实感,让我们添加阴影。

在渲染器上激活阴影贴图:

renderer.shadowMap.enabled = true

激活您认为应该投射阴影的灯光上的阴影:

moonLight.castShadow = true
doorLight.castShadow = true
ghost1.castShadow = true
ghost2.castShadow = true
ghost3.castShadow = true

遍历场景中的每个对象并确定该对象是否可以投射和/或接收阴影:

walls.castShadow = true
bush1.castShadow = true
bush2.castShadow = true
bush3.castShadow = true
bush4.castShadow = true

for(let i = 0; i < 50; i++)
{
    // ...
    grave.castShadow = true
    // ...
}

floor.receiveShadow = true

有了这些阴影,场景看起来好多了,但无论如何我们都应该优化它们。

一件好事是检查每个灯光,在 上创建相机助手 light.shadowMap.camera,并确保 near、 far、 amplitude 或 fov 很好地适合。但是,让我们使用以下应该恰到好处的值。

还可以减少阴影贴图渲染大小以提高性能:

moonLight.shadow.mapSize.width = 256
moonLight.shadow.mapSize.height = 256
moonLight.shadow.camera.far = 15

// ...

doorLight.shadow.mapSize.width = 256
doorLight.shadow.mapSize.height = 256
doorLight.shadow.camera.far = 7

// ...

ghost1.shadow.mapSize.width = 256
ghost1.shadow.mapSize.height = 256
ghost1.shadow.camera.far = 7

// ...

ghost2.shadow.mapSize.width = 256
ghost2.shadow.mapSize.height = 256
ghost2.shadow.camera.far = 7

// ...

ghost3.shadow.mapSize.width = 256
ghost3.shadow.mapSize.height = 256
ghost3.shadow.camera.far = 7

// ...

renderer.shadowMap.type = THREE.PCFSoftShadowMap

在这里插入图片描述

粒子

粒子正是您对该名称的期望。它们非常受欢迎,可以用来实现各种效果,如星星、烟雾、雨、灰尘、火和许多其他东西。

粒子的好处是可以在屏幕上以合理的帧速率显示数十万个粒子。缺点是每个粒子都由一个始终面向相机的平面(两个三角形)组成。

创建粒子就像制作Mesh一样简单。需要一个几何体(理想情况下是BufferGeometry 2),一种可以处理粒子的材质 ( PointsMaterial ),需要创建一个Points来代替生成Mesh。

设置

启动器仅由场景中间的立方体组成。该立方体确保一切正常。
在这里插入图片描述

第一粒子

让我们摆脱我们的立方体并创建一个由粒子组成的球体开始。

几何学

您可以使用任何基本的 Three.js 几何图形。出于与 Mesh 相同的原因,最好使用BufferGeometries 2. 几何的每个顶点都会变成一个粒子:

/**
 * Particles
 */
// Geometry
const particlesGeometry = new THREE.SphereBufferGeometry(1, 32, 32)
点材料

我们需要一种特殊类型的材质,称为PointsMaterial。这种材料已经可以做很多事情,但是我们将在以后的课程中发现如何创建我们自己的粒子材料以更进一步。

PointsMaterial具有多个特定于粒子的属性,例如 控制size 所有粒子大小和 sizeAttenuation 指定远距离粒子是否应小于近距粒子:

// Material
const particlesMaterial = new THREE.PointsMaterial({
    size: 0.02,
    sizeAttenuation: true
})

与往常一样,我们也可以在创建材质后更改这些属性:

const particlesMaterial = new THREE.PointsMaterial()
particlesMaterial.size = 0.02
particlesMaterial.sizeAttenuation = true

积分
最后,我们可以像创建Mesh一样创建最终粒子,但这次使用的是Points类。不要忘记将它添加到场景中:

// Points
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)

在这里插入图片描述

自定义几何

要创建自定义几何图形,我们可以从BufferGeometry 2position ,并像我们在 几何 课中所做的那样添加一个 属性。替换SphereBufferGeometry 1使用自定义几何图形并 ‘position’ 像以前一样添加属性:

// Geometry
const particlesGeometry = new THREE.BufferGeometry()
const count = 500

const positions = new Float32Array(count * 3) // Multiply by 3 because each position is composed of 3 values (x, y, z)

for(let i = 0; i < count * 3; i++) // Multiply by 3 for same reason
{
    positions[i] = (Math.random() - 0.5) * 10 // Math.random() - 0.5 to have a random value between -0.5 and +0.5
}

particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) // Create the Three.js BufferAttribute and specify that each information is composed of 3 values

在这里插入图片描述
在劣质计算机或智能手机上,您将无法获得 60fps 的数百万粒子体验。我们还将添加将大大降低帧速率的效果。但是,这仍然令人印象深刻。

现在,让我们保持计数 5000 并将大小更改为 0.1:

const count = 5000

// ...

particlesMaterial.size = 0.1

// ...

在这里插入图片描述

颜色、贴图和 alpha 贴图

我们可以使用 PointsMaterialcolor 上的属性更改所有粒子的颜色。如果您在实例化材质后更改此属性,请不要忘记您需要使用Color类:

particlesMaterial.color = new THREE.Color('#ff88cc')

我们还可以使用该 map 属性在这些粒子上放置纹理。使用代码中已有的TextureLoader加载位于以下位置的纹理之一 /static/textures/particles/:

/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()
const particleTexture = textureLoader.load('/textures/particles/2.png')

// ...

particlesMaterial.map = particleTexture

在这里插入图片描述

这些纹理是Kenney提供的包的调整大小版本 你可以在这里找到完整的包:https 😕/www.kenney.nl/assets/particle-pack . 但也可以创建自己的。

如您所见,该 color 属性正在更改地图,就像其他材质一样。

如果仔细观察,会发现前面的粒子隐藏着后面的粒子。

需要激活透明度 transparent 并使用 alphaMap 属性上的纹理而不是 map:

// particlesMaterial.map = particleTexture
particlesMaterial.transparent = true
particlesMaterial.alphaMap = particleTexture

在这里插入图片描述
现在好多了,但仍然可以随机看到粒子的一些边缘。
那是因为粒子的绘制顺序与创建它们的顺序相同,而 WebGL 并不真正知道哪个在另一个之前。
有多种方法可以解决此问题。

使用 alphaTest

alphaTest 是一个介于 0 和 之间的 值,1 它使 WebGL 能够根据像素的透明度知道何时不渲染该像素。默认情况下,该值 0 表示无论如何都会渲染像素。如果我们使用较小的值,例如 0.001,如果 alpha 为 ,则不会渲染像素 0:

particlesMaterial.alphaTest = 0.001

这个解决方案并不完美,如果你仔细观察,你仍然可以看到故障,但它已经更令人满意了。

使用深度测试

绘制时,WebGL 会测试正在绘制的内容是否比已经绘制的更接近。这称为深度测试,可以停用(您可以评论 alphaTest):

// particlesMaterial.alphaTest = 0.001
particlesMaterial.depthTest = false

虽然此解决方案似乎完全解决了我们的问题,但如果您的场景中有其他对象或具有不同颜色的粒子,停用深度测试可能会产生错误。可以将粒子绘制为好像它们位于场景的其余部分之上。

将一个立方体添加到场景中以查看:

const cube = new THREE.Mesh(
    new THREE.BoxBufferGeometry(),
    new THREE.MeshBasicMaterial()
)
scene.add(cube)
使用深度写入

正如我们所说,WebGL 正在测试绘制的内容是否比已经绘制的更接近。正在绘制的深度存储在我们所说的深度缓冲区中。我们可以告诉 WebGL 不要在该深度缓冲区中写入粒子,而不是不测试粒子是否比这个深度缓冲区中的更接近(您可以注释 depthTest):

// particlesMaterial.alphaTest = 0.001
// particlesMaterial.depthTest = false
particlesMaterial.depthWrite = false

在我们的例子中,这个解决方案几乎没有缺点地解决了这个问题。有时,其他对象可能会被绘制在粒子的后面或前面,这取决于许多因素,例如透明度、将对象添加到场景中的顺序等。

混合

目前,WebGL 将像素一个接一个地绘制。

通过更改 blending 属性,可以告诉 WebGL 不仅要绘制像素,还要 将该像素的颜色添加 到已绘制的像素的颜色中。这将产生看起来惊人的饱和效果。

要测试它,只需将 blending 属性更改为 THREE.AdditiveBlending (保留 depthWrite 属性):

// particlesMaterial.alphaTest = 0.001
// particlesMaterial.depthTest = false
particlesMaterial.depthWrite = false
particlesMaterial.blending = THREE.AdditiveBlending

在这里插入图片描述
添加更多粒子(比如说 20000)以更好地享受这种效果。
在这里插入图片描述
但要小心,这种效果会影响性能,并且在 60fps 时您将无法拥有与以前一样多的粒子。
现在,可以删除 cube
在这里插入图片描述

不同的颜色

我们可以为每个粒子设置不同的颜色。我们首先需要添加一个新属性,命名 color 为我们为位置所做的那样。一种颜色由红、绿、蓝(3 个值)组成,因此代码与 position 属性非常相似。我们实际上可以对这两个属性使用相同的循环:

const positions = new Float32Array(count * 3)
const colors = new Float32Array(count * 3)

for(let i = 0; i < count * 3; i++)
{
    positions[i] = (Math.random() - 0.5) * 10
    colors[i] = Math.random()
}

particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

注意单数和复数。
要激活这些顶点颜色,只需将 vertexColors 属性更改为 true:

particlesMaterial.vertexColors = true

在这里插入图片描述
材质的主要颜色仍然会影响这些顶点颜色。随意更改该颜色,甚至评论它。

// particlesMaterial.color = new THREE.Color('#ff88cc')

在这里插入图片描述

动画

有多种动画粒子的方法。

通过使用点作为对象

因为Points类继承自Object3D类,所以您可以随意移动、旋转和缩放点。

旋转 tick 函数中的粒子:

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

    // Update particles
    particles.rotation.y = elapsedTime * 0.2

    // ...
}

虽然这已经很酷了,但我们希望对每个粒子进行更多控制。

通过更改属性

另一种解决方案是分别更新每个顶点位置。这样,顶点可以有不同的轨迹。我们将对粒子进行动画处理,就好像它们漂浮在波浪上一样,但首先,让我们看看如何更新顶点。

首先评论我们之前所做的轮换 particles:

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

    // particles.rotation.y = elapsedTime * 0.2

    // ...
}

要更新每个顶点,我们必须更新 position 属性中的右侧部分,因为所有顶点都存储在这个一维数组中,其中前 3 个值对应于 x, 第一个顶点的坐标,y 然后 z 接下来的 3 个值对应于 x, y 以及 z 第二个顶点等。

我们只希望顶点上下移动,这意味着我们将 y 只更新轴。因为 position 属性是一维数组,所以我们必须 3 3 次遍历它,并且只更新第二个值,即 y 坐标。

让我们从遍历每个顶点开始:

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

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3
    }

    // ...
}

在这里,我们选择了一个简单的 for 循环,从 0 to 开始 count ,我们在 i3 内部创建了一个变量,只需乘以 3 即可得到 3 乘 i 3。

模拟波浪运动的最简单方法是使用简单的 sinus。首先,我们将更新所有顶点以相同的频率上下移动。

y 坐标可以在数组中的索引处 访问 i3 + 1:

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

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3

        particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime)
    }

    // ...
}

不幸的是,什么都没有动。问题是必须通知 Three.js 几何体发生了变化。为此,必须在更新完顶点后将属性设置needsUpdate 为 :trueposition

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

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3

        particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime)
    }
    particlesGeometry.attributes.position.needsUpdate = true 

    // ...
}

所有的粒子都应该像飞机一样上下移动。

这是一个好的开始,我们快到了。我们现在需要做的就是对粒子之间的 窦应用一个偏移量, 以便我们得到那个波形。

为此,我们可以使用 x 坐标。为了得到这个值,我们可以使用与 y 坐标相同的技术,但不是 i3 + 1,而是 i3:

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

    for(let i = 0; i < count; i++)
    {
        let i3 = i * 3

        const x = particlesGeometry.attributes.position.array[i3]
        particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime + x)
    }
    particlesGeometry.attributes.position.needsUpdate = true

    // ...
}

你应该得到美丽的粒子波。不幸的是,您应该避免使用这种技术。如果我们有 20000 粒子,我们会遍历每一个,计算一个新的位置,并在每一帧更新整个属性。这可以处理少量粒子,但我们需要数百万个粒子。

通过使用自定义着色器

为了以良好的帧速率更新每帧上的数百万个粒子,需要使用自己的着色器创建自己的材质。

创造银河发电机

既然我们知道如何使用粒子,我们就可以创造像银河一样酷的东西。但是,让我们做一个星系生成器,而不是只生产一个星系。

为此,我们将使用 Dat.GUI 让用户调整参数并在每次更改时生成一个新星系。

设置

启动器仅由场景中间的立方体组成。它确保一切正常。
在这里插入图片描述

基础粒子

首先,移除立方体并创建一个 generateGalaxy 函数。每次调用该函数时,我们都会删除前一个星系(如果有的话)并创建一个新星系。

我们可以立即调用该函数:

/**
 * Galaxy
 */
const generateGalaxy = () =>
{

}

generateGalaxy()
JavaScript

我们可以创建一个包含银河系所有参数的对象。在函数之前创建这个对象 generateGalaxy 。我们将逐步填充它并将每个参数添加到 Dat.GUI:

const parameters = {}

在我们的 generateGalaxy 函数中,我们将创建一些粒子以确保一切正常。我们可以从几何开始,将粒子数添加到参数中:

const parameters = {}
parameters.count = 1000

const generateGalaxy = () =>
{
    /**
     * Geometry
     */
    const geometry = new THREE.BufferGeometry()

    const positions = new Float32Array(parameters.count * 3)

    for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        positions[i3    ] = (Math.random() - 0.5) * 3
        positions[i3 + 1] = (Math.random() - 0.5) * 3
        positions[i3 + 2] = (Math.random() - 0.5) * 3
    }

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
}
generateGalaxy()

这与之前的代码相同,但我们处理循环的方式略有不同。

我们现在可以使用PointsMaterial类创建材质。这一次,我们可以为 parameters 对象添加调整:

parameters.size = 0.02

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

    /**
     * Material
     */
    const material = new THREE.PointsMaterial({
        size: parameters.size,
        sizeAttenuation: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
    })
}

最后,可以使用Points类创建点并将其添加到场景中:

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

    /**
     * Points
     */
    const points = new THREE.Points(geometry, material)
    scene.add(points)
}

在这里插入图片描述

调整

我们已经有两个参数, count 和 size。让我们将它们添加到 我们在代码开头已经创建的Dat.GUI实例中。可以想象,我们必须在创建参数后添加这些调整:

parameters.count = 1000
parameters.size = 0.02

gui.add(parameters, 'count').min(100).max(1000000).step(100)
gui.add(parameters, 'size').min(0.001).max(0.1).step(0.001)

您应该在调整中有两个新范围,但更改它们不会产生新的星系。要生成一个新星系,您必须监听 change 事件。更准确地说是在 finishChange 拖放范围值时防止生成星系的事件:

gui.add(parameters, 'count').min(100).max(1000000).step(100).onFinishChange(generateGalaxy)
gui.add(parameters, 'size').min(0.001).max(0.1).step(0.001).onFinishChange(generateGalaxy)

此代码将不起作用,因为 generateGalaxy 尚不存在。generateGalaxy 您必须在函数之后移动这些调整 。

小心,我们仍然有问题,如果你玩太多的调整,你的电脑就会开始发热。这是因为我们没有摧毁之前创造的星系。我们正在创造一个在另一个之上的星系。

为了使事情正确,我们必须首先将 geometry, material 和 points 变量 移到generateGalaxy.

let geometry = null
let material = null
let points = null

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

    geometry = new THREE.BufferGeometry()

    // ...

    material = new THREE.PointsMaterial({
        size: parameters.size,
        sizeAttenuation: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
    })

    // ...

    points = new THREE.Points(geometry, material)

    // ...
}

然后,在分配这些变量之前,我们可以测试它们是否已经存在。如果是这样,我们可以调用 dispose() 几何和材质的方法。然后使用以下方法从场景中删除点 remove() :

const generateGalaxy = () =>
{
    // Destroy old galaxy
    if(points !== null)
    {
        geometry.dispose()
        material.dispose()
        scene.remove(points)
    }

    // ...
}

现在可以估计可以拥有多少粒子及其大小,更新参数:

parameters.count = 100000
parameters.size = 0.01

在这里插入图片描述

形状

星系可以有几种不同的形状。我们将专注于螺旋一。有很多方法可以定位粒子以创建星系。在测试课程方式之前,请随意尝试您的方式。

半径

首先,让我们创建一个 radius 参数:

parameters.radius = 5

// ...

gui.add(parameters, 'radius').min(0.01).max(20).step(0.01).onFinishChange(generateGalaxy)

每颗星都将根据该半径进行定位。如果半径是 5,星星将被定位在距离 0 to 处 5。现在,让我们将所有粒子放置在一条直线上:

    for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        const radius = Math.random() * parameters.radius

        positions[i3    ] = radius
        positions[i3 + 1] = 0
        positions[i3 + 2] = 0
    }

在这里插入图片描述

分支机构

自旋星系似乎总是至少有两个分支,但它可以有更多。

创建 branches 参数:

parameters.branches = 3

// ...

gui.add(parameters, 'branches').min(2).max(20).step(1).onFinishChange(generateGalaxy)

我们可以使用 Math.cos(…) and Math.sin(…) 将粒子放置在这些分支上。我们首先用模 ( %) 计算一个角度,将结果除以分支计数参数得到 和 之间的角度 0 , 1然后将该值乘以 Math.PI * 2 得到 和 之间的角度 0 。然后我们将这个角度与 Math.cos(…) 和 Math.sin(…) 用于 x 和 z 轴,最后乘以半径:

    for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        const radius = Math.random() * parameters.radius
        const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2

        positions[i3    ] = Math.cos(branchAngle) * radius
        positions[i3 + 1] = 0
        positions[i3 + 2] = Math.sin(branchAngle) * radius
    }

在这里插入图片描述

旋转

添加旋转效果,创建 spin 参数:

parameters.spin = 1

// ...

gui.add(parameters, 'spin').min(- 5).max(5).step(0.001).onFinishChange(generateGalaxy)

然后可以乘以 spinAngle 那个 spin 参数。换句话说,粒子离中心越远,它承受的自旋就越多:

for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        const radius = Math.random() * parameters.radius
        const spinAngle = radius * parameters.spin
        const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2

        positions[i3    ] = Math.cos(branchAngle + spinAngle) * radius
        positions[i3 + 1] = 0
        positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius
    }

在这里插入图片描述

随机性

这些粒子完全对齐。我们需要随机性。但我们真正需要的是在外面散布星星,在里面凝聚更多星星。

创建 randomness 参数:

parameters.randomness = 0.2

// ...

gui.add(parameters, 'randomness').min(0).max(2).step(0.001).onFinishChange(generateGalaxy)

现在用 为每个轴创建一个随机值 Math.random(),将其乘以 radius ,然后将这些值添加到 postions:

    for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        const radius = Math.random() * parameters.radius

        const spinAngle = radius * parameters.spin
        const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2

        const randomX = (Math.random() - 0.5) * parameters.randomness * radius
        const randomY = (Math.random() - 0.5) * parameters.randomness * radius
        const randomZ = (Math.random() - 0.5) * parameters.randomness * radius

        positions[i3    ] = Math.cos(branchAngle + spinAngle) * radius + randomX
        positions[i3 + 1] = randomY
        positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius + randomZ
    }

在这里插入图片描述
可以使用 Math.pow() 粉碎价值。您应用的功率越多,就越接近 0 它。问题是您不能将负值与 Math.pow(). 我们要做的是计算功率,然后 -1 随机乘以。

首先创建功率参数:

parameters.randomnessPower = 3

// ...

gui.add(parameters, 'randomnessPower').min(1).max(10).step(0.001).onFinishChange(generateGalaxy)

然后将幂与 随机Math.pow() 相乘 :-1

        const randomX = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius
        const randomY = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius
        const randomZ = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius

在这里插入图片描述

颜色

为了获得更好的效果,我们需要在我们的创作中添加一些颜色。一个很酷的事情是在星系内部和边缘有不同的颜色。

首先,添加颜色参数:

parameters.insideColor = '#ff6030'
parameters.outsideColor = '#1b3984'

// ...

gui.addColor(parameters, 'insideColor').onFinishChange(generateGalaxy)
gui.addColor(parameters, 'outsideColor').onFinishChange(generateGalaxy)

我们将为每个顶点提供一种颜色。我们必须在材料上激活 vertexColors :

    material = new THREE.PointsMaterial({
        size: parameters.size,
        sizeAttenuation: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        vertexColors: true
    })

然后 color 在我们的几何图形上添加一个属性,就像我们添加 position 属性一样。目前,我们不使用 insideColor and outsideColor 参数:

    geometry = new THREE.BufferGeometry()

    const positions = new Float32Array(parameters.count * 3)
    const colors = new Float32Array(parameters.count * 3)

    for(let i = 0; i < parameters.count; i++)
    {
        // ...

        colors[i3    ] = 1
        colors[i3 + 1] = 0
        colors[i3 + 2] = 0
    }

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

在这里插入图片描述
你应该得到一个红色的星系。

要使用参数中的颜色,我们首先需要创建一个Color每个实例。我们必须在 generateGalaxy函数内部执行此操作,原因您稍后会理解:

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

    const colorInside = new THREE.Color(parameters.insideColor)
    const colorOutside = new THREE.Color(parameters.outsideColor)

    // ...
}

在循环函数内部,我们想将这些颜色混合成第三种颜色。这种混合取决于与银河系中心的距离。如果粒子在银河系的中心,它就会有 , insideColor 并且离中心越远,它与 的混合就越多 outsideColor。

而不是创建第三种颜色 2,我们将克隆 colorInside ,然后使用该 lerp(…) 方法将颜色从该基色插入另一个。的第一个参数 lerp(…) 是另一种颜色,第二个参数是 和 之间的 0 值 1。如果是 0,颜色将保持其基值,如果 1 是结果,颜色将是提供的颜色。我们可以使用 radius 除以半径参数:

        const mixedColor = colorInside.clone()
        mixedColor.lerp(colorOutside, radius / parameters.radius)

然后我们可以 在 数组中使用r, g 和 b 属性 :colors

        colors[i3    ] = mixedColor.r
        colors[i3 + 1] = mixedColor.g
        colors[i3 + 2] = mixedColor.b

在这里插入图片描述
在这里你有一个漂亮的星系发生器。您可以进行调整并继续添加参数并改善星系的风格。
尽量不要烧毁你的电脑。

Raycaster光线施法者

Raycaster 可以向特定方向投射(或射出)光线并测试与它相交的对象。
可以使用该技术检测玩家面前是否有墙,测试激光枪是否击中某物,测试当前是否有东西在鼠标下方以模拟鼠标事件,以及许多其他事情。
在这里插入图片描述

设置

在启动器中,有 3 个红色球体,我们将射出一条射线,看看这些球体是否相交
在这里插入图片描述

创建光线投射器

实例化一个Raycaster 1:

/**
 * Raycaster
 */
const raycaster = new THREE.Raycaster()

要改变光线投射的位置和方向,我们可以使用 set(…) 方法。第一个参数是 position ,第二个参数是 direction。

两者都是Vector3 1,但 direction 必须归一化。归一化向量的长度为 1。不用担心,你不必自己做数学,你可以调用 normalize() 向量上的方法:

const rayOrigin = new THREE.Vector3(- 3, 0, 0)
const rayDirection = new THREE.Vector3(10, 0, 0)
rayDirection.normalize()

raycaster.set(rayOrigin, rayDirection)

这个归一化向量的例子不是很相关,因为我们可以设置 1 而不是 10,但是如果我们更改值,我们仍然有 normalize() 确保向量是 1 单位长度的方法。

在这里,光线位置在我们的场景中应该从左边一点点开始,方向似乎向右。我们的光线应该穿过所有的球体。
在这里插入图片描述

投射光线

要投射光线并获取相交的对象,我们可以使用两种方法, intersectObject(…) (单数)和 intersectObjects(…)(复数)。

intersectObject(…) 将测试一个对象 intersectObjects(…) 并将测试一组对象:

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

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

如果您查看日志,您会看到它 intersectObject(…) 返回了一个包含一个项目(可能是第二个球体) intersectObjects(…)的数组,并且返回了一个包含三个项目(可能是 3 个球体)的数组。

交集的结果

交集的结果始终是一个数组,即使您只测试一个对象。那是因为一条射线可以多次穿过同一个物体。想象一个甜甜圈。光线将穿过环的第一部分,然后是中间的孔,然后再穿过环的第二部分。
在这里插入图片描述
该返回数组的每一项都包含许多有用的信息:

  • distance: 射线原点到碰撞点的距离。
  • face: 几何体的哪个面被射线击中。
  • faceIndex: 那张脸的索引。
  • object: 碰撞涉及什么对象。
  • point:一个向量Vector3 碰撞在 3D 空间中的确切位置。
  • uv:该几何体中的 UV 坐标。

使用这些数据取决于您。如果要测试播放器前面是否有墙,可以测试 distance. 如果要更改对象的颜色,可以更新 object的材质。如果要在冲击点上显示爆炸,可以在该 point 位置创建爆炸。

测试每一帧

目前,只在开始时投射一条光线。如果想在物体移动时对其进行测试,必须在每一帧上进行测试。让我们为球体设置动画,并在射线与球体相交时将它们变为蓝色。

删除我们之前做的代码,只保留 raycaster 实例化:

const raycaster = new THREE.Raycaster()

Math.sin(…) 使用函数中 的经过时间和经典为球体设置动画 tick :

const clock = new THREE.Clock()

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

    // Animate objects
    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

    // ...
}

您应该看到球体以不同的频率上下摆动。

现在让我们像以前一样更新我们的光线投射器,但在 tick 函数中:

const clock = new THREE.Clock()

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

    // Cast a ray
    const rayOrigin = new THREE.Vector3(- 3, 0, 0)
    const rayDirection = new THREE.Vector3(1, 0, 0)
    rayDirection.normalize()

    raycaster.set(rayOrigin, rayDirection)

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

    // ...
}

再一次,我们真的不需要标准化 , rayDirection 因为它的长度已经是 1。normalize() 但如果我们改变方向,最好保留它 。
我们还将要测试的对象数组放在一个变量 objectsToTest中。以后会派上用场的。
如果您查看控制台,您应该得到一个包含交叉点的数组,并且这些交叉点会根据球体的位置不断变化。
现在可以 为 数组object 中的每一项更新属性 的材质:intersects

    for(const intersect of intersects)
    {
        intersect.object.material.color.set('#0000ff')
    }

不幸的是,它们都变成蓝色,但永远不会变回红色。有很多方法可以将未相交的对象变回红色。我们可以做的是将所有球体变为红色,然后将相交的球体变为蓝色:

    for(const object of objectsToTest)
    {
        object.material.color.set('#ff0000')
    }

    for(const intersect of intersects)
    {
        intersect.object.material.color.set('#0000ff')
    }

用鼠标使用光线投射器

正如我们之前所说,我们还可以使用 raycaster 来测试对象是否在鼠标后面。换句话说,如果您悬停一个对象。

从数学上讲,这有点复杂,因为我们需要从相机向鼠标方向投射光线,但幸运的是,Three.js 完成了所有繁重的工作。

现在,让我们在函数中注释掉与光线投射器相关的代码 tick 。

悬停

首先,让我们处理悬停。
首先,我们需要鼠标的坐标。我们不能使用以像素为单位的基本原生 JavaScript 坐标。我们需要一个在水平轴和垂直轴上都从 -1 到 的值,+1 向上移动鼠标时垂直坐标为正。
这就是 WebGL 的工作原理,它与剪辑空间之类的东西有关,但我们不需要理解那些复杂的概念。

例子:

  • 鼠标在页面的左上角: -1 / 1
  • 鼠标在页面左下角: -1 / - 1
  • 鼠标垂直居中,水平居右: 1 / 0
  • 鼠标位于页面中心: 0 / 0

首先,使用Vector2 mouse创建一个 变量,并在鼠标移动时更新该变量:

/**
 * Mouse
 */
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

    console.log(mouse)
})

查看日志并确保值与前面的示例匹配。

我们可以在事件回调中投射光线 mousemove ,但不建议这样做,因为 mousemove 对于某些浏览器,事件触发的频率可能超过帧速率。tick 相反,我们将像以前一样在函数中投射光线 。

要将光线定向到正确的方向,我们可以使用 RaycastersetFromCamera() 上的方法 1. 其余代码与之前相同。如果它们相交或不相交,我们只需将对象材质更新为红色或蓝色:

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

    raycaster.setFromCamera(mouse, camera)

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

    for(const intersect of intersects)
    {
        intersect.object.material.color.set('#0000ff')
    }

    for(const object of objectsToTest)
    {
        if(!intersects.find(intersect => intersect.object === object))
        {
            object.material.color.set('#ff0000')
        }
    }

    // ...
}

如果光标在球体上方,球体应变为红色。

鼠标进入和鼠标离开事件

也不支持 , 等’mouseenter’鼠标 事件 。'mouseleave’如果您想在鼠标“进入”某个对象或“离开”该对象时得到通知,则必须自己完成。

我们可以做的是重现 mouseenter 和 mouseleave 事件,是有一个包含当前悬停对象的变量。

如果有一个对象相交,但之前没有,则表示 mouseenter 该对象上发生了 a。

如果没有对象相交,但之前有一个,则表示 mouseleave 发生了。

我们只需要保存当前相交的对象:

let currentIntersect = null

然后,测试并更新 currentIntersect 变量:

const tick = () =>
{
    // ...
    raycaster.setFromCamera(mouse, camera)
    const objectsToTest = [object1, object2, object3]
    const intersects = raycaster.intersectObjects(objectsToTest)

    if(intersects.length)
    {
        if(!currentIntersect)
        {
            console.log('mouse enter')
        }

        currentIntersect = intersects[0]
    }
    else
    {
        if(currentIntersect)
        {
            console.log('mouse leave')
        }

        currentIntersect = null
    }

    // ...
}
鼠标点击事件

现在我们有了一个包含当前悬停对象的变量,我们可以轻松实现一个 click 事件。

首先,我们需要监听 click 事件,无论它发生在哪里:

window.addEventListener('click', () =>
{

})

currentIntersect 然后,我们可以测试变量中是否有东西 :

window.addEventListener('click', () =>
{
    if(currentIntersect)
    {
        console.log('click')
    }
})

我们还可以测试点击关注的是什么对象:

window.addEventListener('click', () =>
{
    if(currentIntersect)
    {
        switch(currentIntersect.object)
        {
            case object1:
                console.log('click on object 1')
                break

            case object2:
                console.log('click on object 2')
                break

            case object3:
                console.log('click on object 3')
                break
        }
    }
})

重现原生事件需要时间,但是一旦你理解了它,它就非常简单了

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值