近年来Three.js 3D可视化在各种业务场景中频频出镜,其惊艳的表现力往往离不开各种3D效果的加持。本文将着重聊一聊数字孪生系统中常用的3D效果,并以图示的方式拆解其背后的实现原理。
建筑颜色渐变
建筑作为数字孪生城市场景的底座,其重要性不言而喻,通常我们会使用建筑简模搭配渐变色去呈现出一种错落有致的层次感。当然了我们首先需要构造出这些建筑简模。一般情况下我们拿到手上的数据都是基于经纬度坐标的geojson数据,由于Three.js并不像Cesium.js一样自带GIS系统,于是将经纬度坐标转为Three.js默认的世界坐标xyz便是必不可少的一个过程。方便起见我们可以使用d3-geo这个库实现经纬度到世界坐标xy的转换。具体用法如下:
javascript
import './DigitalTwin/d3-array.min.js'
import './DigitalTwin/d3-geo.min.js'
// 将经纬度坐标转换为世界坐标xy
function geoMercator(longitude, latitude) {
if (!d3geoMercator) {
d3geoMercator = d3.geoMercator()
.center([120.22422224, 30.20212491])
.scale(6000000)
.translate([0, 0])
}
return d3geoMercator([longitude, latitude])
}
坐标转换主要调用d3.geoMercator()方法来实现,其中:
.center()用于指定坐标转换的参考点,一般传入自己想要设置的地图中心点的经纬度坐标;
.scale()用于放大坐标;
.translate()用于进行坐标的平移,[0, 0] 表示在x 和 y方向上都没有平移,即将地图中心点作为三维场景的原点。
如果是基于脚手架创建的Vue或者React项目,也可以使用npm安装依赖(npm i d3-geo --save),然后再import 引入进行使用(import * as d3 from 'd3-geo')。
接下来加载geojson数据,查看返回后的数据大概长这个样子:
其中数组的每一项代表一个建筑轮廓、并且带有高度,我们需要做的是将这些经纬度的轮廓数据转换为世界坐标,同时使用Three.js的ExtrudeGeometry将轮廓拉伸为三维模型,如此就能看到立体的建筑简模了:
具体代码如下:
javascript
// 生成建筑简模
function addBuildings() {
fetch('./DigitalTwin/xx区.geojson')
.then(res => res.json())
.then(data => {
const g = new THREE.Group();
data.features.forEach(build => {
if (build.geometry) {
if (build.geometry.type === "MultiPolygon") {
let e = createBuildingMesh(build.geometry.coordinates[0][0], build.properties.height)
g.add(e);
}
}
})
g.rotateX(-Math.PI / 2)
scene.add(g)
})
.catch(e => {
console.log('e:', e)
})
}
建筑简模渲染出来后,接下来就要对其进行渐变着色了。仔细观察文章头部的大图会发现,建筑物越高的部分其颜色越亮,用图形学的语言来表述就是片元的顶点越高其颜色越亮。像这种需要在原本光照着色的基础上进行二次着色的情况一般我们会基于某个已有的材质进行着色器的修改来实现自定义的着色器效果。Three.js提供了一个接口(material.onBeforeCompile)让我们可以在着色器材质编译前混入我们自定义的着色器代码,通过它的回调参数可以看到混入我们自定义的代码前的原始着色器代码:
javascript
material.onBeforeCompile = (shader) => {
console.log('shader.vertexShader:', shader.vertexShader)
console.log('shader.fragmentShader:', shader.fragmentShader)
}
我们需要做的是将顶点数据从顶点着色器传递到片元着色器,以便处理在片元着色器中的相关计算:
javascript
material.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
`
varying vec3 vPosition;
void main() {
vPosition = position;
`
)
......
}
接下来就需要在片元着色器中进行渐变颜色的处理了。上文我们提到颜色渐变的的大致思路就是顶点高度越高的片元颜色越亮。我们可以设置由两个颜色值构成的颜色区间,然后根据一个比例关系线性地设置颜色值,最后只需要将渐变颜色与原本的光照影响后的颜色进行叠加计算即可。在混入我们自定义的着色器之前,我们可以发现console.log('shader.fragmentShader:', shader.fragmentShader)打印出来的原始着色器中的#include <output_fragment>对应的代码就是在处理片元颜色gl_FragColor的:
因此我们只需要将这部分代码替换为我们自定义的着色器片段即可:
javascript
material.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
`varying vec3 vPosition;
void main() {
vPosition = position;
`
)
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`varying vec3 vPosition;
void main() {
`
)
let output = `
// 设置渐变色, 348为最高建筑的高度、我们以此为参考基准来设置比例值
vec3 gradient = mix(vec3(0.0, 35.0/255.0, 55.0/255.0), vec3(0.0, 200.0/255.0, 1.0), vPosition.z/348.0);
// 在原本的光照颜色基础上叠加渐变色
outgoingLight = outgoingLight*gradient;
gl_FragColor = vec4( outgoingLight, 1.0 );
`
shader.fragmentShader = shader.fragmentShader.replace('#include <output_fragment>', output)
};
围栏光效
围栏光效的主要作用在于对特定的区域进行高亮显示。就像这样:
我们需要基于轮廓数据和指定的高度构建一个立起来的面,形似一个围栏,大致的实现思路分为两步:
1、构建围栏的geometry;
2、为围栏设置着色器材质。
我们开始第一步:构建围栏的geometry。在WebGL中三个点构成一个面,再复杂的面也可以由一个个三角面拼合而成。如下图所示,由ABCDE点围合同时向上拉升而成的面实际上可以由一个个的三角面拼合组成。
为围栏的geometry设置顶点的具体代码如下:
javascript
const polygon = [
-1930.2693596828185,
-613.6577958609518,
-2504.7463330383757,
-372.17155608911906,
-2742.8426708498205,
-502.7595637031484,
-2676.659689823466,
-925.1028809261629,
-1910.8349882873947,
-887.3224865459774,
-1930.2693596828185,
-613.6577958609518
]
const geometry = new THREE.BufferGeometry()
const position = [] // 围栏的顶点
......
const height = 200 // 围栏的高度
for (let i = 0; i < polygon.length - 2; i += 2) {
// 用相邻的两个点及其拉升后的两个点位构造两个三角面
// 第一个三角面的顶点和UV坐标
position.push(
polygon[i], 0, polygon[i + 1],
polygon[i + 2], 0, polygon[i + 3],
polygon[i + 2], height, polygon[i + 3]
)
......
// 第二个三角面的顶点和UV坐标
position.push(
polygon[i], 0, polygon[i + 1],
polygon[i + 2], height, polygon[i + 3],
polygon[i], height, polygon[i + 1]
)
......
}
geometry.attributes.position = new THREE.BufferAttribute(new Float32Array(position), 3)
......
再接着为每个顶点设置uv坐标。uv坐标的原点为平面的左下角:
设置uv坐标的具体代码如下:
javascript
const uv = [] // 围栏的UV坐标
......
for (let i = 0; i < polygon.length - 2; i += 2) {
// 用相邻的两个点及其拉升后的两个点位构造两个三角面
// 第一个三角面的顶点和UV坐标
......
uv.push(
0, 0,
1, 0,
1, 1
)
// 第二个三角面的顶点和UV坐标
......
uv.push(
0, 0,
1, 1,
0, 1
)
}
geometry.attributes.uv = new THREE.BufferAttribute(new Float32Array(uv), 2)
围栏的geometry构造完成后,接下来就要为其设置材质了。可以看到围栏光效也是渐变色的、且围栏越高的地方可见度越低,用图形学的语言来表述就是片元的顶点越高其alpha通道的值越低。技术实现的思路上可以参考上文建筑渐变颜色的实现。具体代码如下:
javascript
const material = new THREE.MeshLambertMaterial({
color: 0xffffff,
side: THREE.DoubleSide,
transparent: true,
depthTest: false,
})
material.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
`varying vec3 vPosition;
void main() {
vPosition = position;
`
)
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`varying vec3 vPosition;
void main() {
`
)
.replace('#include <output_fragment>', 'gl_FragColor = vec4( outgoingLight, 1.0 - vPosition.y/200.0 );') // 其中200代表围栏的高度
}
雷达扫描特效
雷达扫描特效经常会在周边分析、报警等场景下使用。它的实现方式多种多样,本文采用自定义着色器的实现方式,大致实现思路如下:
1、创建一个平面,通过着色器材质实现雷达扫描效果、进而作为这个平面的材质;
2、使用造型函数绘制雷达的圆圈;
3、绘制雷达的渐变扇形;
4、将雷达的圆圈和渐变扇形效果结合起来。
第一步:创建用来承载雷达扫描效果的平面:
javascript
const geometry = new THREE.PlaneGeometry(500, 500)
const material = new THREE.ShaderMaterial({
uniforms: {......},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
vec4 modelPosition = modelMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * viewMatrix * modelPosition;
}
`,
fragmentShader: ``,
transparent: true,
side: THREE.DoubleSide
})
let mesh = new THREE.Mesh(geometry, material)
mesh.rotateX(-Math.PI / 2)
scene.add(mesh)
mesh.position.set(-797.7807631102143, 150.00000000000017, 691.6919585211252)
第二步:绘制雷达的圆圈。可以运用shader造型函数的思路,使用两个函数相减达到图形相减的效果,进而实现圆圈的绘制。正如下图中的三个小图所示,用第一个图的黑白区域减去第二个图的黑白区域,进而就可以绘制出第三个图所示的白色圆圈:
具体代码如下:
glsl
// 绘制圆圈
float drawCircle(vec2 vUv, float radius) {
float res = length(vUv);
float width = 0.01;
// 一个smoothstep形成中间黑外围白的图案, 两个smoothstept形成的图案相减就形成了白色圆圈
return smoothstep(radius - width, radius, res) - smoothstep(radius, radius + width, res);
}
第三步:绘制雷达的渐变扇形。绘制扇形扫描效果大致的思路: 固定扇形区域、旋转片元的uv坐标、若片元旋转后的uv坐标落在扇形区域内则为相应的片元上色。
glsl
// 绘制扇形扫描效果, 大致的思路: 固定扇形区域、旋转片元的uv坐标、若片元旋转后的uv坐标落在扇形区域内则为相应的片元上色
float drawSector(vec2 vUv, float radius) {
// 片元的旋转角度
float angle = -u_angle;
// 使用二维旋转矩阵对片元进行旋转
vec2 newvUv = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)) * vUv;
vec2 x = vec2(1.0, 0.0);
vec2 y = vec2(0.0, 1.0);
// 用于判断片元旋转后与y轴的夹角, 值大于0.0则表明夹角处于0-90度之间
float res = dot(newvUv, y);
// 用于计算片元旋转后与x轴的夹角
float angle2 = acos(dot(x, normalize(newvUv)));
// 片元旋转后与x轴、y轴的夹角处于0-90度(即扇形区域的角度范围)之间、同时片元到中心点的距离小于0.45, 则满足条件
if(angle2 > 0.0 && angle2 < PI/2.0 && length(newvUv) < 0.45 && res > 0.0) {
// 片元落在扇形区间内后, 片元与x轴夹角越大片元颜色越浅
return 1.0 - smoothstep(0.0, PI/2.0, angle2);
} else {
return 0.0;
}
}
最后再将雷达的圆圈效果和渐变扇形效果结合起来:
glsl
void main() {
vec2 newvUv = vUv;
// 将uv坐标原点偏移到画布中心
newvUv -= vec2(0.5);
vec3 color = vec3(0.0, 0.0, 0.0);
float circle = drawCircle(newvUv, 0.45);
float circle2 = drawCircle(newvUv, 0.3);
float circle3 = drawCircle(newvUv, 0.1);
color += circle + circle2 + circle3;
color += drawSector(newvUv, 0.45);
gl_FragColor = vec4(color, color.r);
}
流动光效
流动光效常用于表现流向关系,比如行驶的轨迹流向、供电的电流流向。具体效果就像这样:
流动光效可以拆解为3部分:一条轨迹线、一道拖尾的光线、拖尾的光线沿着轨迹线移动。
轨迹线的实现比较简单,指定一些关键坐标,使用Three.js的CatmullRomCurve3对象即可生成一条平滑的曲线:
javascript
// 生成轨迹线line
const lineCurve = new THREE.CatmullRomCurve3([
new THREE.Vector3(-1052.8585850293475, -9.999999999999817, -823.0282686551488),
new THREE.Vector3(-1165.2361736134485, -10.000000000000036, 161.26685483459238),
new THREE.Vector3(-1300.4607290950512, -9.999999999999323, 1045.7116325795666),
new THREE.Vector3(-1938.68644776126, -10.00000000000198, 729.2466850338662),
new THREE.Vector3(-2872.866031260749, -10.000000000000039, 172.88408419892394)
])
const lineGeometry = new THREE.BufferGeometry()
const linePoints = lineCurve.getSpacedPoints(1000) // 将曲线细分出更多的的点
lineGeometry.setFromPoints(linePoints)
const lineMaterial = new THREE.LineBasicMaterial({color: 0xffffff})
const line = new THREE.Line(lineGeometry, lineMaterial)
scene.add(line)
对于拖尾的光线我们可将其拆解为一连串的点,如果一连串的点从大到小紧密排列,这样就能构造出拖尾的效果了。在实现拖尾的光线之前我们先来解决这个问题:如何让拖尾的光线沿着轨迹线运动。我们知道拖尾的光线本质上就是一连串的点,如果这些一连串的点的点位全都取自于轨迹线,同时每隔一段时间我们就在轨迹线上向前偏移选取新的点位设置给拖尾的光线(比如最开始我们取轨迹线上的第1-50个点设置给拖尾的光线,间隔一段时间后我们再取第2-51个点设置给拖尾的光线),如此一来我们就会看到拖尾的光线沿着轨迹线不断向前偏移。
具体代码如下:
javascript
// 生成那道拖尾的光flowingLine
let flowingLineCurve
let flowingLineGeometry = new THREE.BufferGeometry()
let flowingLineLength = 50 // 那道拖尾的光的长度(即占用了轨迹线上多少个点)
let lineIndex = 0 // 拖尾的光从轨迹线的第一个点位开始流动
let flowingLinePoints = linePoints.slice(lineIndex, lineIndex + flowingLineLength) // 拖尾的光对应的那段轨迹线
flowingLineCurve = new THREE.CatmullRomCurve3(flowingLinePoints) // 以拖尾的光对应的那段轨迹线生成拖尾的光它自己的曲线
flowingLinePoints = flowingLineCurve.getSpacedPoints(100) // 将拖尾的光它自己的曲线细分出更多的点方便后续设置一个个点串联成拖尾的光线
flowingLineGeometry.setFromPoints(flowingLinePoints)
const flowingLineMaterial = new THREE.PointsMaterial({
color: 0xfff000,
size: 100.0
})
const flowingLine = new THREE.Points(flowingLineGeometry, flowingLineMaterial)
scene.add(flowingLine)
......
// 每隔一段时间不断在轨迹线上向前取线段从而生成拖尾的光对应的一个个点位
if (lineIndex > linePoints.length - flowingLineLength) {
lineIndex = 0
}
lineIndex += 1
flowingLinePoints = linePoints.slice(lineIndex, lineIndex + flowingLineLength)
flowingLineCurve = new THREE.CatmullRomCurve3(flowingLinePoints)
flowingLinePoints = flowingLineCurve.getSpacedPoints(100)
// 为拖尾的光设置新的点位从而实现流动效果
flowingLine.geometry.setFromPoints(flowingLinePoints)
接下来还要聊一聊如何让拖尾的光线上一连串的点从大到小排列。这一步我们需要进入到顶点着色器为gl_PointSize指定我们传入的顶点大小:
javascript
const scale1 = []
for (let i = 0; i < flowingLinePoints.length; i++) {
scale1.push((i + 1) / flowingLinePoints.length)
}
flowingLineGeometry.attributes.scale1 = new THREE.BufferAttribute(new Float32Array(scale1), 1) // 使拖尾的光串联的点呈现大小比例的变化从而形成拖尾效果
const flowingLineMaterial = new THREE.PointsMaterial({
color: 0xfff000,
size: 100.0
})
flowingLineMaterial.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
`
attribute float scale1;
void main() {
`
)
.replace(
'gl_PointSize = size;',
`
gl_PointSize = size * scale1;
`
)
}
沿路径漫游
沿着某条路径以第一人称的方式漫游场景是三维可视化中经常会有的需求,它的实现方式也有多种,相比较之下本文介绍的实现方式相对更容易理解、漫游动画也更平滑。本方案大致的实现思路如下:
1、让一个空物体沿着指定的路径运动;
2、将相机绑定到这个空物体上、同时让相机和空物体之间保持一定的距离使镜头呈现一种俯瞰的视角;
如此一来空物沿着路径运动、相机也跟着同时沿路径运动。
逻辑原理如下图所示:
先来看看如何实现空物体沿着指定的路径运动。我们只需要每间隔一段时间取轨迹线上的下一个点位(如上图所示的A点)设置为空物体的坐标,同时让这个空物体在移动到下下个点位(如上图所示的B点)的过程中始终朝向下下个点位(如上图所示的B点)。
javascript
let cameraParent = new THREE.Object3D()
cameraParent.position.set(-1052.8585850293475, -9.999999999999817, -823.0282686551488)
scene.add(cameraParent)
let progress = 0
let curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(-1052.8585850293475, -9.999999999999817, -823.0282686551488),
new THREE.Vector3(-1165.2361736134485, -10.000000000000036, 161.26685483459238),
new THREE.Vector3(-1300.4607290950512, -9.999999999999323, 1045.7116325795666),
new THREE.Vector3(-1938.68644776126, -10.00000000000198, 729.2466850338662),
new THREE.Vector3(-2872.866031260749, -10.000000000000039, 172.88408419892394)
])
curve.arcLengthDivisions = 2000
......
// 在动画函数中调用以下程序
progress += 1 / 2000
let point = curve.getPoint(progress)
progress += 1 / 2000
let nextPosition = curve.getPoint(progress)
cameraParent.position.set(point.x, point.y, point.z)
cameraParent.lookAt(new THREE.Vector3(nextPosition.x, nextPosition.y, nextPosition.z))
接下来实现第二步,将相机与空物体进行绑定、同时二者保持一定的距离。首先将相机添加到空物体中作为它的子对象,再设置相机相对于空物体的位置使其位于空物体的后上方,最后让相机也朝向下下个点位即可。
javascript
if (!hasCamera) {
cameraParent.add(camera)
camera.position.set(0, 900, -1200)
hasCamera = true
}
......
controls.target.set(nextPosition.x, nextPosition.y, nextPosition.z)
最后在沿路径漫游结束后还需要将相机从空对象中移除,同时让相机的位置恢复到世界坐标中。
javascript
if (progress >= 1 - 2 / 2000) {
let cameraPosition = camera.getWorldPosition(new THREE.Vector3())
camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z)
cameraParent.remove(camera)
hasCamera = false
progress = 0
......
}
总结
本文主要讲述了数字孪生系统中常用Three.js效果的实现原理:
通过自定义着色器处理材质的顶点或片元可以实现建筑颜色渐变、围栏光效、雷达扫描、流动光效等效果;
通过设置相机与物体的层级关系可以进而实现沿路径漫游。
相信掌握了原理,你也能根据实际的业务需求实现更出彩的3D效果。
本文使用的Three.js版本为r150。 本文完整的工程文件:数字孪生系统中常用Three.js效果的实现原理