NSDT工具推荐Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器 - REVIT导出3D模型插件 - 3D模型语义搜索引擎 - AI模型在线查看 - Three.js虚拟轴心开发包 - 3D模型在线减面 - STL模型在线切割 - 3D道路快速建模

继 Harry Alisavakis 令人惊叹的汤着色器之后,我想使用 Three.js 重新创建类似的卡通着色效果。 我从 Roystan 的卡通着色器教程开始,它是为 Unity 编写的。 在这篇文章中,我将把 Roystan 教程中概述的原则翻译成 Three.js。 下面描述的着色器为创建更加风格化的着色器提供了良好的基础。

点击这里访问具有完整卡通着色器实现的Github存储库。

1、Three.js 着色器概述

本教程需要了解着色器的一般工作原理以及在 Three.js 中的具体工作原理。 我们将使用自定义顶点和片段着色器创建 ShaderMaterial。 简而言之,顶点着色器处理屏幕上顶点数据的位置,而片段着色器处理每个像素呈现的颜色。

需要记住的一些要点:

  • attributes是着色器中可用的值,在网格的每个顶点上定义。 这些是位置、UV 等。
  • uniforms是传递给整个网格着色器的值。 这些信息包括增量时间、摄像机位置或场景中灯光的信息。
  • varyings是从一个着色器传递到另一个着色器的值。 通常这些包括只能在顶点着色器中计算的位置数据,并传递给片段着色器。

2、卡通着色器理论

卡通着色器背后的想法非常简单,但效果却很强大。 虽然我们可以使用很多效果,但对于这个基本的卡通着色器,我们将重点关注创建卡通外观的五个主要方面:

  • 平面色基
  • 单色核心阴影
  • 镜面反射
  • 边缘光
  • 收到的阴影

让我们开始吧!

3、平面色基

首先,我们从两个基本着色器开始:一个顶点着色器,用于设置顶点在剪辑空间中的正确位置;以及一个片段着色器,用于设置给定颜色。 这会导致我们的网格形状被正确绘制,但整个网格是单色的。

import toonVertexShader from './toon.vert'
import toonFragmentShader from './toon.frag'

const toonShaderMaterial = new THREE.ShaderMaterial({
  vertexShader: toonVertexShader,
  fragmentShader: toonFragmentShader,
})

const mesh = new THREE.Mesh(
  new THREE.SphereGeometry(1, 1, 1),
  toonShaderMaterial
)

toon.vert:

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

  gl_Position = clipPosition;
}

toon.frag:

void main() {
  gl_FragColor = vec4(vec3(0.39, 0.58, 0.93), 1.0);
}

效果如下:

由于我们要向 THREE.ShaderMaterial 添加自定义着色器,因此我们首先需要指定使用该材质的网格应该是什么颜色。

虽然我们可以直接在着色器中对颜色进行硬编码,但更好的方法是将其作为统一的颜色传递给着色器。 然后我们还可以将颜色作为属性添加到 dat.GUI 控件中,以便我们可以在运行时更改它。

toon.frag:

uniform vec3 uColor;

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

4、核心阴影

为了获得清晰的阴影外观,我们需要清楚地区分我们认为照亮的网格区域和我们考虑的阴影区域。 为了实现这种效果,我们需要场景的光照信息。

值得庆幸的是,Three.js 为我们提供了开箱即用的光照信息,我们只需要知道如何添加它即可。

首先,我们需要指出,我们的ShaderMaterial需要通过将 lights 属性设置为true来接收光照信息。

其次,在 ShaderMaterial 中,我们通过 ...THREE.UniformsLib.lights 传入预定义的灯光uniforms。 这些uniforms确保我们的着色器知道如何接收照明信息。

scene.js:

const toonShaderMaterial = new THREE.ShaderMaterial({
  lights: true,
  uniforms: {
    ...THREE.UniformsLib.lights,
    uColor: { value: THREE.Color('#6495ED') }
  },
  vertexShader: toonVertexShader,
  fragmentShader: toonFragmentShader,
})

第三,我们要在顶点着色器中计算 varying vec3 vNormal向量并将其传递给片段着色器。 我们需要这个向量来计算给定点的阴影强度。

varying vec3 vNormal;

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

  vNormal = normalize(normalMatrix * normal);

  gl_Position = clipPosition;
}

最后,在我们的片段着色器中,我们需要使用 #include <common>  和 #include <lights_pars_begin> 包含一些通用和灯光助手,并且我们可以访问场景的有向光!

#include <common>
#include <lights_pars_begin>

uniform vec3 uColor;

varying vec3 vNormal;

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

4.1 定向光

场景中的每个定向光都具有以下结构,该结构在我们上面包含的着色器块中定义。

struct DirectionalLight {
	vec3 direction;
	vec3 color;
};

为了计算阴影需要在网格上的位置,我们需要计算出照射到我们可以看到的每个点的漫射光的强度。 为此,我们采用光线方向与任何给定点法线的点积。

