QT示例 基于Subdiv2D的Voronoi图实现鼠标点击屏幕碎裂掉落特效

目录导读

前言

在以前玩过一个破解版的扫雷小游戏,当扫雷失败的时候,炸弹会弹出来把屏幕界面炸的粉碎,那界面动画相当出色,记忆尤新,每当我玩了一把win10版的扫雷后,我开始想这个电脑屏幕的碎片效果,用Qt来实现应该怎么实现,

于是偶尔抽点时间尝试了一下,实现了一个阉割版的示例。

之所以说是阉割版的,就是这碎片的效果不达标,完全没有屏幕玻璃碎片的感觉,但是还是实现了鼠标点击屏幕,屏幕碎开,然后掉落的一系列效果。

就像这样:

鼠标点击屏幕任意位置,屏幕直接碎开,由鼠标点击位置像外蔓延碎片并且掉落...

每块碎片只是简单的加了层阴影,显得不是太立体。

涉及内容

整个示例的效果主要是通过:

  1. 使用WIN API 通过鼠标钩子获取到鼠标点击的屏幕位置 。

    在一开始软件启动时,是隐藏窗体的,直到第一次点击桌面后,获取到点击的坐标,然后软件界面覆盖整个电脑屏幕。

  2. 使用OpencvSubdiv2D类 根据鼠标点周围辐射点生成Voronoi图 ,实现碎片的网状蔓延效果。

    这个功能主要参考OpenCV Subdiv2D 平面细分这篇文章中的说明,

    再使用cv::intersectConvexConvex 方法对所有Voronoi图点集进行一个凸集求交集,移除屏幕外的碎片。

  3. 通过QGraphicsView控件QGraphicsItem图元 ,实现一块块的绘图碎片。

    一开始我是用QFrame控件setMask方法 实现碎片的绘图效果,但是后面发现碎片数量过多时,再加上动画效果后,界面就开始卡顿了,还是改成了QGraphicsView控件 使用QOpenGLWidget控件优化了界面刷新。

  4. 再使用QPropertyAnimation 实现碎片的平移和掉落效果。

    使用QPropertyAnimation 类实现setPos()方法 的动画效果,QGraphicsItem图元 不是QObject对象 ,在实现的时候还得优先继承QObject对象

开发环境: Windows系统 Qt Creator 5.15.2 MSCV2019 X64

整个示例涉及的技术大体就这些内容,拆开来并不复杂。

具体实现

通过WInApi获取点击屏幕坐标

使用SetWindowsHookExUnhookWindowsHookEx 函数熟悉鼠标钩子,在点击屏幕后显示整个软件遮住整个电脑屏幕;

cpp 复制代码
HHOOK g_mouseHook=nullptr;
//! 鼠标按钮回调函数
static std::function<void(QPoint)> MouseCallBack=nullptr;
//! 鼠标事件
static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam) {
    if (nCode >= 0) {
        if (wParam == WM_LBUTTONDOWN || wParam == WM_RBUTTONDOWN) {
            MSLLHOOKSTRUCT* pMouseStruct = (MSLLHOOKSTRUCT*)lParam;
            qDebug() << "Mouse clicked at:" << pMouseStruct->pt.x << pMouseStruct->pt.y;
            if(MouseCallBack!=nullptr)
                MouseCallBack(QPoint(pMouseStruct->pt.x,pMouseStruct->pt.y));
        }
    }
    return CallNextHookEx(g_mouseHook, nCode, wParam, lParam);
}

// 安装鼠标钩子
static void installMouseHook() {
    qDebug()<<"注册鼠标钩子!";
    g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseProc, GetModuleHandle(nullptr), 0);
    if (!g_mouseHook) {
        qDebug() << "Failed to install mouse hook";
    }
}

// 卸载鼠标钩子
static void uninstallMouseHook() {
    qDebug()<<"卸载鼠标钩子!";
    if (g_mouseHook) {
        UnhookWindowsHookEx(g_mouseHook);
        g_mouseHook = nullptr;
    }
}

MouseCallBack回调函数 就是点击鼠标后就直接显示软件,这种方式就不用考虑WinApi函数的静态方法回调的问题了,

只需要在实例化界面时给MouseCallBack回调函数赋值。

根据鼠标点位置,生成周围的随机离散点

根据鼠标点,最大外接半径,生成点个数,以及屏幕的外接矩形,生成一系列的离散点,用于生成Voronoi图点集

cpp 复制代码
static std::vector<cv::Point2f> generateRandomRadialPoints(
    QPoint center, int maxRadius, int n,QRect rectScope) {
    std::vector<cv::Point2f> points;
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_real_distribution<float> radiusDist(0, maxRadius);
    std::uniform_real_distribution<float> angleDist(0, 2 * CV_PI);

    for (int i = 0; i < n; ++i) {
        int radius = radiusDist(gen);
        int angle = angleDist(gen);
        int x = center.x() + radius * std::cos(angle);
        int y = center.y() + radius * std::sin(angle);
        //! 不再范围内 重新生成随机点
        if(!rectScope.contains(QPoint(x,y)))
        {
            i--;
            continue;
        }
        points.emplace_back(x, y);
    }
    return points;
}

