在Three.js中实现球体运动图的教程

384 阅读10分钟

⚠ 本教程假定对Three.js和GLSL有中等程度的熟悉。

概述

你应该先阅读Harry的帖子,因为他提供了有用的视觉效果,但其要点是这样的:

  • 通过根据相机方向偏移纹理查找,为材质添加虚假深度
  • 不要在每次迭代中使用相同的纹理,让我们使用高度图的深度 "切片",这样我们的体积形状就会更有活力。
  • 通过用滚动噪声替换纹理查找来增加波浪形的运动。

这篇文章中有几个部分我并不完全清楚,可能是由于Unity与Three.js的功能不同。其中之一是从平面上的视差贴图跳到球体上。另一个是如何获得顶点切线以转换到切线空间。最后,我不确定高度图的噪声是作为着色器内的代码评估的,还是预先渲染的。经过一些实验,我得出了自己的结论,但我鼓励你想出你自己的这个技术的变化。🙂

下面是我要开始做的Pen,它设置了一个模板式的Three.js应用,有一个init和tick生命周期、色彩管理和一个来自PolyHaven 的环境图用于 照明。

第一步:一个空白的大理石

大理石是由玻璃制成的,Harry的大理石肯定显示出一些镜面光泽。为了制作一个真正漂亮的玻璃材质,需要一些相当复杂的PBR着色器代码,这太费事了!相反,让我们只用一个玻璃材质来制作。相反,让我们从Three.js内置的PBR材质中选取一个,然后把我们的神奇部分挂在上面,就像我们是着色器的寄生虫一样。

输入onBeforeCompile ,这是THREE.Material 基类的一个回调属性,可以让你在WebGL编译之前对内置着色器进行修补。这项技术非常黑,而且在官方文档中没有很好的解释,但要了解更多关于它的信息,Dusan Bosnjak的帖子 "Extending three.js materials with GLSL "是一个好地方这方面最难的部分是确定你需要准确改变着色器的_哪_一部分。不幸的是,你最好的办法就是通读你想修改的着色器的源代码,找到一行或几块 看起来隐约相关的地方,然后试着调整一些东西,直到你想修改的属性出现明显的变化。我一直在写个人笔记,记录我发现的东西,因为真的很难跟踪不同的块和变量的作用。

ℹ 我最近发现有一种更优雅的方法可以使用Three的实验性节点材质来扩展内置的材质,但这值得一个完整的教程,所以在本指南中我将坚持使用更常见的onBeforeCompile

对于我们的目的,MeshStandardMaterial是一个很好的基础。它有镜面反射和环境反射,可以让材质看起来非常玻璃化,另外,如果你想在表面添加划痕,它还可以让你选择添加一个法线贴图。我们要改变的唯一部分是应用照明的基础颜色。幸运的是,这很容易找到。MeshStandardMaterial的碎片着色器定义在Meshphysical_frag.glsl.js中(它是MeshPhysicalMaterial的一个子集,所以它们都被定义在同一个文件中)。很多时候,你需要去挖掘文件中每个#include 语句所代表的着色器块,然而,这是一个罕见的情况,我们想要调整的变量就在眼前。

这就是main() 函数顶部附近的那一行,上面写着:

vec4 diffuseColor = vec4( diffuse, opacity );

这一行通常从漫反射和不透明度制服中读取,你通过材料的.color.opacity JavaScript属性来设置,然后所有之后的块做复杂的照明工作。我们将用我们自己对diffuseColor 的赋值来替换这一行,这样我们就可以在大理石的表面应用我们想要的任何图案。你可以在提供给onBeforeCompile回调的shader.fragmentShader 字段上使用常规的JavaScript字符串方法来完成这个任务。