为了建立直觉,当两个向量指向同一方向时,点积(dot product)为 1;当向量彼此垂直时,点积趋向 0;当它们的角度增加超过 90° 时,点积趋向 -1。 这意味着法线直接指向光源的网格部分应该具有最大的光强度,而垂直或远离光源的部分则不会得到任何光。

由于点积返回从 -1 到 1 的值,并且我们希望阴影和非阴影之间有一个清晰的截止点,因此我们将使用 smoothstep 函数将值的范围限制在 0 和 1 之间

将定向光颜色乘以该光的强度,我们就得到了需要乘以网格体基色的定向光。

片段着色器应类似于下面的代码,其中突出显示新行:

#include <common>
#include <lights_pars_begin>

uniform vec3 uColor;

varying vec3 vNormal;

void main() {
  float NdotL = dot(vNormal, directionalLights[0].direction);
  float lightIntensity = smoothstep(0.0, 0.01, NdotL);
  vec3 directionalLight = directionalLights[0].color * lightIntensity;

  gl_FragColor = vec4(uColor * directionalLight, 1.0);
}

结果如下:

4.2 环境光

阴影现在看起来太暗了,这是因为场景的环境光被忽略了。 在上面的代码中,我们实际上说了

  • 如果表面被照亮→使用基色
  • 如果表面处于阴影中→不使用颜色(即黑色)

由于我们不想要黑色阴影,因此我们还需要考虑环境光。

还记得我们向后几步添加的  #include <lights_pars_begin> 吗? 在这个 #include中,Three.js已经给了我们 ambientLightColor,我们所要做的就是将它应用到 gl_FragColor

gl_FragColor = vec4(uColor * (ambientLightColor + directionalLight), 1.0);

5、镜面反射

虽然核心阴影仅取决于定向光的位置,但镜面反射还取决于观看者的位置,更具体地说是相机的位置。 我们已经将这些数据作为  viewPosition 存在于顶点着色器中。 这是从相机到顶点的矢量,因此为了获得镜面反射光的方向,我们需要做的是将其反转并标准化。

然后我们将值作为 varying vec3 vViewDir 传递给片段着色器。

为了获得镜面反射的强度,我们首先计算出半矢量,即定向光矢量和观察方向中间的矢量。 然后我们取半向量和法向量的点积。 这个点积 NdotH 告诉我们给定点的镜面反射强度。 当我们将其乘以光强度时,我们就可以得到该点定向光的镜面反射有多强。

然后,我们通过应用 pow 和 smoothstep 函数来调整镜面反射强度。 这里我们引入另一个称为 uGlossiness 的uniform,它指定镜面反射应该有多大。 它可以通过 dat.GUI 控件进行调整。

为了更好地描述如何计算镜面反射强度,我强烈建议阅读 Blinn-Phong 镜面反射模型

以下是此步骤的代码更改以及生成的着色器效果:

toon.vert:

varying vec3 vNormal;
varying vec3 vViewDir;

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

  vNormal = normalize(normalMatrix * normal);
  vViewDir = normalize(-viewPosition.xyz);

  gl_Position = clipPosition;
}

toon.frag:

uniform float uGlossiness;
varying vec3 vViewDir;

// other includes, uniforms and varyings...

void main() {
  // directional light ...

  // specular reflection
  vec3 halfVector = normalize(directionalLights[0].direction + vViewDir);
  float NdotH = dot(vNormal, halfVector);

  float specularIntensity = pow(NdotH * lightIntensity, 1000.0 / uGlossiness);
  float specularIntensitySmooth = smoothstep(0.05, 0.1, specularIntensity);

  vec3 specular = specularIntensitySmooth * directionalLights[0].color;

  gl_FragColor = vec4(uColor * (directionalLight + ambientLightColor + specular), 1.0);
}

结果如下:

6、边缘照明

我们要应用的最后一个照明效果是边缘照明。 这种类型的照明是一种很酷的效果,当物体被背光或强光从侧面照亮时就会发生这种效果。 对于我们的卡通着色器,我们将伪造这种效果,但它在物理上不会非常准确。

为了获得物体的轮廓,我们希望目标表面的法线几乎垂直于相机。 通过取表面法线向量和视图方向的点积并反转它,我们得到的值对于直接面向相机的表面为 0,对于背向相机的表面则接近 1。

float rimDot = 1.0 - dot(vViewDir, vNormal);

为了仅显示不在阴影中的区域中的边缘照明,我们将该值与 NdotL 相乘,正如我们在第一步中介绍的那样,NdotL 指定表面是在灯光中还是在阴影中。 在获得边缘光强度后,我们对其进行平滑处理以获得清晰的截止效果。 最后,我们将其乘以定向光的颜色并将其添加到 gl_FragColor:

//toon.frag
varying vec3 vNormal;
varying vec3 vViewDir;

