为网络创建卡通水着色器。 第一部分

在我的教程“ Creating Shaders”中,我主要研究了片段着色器,这些片段着色器足以在ShaderToy上实现任何2D效果和示例。 但是有一整类技术需要使用顶点着色器。 在本教程中,我将讨论创建风格化的卡通水着色器,并向您介绍顶点着色器。 我还将讨论深度缓冲以及如何使用它来获取有关场景的更多信息并创建海泡沫线。

这就是完成的效果。 可以在此处看到一个交互式演示。


此效果包含以下元素:

  1. 一个半透明的水网格,具有细分的多边形和偏移的顶点,以创建波浪。
  2. 表面上有静水管线。
  3. 模拟船浮力。
  4. 水中物体周围的动态泡沫线。
  5. 后处理会造成水下一切变形。

在这种效果下,我喜欢它涉及计算机图形学的许多不同概念的事实,因此它将使我们能够使用以前教程中的思想,以及开发可应用于新效果的技术。

在本教程中,我将使用PlayCanvas ,只是因为它是一个方便的免费Web-IDE,但是所有内容都可以应用于任何其他WebGL环境,而不会出现任何问题。 在本文的结尾,将提供Three.js的源代码版本。 我们将假定您已经精通片段着色器和PlayCanvas界面。 您可以在此处刷新有关着色器的知识,并在此处熟悉PlayCanvas。

环境设定


本部分的目的是配置我们的PlayCanvas项目,并将几个会影响水的环境对象插入其中。

如果您没有PlayCanvas帐户,请注册它并创建一个新的空白项目 。 默认情况下,场景中应该有几个对象,一个摄像头和一个光源。


插入模型


Google Poly专案是寻找网路3D模型的绝佳资源。 我从那里拿船模型 。 下载并解压缩存档后,您将在其中找到.obj.png文件。

  1. 将两个文件都拖到PlayCanvas项目的Assets窗口中。
  2. 选择自动生成的材质,然后选择.png文件作为其漫反射贴图。


现在,您可以将Tugboat.json拖到场景中并删除Box和Plane对象。 如果船看起来太小,则可以增加其比例(我将值设置为50)。


同样,您可以将任何其他模型添加到场景中。

绕行摄像机


要配置在轨道上飞行的摄像机,我们将复制此PlayCanvas示例中的脚本。 单击链接,然后单击“ 编辑器”以打开项目。

  1. 将本教程项目中的mouse-input.jsorbit-camera.js的内容复制到项目中具有相同名称的文件中。
  2. 脚本组件添加到相机。
  3. 将两个脚本附加到相机。

提示:要组织项目,可以在“资产”窗口中创建文件夹。 我将这两个相机脚本放置在Scripts / Camera /文件夹中,将模型放置在Models /中,并将材质放置在Materials /文件夹中。

现在,当您开始游戏时(场景窗口右上角的启动按钮),您应该会看到一条船,您可以通过用鼠标将其在轨道上移动来用摄像机检查。

水面多边形划分


本部分的目的是创建一个细分的网格,该网格将用作水面。

为了创建水面,我们改编了地势生成教程中的部分代码。 创建一个新的Water.js脚本Water.js 。 打开此脚本进行编辑,并创建一个新的GeneratePlaneMesh函数,如下所示:

 Water.prototype.GeneratePlaneMesh = function(options){ // 1 -    ,     if(options === undefined) options = {subdivisions:100, width:10, height:10}; // 2 -  , UV   var positions = []; var uvs = []; var indices = []; var row, col; var normals; for (row = 0; row <= options.subdivisions; row++) { for (col = 0; col <= options.subdivisions; col++) { var position = new pc.Vec3((col * options.width) / options.subdivisions - (options.width / 2.0), 0, ((options.subdivisions - row) * options.height) / options.subdivisions - (options.height / 2.0)); positions.push(position.x, position.y, position.z); uvs.push(col / options.subdivisions, 1.0 - row / options.subdivisions); } } for (row = 0; row < options.subdivisions; row++) { for (col = 0; col < options.subdivisions; col++) { indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + (row + 1) * (options.subdivisions + 1)); } } //   normals = pc.calculateNormals(positions, indices); //    var node = new pc.GraphNode(); var material = new pc.StandardMaterial(); //   var mesh = pc.createMesh(this.app.graphicsDevice, positions, { normals: normals, uvs: uvs, indices: indices }); var meshInstance = new pc.MeshInstance(node, mesh, material); //      var model = new pc.Model(); model.graph = node; model.meshInstances.push(meshInstance); this.entity.addComponent('model'); this.entity.model.model = model; this.entity.model.castShadows = false; //   ,       }; 

现在我们可以在initialize函数中调用它:

 Water.prototype.initialize = function() { this.GeneratePlaneMesh({subdivisions:100, width:10, height:10}); }; 

现在,当您开始游戏时,您应该只会看到一个平坦的表面。 但这不仅是平坦的表面,而且是由数千个峰组成的网格。 作为练习,尝试自己验证一下(这是研究刚刚复制的代码的一个很好的理由)。

问题1:将每个顶点的Y坐标移动一个随机值,以使平面如下图所示。


海浪


本部分的目的是指定您自己的材料的水面并创建动画波浪。

为了获得我们需要的效果,您需要配置自己的材料。 大多数3D引擎都有一组用于渲染对象的预定义着色器,以及一种重新定义它们的方法。 这是有关如何在PlayCanvas中执行此操作的良好链接

着色器附件


让我们创建一个新的CreateWaterMaterial函数,该函数CreateWaterMaterial具有更改的着色器CreateWaterMaterial新材质并返回它:

 Water.prototype.CreateWaterMaterial = function(){ //     var material = new pc.Material(); //    ,       material.name = "DynamicWater_Material"; //    //        . var gd = this.app.graphicsDevice; var fragmentShader = "precision " + gd.precision + " float;\n"; fragmentShader = fragmentShader + this.fs.resource; var vertexShader = this.vs.resource; //       . var shaderDefinition = { attributes: { aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0, }, vshader: vertexShader, fshader: fragmentShader }; //     this.shader = new pc.Shader(gd, shaderDefinition); //      material.setShader(this.shader); return material; }; 

此函数从脚本属性中获取顶点和片段着色器代码。 因此,让我们在文件顶部(在pc.createScript行之后)定义它们:

 Water.attributes.add('vs', { type: 'asset', assetType: 'shader', title: 'Vertex Shader' }); Water.attributes.add('fs', { type: 'asset', assetType: 'shader', title: 'Fragment Shader' }); 

现在,我们可以创建这些着色器文件并将其附加到脚本中。 返回编辑器并创建两个着色器文件: Water.fragWater.vert 。 如下图所示,将这些着色器附加到脚本。


如果新属性未在编辑器中显示,请单击“ 解析”按钮以更新脚本。

现在,将此基本着色器粘贴到Water.frag中

 void main(void) { vec4 color = vec4(0.0,0.0,1.0,0.5); gl_FragColor = color; } 

这是在Water.vert中

 attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0); } 