通过OPencv的Subdiv2D类生成Voronoi图

初始化Subdiv2D类,生成Voronoi图的点集集合,

这个详细建议看上面的参考文章,介绍的很详细了,
std::vector<cv::Point2f> centers; //中心点
std::vector<std::vector<cv::Point2f>> facets; //Voronoi 图点集合

cpp 复制代码
try
    {
        qDebug()<<"CreateVoronoiFacet -->";
        using namespace cv;        //!初始化Subdiv2D类
        Rect rect(0, 0, screenGeometry.width(), screenGeometry.height());
        // 2. 构建Voronoi图
        Subdiv2D subdiv(rect);
        std::vector<cv::Point2f> points;
        points=generateRandomRadialPoints(MouseChecked,qMax(screenGeometry.width()/2,screenGeometry.height()/2),centernumber,screenGeometry);
        //! 插入端点
        for (size_t i = 0; i < points.size(); i++)
        {
            //! 排除不在范围内的点
            if(rect.contains(points[i]))
            {
                subdiv.insert(points[i]);
            }
        }
        facets.clear();
        centers.clear();
        subdiv.getVoronoiFacetList(std::vector<int>(), facets, centers);
        qDebug()<<"getVoronoiFacetList over -->";
    }
    catch(...){
        qDebug()<<"未知错误";
        // 显示一个带有"是/否"选择的对话框
        QMessageBox::StandardButton reply;
        reply = QMessageBox::question(q_ptr, "提示", "Subdiv2D类未知错误 请重启尝试!",QMessageBox::Yes | QMessageBox::No);
        qApp->quit(); //!
    }

根据Voronoi图点集合,生成所有碎片,并平铺到界面上

cpp 复制代码
void QGraphicsViewResetPrivate::CreateQGraphicsItem()
{
    if(backDropImg.isNull())
        return;

    //!屏幕边框 用于裁剪界面内容
    std::vector<cv::Point2f> polyrect;
    polyrect.push_back(cv::Point2f(0,0));
    polyrect.push_back(cv::Point2f(screenGeometry.width(),0));
    polyrect.push_back(cv::Point2f(screenGeometry.width(),screenGeometry.height()));
    polyrect.push_back(cv::Point2f(0,screenGeometry.height()));

    //! 遍历切片
    for(int i=0;i<facets.size();i++)
    {
        std::vector<cv::Point2f> points=facets[i];
        //! 判断是否需要求交集
        bool isneed=false;
        for(int j=0;j<points.size();j++)
        {
            if(!screenGeometry.contains(QPoint(points[j].x,points[j].y)))
            {
                isneed=true;
                break;
            }
        }
        //!求交集
        if(isneed)
            convexIntersection(points,polyrect);

        //! 转换坐标单位类
        QPolygon polygon;
        for(int j=0;j<points.size();j++)
        {
            polygon.append(QPoint(points[j].x,points[j].y));
        }
        QRectF boundingRectf = polygon.boundingRect();
        QRect boundingRect(boundingRectf.x(),boundingRectf.y(),boundingRectf.width(),boundingRectf.height());
        //! 切片
        QPixmap cropimage=backDropImg.copy(boundingRect);

        //! 计算相对坐标,用于图元内部图型定型
        QPolygon relativepolygon;
        for(int j=0;j<polygon.size();j++)
        {
            relativepolygon.append(polygon[j]-boundingRect.topLeft());
        }

        QGraphicsItemReset* item=new QGraphicsItemReset();
        item->SetData(relativepolygon,cropimage);
        item->setPos(boundingRect.topLeft());
        item->setZValue(1);
        q_ptr->scene()->addItem(item);

        double dx=qAbs(distanceBetweenPoints(QPoint(centers[i].x,centers[i].y),MouseChecked));
        int xm=dx*1;
        int t=dx*10;
        QTimer::singleShot(xm,[item](){
            //! 添加阴影 玻璃碎片感觉
            QGraphicsDropShadowEffect *shadow = new QGraphicsDropShadowEffect();
            shadow->setBlurRadius(10);      // 阴影模糊半径
            shadow->setColor(QColor(0, 0, 0,255)); // 阴影颜色
            shadow->setOffset(0);        // 阴影偏移量
            item->setGraphicsEffect(shadow);
        });
        moveFrameBetweenPoints(item,MouseChecked,QPoint(centers[i].x,centers[i].y),t+xm);
        dropout(item,t+xm+2000);
    }
}

在一开始不用给碎片阴影效果,而根据 QTimer::singleShot(碎片到鼠标点的距离时间) 开始添加阴影,这样就有了一个碎片向外蔓延的效果。

重写GraphicsItem图元控件实现了碎片的不规则形状和绘图。

