OpenGL ES 案例复刻(二) 一个律动的爱心

2,825 阅读5分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

What

这次我们来做点有趣的事,实现一个律动的爱心,先来看一下效果图

1667720346176.gif

这个特效来源于SahderToy,我也是从这篇博客了解到的,但是说实话原博客对于实现过程分析得不够清晰,我是没看明白,所以才写下这篇。

我们先实现原始效果,然后逐步了解实现过程,最后我们可以尝试进行一些改动,比如我这里改了改动画,让动效看起来像是心跳

1667720913073.gif

本文所有代码都在这里

How

shaderToy上面的代码直接拿过来用就可以了,但是它只是一个片段着色器,需要修改一下,主要是有一些uniform参数需要传入着色器

#version 300 es
precision highp float;

out vec4 fragColor;
uniform vec2 layerSize;
uniform float delta;

const float e = 2.718281828459045;
const float PI = 3.141593;

void main() {
    // 坐标转换,转换为以短轴长度为基准,以中心点为原点的相对坐标
    // (xy - wh/2) / (短轴/2) =>(2xy - wh)/ 短轴
    vec2 p = (2.0 * gl_FragCoord.xy - layerSize) / min(layerSize.y, layerSize.x);

    // background color
    vec3 bcol = vec3(1.0,0.8,0.7-0.07*p.y)*(1.0-0.25*length(p));

    // animate
    float tt = mod(delta,1.5)/1.5;
    float ss = pow(tt,.2)*0.5 + 0.5;
    ss = 1.0 + ss*0.5*sin(tt*6.2831*3.0 + p.y*0.5)*exp(-tt*4.0);
    p *= vec2(0.5,1.5) + ss*vec2(0.5,-0.5);
   
    p.y -= 0.25;
    float a = atan(p.x,p.y)/3.141593;
    float r = length(p);
    float h = abs(a);
    float d = (13.0*h - 22.0*h*h + 10.0*h*h*h)/(6.0-5.0*h);

    // color
    float s = 0.75 + 0.75*p.x;
    s *= 1.0-0.4*r;
    s = 0.3 + 0.7*s;
    s *= 0.5+0.5*pow( 1.0-clamp(r/d, 0.0, 1.0 ), 0.1 );
    vec3 hcol = vec3(1.0,0.4*r,0.3)*s;

    vec3 col = mix( bcol, hcol, smoothstep( -0.01, 0.01, d-r) );

    fragColor = vec4(col,1.0);
}

接下来还需要Renderer和顶点着色器,非常简单就不多讲了,只是用两个三角形填充整个屏幕

#version 300 es
layout(location = 0) in vec2 mPosition;

void main() {
    gl_Position = vec4(mPosition, 0.0, 1.0);
}
#include <glm/glm.hpp>
#include <chrono>
#include "core/advanced/HeartRenderer.h"

void HeartRenderer::onSurfaceCreated() {
    shader = Shader("shader/heart/heart.vert", "shader/heart/heart2.frag");

    float vertices[] = {
            -1.0f, -1.0f,
            1.0f, -1.0f,
            -1.0f, 1.0f,

            1.0f, -1.0f,
            1.0f, 1.0f,
            -1.0f, 1.0f,
    };

    glGenBuffers(1, &VBO);
    glGenVertexArrays(1, &VAO);

    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    glBindVertexArray(VAO);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 2, GL_FLOAT, false, 2 * sizeof(float), nullptr);
    shader.use();
}

void HeartRenderer::onDraw() {
    using namespace std::chrono;
    auto t = system_clock::now();
    auto tt = duration_cast<duration<long long, std::ratio<1, 1000>>>(t.time_since_epoch()).count();
    // 2秒为一个动画周期
    auto delta = static_cast<float>(fmod(tt, 2000) / 2000);
    shader.setVec2("layerSize", glm::vec2(surfaceWidth, surfaceHeight));
    shader.setFloat("delta", delta);
    glClear(GL_COLOR_BUFFER_BIT);
    glClearColor(0.1f, 0.1f, 0.1f, 0.0f);
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 6);
}