最后,返回Water.js以使用我们的新材料代替标准材料。 即,代替:

 var material = new pc.StandardMaterial(); 

插入:

 var material = this.CreateWaterMaterial(); 

现在,开始游戏后,飞机应该是蓝色的。


热重启


现在,我们只是为新材料设置着色器毛坯。 在开始编写真实效果之前,我想设置自动代码重新加载。

在任何脚本文件(例如,Water.js)中取消注释掉swap功能后,我们将启用热重装。 稍后,即使实时更新代码,我们也会看到如何使用它来维护状态。 但是现在,我们只想在进行更改后重新应用着色器。 在WebGL中运行之前,先对着色器进行编译,因此要进行此操作,我们需要重新创建材质。

我们将检查着色器代码的内容是否已更改,如果已更改,请再次创建材质。 首先,将当前着色器保存在initialize中

 //  initialize,       Water.prototype.initialize = function() { this.GeneratePlaneMesh(); //    this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; }; 

更新中,我们检查是否发生了任何更改:

 //  update,     Water.prototype.update = function(dt) { if(this.savedFS != this.fs.resource || this.savedVS != this.vs.resource){ //   ,      var newMaterial = this.CreateWaterMaterial(); //     var model = this.entity.model.model; model.meshInstances[0].material = newMaterial; //    this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; } }; 

现在,要确保能正常工作,请开始游戏,并将Water.frag中飞机的颜色更改为更令人愉悦的蓝色。 保存文件后,即使没有重新启动也应重新启动它! 这是我选择的颜色:

 vec4 color = vec4(0.0,0.7,1.0,0.5); 

顶点着色器


要创建波浪,我们必须在每一帧中移动网格的每个顶点。 似乎效率很低,但是每个模型的每个顶点已经在每个渲染帧中进行了变换。 这就是顶点着色器的作用。

如果我们认为片段着色器是针对每个像素执行的函数,获取其位置并返回颜色,则顶点着色器是针对每个像素运行,获取其位置并返回其position的函数

默认情况下,顶点着色器模型世界中获得一个位置,在屏幕上返回其位置 。 我们的3D场景设置为x,y和z坐标,但是监视器是平面二维平面,因此我们将3D世界投影到2D屏幕上。 这种投影涉及类型,投影和模型的矩阵,因此在本教程中我们将不考虑它。 但是,如果您想了解每个阶段到底发生了什么,那么这里有个很好的指南

也就是说,这一行:

 gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0); 

接收aPosition作为特定顶点在3D世界中的位置,并将其转换为gl_Position ,即转换为2D屏幕上的最终位置。 aPosition中的前缀“ a”表示此值是一个属性 。 不要忘了变量Uniform是我们可以在CPU中定义并将其传递给着色器的值。 对于所有像素/顶点,它保持相同的值。 另一方面,该属性值是从指定的CPU array获得的。 为此属性数组的每个值调用一个顶点着色器。

您可以看到这些属性是在我们在Water.js中设置的着色器定义中配置的:

 var shaderDefinition = { attributes: { aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0, }, vshader: vertexShader, fshader: fragmentShader }; 

