基于threejs的商品VR展示平台的设计与实现思路
前言
本设计针对目前互联网销售传统展示的现状,考虑当前市场形式,利用虚拟现实技术理论,结合计算机网络、交互设计实现一个以普通终端浏览器为载体的适用于用户或消费者需求的VR
展示平台系统,打造一种全新的商品展示方式,拉近用户或者消费者于商品的距离,提供商品全面的信息,提高商品的可信度,降低交易失败的风险,带来一次愉快完美的购物体验。
本文仅仅展示部分核心内容
点击资源下载 查看更多
整个VR商品展示平台系统的最核心的部分是VR引擎部分,是虚拟现实系统的最核心的地方。在浏览器和Threejs以及云数据库做储存的支持下处理和产生三维立体的虚拟空间环境,使得浏览者有了身临其境的“沉浸感”和人机交互的能力。
vr模型制作演示
总体开发方案设计
根据当前互联网销售行业的现状的展示方面的不足和局限,从新生一代的消费群体的消费观念以及需求进行了设计,设计出一种全新的设计思路,其扩展性、适用性和交互性得到了充分的体现。总体设开发设计思维导图如下图所示:
总体开发设计思维导图
- 对于企业用户,该平台实现如下的功能:可以加载产品模型,可以将制作的三维模型添加到特定的虚拟环境中,前提是符合系统平台支持的文件格式的三维模型文件;可以构建3D模型组件信息库,即企业或商家将商品的3D模型导入到该平台中,平台将该模型中各个组件模型的关联信息与构成信息,封装存储在云存储中,以便日后组合使用;设定模型组件之间的关系,即企业或者商家可以通过一些简单的便捷的操作的方式建立组件间的关系,形成树状结构;可以设定组件运动行为参数,即企业或者商家可以设定模型的分解状态;设定组件的文字说明信息,即企业或者商家可以对组件添加商品文字信息说明,或者重新命名,或者添加一些参数,设定和编辑顾客用户自定义的信息,即企业或商家可以设定顾客用户自定义的权限;方便快捷的在线编辑功能,即提供一个在线编辑器功能,可以供商家实时修改,实时预览,简单操作即可完成商品模型的制作,商品模型的运动分解参数,商品信息的添加等。
- 对于顾客用户来说,该平台如下的功能:任意调整观察点,即顾客用户在可以随意角度浏览该VR商品展示平台上的商品;获取模型组件的信息,即顾客用户以鼠标点击或者手指触摸的方式,触发查看商品模型的某个组件模型,同时可以获得该组件模型的名称、尺寸、组成成分等参数;分解展示,即顾客用户以鼠标点击或者手指触摸的方式,决定让商品模型进行分解运动,使该商品模型的内部结构关系展现出来;搭配商品,即顾客用户可以选择商品的一些规格参数,比如口味,尺寸大小等;个性化定制,即顾客用户可以对商品模型进行自定义设置。
平台整体架构核心
平台整体架构核心如下图所示,其核心主要有四个部分组成,分别是模型制作模块、后台管理模块、数据存储模块和前端展示模块:
模型制作模块
模型制作模块,主要又分为模型导入和模型编辑,让企业或商家块可以进行商品模型和材质的导入、商品模型的重命名、商品模型组件的分解运动的设定、商品信息的录入修、商品模型和材质的关联设定等。
前端展示模块
前端展示模块,主要面向顾客,给顾客展示商品的信息,商家动态,商品购买的方式等,在VR商品展示中能够任意的改变观察的角度,视点的距离,将商品模型进行分解展示,更好地了解和认识想要了解的商品信息和特征,并且可以对商品模型进行一定的修改,满足不同顾客的不同个性化需求。主要分为商品展示、模型交互展示和模型自定义。
存储模块
存储模块,所有的商品数据,商品模型数据,商品材质纹理,顾客自定义的信息,商品交易等都将存放到存储模块中。
后端管理模块
后端管理模块的功能主要是供给企业对商品的增删改查,前端展示网站的设置,活动的设定,商品售后处理管理,订单管理,商品数据的统计分析以及模型的管理和信息的设置等。
根据预期实现目标和制定的总体开发方案,以太泗蒂艺术蛋糕为实例,分别实现了后台管理、商品模型制作、数据存储和前端展示。
后台管理实现
后台管理主要用到了Vue
框架和Element UI
组件实现包括主界面、登录界面、商品管理、商品列表、订单管理、网站管理、数据管理等,便于商家用户管理商品,管理订单和管理商品模型。★ 主界面:一进入后台管理,就将给商家关心的订单数据,访问数据,营销统计图表等展示出来。便于商家快速直观获得,平台运营概况以及待办事项。后台管理的首页界面效果图如下图所示:
★ 商品列表:当企业或者商家点击商品列表,那么后台管理系统将会把当前所有的商品信息以表格形式展示出来,便于企业或者商家快捷的对自家的商品集中查看,批量处理。同时也可以点击操作栏对单个商品进行编辑或删除,同时支持多关键字筛选功能,可以输入商品名称、商品的分类,或者商品的唯一ID作为关键字,快速查找想要处理的商品。商品列表效果图如下图所示:
★ 订单管理:点击订单管理,可以及时获取最新的订单,用户自定义商品信息,维权信息等(页面效果与商品列表相似,就不再展示)。★ 数据分析:点击数据分析,可以获取近段时间的商品或者历史信息相关的分析统计信息,收益统计,客户端访问情况等。★ 网站管理:点击网站管理,可以对网站的一些展示模块信息模块进行管理(例如:首页banner、新品展示等),可以发通知公告,管理留言信息,制定商品的营销方式,管理买家秀等。
商品模型制作
在制作商品模型制作主要分为前期准备、环境搭建、组件制作、模型合成等步骤,如下图所示。
商品模型前期准备
在制作商品模型前,当然是需要做一点准备工作,需要了解商品模型的原型(以太泗蒂艺术蛋糕的一款蛋糕为例,如下图所示 上图为商品实物,下图为模型
),并了解原型的真实结构(如:单层或是双层,是否含有夹层等)、组成成分(如:胚子为慕斯还是传统蛋糕胚子;夹层为布丁或水果)、商品属性(包括颜色、形状、口味、口感、尺寸)、适宜人群、适合场景等,收集原料信息等相关参数,分析产品建模方式,根据预计效果针对性的去采集该商品的局部细节图或纹理,用于制作商品模型的局部UV贴图
等,更好的将商品模型制作出来。
商品模型展示环境搭建
商品模型的虚拟展示环境主要色调为冷色调的黑色,与实例太泗蒂艺术蛋糕的主营商品:慕斯蛋糕属于一种奶冻式的甜点,口感冰凉细腻相符合。虚拟展示环境的视点相机设置Perspective Camera
,即透视投影照相机,投影方式为远景投影,为人眼看世界的模式;将相机的视点位置设置在整个渲染场景略高处,目的是达到人眼俯视效果。虚拟环境中设置一个圆形镜面,与蛋糕制作的工作台相似,再在上面搭建一个长方形半透明托盘,用于放置蛋糕的3D模型。然后在圆形镜面的周围设置4个颜色不相同的PointLight
(点光源),作为虚拟环境中商品模型的展射灯,分别位于东西南北四个方位。最后给创建好的三维虚拟环境绑定一个OrbitControls
(场景控制器)。设置一个监听器去实时监听,虚拟环境中发生变化,及时更新渲染虚拟环境,这里调用浏览器自带的API,requestAnimationFrame
函数,由浏览器内核去决定该函数的回调函数的执行时机,有60Hz的刷新频率,那么每次刷新的间隔中会执行一次回调函数,不会引起丢帧,不会卡顿,它与曾经使用频率很高的setTimeout
函数相比而言,更节约CPU性能,更加流畅。场景搭建完成效果如下图所示。
商品模型组件制作
根据蛋糕商品的原型以及相关信息,开始制作蛋糕的3D模型。对于常用的基础模型Threejs已经集成封装好了,但是稍微特殊一点的模型就需要单独去制作。一个3D模型,本质上由无数的点组成,点可以组成线,而三个不在一条直线上的点,就可以组成一个三角形的面,无数的三角形的面就可以通过一定的组合和排列组成各种各样丰富的网络结构或者说网络模型,我们一般称它为Mesh模型。给Mesh模型贴上纹理,就变成了3D模型。以制作空心圆柱体为例,以原点为中心,固定Y轴在ZOX平面通过Math.sin函数画圆,就会形成一圈点,然后再沿着Y轴一层一层叠加,就会构成一个圆柱面,然后减小画圆的半径,形成另一个圆柱面,此时一个空心圆柱的内外壁面就完成了。当然现在还是一圈一圈的散点,然后根据一定规则将每三个点构成一个一个的小三角形,每个小三角形环环相扣,就完成了Mesh模型的创建,然后将内外壁面的边缘的点按照一定的规则链接起来,最后利用片元着色器将三角形填充或者填充上纹理。至此,一个空心圆柱模型就制作完成了。添加到虚拟环境中进行渲染。就能看见一个空心圆柱体。如图下图所示:
//制作空心圆柱源码varHollowBufferCylinder=function (radiusTop, radiusBottom, radiusStep, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength, phiSegments) { BufferGeometry.call(this); this.type='HollowBufferCylinder'; this.parameters= { radiusTop: radiusTop, radiusBottom: radiusBottom, radiusStep: radiusStep, height: height, radialSegments: radialSegments, heightSegments: heightSegments, openEnded: openEnded, thetaStart: thetaStart, phiSegments: phiSegments, thetaLength: thetaLength }; varscope=this; radiusTop=radiusTop!==undefined?radiusTop : 10; radiusBottom=radiusBottom!==undefined?radiusBottom : 10; height=height||10; radiusStep=radiusStep||2radialSegments=Math.floor(radialSegments) ||8; heightSegments=Math.floor(heightSegments) ||1; phiSegments=Math.floor(phiSegments) ||8; openEnded=openEnded!==undefined?openEnded : false; thetaStart=thetaStart!==undefined?thetaStart : 0.0; thetaLength=thetaLength!==undefined?thetaLength : Math.PI*2; varindices= []; varvertices= []; varnormals= []; varuvs= []; vararr= []; varindex=0; varhalfHeight=height/2; vargroupStart=0; generateTorso(true);//InsidegenerateTorso(false);//Outsideif (radiusTop>0) generateCap(true); if (radiusBottom>0) generateCap(false); generateTorsoSide(true); generateTorsoSide(false); this.setIndex(indices); this.setAttribute('position', newFloat32BufferAttribute(vertices, 3)); this.setAttribute('normal', newFloat32BufferAttribute(normals, 3)); this.setAttribute('uv', newFloat32BufferAttribute(uvs, 2)); functiongenerateTorso(inside) { varx, y; varnormal=newVector3(); varvertex=newVector3(); varindexArray= []; vargroupCount=0; varslope= (radiusBottom-radiusTop) /height; for (y=0; y<=heightSegments; y++) { varindexRow= []; varv=y/heightSegments; varradius=inside?v* (radiusBottom-radiusTop) +radiusTop-radiusStep : v* (radiusBottom-radiusTop) +radiusTop; for (x=0; x<=radialSegments; x++) { varu=x/radialSegments; vartheta=u*thetaLength+thetaStart; varsinTheta=Math.sin(theta); varcosTheta=Math.cos(theta); vertex.x=radius*sinTheta; vertex.y=-v*height+halfHeight; vertex.z=radius*cosTheta; vertices.push(vertex.x, vertex.y, vertex.z); normal.set(sinTheta, slope, cosTheta).normalize(); normals.push(normal.x, normal.y, (inside?1 : -1) *normal.z); uvs.push(u, 1-v); indexRow.push(index++); } indexArray.push(indexRow); } for (x=0; x<radialSegments; x++) { for (y=0; y<heightSegments; y++) { vara=indexArray[y][x]; varb=indexArray[y+1][x]; varc=indexArray[y+1][x+1]; vard=indexArray[y][x+1]; indices.push(a, b, d); indices.push(b, c, d); groupCount+=6; } } scope.addGroup(groupStart, groupCount, inside===true?0 : 1); groupStart+=groupCount; } functiongenerateTorsoSide(sideLeft) { vargroupCount=0; vartheta=sideLeft==true?thetaLength+thetaStart : thetaStart; vertices.push(radiusTop*Math.sin(theta), halfHeight, radiusTop*Math.cos(theta)); normals.push(Math.sin(theta), (sideLeft?1 : -1) *-1, Math.cos(theta)); uvs.push(0, 0); vertices.push((radiusTop-radiusStep) *Math.sin(theta), halfHeight, (radiusTop-radiusStep) *Math.cos(theta)); normals.push(Math.sin(theta), (sideLeft?1 : -1) *-1, Math.cos(theta)); uvs.push(0, 1); vertices.push(radiusBottom*Math.sin(theta), -halfHeight, radiusBottom*Math.cos(theta)); normals.push(Math.sin(theta), (sideLeft?1 : -1) *1, Math.cos(theta)); uvs.push(1, 0); vertices.push((radiusBottom-radiusStep) *Math.sin(theta), -halfHeight, (radiusBottom-radiusStep) *Math.cos(theta)); normals.push(Math.sin(theta), (sideLeft?1 : -1) *1, Math.cos(theta)); uvs.push(1, 1); vara=index++; varb=index++; varc=index++; vard=index++; if (sideLeft) { indices.push(d, c, b); indices.push(b, a, c); } else { indices.push(a, b, c); indices.push(c, d, b); } groupCount+=6; scope.addGroup(groupStart, groupCount, sideLeft===true?4 : 5); groupStart+=groupCount; } functiongenerateCap(top) { varx, centerIndexStart, centerIndexEnd; varuv=newVector2(); varvertex=newVector3(); vargroupCount=0; varouterRadius= (top===true) ?radiusTop : radiusBottom; varsign= (top===true) ?1 : -1; varsegment; varradius=outerRadius-radiusStep; varj, i; centerIndexStart=index; for (j=0; j<=phiSegments; j++) { for (i=0; i<=radialSegments; i++) { egment=thetaStart+i/radialSegments*thetaLength; vertex.x=radius*Math.sin(segment); vertex.y=halfHeight*sign; vertex.z=radius*Math.cos(segment); vertices.push(vertex.x, vertex.y, vertex.z); normals.push(0, sign, 0); uv.x= (vertex.x/outerRadius+1) /2; uv.z= (vertex.z/outerRadius+1) /2; uvs.push(uv.x, uv.z); index++; } radius+=radiusStep/phiSegments; } for (j=0; j<phiSegments; j++) { varradialSegmentLevel=j* (radialSegments+1); for (i=0; i<radialSegments; i++) { segment=i+radialSegmentLevel; vara=centerIndexStart+segment; varb=centerIndexStart+segment+radialSegments+1; varc=centerIndexStart+segment+radialSegments+2; vard=centerIndexStart+segment+1; indices.push(a, b, d); indices.push(b, c, d); groupCount+=6; } } scope.addGroup(groupStart, groupCount, top===true?2 : 3); groupStart+=groupCount; } }
模型在线编辑功能实现
为便于管理者制作和修改商品模型,所以需要一个在线编辑功能,即在线编辑器,可以供商家实时修改,实时预览,通过简单的鼠标点击拖动和键盘输入等操作即可完成对商品模型的编辑制作、分解运动参数的设置、模型各部分详细信息的添加编辑和设置顾客用户可自定义的组件(如下图所示),无需专业的建模师就能对商品模型进行制作,极大节约了企业的建模成本。该在线编辑器主要分四个模块,分别为 实时预览模块,功能模块,树形结构展示模块,模型编辑模块。
模型轮廓高亮实现
为了给用户带来更加友好,愉快的浏览体验,以及对商品的组成成分(模型组件)的直观了解,采用广度优先搜索算法与并查集算法,获取同类别组成成分(同编号模型组件),加入选定对象数组(selectedObjects),实时进行高亮渲染选定数组中的模型组件对象。
模型分解运动的实现
为了给用户对商品的内部构成(模型组件)的直观详细的了解,实现一个模型分解功能,将商品模型内部无法观察的部分展示出来,用户点击分解按钮,商品模型开始按照每个模型的分解运动参数(下表所示)进行分解运动。分解运动参数保存在每一个模型组件的userData对象中,其中属性id的值决定该模型组件是否分解运动模型,id值两位数以下,为分解运动模型,三位数以上则为随动分解运动模型或不分解运动模型;sO(speed Orientation)
代表运动方向,采用三位二进制表示,对应坐标轴的XYZ,例如4(100)代表沿着X正向运动,-2(-010)代表沿着-Y轴运动。sV(speed Velocity)
代表运动的速度(范围1-100)。值越大,该模型组件的分解运动速度越快。sI(speed Increment)
代表运动增量,决定分解运动的最终增加的距离,及原位置的相对位移大小。当用户点击合成按钮时,通过反向运动得以复原。
//解析函数源码 functionComponent(_Group, modelsConfig) { // 解析器函数for (leti=0; i<modelsConfig.length; i++) { if (modelsConfig[i].Nid) continue; var_M=modelsConfig[i].ChildrenModel&&modelsConfig[i].Type=="Group"?buildGroup(modelsConfig[i]) : buildModel(modelsConfig[i]); _MinstanceofArray?addArray(_Group, _M) : _Group.add(_M); } } functionbuildGroup(GConfig) { /*创模型组*/var_G=newTHREE.Group(); /*模组名称*/_G.name=GConfig.Name; /*模组数据*/GConfig.UserData?_G.userData=GConfig.UserData : ""; /*模组位置*/GConfig.Position?_G.position.set(GConfig.Position[0], GConfig.Position[1], GConfig.Position[2]) : ""; /*模组方向*/GConfig.Rotate?_G.rotation.set(GConfig.Rotate[0] /180*Math.PI, GConfig.Rotate[1] /180*Math.PI, GConfig.Rotate[2] /180*Math.PI) : ''; /*模组缩放*/GConfig.Scale?_G.scale.set(GConfig.Scale[0], GConfig.Scale[1], GConfig.Scale[2]) : ""; /*模组组件*/for (leti=0; i<GConfig.ChildrenModel.length; i++) { if (GConfig.ChildrenModel[i].Nid) continue; var_M=GConfig.ChildrenModel[i].ChildrenModel&&GConfig.ChildrenModel[i].ChildrenModel.Type=="Group"?buildGroup(GConfig.ChildrenModel[i]) : buildModel(GConfig.ChildrenModel[i]); _MinstanceofArray?addArray(_G, _M) : _G.add(_M); } /*组件克隆*/if (GConfig.CloneList) { varcloneList= [_G]; for (leti=0; i<GConfig.CloneList.length; i++) { cloneList.push(cloneModel(_G, GConfig.CloneList[i])); } returncloneList; } return_G} functionbuildModel(MConfig) { /*材料*/var_Material=MConfig.Material.Color.indexOf(".") !=-1?newTHREE['MeshBasicMaterial']({ map: newTHREE.ImageUtils.loadTexture(MConfig.Material.Color) }) : newTHREE['MeshBasicMaterial']({ color: MConfig.Material.Color }); MConfig.Side?_Material.side=THREE.DoubleSide : ''; /*两面都显示*/MConfig.Material.Others?setOthers(_Material, MConfig.Material.Others) : ""; /*几何*/var_Geometry=newTHREE[MConfig.Type](MConfig.Paramete[0], MConfig.Paramete[1], MConfig.Paramete[2], MConfig.Paramete[3], MConfig.Paramete[4], MConfig.Paramete[5], MConfig.Paramete[6], MConfig.Paramete[7], MConfig.Paramete[8], MConfig.Paramete[9]); /*模型*/var_M=newTHREE.Mesh(_Geometry, _Material); /*名称*/_M.name=MConfig.Name; /*位置*/MConfig.Position?_M.position.set(MConfig.Position[0], MConfig.Position[1], MConfig.Position[2]) : ""; /*方向*/MConfig.Rotate?_M.rotation.set(MConfig.Rotate[0] /180*Math.PI, MConfig.Rotate[1] /180*Math.PI, MConfig.Rotate[2] /180*Math.PI) : ''; /*缩放*/MConfig.Scale?_M.scale.set(MConfig.Scale[0], MConfig.Scale[1], MConfig.Scale[2]) : ""; /*数据*/MConfig.UserData?_M.userData=MConfig.UserData : ""; /*其他*/MConfig.Others?setOthers(_M, MConfig.Others) : ""; if (MConfig.ChildrenModel) { for (leti=0; i<MConfig.ChildrenModel.length; i++) { if (MConfig.ChildrenModel[i].Cid) continue; var_CM=MConfig.ChildrenModel[i].ChildrenModel&&MConfig.ChildrenModel[i].ChildrenModel.Type=="Group"?buildGroup(MConfig.ChildrenModel[i]) : buildModel(MConfig.ChildrenModel[i]); _CMinstanceofArray?addArray(_M, _CM) : _M.add(_CM); } } if (MConfig.CloneList) { varcloneList= [_M]; for (leti=0; i<MConfig.CloneList.length; i++) { cloneList.push(cloneModel(_M, MConfig.CloneList[i])); } returncloneList; } return_M; } functioncloneModel(M, CConfig) { /*克隆母体*/var_C=M.clone(); /*重置位置*/CConfig.Position?_C.position.set(CConfig.Position[0], CConfig.Position[1], CConfig.Position[2]) : ""; /*重置方向*/CConfig.Rotate?_C.rotation.set(CConfig.Rotate[0] /180*Math.PI, CConfig.Rotate[1] /180*Math.PI, CConfig.Rotate[2] /180*Math.PI) : ''; /*重置缩放*/CConfig.Scale?_C.scale.set(CConfig.Scale[0], CConfig.Scale[1], CConfig.Scale[2]) : ""; /*重置数据*/CConfig.UserData?_C.userData=CConfig.UserData : ''; return_C; } functionsetOthers(G, M) { for (varkeyinM) { G[key] =M[key]; } } functionaddArray(G, M) { M.forEach(ele=> { G.add(ele); }); }