在
第一部分中,我们着眼于设置环境和水面。 在这一部分中,我们将为对象提供浮力,在表面上添加水线,并在与表面相交的对象边界周围创建深度缓冲的泡沫线。
为了使场景看起来更好一点,我对其进行了一些小的更改。 您可以按照自己的方式自定义场景,但是我做了以下工作:
- 添加了灯塔和章鱼的模型。
- 添加了具有
#FFA457
颜色的地面模型。 #6CC8FF
添加了#6CC8FF
的天空色。- 在场景中添加了
#FFC480
背光颜色(这些参数可以在场景设置中找到)。
我的原始场景现在看起来像这样。
浮力
创建浮力的最简单方法是使用脚本上下推对象。 创建一个新的
Buoyancy.js脚本,并在其初始化中设置以下内容:
Buoyancy.prototype.initialize = function() { this.initialPosition = this.entity.getPosition().clone(); this.initialRotation = this.entity.getEulerAngles().clone();
现在在更新中,我们运行时间增量并旋转对象:
Buoyancy.prototype.update = function(dt) { this.time += 0.1;
将此脚本应用到船上,看看它是如何在水上跳跃的! 您可以将此脚本应用于多个对象(包括相机-尝试一下)!
表面质感
当我们看到波浪时,我们看着水面的边缘。 添加纹理将使表面运动更加明显。 此外,这是一种模拟反射和焦散的低成本方法。
您可以尝试找到一些腐蚀性纹理或自己创建一个。 我在Gimp中绘制
了可以自由使用
的纹理 。 只要可以将其平铺而无明显缝隙,则任何纹理均适用。
选择所需的纹理后,将其拖动到项目的Assets窗口中。 我们需要从Water.js脚本中引用此纹理,因此让我们为其创建一个属性:
Water.attributes.add('surfaceTexture', { type: 'asset', assetType: 'texture', title: 'Surface Texture' });
然后在编辑器中分配它:
现在我们需要将其传递给着色器。 进入
Water.js ,并将
CreateWaterMaterial
函数
CreateWaterMaterial
新参数:
material.setParameter('uSurfaceTexture',this.surfaceTexture.resource);
现在回到
Water.frag并声明一个新的制服:
uniform sampler2D uSurfaceTexture;
我们快完成了。 要在平面上渲染纹理,我们需要知道每个像素在网格中的位置。 也就是说,我们需要将数据从顶点着色器传输到片段1。
变化的变量
各种变量允许您将数据从顶点着色器传输到片段着色器。 这是可以在着色器中使用的第三种特殊变量(前两种是
Uniform和
attribute )。 为每个顶点设置一个变量,每个像素都可以访问它。 由于像素多于顶点,因此会在顶点之间插值(因此,名称为“可变”-偏离传递给它的值)。
要在运行中对其进行测试,请在
Water.vert中声明一个新变量,
使其变化:
varying vec2 ScreenPosition;
然后在计算后为其分配值
gl_Position
:
ScreenPosition = gl_Position.xyz;
现在回到
Water.frag并声明相同的变量。 我们无法从着色器获取调试数据的输出,但可以使用颜色进行可视调试。 方法如下:
uniform sampler2D uSurfaceTexture; varying vec3 ScreenPosition; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5);
现在,该平面应为黑色和白色,并且分色线将
ScreenPosition.x
= 0的位置。颜色值仅从0变为1,但是
ScreenPosition
的值可能不在此范围内。 它们是自动限制的,因此,当您看到黑色时,它可以是0或负数。
我们刚刚要做的是将每个顶点的屏幕位置传递给每个像素。 您会看到,将黑白两边分开的线将始终通过屏幕的中心,而不管表面实际在世界上的何处。
任务1:创建一个新的变化变量,以转移世界上的位置而不是屏幕位置。 以相同的方式可视化它。 如果颜色不随相机的移动而改变,则说明一切正确。
使用紫外线
UV是网格中每个顶点的2D坐标,从0标准化为1。对于将纹理正确采样到平面,它们是必需的,我们已经在上一部分中对其进行了配置。
我们在
Water.vert中声明一个新属性(此名称取自Water.js中的着色器定义):
attribute vec2 aUv0;
现在我们只需要将其传递给片段着色器,因此只需创建variant并将属性值分配给它即可:
现在,我们将在片段着色器中声明相同的变量。 为了确保一切正常,我们可以像以前一样可视化调试,然后Water.frag将如下所示:
uniform sampler2D uSurfaceTexture; varying vec2 vUv0; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5);
您应该看到一个渐变,确认我们一端的值为0,另一端的值为1。 现在要对纹理进行真实采样,我们需要做的是:
color = texture2D(uSurfaceTexture,vUv0);
之后,我们将在表面上看到纹理:
纹理样式
让我们将其与现有的蓝色结合起来,而不仅仅是将纹理设置为新的颜色:
uniform sampler2D uSurfaceTexture; varying vec2 vUv0; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); vec4 WaterLines = texture2D(uSurfaceTexture,vUv0); color.rgba += WaterLines.r; gl_FragColor = color; }
这是可行的,因为除水线以外的所有地方纹理颜色均为黑色(0)。 加上它,我们不会更改初始的蓝色,除非带有线条的地方变浅。
但是,这不是组合颜色的唯一方法。
任务2:是否可以组合颜色以得到如下所示的较弱效果?
移动纹理
作为最后的效果,我们希望线条沿表面移动,并且看起来不是那么静态。 为此,我们将利用一个事实,即传递给
texture2D
函数的从0到1的间隔之外的任何值都将被传输(例如1.5和2.5都等于0.5)。 因此,我们可以通过已经设置的统一时间变量来增加位置,以增加或减少表面上线条的密度,这将使最终的片段着色器具有以下形式:
uniform sampler2D uSurfaceTexture; uniform float uTime; varying vec2 vUv0; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); vec2 pos = vUv0;
泡沫线和深度缓冲
在水中围绕对象绘制泡沫线使查看对象浸入的程度以及它们在表面的位置变得容易得多。 另外,这样我们的水变得更加可信。 为了实现泡沫线,我们需要以某种方式找出每个物体的边界在哪里,并有效地做到这一点。
绝招
我们需要学习确定水表面的像素是否靠近物体。 如果是这样,那么我们可以将其涂成泡沫的颜色。 据我所知,没有简单的方法可以解决此问题。 因此,为了解决这个问题,我使用了一种有用的技术来解决问题:我将以一个我们知道答案的示例为例,看看是否可以将其概括。
看一下下面的图片。
泡沫应包含哪些像素? 我们知道它应该看起来像这样:
因此,让我们看两个特定的像素。 在下面,我用星号标记了它们。 黑色会出现在泡沫上,而红色不会出现在泡沫上。 我们如何在着色器中区分它们?
我们知道,即使屏幕空间中的这两个像素彼此靠近(均在灯塔顶部渲染),但实际上它们在世界空间中相距甚远。 我们可以从不同的角度看同一场景来验证这一点。
请注意,在我们看来,红色的星星并不位于灯塔上,而实际上黑色的星星就在灯塔上。 我们可以通过使用到相机的距离(通常称为“深度”)来区分。 深度1表示该点非常靠近相机,深度0表示该点很远。 但这不仅是世界上绝对距离,深度或摄像机的问题。
相对于其背后像素的深度
很重要。
再次查看第一个视图。 假设灯塔的船体的深度值为0.5。 黑星的深度将非常接近0.5。 也就是说,它和它下面的像素具有非常接近的深度值。 另一方面,红色星号的深度要大得多,因为它更靠近相机,例如0.7。 并且尽管其后面的像素仍在灯塔上,但其深度值为0.5,即差异更大。
这是诀窍。
当水面上像素的深度足够接近其绘制像素的深度时,我们就非常接近某些对象的边界,并且可以像泡沫一样渲染像素。
也就是说,我们需要比任何像素都更多的信息。 我们不知何故需要找出应在其上绘制像素的深度。 在这里,深度缓冲区对我们很有用。
深度缓冲
您可以将帧缓冲区或帧缓冲区视为屏幕外目标渲染或纹理。 当我们需要读取数据时,我们需要在屏幕外渲染。 此技术用于
烟熏效果 。
深度缓冲区是一种特殊的目标渲染,其中包含有关每个像素的深度值的信息。 不要忘记顶点着色器中
gl_Position
计算的值是屏幕空间值,但它还有第三个坐标-Z值,此Z值用于计算深度,并将其写入深度缓冲区。
深度缓冲区用于正确渲染场景,而无需从后到前对对象进行排序。 首先要绘制的每个像素都会检查深度缓冲区。 如果其深度值大于缓冲区中的值,则将其绘制,并且其自身的值将覆盖缓冲区的值。 否则,它将被丢弃(因为这意味着它前面有另一个对象)。
实际上,您可以禁用对深度缓冲区的写入,以查看没有该缓冲区的一切情况。 让我们尝试在Water.js中做到这一点:
material.depthTest = false;
您会注意到,即使水在不透明的物体后面,现在也总是从上方抽水。
深度缓冲区可视化
让我们为渲染目的添加一种渲染深度缓冲区的方法。 创建一个新的
DepthVisualize.js脚本。 将其安装到相机。
要访问PlayCanvas中的深度缓冲区,只需编写以下内容:
this.entity.camera.camera.requestDepthMap();
因此,我们将统一变量自动注入到所有着色器中,可以通过声明如下进行使用:
uniform sampler2D uDepthMap;
下面是一个示例脚本,该脚本请求深度图并将其渲染到场景的顶部。 他配置了热重启。
var DepthVisualize = pc.createScript('depthVisualize');
尝试复制代码并注释掉该行
this.app.scene.drawCalls.push(this.command);
启用/禁用深度渲染。 这看起来应如下图所示。
任务3:未将水表面吸入深度缓冲区。 PlayCanvas引擎是故意这样做的。 你能弄清楚为什么吗? 水材料有什么特别之处? 换句话说,给定我们检查深度的规则,如果将水像素写入深度缓冲区会发生什么情况?
提示:您可以在Water.js中更改一行,从而可以将水写入深度缓冲区。还应该注意的是,在初始化函数中,我将深度值乘以30。要清楚地看到它,这是必需的,因为否则值的范围将太小而无法显示色调。
技巧实施
PlayCanvas引擎中有几个用于处理深度值的辅助功能,但是在编写时,它们尚未在生产中发布,因此我们必须自己配置它们。
我们在
Water.frag中定义以下统一变量:
我们在主要功能上定义了这些辅助功能:
#ifdef GL2 float linearizeDepth(float z) { z = z * 2.0 - 1.0; return 1.0 / (camera_params.z * z + camera_params.w); } #else #ifndef UNPACKFLOAT #define UNPACKFLOAT float unpackFloat(vec4 rgbaDepth) { const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0); return dot(rgbaDepth, bitShift); } #endif #endif float getLinearScreenDepth(vec2 uv) { #ifdef GL2 return linearizeDepth(texture2D(uDepthMap, uv).r) * camera_params.y; #else return unpackFloat(texture2D(uDepthMap, uv)) * camera_params.y; #endif } float getLinearDepth(vec3 pos) { return -(matrix_view * vec4(pos, 1.0)).z; } float getLinearScreenDepth() { vec2 uv = gl_FragCoord.xy * uScreenSize.zw; return getLinearScreenDepth(uv); }
我们将在
Water.js中传递有关相机的着色器信息。 将其粘贴到传递其他统一变量(如uTime)的位置:
if(!this.camera){ this.camera = this.app.root.findByName("Camera").camera; } var camera = this.camera; var n = camera.nearClip; var f = camera.farClip; var camera_params = [ 1/f, f, (1-f / n) / 2, (1 + f / n) / 2 ]; material.setParameter('camera_params', camera_params);
最后,我们需要为片段着色器在每个像素的世界中放置一个位置。 我们必须从顶点着色器获取它。 因此,我们将在
Water.frag中定义一个变化的变量:
varying vec3 WorldPosition;
在
Water.vert中定义相同的变化变量。 然后,从顶点着色器为它分配一个变形的位置,以便完整的代码如下所示:
attribute vec3 aPosition; attribute vec2 aUv0; varying vec2 vUv0; varying vec3 WorldPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; uniform float uTime; void main(void) { vUv0 = aUv0; vec3 pos = aPosition; pos.y += cos(pos.z*5.0+uTime) * 0.1 * sin(pos.x * 5.0 + uTime); gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); WorldPosition = pos; }
我们意识到真正的窍门
现在,我们终于准备好实施本节开头介绍的技术。 我们想将我们所在的像素的深度与它下方的像素的深度进行比较。 我们所在的像素是从世界上某个位置获取的,而其下方的像素是从屏幕位置获取的。 因此,我们采取以下两个深度:
float worldDepth = getLinearDepth(WorldPosition); float screenDepth = getLinearScreenDepth();
任务4:这些值之一将永远不会大于另一个(假设depthTest = true)。 你能确定哪一个吗?
我们知道,泡沫将是两个值之间的距离较小的地方。 因此,让我们为每个像素渲染此差异。 将其粘贴到着色器的末尾(并关闭上一部分中的深度可视化脚本):
color = vec4(vec3(screenDepth - worldDepth),1.0); gl_FragColor = color;
它看起来应该像这样:
也就是说,我们正确地实时选择了浸入水中的任何物体的边界! 当然,您可以缩放差异以使泡沫更厚或更不常见。
现在,我们有很多选择,可以将此输出与水面结合起来以创建漂亮的泡沫线。 您可以为它们保留渐变色,用于从其他纹理进行采样,或者在差异小于或等于某个极限值时为它们指定特定的颜色。
我最喜欢的是分配与静态水线相似的颜色,所以我完成的主要功能如下所示:
void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); vec2 pos = vUv0 * 2.0; pos.y += uTime * 0.02; vec4 WaterLines = texture2D(uSurfaceTexture,pos); color.rgba += WaterLines.r * 0.1; float worldDepth = getLinearDepth(WorldPosition); float screenDepth = getLinearScreenDepth(); float foamLine = clamp((screenDepth - worldDepth),0.0,1.0) ; if(foamLine < 0.7){ color.rgba += 0.2; } gl_FragColor = color; }
总结一下
我们创建了浸入水中的物体的浮力,在表面上应用了移动的纹理以模拟焦散,并学习了如何使用深度缓冲区创建动态泡沫条纹。
在第三部分和最后一部分中,我们将添加后处理的效果,并学习如何使用它们来创建水下失真的效果。
源代码
可以在
这里找到完成的PlayCanvas项目。 我们的存储库还在
Three.js下有一个
项目端口 。