纹理投影是一种将纹理映射到 3D 对象并使其看起来像是从单个点投影的方法。 把它想象成投射到云上的蝙蝠侠符号,云是我们的对象,蝙蝠侠符号是我们的纹理。 它用于游戏和视觉效果,以及创意世界的更多部分。
工具:使用 NSDT场景编辑器 快速搭建 数字孪生3D场景。
1、纹理投影MVP
首先,让我们设置场景。 每个Three.js项目的场景搭建代码都是一样的,这里就不赘述了。 如果你以前没有做过,你可以熟悉下官方指南。 我个人使用 threejs-modern-app 中的一些实用程序,所以我不需要担心样板代码。
首先我们需要一个相机来投影纹理。
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 3)
camera.position.set(-1, 1.2, 1.5)
camera.lookAt(0, 0, 0)
然后,我们需要在其上投影纹理的对象。 为了进行投影映射,我们将编写一些自定义着色器代码,因此让我们创建一个新的 ShaderMaterial:
// create the mesh with the projected material
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.ShaderMaterial({
uniforms: {
texture: { value: assets.get(textureKey) },
},
vertexShader: '',
fragShader: '',
})
const box = new THREE.Mesh(geometry, material)
但是,由于我们可能需要多次使用我们投影的材质,我们可以将它单独放在一个组件中,然后像这样使用它:
class ProjectedMaterial extends THREE.ShaderMaterial {
constructor({ camera, texture }) {
// ...
}
}
const material = new ProjectedMaterial({
camera,
texture: assets.get(textureKey),
})
现在让我们写着色器!
在着色器代码中,我们基本上会对纹理进行采样,就好像它是从相机投射出来的一样。 不幸的是,这涉及到一些矩阵乘法。 但不要害怕! 我将以一种简单易懂的方式对其进行解释。 如果你想更深入地研究这个主题,可以查看这篇关于矩阵运算的非常好的文章。
在顶点着色器中,我们必须将每个顶点视为从投影相机中查看,所以我们只使用投影相机的 projectionMatrix 和 viewMatrix 而不是场景相机中的那些。 我们使用可变变量将这个转换后的位置传递给片段着色器。
vTexCoords = projectionMatrixCamera * viewMatrixCamera * modelMatrix * vec4(position, 1.0);
在片元着色器中,我们必须将位置从世界空间转换到剪切空间。 我们通过将向量除以它的 .w 分量来做到这一点。 GLSL 内置函数 texture2DProj(或更新的 textureProj)也在内部执行此操作。
在同一行中,我们还将剪切空间范围 [-1, 1] 转换为 uv 查找范围 [0, 1]。 我们使用这个变量稍后从纹理中采样。
vec2 uv = (vTexCoords.xy / vTexCoords.w) * 0.5 + 0.5;
结果如下:
请注意,我们编写了一些代码以仅将纹理投影到立方体面向相机的面上。 默认情况下,每个面都会得到纹理投影,因此我们通过查看法线和相机方向的点积来检查脸是否真的面向相机。 这种技术在照明中确实很常见,如果你想有关此主题的信息,请阅读这篇文章。
// this makes sure we don't render the texture also on the back of the object
vec3 projectorDirection = normalize(projPosition - vWorldPosition.xyz);
float dotProduct = dot(vNormal, projectorDirection);
if (dotProduct < 0.0) {
outColor = vec4(color, 1.0);
}
第一部分,我们现在想让它看起来像贴在物体上的纹理。
我们只需在开始时保存对象的位置,然后使用它而不是更新后的对象位置来计算投影,这样即使对象之后移动,投影也不会改变。
我们可以将对象初始模型矩阵存储在统一的 savedModelMatrix 中,因此我们的计算变为:
vTexCoords = projectionMatrixCamera * viewMatrixCamera * savedModelMatrix * vec4(position, 1.0);
我们可以公开一个 project() 函数,它将 savedModelMatrix 设置为对象的当前 modelMatrix。
export function project(mesh) {
// make sure the matrix is updated
mesh.updateMatrixWorld()
// we save the object model matrix so it's projected relative
// to that position, like a snapshot
mesh.material.uniforms.savedModelMatrix.value.copy(mesh.matrixWorld)
}
这是我们的最终结果:
就是这样! 现在立方体看起来像是贴上了纹理! 这可以扩展到任何类型的 3D 模型,所以让我们举一个更有趣的例子。
2、纹理投影的有趣案例
对于前面的示例,我们创建了一个用于投影的新相机,但是如果我们使用渲染场景的相同相机进行投影呢? 这样我们就可以准确地看到 2D 图像! 这是因为投影点与视点重合。
另外,让我们尝试投影到多个对象上:
看起来很有趣! 然而,正如你从示例中看到的那样,图像看起来有点扭曲,这是因为纹理被拉伸以填充相机平截头体。 但是如果我们想保留图像的原始比例和尺寸怎么办?
此外,我们根本没有考虑照明。 片段着色器中需要一些代码来说明我们在场景中放置的灯光如何照亮表面。
此外,如果我们想投影到更多的对象上怎么办? 性能会迅速下降。 这就是 GPU 实例化提供帮助的地方! 实例化将繁重的工作转移到 GPU 上,Three.js 最近为其实现了一个易于使用的 API。 唯一的要求是所有实例化对象必须具有相同的几何体和材质。 幸运的是,这就是我们的情况! 所有对象都具有相同的几何形状和材质,唯一的区别是 savedModelMatrix,因为每个对象在投影时都有不同的位置。 但是我们可以将它作为统一传递给每个实例,就像在这个 Three.js 示例中一样。
事情开始变得复杂,但别担心! 我已经对这些东西进行了编码并将其放入three-projected-material库中,因此使用起来更容易,而且你不必每次都重写相同的东西! 如果你对我如何克服剩下的挑战感兴趣,可以去看看。
从现在开始,我们将使用该库。
3、纹理投影进阶
现在我们可以投影到许多对象上并为其设置动画,让我们尝试从中制作一些真正有用的东西。
例如,让我们尝试将其集成到幻灯片中,将图像投影到大量 3D 对象上,然后以有趣的方式对对象进行动画处理。
对于第一个例子,灵感来自 Refik Anadol。 他做了一些非常棒的事情。 但是,我们不能像他那样对速度和力进行全面的模拟,我们需要控制物体的运动; 我们需要它在正确的时间到达正确的地方。
我们通过将对象放置在一些轨迹上来实现这一点:我们定义对象必须遵循的路径,然后在该路径上为对象设置动画。 这是一个 Stack Overflow 答案,解释了实现的原理。
为了进行投影,我们
- 将元素移动到中间点
- 执行调用 project() 的纹理投影
- 将元素放回起点
这是同步发生的,所以用户不会看到任何东西。
现在我们可以自由地以任何我们想要的方式对这些路径进行建模!
但首先,我们必须确保在中间点,元素将正确覆盖图像区域。 为此,我使用了泊松盘采样算法,该算法将点更均匀地分布在表面上,而不是随机定位它们。
this.points = poissonSampling([this.width, this.height], 7.73, 9.66) // innerradius and outerradius
// here is what this.points looks like,
// the z component is 0 for every one of them
// [
// [
// 2.4135735314978937, --> x
// 0.18438944023363374 --> y
// ],
// [
// 2.4783704056100464,
// 0.24572635574719284
// ],
// ...
下面我们来看看第一个demo中路径是如何生成的。 在此演示中,大量使用了 perlin 噪声(或者更确切地说是它的开源对应物,open simple noise)。 还要注意 mapRange() 函数(处理中的 map()),它基本上将一个数字从一个区间映射到另一个区间。 另一个执行此操作的库是 d3-scale 及其 d3.scaleLinear()。 还使用了一些缓动函数。
const segments = 51 // must be odds so we have the middle frame
const halfIndex = (segments - 1) / 2
for (let i = 0; i < segments; i++) {
const offsetX = mapRange(i, 0, segments - 1, startX, endX)
const noiseAmount = mapRangeTriple(i, 0, halfIndex, segments - 1, 1, 0, 1)
const frequency = 0.25
const noiseAmplitude = 0.6
const noiseY = noise(offsetX * frequency) * noiseAmplitude * eases.quartOut(noiseAmount)
const scaleY = mapRange(eases.quartIn(1 - noiseAmount), 0, 1, 0.2, 1)
const offsetZ = mapRangeTriple(i, 0, halfIndex, segments - 1, startZ, 0, endZ)
// offsetX goes from left to right
// scaleY shrinks the y before and after the center
// noiseY is some perlin noise on the y axis
// offsetZ makes them enter from behind a little bit
points.push(new THREE.Vector3(x + offsetX, y * scaleY + noiseY, z + offsetZ))
}
我们可以处理的另一件事是每个元素到达的延迟。 我们在这里也使用了 Perlin 噪音,这使得它们看起来像是“成群结队”地到达。
const frequency = 0.5
const delay = (noise(x * frequency, y * frequency) * 0.5 + 0.5) * delayFactor
我们还在波浪效果中使用了柏林噪声,它修改了曲线的每个点,使其具有“旗帜波浪”效果。
const { frequency, speed, amplitude } = this.webgl.controls.turbulence
const z = noise(x * frequency - time * speed, y * frequency) * amplitude
point.z = targetPoint.z + z
对于鼠标交互,我们检查路径的点是否比某个半径更近,如果是,我们计算从鼠标点到路径点的向量。 然后我们沿着该向量的方向稍微移动路径点。 为此,我们使用 lerp() 函数,它以特定百分比返回指定范围内的插值。 例如 0.2 表示 20%。
// displace the curve points
if (point.distanceTo(this.mousePoint) < displacement) {
const direction = point.clone().sub(this.mousePoint)
const displacementAmount = displacement - direction.length()
direction.setLength(displacementAmount)
direction.add(point)
point.lerp(direction, 0.2) // magic number
}
// and move them back to their original position
if (point.distanceTo(targetPoint) > 0.01) {
point.lerp(targetPoint, 0.27) // magic number
}
剩下的代码处理幻灯片样式的动画,有兴趣的可以去看看源码!
在另外两个演示中,我使用了一些不同的函数来塑造元素移动的路径,但总体而言,代码非常相似。
4、纹理投影结束语
我希望这篇文章简单易懂,足以让你深入了解纹理投影技术。 可以查看 GitHub 上的代码并下载它! 我确保以易于理解的方式编写代码并提供大量注释。
原文链接:Three.js纹理投影 — BimAnt