three.js与QT结合实现简单跨平台三维开发

什么都不说先秀一波,下面这个是three.js的官方案例大家感受一下,想知道怎么写的F12可以直接拿到代码我就不细谈了。这里主要是提出一种将threeJS集成到QT里面的方法,但是这个方法其实已经没有什么用了因为现在找到了更好的方法,这里分享一下这种老手段大家可以猜一下新的集成方式是什么。

 

WebGL渲染机制

首先webgl本身就是opengl,我们使用的three.js仅仅是一个上层封装的图形引擎,类似于UE4。三维渲染采用的机制与桌面程序有本质上的不同首先是窗口问题,桌面平台的任何一个三维程序都是一个独立窗口并且通过句柄传入OpenGL进行功能调用,然后OpenGL执行GPU指令进行渲染。但是在web中没有窗口句柄的概念,能够使用OpenGL的并不是网页而是浏览器,webgl仅仅是一层透明性质的封装协议本质上就是OpenGL。准确来讲是OpenGL ES,能够使用webGL的在html上面只有canvas可以,使用JS驱动canvas进行三维绘制,但是如果使用原生OpenGL难度巨大,我们都不具备底层三维编程能力,所有three.js就是在这个基础上进行封装将OpenGL的大部分功能制作为常规图形API。

QML实现三维窗口

在QT之中如果直接使用WebEngineView进行本地html与three.js编写的三维程序最大的问题是webengine不支持wengl的js本地解析它与浏览器功能一致但是不具备webgl的本地功能所有不能使用html编写,但是可以部署上线的webgl网页让webengine去访问这样就没有什么问题。由于对集成度的要求我们只能使用QML进行本地的部署。QML提供完整的跨平台前端编辑功能但是也与web有很大区别目前已知的是three.js官方为QT进行了一个封装层可以让QML调用,重点是QML不兼容html但是可以使用three.js。

Canvas3D {
        id: canvas3d
        anchors.fill: parent
        focus: true
        onInitializeGL: {
            GLCode.initializeGL(canvas3d,eventSource);
            //初始化GL
        }

        onPaintGL: {
            GLCode.paintGL();
            //帧tick绘制GL
        }

        onResizeGL: {
            GLCode.resizeGL(canvas3d);
            //重置大小
        }
}

这里就是QML基础的部分代码,方式很简单创建一个QML对象在这个对象中包含一个Canvas3D控件。这个控件是可以让JavaScript驱动绘制的画布同理与html的canvas但是差异巨大。OpenGL使用流程的三个最大的流程为代码中的三部分,初始化为三维场景搭建,绘制为每一帧调用,重置大小为窗口缩放。这里使用的GLCode是我们自己场景绘制的一个js文件导入之后形成的一个全局对象方式为:

import "render.js" as GLCode

然后再对应的js文件中导入three.js进行使用就可以了。对应的在我们的场景js文件中需要根据three.js的方式来进行场景的搭建所以上面QML直接调用的函数是js中实现写好的。

Qt.include("three.js")

function initializeGL(canvas,eventSource){
  //场景搭建
}
function resizeGL(canvas) {
  //重置大小函数
}
function paintGL() {
  //绘制部分
}

这样基础的QML窗口就可以直接使用three.js引擎进行绘制了。

C++部分依旧是桌面开发的框架,所以对三维部分一定要有一个窗口,如果需要集成窗口到一个widget之中只能使用QT里面的WindowContainer来创建一个widget包裹这个窗口,所以我们只能使用QquickView来进行这个操作,一旦使用QquickWidget的话会丢失窗口句柄导致GL着色器错误(集成父级窗口也不行),这仅仅目前的解决方法有其他方法可以提出。