HeartRenderer::~HeartRenderer() {
    glDeleteBuffers(1, &VBO);
    glDeleteVertexArrays(0, &VAO);
    shader.release();
}

这就完成了,现在你可以运行看看效果

Why

现在我们来一起看看这么有趣的效果是怎么用短短几十行代码实现的,但是在那之前,先做点别的事情,先来画一个圆,如果你还不知道该怎么做的话。

绘制一个圆

创建一个circle.frag片段着色器,代码如下

#version 300 es
precision highp float;

out vec4 fragColor;
uniform vec2 layerSize;

// 画一个圆
void main() {
    // 坐标转换,转换为以短轴长度为基准,以中心点为原点的相对坐标
    // (xy - wh/2) / (短轴/2) =>(2xy - wh)/ 短轴
    vec2 uv = (2.0 * gl_FragCoord.xy - layerSize) / min(layerSize.y, layerSize.x);
    float r = length(uv);
    // 距离原点小于0.5的片段渲染为红色,smoothstep做边缘平滑
    // t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
    // smoothstep(edge0, edge1, x) = (3.0 - 2.0 * t) * t * t;
    // c = r < 0.5 => 1  r > 0.51 > 0
    float c = smoothstep(0.51, 0.5, r);
    // float c = (r > 0.4 && r < 0.5) ? 1.0 : 0.0;
    fragColor = vec4(.8,.2,0.3,0.0) * c;
}
  • gl_FragCoord是以屏幕左下角为原点的,所以先把片段坐标转换为以屏幕中心为原点的的坐标系,并且把绝对坐标转换为[-1, 1]的相对坐标,以宽度为基准
  • 判断该片段是否在圆的内部,这里我们想要画一个半径为0.5的圆,所以只需要判断和原点的距离小于0.5就好了,在这之外的片段渲染成黑色

这里用到了smoothstep函数,它用来做边缘平滑,半径在[0.5, 0.51]范围内的片段和背景做一个混合,让边缘看起来更自然。

来用desmos看看smoothstep函数的作用

image.png

限制一个上界t1一个下界t2,输入 < t1时输出为1,> t2时输出为0,在[t1, t2]内时平滑过渡,用在这里正好对应了圆内、圆外、边缘。

绘制爱心

上面我们试着绘制了一个圆,接下来我们来绘制一个初步的爱心,代码如下

#version 300 es
precision highp float;

out vec4 fragColor;
uniform vec2 layerSize;
uniform float delta;

const float PI = 3.141593;

void main() {
    // 坐标转换,转换为以短轴长度为基准,以中心点为原点的相对坐标
    // (xy - wh/2) / (短轴/2) =>(2xy - wh)/ 短轴
    vec2 uv = (2.0 * gl_FragCoord.xy - layerSize) / min(layerSize.y, layerSize.x);
    // 背景色从中心到四周由浅到深
    vec3 bgColor = vec3(1.0, 0.8, 0.7 - 0.07 * uv.y) * (1.0 - 0.25 * length(uv));
    
    // 上移
    uv.y -= 0.25;
    // 同一个方向上的片段atan值相同
    // 原函数本应该是atan2(y, x),表示坐标(x,y)对应角的弧度
    // 但是这样绘制出来的是一个横向的心型,所以交换x,y
    // atan2(x, y) [-PI,PI] => [-1, 1]
    float a = atan(uv.x, uv.y) / PI;
    float h = abs(a);
    float r = length(uv);

    // 爱心颜色
    vec3 hcol = vec3(1.0, 0.4, 0.3);

    vec3 col = r < h ? hcol : bgColor;
    // 边缘平滑
    // vec3 col = mix(bgColor, hcol, smoothstep(-0.01, 0.01, h - r));
    fragColor = vec4(col, 1.0);
}

运行结果如下

image.png
心形图案主要是靠atan2函数绘制出来的,atan2(x, y)/π得到的值在每个方向上面都相同,如果我们以原点为中心,以每个方向求得的h值为长度画出一条条线,它们刚好就能构成一个心形。