void main() {
  // directional light, specular reflection...

  // rim lighting
  float rimDot = 1.0 - dot(vViewDir, vNormal);
  float rimAmount = 0.6;

  float rimThreshold = 0.2;
  float rimIntensity = rimDot * pow(NdotL, rimThreshold);
  rimIntensity = smoothstep(rimAmount - 0.01, rimAmount + 0.01, rimIntensity);

  vec3 rim = rimIntensity * directionalLights[0].color;

  gl_FragColor = vec4(uColor * (directionalLight + ambientLightColor + specular + rim), 1.0);
}

结果如下:

7、接收阴影

我们的材质对场景中的定向光完全做出反应,但它不会接收阻挡光线的物体的阴影。 幸运的是,Three.js 还可以帮助我们访问在着色器内为此光创建的阴影贴图。

为了让你的对象接收阴影,首先渲染器必须启用阴影贴图,并且定向光必须投射阴影。

//scene.js
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // not necessary but it makes the shadows a little nicer

directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 4096; // increases the shadow mapSize so the shadows are sharper
directionalLight.shadow.mapSize.height = 4096;

你还需要一个带有castShadow = true的对象来阻挡进入卡通着色对象的光线。

接下来,我们需要在顶点和片段着色器中使用 Three.js 中的一些实用程序,以便将阴影贴图数据正确传递到片段着色器。

toon.vert:

#include <common>
#include <shadowmap_pars_vertex>

void main() {
    #include <beginnormal_vertex>
    #include <defaultnormal_vertex>

    #include <begin_vertex>

    #include <worldpos_vertex>
    #include <shadowmap_vertex>

    // ... rest stays the same
}

toon.frag:

#include <common>
#include <packing>
#include <lights_pars_begin>
#include <shadowmap_pars_fragment>
#include <shadowmask_pars_fragment>

void main() {
    // ...rest stays the same
}

通过这些包含,我们现在可以访问 orientalLightShadows 数组和函数 getShadow。 从这里,我们使用适当的定向光阴影调用 getShadow 函数,内置 Three.js 着色器将根据已经为灯光生成的阴影贴图计算给定顶点的阴影。 可以在shadowmap_pars_fragment.glsl.js 中找到此函数的源代码。

这就是最终的 toon.frag 片段着色器的样子,其中突出显示了阴影计算。

#include <common>
#include <packing>
#include <lights_pars_begin>
#include <shadowmap_pars_fragment>
#include <shadowmask_pars_fragment>

uniform vec3 uColor;
uniform float uGlossiness;

varying vec3 vNormal;
varying vec3 vViewDir;

void main() {
  // shadow map
  DirectionalLightShadow directionalShadow = directionalLightShadows[0];

  float shadow = getShadow(
    directionalShadowMap[0],
    directionalShadow.shadowMapSize,
    directionalShadow.shadowBias,
    directionalShadow.shadowRadius,
    vDirectionalShadowCoord[0]
  );

  // directional light
  float NdotL = dot(vNormal, directionalLights[0].direction);
  float lightIntensity = smoothstep(0.0, 0.01, NdotL * shadow);
  vec3 directionalLight = directionalLights[0].color * lightIntensity;

  // specular reflection
  vec3 halfVector = normalize(directionalLights[0].direction + vViewDir);
  float NdotH = dot(vNormal, halfVector);

  float specularIntensity = pow(NdotH * lightIntensity, 1000.0 / uGlossiness);
  float specularIntensitySmooth = smoothstep(0.05, 0.1, specularIntensity);

  vec3 specular = specularIntensitySmooth * directionalLights[0].color;

  // rim lighting
  float rimDot = 1.0 - dot(vViewDir, vNormal);
  float rimAmount = 0.6;

  float rimThreshold = 0.2;
  float rimIntensity = rimDot * pow(NdotL, rimThreshold);
  rimIntensity = smoothstep(rimAmount - 0.01, rimAmount + 0.01, rimIntensity);

  vec3 rim = rimIntensity * directionalLights[0].color;

  gl_FragColor = vec4(uColor * (ambientLightColor + directionalLight + specular + rim), 1.0);
}

效果如下:

8、结束语

就像罗伊斯坦在上面链接的教程中所说的那样,卡通着色本质上是实现一个应用阶跃函数的照明模型,以便在光和阴影之间有清晰的截止。 尽管如此,这个风格化的着色器仍然可以调整以获得额外的效果。

我在开发这个着色器时注意到的几点可能有用:

  • 多边形数量将影响投射的阴影对象的类型(实际上与着色器无关)
  • 平滑着色的对象将具有更好的核心阴影、镜面反射和边缘照明。
  • 如果你在其他 3D 软件中使用平滑着色创建模型,请确保导出具有计算法线的模型

原文链接:Custom Toon Shader in Three.js

BimAnt翻译整理,转载请标明出处