WebglView = new QQuickView(this->windowHandle());
WebglView->setSource(QUrl(//QML文件路径));
//创建three.js视图
container = QWidget::createWindowContainer(WebglView,this);

这里所以的QML文件与js文件必须全部编译进入exe文件中才能正常使用外载使用windows的文件系统仅仅相当于文件浏览器不会有关联关系,所以这里我们必须使用qrc文件进行相关文件的载入最终会全部编译进入exe文件之中。(对应html的webengine也是这样处理的)

事件传入

QML与html的区别还有就是事件不同,在html里面可以使用dom来获取所有的标签与标签对应的事件,但是在QML里面没有这个东西,需要API来完成事件的传入本质上是一个qml文件里面仿造html的dom定义了一组对象来给three.js使用代码如下,对应QML标签能够使用的所有东西都在里面进行了定义无非就是键盘事件、鼠标事件、触屏事件。

Item {
    id: ctrlEventSource

    property alias cursorShape: inputArea.cursorShape
    //创建Item并给初始属性
    signal mouseMove(int x, int y);
    signal mouseDown(int x, int y, int buttons);
    signal mouseUp(int x, int y);
    signal mouseWheel(int x, int y);
    signal mouseIn();
    signal mouseOut();
    //定义鼠标操作的信号。
    signal keyDown(var event);
    signal keyUp(var event);
    //定义键盘输入信号。
    function addEventListener(event, handler, ignored)
    {
        if (event === 'keydown') {
            ctrlEventSource.keyDown.connect(handler);
        } else if (event === 'keyup') {
            ctrlEventSource.keyUp.connect(handler);
        } else if (event === 'mousedown') {
            ctrlEventSource.mouseDown.connect(handler);
        } else if (event === 'mouseup') {
            ctrlEventSource.mouseUp.connect(handler);
        } else if (event === 'mousemove') {
            ctrlEventSource.mouseMove.connect(handler);
        } else if (event === 'mousewheel') {
            ctrlEventSource.mouseWheel.connect(handler);
        } else if (event === 'mouseout') {
            ctrlEventSource.mouseOut.connect(handler);
        } else if (event === 'mouseout') {
            ctrlEventSource.mouseIn.connect(handler);
        } else if (event === 'touchstart') {
        } else if (event === 'touchmove') {
        } else if (event === 'touchend') {
        }
    }
    //定义函数addEventListener,原生的Three.js里面使用各类事件的时候只要封装了都是addEventListener
    //但是QML不具备这个函数,通过HTML中事件的名将其进行封装,做了一个双方都可以使用的接口,handler参数就
    //是Three.js里面的响应事件函数。ignored参数是html里面执行阶段布尔值,QT里面没有就不用但是申明要写出
    //来不然js报错。
    function removeEventListener(event, handler, ignored)
    {
        if (event === 'keydown') {
            ctrlEventSource.keyDown.disconnect(handler);
        } else if (event === 'keyup') {
            ctrlEventSource.keyUp.disconnect(handler);
        } else if (event === 'mousedown') {
            ctrlEventSource.mouseDown.disconnect(handler);
        } else if (event === 'mouseup') {
            ctrlEventSource.mouseUp.disconnect(handler);
        } else if (event === 'mousemove') {
            ctrlEventSource.mouseMove.disconnect(handler);
        } else if (event === 'mousewheel') {
            ctrlEventSource.mouseWheel.disconnect(handler);
        } else if (event === 'mouseout') {
            ctrlEventSource.mouseOut.disconnect(handler);
        } else if (event === 'mouseout') {
            ctrlEventSource.mouseIn.disconnect(handler);
        } else if (event === 'touchstart') {
        } else if (event === 'touchmove') {
        } else if (event === 'touchend') {
        }
    }
    //与addEventListener一样的道理,解除绑定。本质上addEventListener算是一个信号槽结构所以直接写就可以了。
    MouseArea {
        id: inputArea
        anchors.fill: parent
        hoverEnabled: true
        //实例化MouseArea得到里面的函数并将各类事件封装给JS然后用上面的函数进行与JS的绑定。
        onPositionChanged: {
            ctrlEventSource.mouseMove(mouse.x, mouse.y);
        }
        onPressed: {
            ctrlEventSource.mouseDown(mouse.x, mouse.y, mouse.buttons);
        }
        onReleased: {
            ctrlEventSource.mouseUp(mouse.x, mouse.y);
        }
        onWheel: {
            ctrlEventSource.mouseWheel(wheel.angleDelta.x, wheel.angleDelta.y);
        }
        onEntered: {
            ctrlEventSource.mouseIn();
        }
        onExited: {
            ctrlEventSource.mouseOut();
        }
    }
    Keys.onPressed: {
        ctrlEventSource.keyDown(event);
    }
    Keys.onReleased: {
        ctrlEventSource.keyUp(event);
    }
    //这里是键盘按键事件,对应QT支持的所有控制器都在其中所以有一些event参数Three.js不能使用,但是three里面只是不响应不会影响使用,也不会在前端报错。
}

在主页面的QML中引入这个文件,然后定义出这个类传入给three.js就可以使用了,方式就是在init函数里面进行对象参数传入,最终使用就用这个对象与和其对于的three.js库就可以了,不需要做什么代码上面的修改。

JS与C++的通讯

C++与QML通讯采用信号与槽的方式,新建一个C++类,继承与QObject,使用单例的方式构造,但是构造函数必须公有,这个单例在QML里面构造在C++里面得到实例,然后这个对象便可以为QML与C++两边同时提供信号与槽,这样就完成了通讯,有一点先决条件就是QML必须能够使用这个类,方式是将其通过C++注册为QML类,然后QML就可以调用了。注册必须在main函数里面完成。本质上QML是一个C++的封装所以使用这个单例便可以同时存在于两边。
例子:这里的Tunnel就是我们的单例类

qmlRegisterType<Tunnel>("Tunnel_Type",1,0,"Tunnel");
//注册C++类供QML使用(main函数中)
Tunnel {
   id: tunnel
   //构造对象
}
Component.onCompleted: {
   tunnel.changesize.connect(changesize);
   tunnel.buttonclick.connect(buttonclick);
   //绑定信号
}

QML与JS通信,分为两种情况,一是QML向JS通信,这里可以在QML中直接调用JS函数所以不用阐述;二是JS向QML通信,这里目前有两个方法,现在单说其中一种,另一种在JS与C++通信里面阐述,这种方式是在JS里面创建一个QML对象,这个在对象作为一个全局变量可以在QML里面访问同时也可以在JS里面访问这样就搞定了通讯:实例如下:
JS代码:

var internalQmlObject = Qt.createQmlObject('import QtQuick 2.9; QtObject { signal getname(string value) }', Qt.application, 'InternalQmlObject');

function _GetName(name){
    internalQmlObject.getname(name);
    //提供一个JS函数由JS调用,直接在QML中将此绑定起来便可以使用了
}

QML代码:

Component.onCompleted: {
    GLCode.internalQmlObject.getname.connect(tunnel.getname);
    //作为信号绑定到另一个槽函数上面就可以使用了
}

C++与原生JS通讯,上述方式可以完成C++到JS的通讯但是仅仅实用与QML页面如果是原生html页面便于QML不兼容导致js不能使用任何QT功能这里只有一个方法使用QT提供的webchannel类完成,对应有一个qwebchannel.JS文件本质上这个东西是通过JSON请求发送的消息到C++与JS之中,可以解决原生通讯的问题。方式与C++通讯QML类似还是需要一个全局单例:Tunnel来当信号与槽函数的提供C++代码为:

WebView_Button = new QWebEngineView(this);
WebView_Button->load(QUrl("qrc:/Web_Widget/Button.html"));
WebView_Button->setContextMenuPolicy(Qt::NoContextMenu);
WebView_Button->page()->setBackgroundColor(Qt::transparent);
//创建按钮web
QWebChannel *channel = new QWebChannel(this);
channel->registerObject(QStringLiteral("content"), Tunnel::Get_Instance());
//使用webchannel通讯原生JS,不能用QML
WebView_Button->page()->setWebChannel(channel);
WebView_Button->show();

这里是将webview的page来进行连接通讯的,类似客户端与服务端的方式,JS代码为:

var webchannel = new QWebChannel(qt.webChannelTransport, function(channel){
   var content = channel.objects.content;
   button1 = document.getElementById("button1").addEventListener("click",function(){
      content.onbuttonclick(1);
   });
   //这里是使用html将事件绑定到单例类下面的一个槽函数上面
   //槽函数执行一个新的信号便可以用户QML与C++之中
   //我的写法是将这个单例同时用于QML之中这样就可以使用了
});

着色器编写

这里是一个坑,着色器不同于html的web写法,教程也找不到只能根据案例里面的东西自己慢慢搞移植,可以说是试出来的,方式只有一种,QML里面提供了一个QqucikShader在Canvas3D里面的ShaderEffect里面但是这个东西定义好之后传入JS会出现问题,在于JS不会将传入的东西认为是字符串也没有一个转换函数可以完成这个事情于是没法用,同时在Qt里面的着色器不会将值定义出来我们写的全局量这里不会认所以有问题,我使用的方式是在JS里面用字符串写shader然后调用,这样就可以使用了,由于之前所有的操作都没有用过着色器写的可能有问题但是目前没有发现,如果有新的方式可以直接写在下面,先解释一下着色器的作用:
着色器基本有两个作用“变换三维顶点坐标”和“光照计算”,对应这两个作用是在管线渲染的最后一步由GPU直接完成了,所有的shader编写使用C语言最终会在GPU直接调用,性能开销不算很大但是形成的效果比较好。对应变换定点坐标为定点着色器,计算光照为片元着色器这两个东西无论是什么图形API都是支持的,无论是什么显卡也都是支持了,上层来讲无论是什么引擎也都是支持的所有比较万金油,最好在了解计算机图形学之后马上开始简单的shader学习,可以以很低的代价完成较好的效果。
JS代码如下:

uniforms = {
        time: { type: "f", value: 1.0 },
        resolution: { type: "vec2", value: new THREE.Vector2() }
    };
    //定义着色器的全局变量
    var vertexShader = "varying vec2 vUv;void main(){vUv = uv;vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );gl_Position = projectionMatrix * mvPosition;}";
    //编写定点着色器,使用C语言,GLSL统一接口,OpenGL ES标准
    var fragmentShader = "uniform float time;uniform vec2 resolution;varying vec2 vUv;void main( void ){vec2 position = -1.0 + 2.0 * vUv;float red = abs( sin( position.x * position.y + time / 5.0 ) );float green = abs( sin( position.x * position.y + time / 4.0 ) );float blue = abs( sin( position.x * position.y + time / 3.0 ) );gl_FragColor = vec4( red, green, blue, 1.0 );}";
    //编写片元着色器,使用C语言,GLSL统一接口,OpenGL ES标准
varying vec2 vUv;
void main()
{
   vUv = uv;vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
   gl_Position = projectionMatrix * mvPosition;
}
//定点着色器代码
uniform float time;
uniform vec2 resolution;
varying vec2 vUv;
void main( void )
{
   vec2 position = -1.0 + 2.0 * vUv;
   float red = abs( sin( position.x * position.y + time / 5.0 ) );
   float green = abs( sin( position.x * position.y + time / 4.0 ) );
   float blue = abs( sin( position.x * position.y + time / 3.0 ) );
   gl_FragColor = vec4( red, green, blue, 1.0 );
}
//片元着色器代码

在init函数之中完成编写与全局量的创建,这里使用了两个全局量分辨率与时间,分辨率得到屏幕尺寸,时间进行状态更新,定点着色器用来使用屏幕空间UV投影到模型上面,片元着色器用来进行颜色计算。

uniforms.resolution.value.x = canvas.width;
uniforms.resolution.value.y = canvas.height;
//更新着色器分辨率

在resize函数之中重新计算分辨率与投影矩阵

uniforms.time.value += delta * 5;
/更新着色器数据

在paintgl函数中更新时间,让颜色随时间变化。

补间动画

采用补间的方式运用了一个新的js库,其实也算是three.js的官方拓展,这个东西在官方实例里面有,所有没有多久就移植出来了,方式很简单直接引入tween.js然后定义函数需要的时候调用就可以了。补间意思为设置一个输入一个初始值然后给定一个终点的值,给定一个时间,便自动计算在这个时间内状态A到状态B的中间所以状态。然后tick里面直接去将状态给到每一帧动画就好了,原理是这样实际调用非常简单,js代码:

function updatacam(position,tager,time)
{
    var tween_pos = new TWEEN.Tween(camera.position).to(position, time);
    tween_pos.onComplete(function(){
        camera.lookAt(tager);
    });
    tween_pos.start();
    //设置相机动画状态并执行,其中完成回调来重置相机目标点位置(补间方式动画)
}

然后只需要在tick里面进行对Tween对象的更新和摄像机的更新就可以了

TWEEN.update();
//更新补间
camera.updateProjectionMatrix();
//更新相机状态

three.js与QT结合实现简单跨平台三维开发》有9条留言

  1. 新的方式是采用CEF库提供完整的谷歌V8引擎然后作为一个窗口控件进行浏览器渲染

  2. “大家可以猜一下新的集成方式是什么”
    您好,麻烦问下新的方式是什么呢,最近也在做threeJS在Qt下的集成工作,谢谢

    1. 新版本的threeJS已经不对QML提供更新了,基本上使用QML集成three到QT算是废了,新的方式是采用CEF库提供完整的谷歌V8引擎然后作为一个窗口控件进行浏览器渲染。

      1. 我想用Qt实现本地加载vrm模型,使用旧版的three.js集成QML可以吗,用你评论区说的方法能不能实现呢