image.png
现在问题就变得容易解决了,我们可以像之前画圆一样来判断当前片段的坐标和原点的距离r是否小于h,来断定该片段是否在爱心的内部,从而把它画出来。

让爱心变得更加苗条

现在的爱心有点太饱满了,我们来给它瘦身一下,只需要一行代码对h值做一个非线性变换

void main() {
    ......
    float d = (13.0 * h - 22.0 * h * h + 10.0 * h * h * h) / (6.0 - 5.0 * h);
    ......
    vec3 col = mix(bgColor, hcol, smoothstep(-0.01, 0.01, d - r));
    fragColor = vec4(col, 1.0);
}

还是来看一下这个函数的曲线

image.png 原本的h值函数曲线是平滑递增的(由于desmos不支持atan2函数,可以在geogebra上面试试看原函数曲线),那么想要爱心曲线有一些变化就需要对原函数进一步变换。把h值代入为x,进行进一步的变换,可以看到曲线后半段变化量放缓然后又加快,这样子就可以在心形的下半部产生凹陷,得到想要的结果。

image.png

增加立体感

让每个片段的明暗跟r、d、x坐标联系起来,具体作用写在注释里了

// 颜色插值,让爱心更有立体感
// 让x轴正半轴方向更亮
float s = 0.75 + 0.75 * uv.x;
// 越向外越暗
s *= 1.0 - 0.4 * r;
s = 0.3 + 0.7 * s;
s *= 0.5 + 0.5 * pow(1.0 - clamp(r / d, 0.0, 1.0), 0.1);
vec3 hcol = vec3(1.0, 0.4 * r, 0.3) * s;

image.png

动起来

先来观察一下这个动画,类似一个横向的挤压回弹效果,那么我们需要在x方向上向内偏移,y方向上向外偏移;因为有几次回弹效果,所以偏移量还需要正负交替,而且因为动画幅度是渐弱的,所以偏移量还需要逐步变小;最后,偏移量周期性变化就根据当前帧数来计算。

uniform float delta;
......
float ss = pow(delta, .2) * 0.5 + 0.5;
ss = 1.0 + ss * 0.5 * sin(delta * 6.2831 * 3.0 -uv.y * 0.5) * exp(-delta * 4.0);
uv *= (vec2(0.5, 1.5) + ss * vec2(0.5, -0.5));
auto t = system_clock::now();
auto tt = duration_cast<duration<long long, std::ratio<1, 1000>>>(t.time_since_epoch()).count();
// 2秒为一个动画周期
auto delta = static_cast<float>(fmod(tt, 2000) / 2000);

将动画周期设为两秒,变化量值在[0, 1]区间,将每帧的时间变化量传入shader,再经过函数计算变换为片段的偏移量

同样来看一下这个函数的曲线,这里我们把y值设为滑块a,然后分别看看y和x偏移后的值随delta的变化,红线是x,黑线是y

image.png

image.png 可以看到函数曲线和我们分析的一样,x、y偏移量相反,偏移量相对于初始量增减交替,并且渐弱,这样就完成了最终的效果

Extra

分析完了原实现,我们来尝试改改动画,实现心跳效果

其实很简单,我们只需要一个渐弱的缩小动画,也就是片段向内偏移,代码就两行

float ss = (1.0 - 0.3/(delta + 0.3));
uv *= 0.85 + ss * 0.15;

它的函数曲线如下,在[0.85-1.0]递增,先快后慢。

image.png
xy变小会让心形变大,所以这里其实是呈现一个逐渐缩小的动画,缩到最小后又突然放大回原尺寸,看起来就像是心跳了,注意将动画周期缩短为一秒看起来更加真实

End

不管是圆形还是爱心,实际上我们都用了同一个手段

  1. 通过数学工具得出图形的边界函数
  2. 通过判断片段到目标形状边界的距离(片段是否在目标形状的内部)渲染出不同的颜色得到结果

这项技术被称为有向距离场(Signed Distance Field),通过这种手段可以绘制出各种各样的形状,具体的实现和推导可以看看下面这两篇文章有个简单的了解
zhuanlan.zhihu.com/p/26217154
zhuanlan.zhihu.com/p/420700051