material.onBeforeCompile = shader => {
  shader.fragmentShader = shader.fragmentShader.replace('/vec4 diffuseColor.*;/, `
    // Assign whatever you want!
    vec4 diffuseColor = vec4(1., 0., 0., 1.);
  `)
}

顺便说一下,那个神秘的回调参数的类型定义可以在这里找到。

在下面的Pen中,我把我们的几何体换成了球体,降低了粗糙度,并用屏幕空间法线填充diffuseColor ,这些法线在标准碎片着色器中是可用的vNormal 。结果看起来就像一个闪亮的MeshNormalMaterial的版本。

第二步:伪造体积

现在是最难的部分--使用漫反射颜色在我们的大理石中创造出体积的幻觉。在Harry之前的视差文章,他谈到了在切线空间中找到摄像机的方向,并使用它来抵消UV坐标。在learnopengl.com和这个存档的帖子中,对这个视差效果的一般原理有一个很好的解释

然而,在Three.js中把东西转换到切线空间可能很麻烦。据我所知,没有像其他空间转换那样的内置工具来帮助解决这个问题,所以需要花一些时间来生成顶点切线,然后组装一个TBN矩阵来执行转换。此外,由于毛球定理(是的,那是一个真实的东西),球体不是一个很好的切线形状,而且Three的computeTangents() 函数对我来说产生了不连续,所以你基本上必须手动计算切线。呸!!!"。

幸运的是,如果我们把这个问题看作是一个三维光线行进的问题,我们就不需要使用切线空间。我们有一条从摄像机指向大理石表面的光线,我们想让它穿过球体,并沿着高度贴图的片断行进。我们只需要知道如何将三维空间中的一个点转换成球体表面的一个点,这样我们就可以进行纹理查找。理论上,你也可以把3D位置直接插入你选择的噪声函数中,并跳过使用纹理,但这种效果依赖于大量的迭代,而我的假设是,纹理查找比所有发生在例如3D单纯噪声函数中的数字计算更便宜(着色器大师们,如果我错了请纠正我)。读取纹理的另一个好处是,它允许我们使用一个更加面向艺术的管道来制作我们的高度图,所以我们可以在不写新代码的情况下做出各种有趣的体积。

最初我写了一个函数来做这个球形XYZ→UV的转换,基于我在网上看到的一些答案,但事实证明,在common.glsl.js中已经有一个函数在做同样的事情,叫做equirectUv 。只要把我们的光线行进逻辑放在标准着色器中的#include <common> 行之后,我们就可以重新使用它。

创建我们的高度图

对于高度图,我们想要一个能够无缝投射在UV球体表面的纹理。在网上找到无缝噪声纹理 并不难,但问题是这些平坦的噪声投影在应用于球体时,在两极附近看起来会有扭曲。为了解决这个问题,让我们用Blender制作我们自己的纹理一种方法是使用 "简单变形修改器 "的两个实例将高分辨率的 "网格 "网格弯曲成一个球体,将产生的 "物体 "纹理坐标插入你选择的程序性着色器中,然后用Cycles渲染器 做一个发射性烘烤。我还在两极附近添加了一些循环切割和一个细分修改器,以防止烘烤过程中出现任何伪影。

雷击术

现在是我们一直在等待(或害怕)的时刻--光线行进!实际上,这并不坏。这其实并不坏,下面是代码的一个缩写版本。现在还没有动画,我只是用smoothstep(注意平滑因子,它有助于隐藏层间的尖锐边缘)对高度图进行切片,把它们加起来,然后用这个来混合两种颜色。

uniform sampler2D heightMap;
uniform vec3 colorA;
uniform vec3 colorB;
uniform float iterations;
uniform float depth;
uniform float smoothing;

/**
  * @param rayOrigin - Point on sphere
  * @param rayDir - Normalized ray direction
  * @returns Diffuse RGB color
  */
vec3 marchMarble(vec3 rayOrigin, vec3 rayDir) {
  float perIteration = 1. / float(iterations);
  vec3 deltaRay = rayDir * perIteration * depth;

  // Start at point of intersection and accumulate volume
  vec3 p = rayOrigin;
  float totalVolume = 0.;

  for (int i=0; i<iterations; ++i) {
    // Read heightmap from current spherical direction
    vec2 uv = equirectUv(p);
    float heightMapVal = texture(heightMap, uv).r;

    // Take a slice of the heightmap
    float height = length(p); // 1 at surface, 0 at core, assuming radius = 1
    float cutoff = 1. - float(i) * perIteration;
    float slice = smoothstep(cutoff, cutoff + smoothing, heightMapVal);

    // Accumulate the volume and advance the ray forward one step
    totalVolume += slice * perIteration;
    p += deltaRay;
  }
  return mix(colorA, colorB, totalVolume);
}

/**
 * We can user this later like:
 *
 * vec4 diffuseColor = vec4(marchMarble(rayOrigin, rayDir), 1.0);
 */

ℹ 这种逻辑在物理上并不准确--根据迭代指数对高度图进行切片,假设光线是指向球体中心的,但对大多数像素来说,这并不正确。因此,大理石似乎有一些严重的折射。然而,我认为这实际上看起来很酷,并进一步推销了它是固体玻璃的效果!

注入制服

在我们看到我们的劳动成果之前,还有最后一点--我们如何将所有这些定制的制服纳入我们的修改材料中?我们不能像你用THREE.ShaderMaterial ,只是把东西粘在material.uniforms 。诀窍是创建你自己的制服对象,然后把它的内容连接到onBeforeCompile 中的shader 参数上。比如说:

const myUniforms = {
  foo: { value: 0 }
}

material.onBeforeCompile = shader => {
  shader.uniforms.foo = myUniforms.foo

  // ... (all your other patches)
}

当着色器试图读取它的shader.uniforms.foo.value 引用时,它实际上是从你的本地myUniforms.foo.value ,所以你的制服对象中的任何值的变化都会自动反映在着色器中。

我通常使用JavaScript的spread operator来一次性连接所有的uniforms。

const myUniforms = {
  // ...(lots of stuff)
}

material.onBeforeCompile = shader => {
  shader.uniforms = { ...shader.uniforms, ...myUniforms }

  // ... (all your other patches)
}

把这一切放在一起,我们就得到了一个气体(和玻璃)的体积。我在这个笔上添加了滑块,所以你可以玩玩迭代次数、平滑度、最大深度和颜色。

ℹ 技术上来说,射线的原点和射线的方向应该是在本地空间,这样当大理石移动时,效果就不会中断。然而,我跳过这个转换,因为我们没有移动大理石,所以世界空间和本地空间是可以互换的。工作要更聪明,而不是更努力!

第三步:波浪形运动

几乎完成了!最后一步是通过对体积的动画化来使这个大理石变得生动。Harry的波浪形位移帖子解释了他是如何使用二维位移纹理来完成的。然而,就像高度图一样,一个平面的位移纹理在球体的两极附近会发生扭曲。所以,我们将再次制作我们自己的。你可以使用与之前相同的Blender设置,但这次让我们在RGB通道上烘烤一个3D噪音纹理。

然后在我们的marchMarble 函数中,我们将使用与之前相同的equirectUv 函数读取这个纹理,将数值居中,然后将这个矢量的缩放版本添加到用于高度图纹理查询的位置。为了使位移产生动画效果,引入一个time 统一,用它来水平滚动位移纹理。为了达到更好的效果,我们将对位移贴图进行两次采样(一次是直立的,然后是颠倒的,所以它们永远不会完全对齐),向相反的方向滚动,并将它们加在一起,产生看起来很混乱的噪声。这个一般的策略经常被用在水的着色器 中来创造波浪。

uniform float time;
uniform float strength;

// Lookup displacement texture
vec2 uv = equirectUv(normalize(p));
vec2 scrollX = vec2(time, 0.);
vec2 flipY = vec2(1., -1.);
vec3 displacementA = texture(displacementMap, uv + scrollX).rgb;
vec3 displacementB = texture(displacementMap, uv * flipY - scrollX).rgb;

// Center the noise
displacementA -= 0.5;
displacementB -= 0.5;

// Displace current ray position and lookup heightmap
vec3 displaced = p + strength * (displacementA + displacementB);
uv = equirectUv(normalize(displaced));
float heightMapVal = texture(heightMap, uv).r;

看啊,你的神奇的弹珠

结语

再次感谢Harry提供的优秀学习资源。我在尝试重现这个效果的过程中获得了大量的乐趣,而且我在这个过程中学到了很多东西。希望你也能学到一些东西!

你现在面临的挑战是如何利用这些例子来运行它们。改变代码、纹理和颜色,做出你自己的神奇的大理石。

给我一个惊喜吧!