在传递此枚举时,PlayCanvas会为aPosition设置并传输一组顶点位置,但是在一般情况下,我们可以将任何数据数组传递给顶点着色器。

顶点运动


假设我们要通过将所有x值乘以0.5来压缩整个平面。 我们需要更改aPositiongl_Position吗?

让我们先尝试一个位置。 我们不能直接更改属性,但可以创建一个副本:

 attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { vec3 pos = aPosition; pos.x *= 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); } 

现在,平面应该看起来更像一个矩形。 也没有什么奇怪的。 但是,如果我们尝试更改gl_Position会发生什么呢?

 attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { vec3 pos = aPosition; //pos.x *= 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); gl_Position.x *= 0.5; } 

在开始移动相机之前,它可能看起来相同。 我们更改屏幕空间的坐标,也就是说,图片将取决于我们如何看待它

因此我们可以移动顶点,同时区分世界和屏幕空间中的作品也很重要。

任务2:是否可以在顶点着色器中将平面的整个表面(沿Y轴)向上移动几个单位而不会扭曲其形状?

任务3:我说过gl_Position是二维的,但是gl_Position.z也存在。 您可以检查此值是否影响某些东西,如果有影响,它的作用是什么?

加时间


开始创建移动波之前,我们需要做的最后一件事是可用作时间的统一变量。 在顶点着色器中声明统一:

 uniform float uTime; 

现在,将其传递给着色器, 让我们回到Water.js并在initialize中定义时间变量:

 Water.prototype.initialize = function() { this.time = 0; /////     this.GeneratePlaneMesh(); //    this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; }; 

现在,要将变量传输到着色器,我们使用material.setParameter 。 首先,我们在CreateWaterMaterial函数的末尾设置初始值:

 //     this.shader = new pc.Shader(gd, shaderDefinition); //////////////   material.setParameter('uTime',this.time); this.material = material; //      //////////////// //      material.setShader(this.shader); return material; 

现在,在update功能中,我们可以增加时间并使用为此创建的链接来访问资料:

 this.time += 0.1; this.material.setParameter('uTime',this.time); 

最后,在交换功能中,我们复制了旧时间值,以便即使更改代码后,它仍会继续增加而不会重置为0。

 Water.prototype.swap = function(old) { this.time = old.time; }; 

现在一切就绪。 运行游戏以确保没有错误。 现在,使用Water.vert的时间函数移动飞机:

 pos.y += cos(uTime) 

而且我们的飞机应该开始上下移动! 由于我们现在有了交换功能,因此我们也可以更新Water.js,而无需重新启动。 为确保此方法有效,请尝试更改时间增量。


任务4:是否可以移动顶点,使其看起来像下图中的波浪?


让我告诉你,我在这里详细研究了各种创建波浪的方法的主题。 本文与2D有关,但数学计算适用于我们的案例。 如果您只想查看解决方案,那么这里就是要点

半透明


本部分的目的是创建半透明的水表面。

您可能会注意到,返回到Water.frag的颜色的alpha通道值为0.5,但是表面仍然保持不透明。 在许多情况下,透明度仍然是计算机图形学中尚未解决的问题。 解决该问题的一种低成本方法是使用混合。

通常,在绘制像素之前,它会检查深度缓冲区中的值,并将其与自己的深度值(其在Z轴上的位置)进行比较,以确定是否重绘屏幕的当前像素。 这样一来,您就可以正确渲染场景,而不必将对象放回到最前面。

混合时,我们可以将已经渲染的像素(目标)的颜色与将要绘制的像素(源)组合在一起,而不是简单地拒绝像素或覆盖。 可在此处找到WebGL中所有可用混合功能的列表。

为了使Alpha通道能够按照我们的期望工作,我们希望结果的组合颜色是源乘以Alpha通道再加上目标像素乘以一个负Alpha。 换句话说,如果alpha = 0.4,则最终颜色应具有一个值:

 finalColor = source * 0.4 + destination * 0.6; 

在PlayCanvas中,这是pc.BLEND_NORMAL执行的操作。

要启用它,只需在CreateWaterMaterial设置material属性:

 material.blendType = pc.BLEND_NORMAL; 

如果您现在开始游戏,那么水将变得半透明! 但是,它仍然是不完善的。 如下图所示,当半透明表面叠加在其自身上时会出现问题。


我们可以通过使用alpha覆盖率 (一种用于透明度的多重采样技术)代替混合来消除它:

 //material.blendType = pc.BLEND_NORMAL; material.alphaToCoverage = true; 

但是它仅在WebGL 2中可用。在本教程的其余部分中,为了简单起见,我将使用混合。

总结一下


我们设置环境,并使用来自顶点着色器的动画波创建水的半透明表面。 在本教程的第二部分中,我们将考虑对象的浮力,在水表面添加线条,并沿着与该表面相交的对象边界创建泡沫线。

在第三部分(最后)中,我们将考虑水下扭曲的后处理效果的应用,并考虑进一步改进的思路。

源代码


可以在这里找到完成的PlayCanvas项目。 我们的存储库还在Three.js下有一个项目端口

Source: https://habr.com/ru/post/zh-CN416953/


All Articles