远算前端团队,主要分享一些WEBGL、Three.js等三维技术,团队主要做数字孪生相关的数字大屏,项目一般涉及仿真、后处理,以及三维可视化。
欢迎关注公众号 远算前端团队
三维渲染
1.1 过场动画
海上风机数量很多,如何实现不同风机的切换呢?效果视频如下: 哔哩哔哩
实现逻辑:
// 初始 相机位置 & 控制中心位置
let oldP = this.camera.position;
let oldT = this.controls.target;
// 要过渡到的 相机位置 & 控制中心位置
let newP = new THREE.Vector3(
currentModel.position.x + this.windmillPos.latD + this.cameraPos.x,
currentModel.position.y + this.cameraPos.y,
currentModel.position.z + this.windmillPos.longD + this.cameraPos.z,
);
let newT = new THREE.Vector3(
currentModel.position.x + this.windmillPos.latD,
currentModel.position.y,
currentModel.position.z + this.windmillPos.longD,
);
// 动画逻辑
this.updateCamera(oldP, oldT, newP, newT, this.tweenEnd);
updateCamera(
oldP: THREE.Vector3,
oldT: THREE.Vector3,
newP: THREE.Vector3,
newT: THREE.Vector3,
callBack: Function | null = null,
) {
this.tween = new TWEEN.Tween({
x1: oldP.x, // 相机x
y1: oldP.y, // 相机y
z1: oldP.z, // 相机z
x2: oldT.x, // 控制点的中心点x
y2: oldT.y, // 控制点的中心点y
z2: oldT.z, // 控制点的中心点z
})
.to(
{
x1: newP.x,
y1: newP.y,
z1: newP.z,
x2: newT.x,
y2: newT.y,
z2: newT.z,
},
2000,
)
.onUpdate((object) => {
this.camera.position.x = object.x1;
this.camera.position.y = object.y1;
this.camera.position.z = object.z1;
this.camera.updateMatrixWorld();
this.controls.target.x = object.x2;
this.controls.target.y = object.y2;
this.controls.target.z = object.z2;
this.controls.update();
})
.easing(TWEEN.Easing.Cubic.InOut)
.start()
.onComplete(() => {
this.eventFlag = false;
this.controls.enabled = true;
this.tween.stop();
this.tween = null;
callBack && callBack.call(this);
});
}
复制代码
1.2 3D精灵图
每个风机的序号是通过精灵图实现
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
// ...画图逻辑...
var texture = new THREE.Texture(canvas);
texture.needsUpdate = true;
var spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true,
});
var sprite = new THREE.Sprite(spriteMaterial);
sprite.position.set(
windmill.position.x,
windmill.position.y + 80 * this.scale,
windmill.position.z,
);
sprite.center.set(0.5, 0);
sprite.scale.set(20 * this.scale, 10 * this.scale, 1.0);
复制代码
1.3 海缆
pointArr = [point1,point2]
const flowingLineTexture = await this.textureLoader.load(
'/images/texture/arrow2.svg',
);
flowingLineTexture.wrapS = THREE.RepeatWrapping;
flowingLineTexture.wrapT = THREE.RepeatWrapping;
flowingLineTexture.repeat.set(30, 1); //水平重复30次
// flowingLineTexture.needsUpdate = true;
// 曲线管道
let material = new THREE.MeshBasicMaterial({
map: flowingLineTexture,
side: THREE.BackSide, //显示背面
// transparent: true,
// opacity: 0.99,
});
let curve = new THREE.CatmullRomCurve3(pointArr);
let tubeGeometry = new THREE.TubeGeometry(curve, 60, 4);
let mesh = new THREE.Mesh(tubeGeometry, material);
animate(){
mesh.material.map.offset.x += 0.01;
this.animateID = requestAnimationFrame(this.animate.bind(this));
}
复制代码
1.4 水面掏洞
为了实现每个风机基桩冲刷的效果,当每个风机聚焦的时候,通过水面掏洞,覆盖上云图实现冲刷效果
// 依赖genThreeBsp函数库
import genThreeBsp from './genThreeBsp';
const ThreeBSP = genThreeBsp();
// 平面几何体
const geometry = new THREE.PlaneGeometry(options.size, options.size / 2);
let totalMesh = this.createMesh(geometry);
// 圆柱体
let cylinderGeometry = new THREE.CylinderGeometry(
options.radius,
options.radius,
options.height,
options.segments !== undefined ? 100 : options.segments,
);
cylinderGeometry.rotateX(Math.PI / 2);
let subMesh = this.createMesh(cylinderGeometry);
// 进行差集运算
let resultBsp = totalBsp.subtract(subBsp);
// 运算结果生成新的 mesh
const result = resultBsp.toMesh();
result.geometry.computeVertexNormals();
result.geometry.buffersNeedUpdate = true;
result.geometry.uvsNeedUpdate = true;
// 渲染水面
this.water = new Water(result.geometry, {
textureWidth: 512,
textureHeight: 512,
waterNormals: new THREE.TextureLoader().load(
'/images/water/waternormals.jpg',
(texture) => {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
},
),
sunColor: 0xffffff,
waterColor: 0x001e0f,
eye: new THREE.Vector3(1000, 1000, 1000),
distortionScale: 3.7,
});
// 进行水面旋转,否则渲染不正确
this.water.rotateX(-Math.PI / 2);
createMesh(geometry) {
// 创建一个线框纹理
const wireFrameMat = new THREE.MeshBasicMaterial({
opacity: 0.5,
wireframeLinewidth: 0.5,
});
wireFrameMat.wireframe = true;
// 创建模型
const mesh = new THREE.Mesh(geometry, wireFrameMat);
return mesh;
}
复制代码
其他UI渲染
2.1 屏幕适配
针对不同尺寸的屏幕进行了适配,保证设计稿中能显示出的UI元素等比例显示在浏览器窗口
import store from '../store';
import Operator from './Operator';
((window, store, Operator) => {
let screenRatioByDesign = 16 / 9; // 设计稿宽高比
// 延时时间过长时,使用F11切换屏幕全屏到非全屏,页面尺寸来不及进行重置
let delay = 10; // 防抖延时ms
let minWidth = 1200; // 最小宽度
let minHeight = minWidth / screenRatioByDesign;
let grids = 1920; // 页面栅格份数
let designWidth = 1920;
let docEle = document.documentElement;
const setHtmlFontSize = () => {
const clientWidth =
docEle.clientWidth > minWidth ? docEle.clientWidth : minWidth;
const clientHeight =
docEle.clientHeight > minHeight ? docEle.clientHeight : minHeight;
let screenRatio = clientWidth / clientHeight;
let fontSize =
((screenRatio > screenRatioByDesign
? screenRatioByDesign / screenRatio
: 1) *
clientWidth) /
grids;
docEle.style.fontSize = fontSize.toFixed(6) + 'px';
store.rootFont.updateFontSize(+fontSize.toFixed(6));
};
const setWidthRate = () => {
const clientWidth =
docEle.clientWidth > minWidth ? docEle.clientWidth : minWidth;
const widthRate = clientWidth / designWidth;
store.wRate.updateWidthRate(+widthRate.toFixed(6));
};
setHtmlFontSize();
setWidthRate();
let operator1 = new Operator('set_root_font_size');
let operator2 = new Operator('set_width_rate');
window.addEventListener('resize', () => {
operator1.debounce(setHtmlFontSize, delay);
operator2.debounce(setWidthRate);
});
window.addEventListener(
'pageshow',
(e) => {
if (e.persisted) {
operator1.debounce(setHtmlFontSize, delay);
operator2.debounce(setWidthRate);
}
},
false,
);
})(window, store, Operator);
复制代码
2.2 bizCharts 自定义图形API——registerShape
registerShape('interval', 'delta-bar', {
getPoints(pointInfo: { x: number; y: number; y0: number; size: number }) {
let arr: Array<Point> = [];
// pointInfo.y0 = 0.1;
let point1: Point = {
x: pointInfo.x - pointInfo.size / 2,
y: pointInfo.y0,
};
let point2: Point = {
x: pointInfo.x - pointInfo.size / 2,
y:
pointInfo.y - (pointInfo.size / 2) * Math.tan(angle) + pointInfo.y0 < 0
? 0
: pointInfo.y - (pointInfo.size / 2) * Math.tan(angle) + pointInfo.y0,
};
let point3: Point = {
x: pointInfo.x,
y: pointInfo.y + pointInfo.y0,
};
let point4: Point = {
x: pointInfo.x + pointInfo.size / 2,
y:
pointInfo.y - (pointInfo.size / 2) * Math.tan(angle) + pointInfo.y0 < 0
? 0
: pointInfo.y - (pointInfo.size / 2) * Math.tan(angle) + pointInfo.y0,
};
let point5: Point = {
x: pointInfo.x + pointInfo.size / 2,
y: pointInfo.y0,
};
let point6: Point = {
x: pointInfo.x,
y: pointInfo.y0,
};
let point7: Point = {
x: pointInfo.x,
y:
pointInfo.y - pointInfo.size * Math.tan(angle) + pointInfo.y0 < 0
? 0
: pointInfo.y - pointInfo.size * Math.tan(angle) + pointInfo.y0,
};
arr.push(point1, point2, point3, point4, point5, point6, point7);
return arr;
},
draw(cfg: ShapeInfo, container) {
const { points } = cfg;
if (cfg?.data?.deep < maxValue && cfg?.data?.deep >= midValue) {
color = colors[1];
} else if (cfg?.data?.deep < midValue) {
color = colors[2];
} else {
color = colors[0];
}
let path: Array<Array<string | number>> = [];
points?.forEach((item) => {
path.push(['L', item.x, item.y]);
});
path = this.parsePath(path); // 将 0 - 1 转化为画布坐标
let pointsArr = path.map((item: any, index) => {
return item.filter((innerItem) => {
return typeof innerItem !== 'string';
});
});
const group = container.addGroup();
let pointArrLeft = [pointsArr[0], pointsArr[1], pointsArr[6], pointsArr[5]];
group.addShape('polygon', {
attrs: {
points: pointArrLeft,
fill: color[1],
},
});
let pointArrTop = [pointsArr[1], pointsArr[2], pointsArr[3], pointsArr[6]];
group.addShape('polygon', {
attrs: {
points: pointArrTop,
fill: color[0],
},
});
let pointArrRight = [
pointsArr[5],
pointsArr[6],
pointsArr[3],
pointsArr[4],
];
group.addShape('polygon', {
attrs: {
points: pointArrRight,
fill: color[2],
},
});
return group;
},
});
复制代码
2.3 温度计组件
Canvas 元素,两个宽高
- css像素宽高
- canvas画布宽高
let drawer = canvas.current;
const ctx = drawer?.getContext('2d');
// 像素比
let radio = 2;
let style, width, height, tid, image;
let y = ((temp + 50) / 100) * (17 - 139) + 139;
style = window.getComputedStyle(drawer?.parentElement);
drawer.height = height =
parseFloat(style.getPropertyValue('height')) * radio;
drawer.width = width = parseFloat(style.getPropertyValue('width')) * radio;
// ctx?.scale(radio, radio);
const img = new Image();
image = new Image();
img.src = thermometer;
img.onload = () => {
ctx?.drawImage(img, 0, 0, width, height);
drawLine(ctx);
image.src = drawer?.toDataURL('image/webp', 1);
};
const drawLine = (ctx) => {
let lineGradient = ctx.createLinearGradient(
63 * store.rootFont.fontSize * radio,
17 * store.rootFont.fontSize * radio,
63 * store.rootFont.fontSize * radio,
151 * store.rootFont.fontSize * radio,
);
lineGradient.addColorStop(0, '#03386C');
lineGradient.addColorStop(1, '#236FC5');
ctx.strokeStyle = lineGradient;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(
63 * store.rootFont.fontSize * radio,
151 * store.rootFont.fontSize * radio,
);
ctx.lineTo(
63 * store.rootFont.fontSize * radio,
y * store.rootFont.fontSize * radio,
);
ctx.lineWidth = 4 * store.rootFont.fontSize * radio;
ctx.stroke();
复制代码
3D场景性能优化
3.1 清除动画
在组件销毁时一定要清除代码中的 requestAnimationFrame;否则即使当前组件销毁以后,渲染逻辑依然在执行,可能会导致内存溢出;
stop() {
this.tid && clearTimeout(this.tid);
if (this.water) {
this.water.tid && clearTimeout(this.water.tid);
}
this.animateID && cancelAnimationFrame(this.animateID);
}
复制代码
3.2 自定义渲染帧率
const FPS = 50; // 指的是 50帧每秒的情况
const singleFrameTime = 1 / FPS;
animate() {
this.animateID && cancelAnimationFrame(this.animateID);
const delta = this.clock.getDelta(); //获取距离上次请求渲染的时间
timeStamp += delta;
if (timeStamp > singleFrameTime) {
this.render();
timeStamp = timeStamp % singleFrameTime;
}
this.render();
this.animateID = requestAnimationFrame(this.animate.bind(this));
}
复制代码
3.3 模型处理
当渲染模型过大时,可以去除模型中不是重点的结构或者降低模型中面的细分;
3.4 异步执行
异步执行渲染逻辑中耗时或者比较占用性能的步骤,比如水面倒影渲染的计算,需要占用大部分的CPU,可以异步执行该逻辑;
前提:异步执行的逻辑不影响页面数据和状态传递;对页面渲染的影响不大;
欢迎关注公众号 远算前端团队