这里的QGraphicsItemReset 继承了QGraphicsPolygonItemQObject ,通过重写 shape()paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget )
限制了绘图区域:

cpp 复制代码
QPainterPath QGraphicsItemReset::shape() const
{
    QPainterPath path;
    path.addPolygon(Posfacet);
    path.setFillRule(Qt::WindingFill);
    return path;
}

void QGraphicsItemReset::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget )
{
    //! 设置背景图片
    // 高质量渲染设置组合
    painter->setRenderHints(QPainter::Antialiasing |
                           QPainter::TextAntialiasing |
                           QPainter::SmoothPixmapTransform );
    // 2. 保存当前画布状态(保护后续绘制不受裁剪影响)
    painter->save();
    // painter.drawRoundedRect(polygon.boundingRect(),5,5);
    QRegion reg(Posfacet);
    // 2. 设置遮罩(限制绘制区域)
    // painter->setClipRegion(reg,Qt::IntersectClip);
    QPainterPath clipPath;
    clipPath.addPolygon(Posfacet); // 使用当前多边形作为裁剪区域
    clipPath.setFillRule(Qt::WindingFill);
    painter->setClipPath(clipPath);
    if(!backImage.isNull())
        painter->drawPixmap(0,0,backImage);
    // 5. 恢复画布状态(关闭裁剪)
    painter->restore();
}

从而实现了碎片的不规则形状。

使用QPropertyAnimation 实现动画沿鼠标点向外延伸和掉落动画

QGraphicsItem类 中的setPos()方法不是元属性不能直接应用动画,所以需要创建个QObject元属性封装下:

cpp 复制代码
 	//! 自定义元属性
	Q_PROPERTY(QPoint orientation READ getOrientation WRITE SetOrientation)
	QPoint getOrientation(){return orientation;}
	void SetOrientation(QPoint _orientation){orientation=_orientation; setPos(_orientation);}

在设置由 鼠标点到碎片中心向外延伸随机5像素 的动画效果:

cpp 复制代码
void QGraphicsViewResetPrivate::moveFrameBetweenPoints(QGraphicsItemReset* frame, const QPoint& pointA, const QPoint& pointB,int time)
{
    // 计算两点之间的方向向量
    QPointF direction = QPointF(pointB - pointA);
    qreal length = sqrt(direction.x() * direction.x() + direction.y() * direction.y());

    // 标准化方向向量并乘以5像素
    if (length > 0) {
        direction = (direction / length) * QRandomGenerator::global()->bounded(5);
    }
    // 创建动画对象
    QPropertyAnimation *animation = new QPropertyAnimation((QObject*)frame, "orientation");
    animation->setDuration(QRandomGenerator::global()->bounded(401));  // 动画持续时间(毫秒)
    animation->setStartValue(frame->pos());
    animation->setEndValue(frame->pos() + direction.toPoint());
    animation->start(QAbstractAnimation::DeleteWhenStopped);
}

在设置 向下掉落 动画:

cpp 复制代码
 QTimer::singleShot(dx,[&,frame](){
        // 创建动画对象
        QPropertyAnimation *animation = new QPropertyAnimation((QObject*)frame, "orientation");
        QObject::connect(animation,&QPropertyAnimation::finished,[&,frame](){
        //! 掉出界面后移出场景
            q_ptr->scene()->removeItem(frame);
        });
        animation->setDuration(4000);  // 动画持续时间(毫秒)
        animation->setStartValue(frame->pos());
        animation->setEndValue(frame->pos() + screenGeometry.bottomLeft());
        animation->start(QAbstractAnimation::DeleteWhenStopped);
    });

这里还使用了一个 QTimer::singleShot 就是为了等待前面的动画完成后再开始掉落。

这样一来整个鼠标点击屏幕碎裂掉落的效果就完成了。

效果展示

整个效果展示:

看起来还是太粗糙,碎片的玻璃层次感还需要改改。

总结

总体上并不是太复杂,涉及的内容都有现成的方法,只是细节上很耗时间,比如碎片上的多边形图像截取绘制,图片点击时的碎片移动距离,甚至为了实现声效愣是找了一圈都没有找到相关的素材。
但值得注意的也有

QOpenGLWidget 的使用优化

在测试的时候即使添加了 QOpenGLWidget 优化界面,

在界面频繁刷新还是会闪烁,界面黑一下,

后来发现需要在main.cpp 添加 QCoreApplication::setAttribute(Qt::AA_UseOpenGLES) 配合使用,
具体优化项:

cpp 复制代码
QOpenGLWidget* widget=new QOpenGLWidget();
// 在设置 QOpenGLWidget 后,强制启用 VSync
QSurfaceFormat format;
format.setSwapInterval(1); // 1 = 启用 VSync,0 = 禁用
widget->setFormat(format);
setViewport(widget); // 关键步骤:启用 GPU 加速
setViewportUpdateMode(QGraphicsView::FullViewportUpdate); // 强制全屏缓冲(减少闪烁)

到此完!