
三维绘图,数字孪生(Three.js/PlayCanvas)

绘图工具
Canvas 3D(webGL):Three.js、Babylonjs
操控相机
交互:左键拖动(旋转)、右键拖动(上下左右移动)、滚轮滚动(前进后退)、按键wasd(前进后退/左右移动)、双击(转向双击处)
旋转模式
环绕模式:维护一个distance以确定环绕点,左键拖动时旋转相机,设置新的位置(原环绕点 + distance + 新camera.forward 推算出)使得相机始终朝向同一个点(环绕点);滚轮滚动向前时逼近环绕点
原地旋转模式:不维护distance,没有环绕点,左键拖动时相机原地旋转;滚轮滚动前进时没有限制
操控方向:鼠标拨动模式(场景往鼠标移动的方向流动),鼠标延展模式(场景从鼠标移动的方向展开)
操控方式:2种旋转模式*6种纬度组合(屏幕2纬度分给空间3纬度)*4种方向组合 = 48种方式
最佳鼠标控制:camera.rotateLocal(-dy * rotationSpeed, -dx * rotationSpeed, 0) ,采用原地旋转模式,避免相机位置变化;上下控制俯仰[绕相机X轴],左右控制航向[绕相机Y轴],避免相机翻滚[绕相机Z轴];采用鼠标动模模式 更符合直觉
Three.js
概念
indices 顶点
PBR 基于物理的渲染
贴图库 poliigon
资源:Threejs/examples/js
Float32Array 32位浮点数数组
几何体
BufferGeometry 几何体,每三个点组成一个三角形面
attributes.position.count 顶点数量,多个三角形面之间重合的顶点分别算
attributes.position.array 顶点坐标数组,一个顶点占三个轴坐标
attributes.position.uv 几何体展开图,用于确定贴图位置
attributes.position.normal 确定姿态
BoxGeometry 立方体 attributes.position.count 是24,估计是顶点复用
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="three.min.js"></script>
<!-- threejs项目源码中 /examples/js 下有很多插件 -->
<script src="../examples/js/controls/OrbitControls.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.10.4/gsap.min.js"></script>
</head>
<body>
<script>
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
// 场景
const scene = new THREE.Scene();
// 物体
const geometry = new THREE.BoxGeometry(100, 150, 200); // 几何体
const material = new THREE.MeshLambertMaterial({ // 材质
color: 0x00ff00,
transparent: true,
opacity: 0.6
});
const mesh = new THREE.Mesh(geometry, material); // 物体(由几何体和材质确定)
mesh.position.set(0, 0, 100); // 物体质心的位置
mesh.scale.set(2, 1, 3); // 缩放
/* 参考世界空间坐标系旋转,绕着穿过质心的轴线(平行于世界坐标系的轴)旋转,设置最终旋转弧度 */
// 无论书写顺序如何,都是先绕Z轴,再绕Y轴,再绕X轴
// 观察者朝轴正方向观察,物体绕轴顺时针转动的弧度
mesh.rotation.x = Math.PI / 4;
mesh.rotation.y = Math.PI / 4;
mesh.rotation.z = Math.PI / 4;
/* 参考局部空间坐标系旋转,即以穿过质心的轴线(平行于世界坐标系的轴)为初始参考轴线;参考轴线随物体旋转,累加旋转 */
mesh.rotateX(Math.PI / 4);
mesh.rotateY(Math.PI / 4);
mesh.rotateZ(Math.PI / 4);
/* 参考局部空间坐标系旋转,即以穿过质心的向量为参考轴线;参考轴线随物体旋转,累加旋转 */
// 适用于欧拉角位姿(yaw,pitch,roll)
carMesh.rotateOnAxis(new THREE.Vector3(0, 0, 1), yaw);
carMesh.rotateOnAxis(new THREE.Vector3(0, 1, 0), pitch);
carMesh.rotateOnAxis(new THREE.Vector3(1, 0, 0), roll);
scene.add(mesh); // 往场景里添加物体
// 光源
const light = new THREE.PointLight(0xffffff, 1, 10000); // 点光源
light.position.set(300, 400, 500); // 光源位置
scene.add(light);
// 坐标轴
const axesHelper = new THREE.AxesHelper(500); // x红 y绿 z蓝
scene.add(axesHelper);
// 可视化点光源
const pointLightHelper = new THREE.PointLightHelper(light, 1);
scene.add(pointLightHelper);
// 透视相机(fov水平视场角,fov和aspect间接确定了垂直视场角,near和far确定了相机观察的距离区间)
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(600, 600, 600); // 相机位置
// 拍摄目标,即朝向;或者用camera.up.set(0, 1, 0);
camera.lookAt(0, 0, 0);
/* 相机姿态:
1、相机鼻线、视线会与穿过target且平行于Y轴的轴线在同一个平面,且鼻线正方向,指向Y轴正方向
2、如果视线指向Y轴正方向,此时鼻线垂直于Y轴,则鼻线指向Z轴正方向
3、如果视线指向Y轴负方向,此时鼻线垂直于Y轴,则鼻线指向Z轴负方向
*/
// 渲染器,即canvas画布
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight); // canvas 尺寸,单位为像素,与场景里的尺寸无关
renderer.setClearColor(0xffffff); // 画布颜色
renderer.render(scene, camera);
document.body.appendChild(renderer.domElement); // 将canvas加入到 dom
// 相机控制器,改变的是相机的位置
// 滚轮,改变相机位置-朝向保持不变(即相机在视线上移动,始终朝向拍摄目标)
// 鼠标右键拖动,平移,改变拍摄目标
// 鼠标左键拖动,改变相机位置-与拍摄目标距离保持不变(即相机在球面上移动,始终朝向拍摄目标)
// 左键左右拖动,场景水平旋转,即绕Y轴旋转
// 右键上下拖动,场景垂直旋转,即绕穿过target且平行于相机双眼线的轴旋转
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0); // 拍摄目标,即朝向
controls.update(); // 会覆盖 camera.lookAt
controls.addEventListener("change", () => {
renderer.render(scene, camera); // 移动相机后,重新渲染画布
});
// 动画
const clock = new THREE.Clock();
function animate() {
// console.log(clock.getDelta()); // 间隔时间,用于获取渲染耗时
renderer.render(scene, camera);
mesh.rotateY(0.01);
window.requestAnimationFrame(animate)
}
animate();
// GSAP 动画库
let animate1 = gsap.to(mesh.position, {
x: 300,
duration: 5,
ease: "bounce.inOut", // 速度曲线
delay: 2,
repeat: 2,
yoyo: true, // 往返
onStart: ()=>{
console.log('动画开始');
},
onComplete: () => {
console.log('动画结束');
}
});
window.addEventListener('click', (event) => {
if(animate1.isActive){
animate1.pause(); // 暂停动画
}
else{
animate1.resume(); // 恢复动画
}
});
// 画布点投射,即画布上的一点沿着视锥线画一条射线;用于寻找与射线交汇的物体,即鼠标拾取,进而实现交互;透明Mesh可拾取,Group不可拾取
window.addEventListener('click', (event) => {
const pointer = new THREE.Vector2(); // 画布点
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster(); // 射线
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(scene.children); // 找出与射线交汇的物体
for (let i = 0; i < intersects.length; i++) {
console.log(intersects[i]);
}
});
/* webgl坐标转画布坐标,画布外的webgl坐标仍然有效 */
function webgl2screen(webglVector) {
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const standardVector = webglVector.project(camera);
const screenX = Math.round(centerX * standardVector.x + centerX);
const screenY = Math.round(-centerY * standardVector.y + centerY);
return new THREE.Vector2(screenX, screenY);
}
/* 加载3D模型 */
let loader = new GLTFLoader();
loader.load(`https://**.glb`, (gltf) => {
// 如果比较暗淡,需要 自发光处理
gltf.scene.traverse(function (child) {
if (child.isMesh) {
child.material.emissive = child.material.color;
child.material.emissiveMap = child.material.map;
}
});
// 如果C4D的设计单位是mm,导出比率是1米,即设计稿里的1米为glb里的1单位长度;例如设计长度为4000mm,glb里为4单位长度
scene.add(gltf.scene);
});
// 释放资源
function clear(){
// 递归遍历所有后代
scene.traverse(function(obj) {
if (obj.type === 'Mesh') {
obj.geometry.dispose();
obj.material.dispose();
}
});
scene.remove(...scene.children);
}
/* 计算 */
let box = new THREE.Box3().setFromObject(mesh);
console.log(box.max.x - box.min.x); // 物体的坐标范围
// 视觉尺寸保持,传入需要保持的视角大小
function getSizeByDeg(deg) {
// 等腰三角形的底边垂线h,底边l,底边对角rad,tan(rad/2)*h=l/2
let rad = THREE.MathUtils.degToRad(deg); // 角度转弧度
let h = camera.position.z;
let l = Math.tan(rad / 2) * h * 2;
return l;
}
// 俯视一个物体及其周边,横向前后100
fitViewToMesh(mesh) {
// 求出纵向
let y = 100 * (renderer.domElement.clientHeight / renderer.domElement.clientWidth);
// fov是视场纵向角度
let z = y / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
camera.position.set(mesh.position.x, mesh.position.y, z + mesh.position.z);
camera.lookAt(mesh.position.x, mesh.position.y, mesh.position.z);
},
</script>
</body>
</html>
相机控制器选择
DragControls/TransformControls:不适用
FirstPersonControls:问题(鼠标键盘操作没有任何反应)
PointerLockControls:鼠标移动-旋转,问题(不能前后移动)
ArcballControls/TrackballControls/OrbitControls:鼠标左键拖动-旋转,鼠标右键拖动/键盘操作-左右/上下移动,鼠标滚动-缩放类似于前后移动,问题(鼠标滚动放大会在焦点处终止,不会一直向前)
FlyControls:鼠标左键点击-向前,鼠标右键点击-向后,鼠标不在中心点-旋转,键盘方向键-旋转,问题(鼠标不在中心点时出现旋转)
MapControls:鼠标左键拖动-左右/前后移动,鼠标右键拖动-旋转,鼠标滚动-缩放类似于前后移动
向量
let vector1 = new THREE.Vector3(1, 0, 0);
let vector2 = new THREE.Vector3(0, 1, 0);
vector1.angleTo(vector2); // 向量之间的夹角
vector1.distanceTo(vector2); // 两个点之间的距离
// 向量绕着指定穿过世界坐标原点的轴旋转
vector.applyAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI/2);
// 向量1 转到与 向量2 平行,所需位姿调整(yaw,pitch)
function getYawAndPitch(vector1, vector2) {
// vector2在 XY 平面上的投影
let projectionVector = new THREE.Vector3(vector2.x, vector2.y, 0);
// vector2 投影 与 vector1 的夹角
let yaw = vector1.angleTo(projectionVector);
// yaw 是 观察者朝z轴正方向观察,物体绕z轴顺时针转动的弧度
yaw = vector2.y > 0 ? yaw : 2 * Math.PI - yaw;
// vector2 的XY平面投影,与自身的夹角
let pitch = projectionVector.angleTo(vector2);
// pitch 是 观察者朝y轴正方向观察,物体绕y轴顺时针转动的弧度
pitch = vector2.z < 0 ? pitch : 2 * Math.PI - pitch;
return {
yaw,
pitch,
};
}
组
let group = new THREE.Group();
// 世界坐标转局部坐标
let localPoint = group.worldToLocal(new THREE.Vector3(x, y, z));
// 局部坐标转世界坐标
let worldPosition = group.localToWorld(new THREE.Vector3(x, y, z));
正交相机
/* 保持正交视场长宽比与画布一致,物体才不会变形 */
let width = 200;
let height = width * (canvas_wrap.clientHeight / canvas_wrap.clientWidth);
// 以camera.position为原点,垂直于camra.up,画一个矩形;参数值都是相对于原点
camera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, 0.01, 10000);
// 滚轮控制的是 camera.zoom
controls = new OrbitControls(camera, renderer.domElement);
// 视觉尺寸保持,传入需要保持的画布占比
getSizeByPercent(percent) {
let width = (camera.right - camera.left) / camera.zoom;
return width * percent;
},
// 俯视一个物体及其周边,横向前后100
fitViewToMesh(mesh) {
camera.zoom = 1;
// 横向前后100米
let width = 200;
camera.left = -width / 2;
camera.right = width / 2;
// 正交视场长宽比与画布保持一致,物体才不会变形
let height = width * (canvas_wrap.clientHeight / canvas_wrap.clientWidth);
camera.top = height / 2;
camera.bottom = -height / 2;
camera.position.set(mesh.position.x, mesh.position.y, mesh.position.z + 100);
camera.updateProjectionMatrix();
controls.target.set(mesh.position.x, mesh.position.y, mesh.position.z);
controls.update();
},
截图
camera.updateProjectionMatrix();
renderer.clear();
renderer.render(scene, camera);
const dataURL = renderer.domElement.toDataURL('image/png');
方案
一、给 圆CircleGeometry 包边
1、边缘几何体EdgesGeometry,设置线宽无效
2、椭圆曲线EllipseCurve,设置线宽无效
3、圆环几何体RingGeometry、TorusGeometry
二、给矩形描边,解决 THREE.Line 设置 linewidth 无效的问题
import { Line2 } from 'three/examples/jsm/lines/Line2';
import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry';
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial';
const planeMeshBox = new THREE.Box3().setFromObject(planeMesh);
const pointArr = [
areaMeshBox.min.x,
areaMeshBox.max.y,
0,
areaMeshBox.max.x,
areaMeshBox.max.y,
0,
areaMeshBox.max.x,
areaMeshBox.min.y,
0,
areaMeshBox.min.x,
areaMeshBox.min.y,
0,
areaMeshBox.min.x,
areaMeshBox.max.y,
0,
];
const geometry = new LineGeometry();
geometry.setPositions(pointArr); // 不能用edge_geom.setFromPoints()
let material = new LineMaterial({ linewidth: 5 });
material.resolution.set(window.innerWidth, window.innerHeight); // 这一句必须有
const lineMesh = new Line2(geometry, material);
裁剪
// 用于裁剪的平面
// 第一个参数必须是单位向量,即.normalize()过
// 第二个参数为平面到原点的距离,正负以法向量方向为基准
const plane = new THREE.Plane(new THREE.Vector3(1,0,0), -Math.sqrt(2));
const geometry = new THREE.CylinderGeometry( 2, 2, Math.sqrt(8), 64);
// clippingPlanes 缺点:裁剪平面为世界坐标系里的平面,物体移动旋转时,裁剪平面也要相应处理,否则裁剪部分变化
const material = new THREE.MeshBasicMaterial( {color: 0x00ffff, clippingPlanes: [plane] } );
const cylinder = new THREE.Mesh( geometry, material );
案例
1、视锥体,由圆柱侧面+2个圆锥内表面+2个三角形 组成
可进行视场角、视场宽高比、位姿 调整
// 摄像机视锥体,已知 水平fov,图像宽高比
let cameraVisionCone = (horizontal_fov_deg, image_size_x, image_size_y) => {
let horizontal_fov = THREE.MathUtils.degToRad(horizontal_fov_deg);
let fovSign = horizontal_fov > Math.PI ? Math.PI * 2 - horizontal_fov : horizontal_fov;
// 视锥横向三角的底边
let width = horizontal_fov > Math.PI ? radius * 2 : radius * Math.sin(fovSign / 2) * 2;
// 视锥体前弧面的高度
let height = (width * image_size_y) / image_size_x;
return getVisionCone(horizontal_fov_deg, height / 2, height / 2);
};
// 毫米波雷达视锥体,已知 水平fov,垂直fov
let getRadarSensor = (horizontal_fov_deg, vertical_fov_deg) => {
let vertical_fov_half = THREE.MathUtils.degToRad(vertical_fov_deg / 2);
// 视锥体前弧面的高度
let height_half = Math.tan(vertical_fov_half) * radius;
return getVisionCone(horizontal_fov_deg, height_half, height_half);
};
// 激光雷达视锥体,已知 水平fov,上下垂直fov
let lidarVisionCone = (horizontal_fov_deg, upper_fov_deg, lower_fov_deg) => {
let upper_fov = THREE.MathUtils.degToRad(upper_fov_deg);
let lower_fov = THREE.MathUtils.degToRad(lower_fov_deg);
// 视锥体前弧面的高度
let height_upper = Math.tan(upper_fov) * radius;
let height_lower = Math.tan(lower_fov) * radius;
return getVisionCone(horizontal_fov_deg, height_upper, height_lower);
};
let getVisionCone = (horizontal_fov_deg, height_upper, height_lower) => {
let radius = 5;
let horizontal_fov = THREE.MathUtils.degToRad(horizontal_fov_deg);
let fovSign = horizontal_fov > Math.PI ? Math.PI * 2 - horizontal_fov : horizontal_fov;
const group = new THREE.Group();
let material = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, opacity });
let edgeMaterial = new THREE.LineBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, opacity: opacity + 0.2 });
// 视锥体前弧面
let geometry = new THREE.CylinderGeometry(radius, radius, height_upper + height_lower, 64, 1, true, (Math.PI - horizontal_fov) / 2, horizontal_fov);
const cylinder = new THREE.Mesh(geometry, material);
cylinder.position.y = (height_upper - height_lower) / 2;
group.add(cylinder);
// 补上圆锥内表面
geometry = new THREE.ConeGeometry(radius, height_upper, 64, 1, true, (Math.PI - horizontal_fov) / 2, horizontal_fov);
let cone = new THREE.Mesh(geometry, material);
cone.rotateOnAxis(new THREE.Vector3(1, 0, 0), Math.PI);
cone.position.y = height_upper / 2;
group.add(cone);
// 补下圆锥内表面
geometry = new THREE.ConeGeometry(radius, height_lower, 64, 1, true, (Math.PI - horizontal_fov) / 2, horizontal_fov);
cone = new THREE.Mesh(geometry, material);
cone.position.y = -height_lower / 2;
group.add(cone);
// 描边
let curve = new THREE.EllipseCurve(0, 0, radius, radius, (Math.PI - horizontal_fov) / 2, (Math.PI + horizontal_fov) / 2);
geometry = new THREE.BufferGeometry().setFromPoints(curve.getPoints(64));
let ellipse = new THREE.Line(geometry, edgeMaterial);
ellipse.rotateOnAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2);
ellipse.rotateOnAxis(new THREE.Vector3(0, 0, 1), -Math.PI / 2);
ellipse.position.y = height_upper;
group.add(ellipse);
ellipse = ellipse.clone();
ellipse.position.y = -height_lower;
group.add(ellipse);
// 补侧面三角形
if(horizontal_fov_deg < 360){
let xSign = horizontal_fov > Math.PI ? -1 : 1;
geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
0,
0,
0,
Math.cos(fovSign / 2) * radius * xSign,
height_upper,
Math.sin(fovSign / 2) * radius,
Math.cos(fovSign / 2) * radius * xSign,
-height_lower,
Math.sin(fovSign / 2) * radius,
0,
0,
0,
Math.cos(fovSign / 2) * radius * xSign,
height_upper,
-Math.sin(fovSign / 2) * radius,
Math.cos(fovSign / 2) * radius * xSign,
-height_lower,
-Math.sin(fovSign / 2) * radius,
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
let mesh = new THREE.Mesh(geometry, material);
group.add(mesh);
// 描边
let edge = new THREE.EdgesGeometry(geometry);
let line = new THREE.LineSegments(edge, edgeMaterial);
group.add(line);
}
group.rotateOnAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2);
const result = new THREE.Group();
result.add(group);
return result;
};
2、PCD点云播放
<template>
<div style="height: 100%; width: 100%; position: relative" ref="pcd_canvas">
<div class="pcd-control">
<div class="btn" @click="toggleStop">
<svg class="icon" aria-hidden="true" v-if="pcdStop">
<use xlink:href="#icon-run"></use>
</svg>
<svg class="icon" aria-hidden="true" v-else>
<use xlink:href="#icon-pause"></use>
</svg>
</div>
<el-slider v-model="curIndex" :min="0" :max="pcdTiming.length" :show-tooltip="false" @change="pcdSliderChange" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import * as THREE from 'three';
import { PCDLoader } from './PCDLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
onMounted(() => {
fetchPcdList();
});
let pcd_canvas = ref();
let canvas_width = 857;
let canvas_height = 484;
let cameraFov = 90;
const scene = new THREE.Scene();
const pcdLoader = new PCDLoader();
const camera = new THREE.PerspectiveCamera(cameraFov, canvas_width / canvas_height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(canvas_width, canvas_height);
renderer.render(scene, camera);
const controls = new OrbitControls(camera, renderer.domElement);
controls.minAzimuthAngle = 0;
controls.maxAzimuthAngle = 0;
controls.minPolarAngle = Math.PI / 2;
controls.maxPolarAngle = Math.PI;
let pcdTiming = ref([]); // pcd文件路径列表
let handControl = false;
let pcdStop = ref(false);
let curIndex = ref(0);
let preLoadIndex = 0;
let preFrameTime;
let fetchPcdList = () => {
handControl = false;
pcdStop.value = false;
curIndex.value = 0;
preLoadIndex = 0;
pcdCache = {};
fetch(pcd_list_url).then((response) => {
response.json().then((data) => {
pcdTiming.value = data.timing;
preLoad();
renderPCD();
});
});
};
let renderPCD = () => {
pcd_canvas.value.appendChild(renderer.domElement);
controls.addEventListener('change', () => {
handControl = true;
renderer.render(scene, camera);
});
animationPCD();
};
let animationPCD = () => {
if (pcdStop.value) {
return;
}
if (curIndex.value >= pcdTiming.value.length) {
pcdStop.value = true;
return;
}
if (!preFrameTime || new Date().getTime() - preFrameTime >= 50) {
if (pcdCache[curIndex.value]) {
updateScene(pcdCache[curIndex.value]);
} else {
requestAnimationFrame(animationPCD);
}
} else {
requestAnimationFrame(animationPCD);
}
};
let updateScene = (pcd) => {
scene.clear();
pcd = pcd.clone(); // 重复使用时避免重复旋转
// PCD点云的初始朝向为 X 轴正方向,相机的头顶方向为 Y 轴正方向,需要把点云转到 Y 轴正方向上
pcd.rotateOnAxis(new THREE.Vector3(0, 0, 1), -Math.PI / 2);
if (!handControl) {
let bbox = new THREE.Box3().setFromObject(pcd);
// 纵向离原点的最大距离
let maxY = Math.max(Math.abs(bbox.max.y), Math.abs(bbox.min.y));
// 横向离原点的最大距离
let maxX = Math.max(Math.abs(bbox.max.x), Math.abs(bbox.min.x));
// 纵向能看到这个距离才能横向和纵向都看全
maxY = Math.max(maxY, maxX * (canvas_height / canvas_width));
// 相机在这个位置才能看全横向和纵向
let cameraZ = maxY / Math.tan(THREE.MathUtils.degToRad(cameraFov) / 2);
// 垂向也看全
cameraZ = cameraZ + bbox.max.z;
camera.position.set(0, 0, cameraZ);
controls.target.set(0, 0, 0);
}
scene.add(pcd);
controls.update();
renderer.render(scene, camera);
preFrameTime = new Date().getTime();
curIndex.value++;
requestAnimationFrame(animationPCD);
};
let toggleStop = () => {
pcdStop.value = !pcdStop.value;
if (!pcdStop.value) {
animationPCD();
}
};
let pcdSliderChange = () => {
// 之前的预加载是否已结束
if(preLoadIndex >= pcdTiming.value.length) {
preLoadIndex = curIndex.value;
preLoad();
}
else {
preLoadIndex = curIndex.value;
}
};
// 预加载 pcd
let preLoad = () => {
if (preLoadIndex >= pcdTiming.value.length) {
return;
}
if (pcdCache[preLoadIndex]) {
preLoadIndex++;
preLoad();
} else {
let index = preLoadIndex;
let pcdItem = pcdTiming.value[index];
pcdLoader.load(pcdItem.url, (pcd) => {
pcdCache[index] = pcd;
// 拖动进度条时,preLoadIndex 发生改变
if (index === preLoadIndex) {
preLoadIndex++;
}
preLoad();
});
}
};
</script>
<style lang="scss" scoped>
.pcd-control {
position: absolute;
width: 100%;
height: 50px;
bottom: 0;
background: rgba(0, 0, 0, 0);
padding: 5px 10px;
display: flex;
align-items: center;
.btn {
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 50%;
color: #fff;
text-align: center;
line-height: 40px;
margin-right: 20px;
&:hover {
background: #151515;
}
}
}
}
</style>
PCDLoader.js
if ( PCDheader.data === 'ascii' ) {
const offset = PCDheader.offset;
const pcdData = textData.slice( PCDheader.headerLen );
const lines = pcdData.split( '\n' );
// 获取 intensity 存在范围
let minIntensity,maxIntensity;
for ( let i = 0, l = lines.length; i < l; i ++ ) {
if ( lines[ i ] === '' ) continue;
const line = lines[ i ].split( ' ' );
let intensity = parseFloat( line[ offset.intensity ] );
minIntensity = minIntensity === undefined || minIntensity > intensity ? intensity : minIntensity;
maxIntensity = maxIntensity === undefined || maxIntensity < intensity ? intensity : maxIntensity;
}
let intensityRange = maxIntensity - minIntensity;
for ( let i = 0, l = lines.length; i < l; i ++ ) {
if ( lines[ i ] === '' ) continue;
const line = lines[ i ].split( ' ' );
// 根据 intensity 设置亮度和颜色
if ( offset.intensity !== undefined ) {
let intensity = parseFloat( line[ offset.intensity ] );
let intensityWeight = (intensity - minIntensity) / intensityRange;
color.push( intensityWeight );
color.push( 1 - Math.abs(intensityWeight - 0.5) / 0.5 );
color.push( 1 - intensityWeight );
}
}
}
3、PCD点云画3D框
<template>
<div v-loading="pcdLoading" ref="canvas_3d" :style="{ width: canvas3dWidth + 'px', height: canvas3dHeight + 'px' }"></div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import * as THREE from 'three';
import { PCDLoader } from './PCDLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
let pcdUrl = '';
let jsonUrl = ''; // 框数据
let canvas3dWidth = ref(800);
let canvas3dHeight = ref(450);
let cameraFov = 90;
let canvas_3d = ref();
let pcdLoading = ref(false);
let scene, renderer, camera, controls;
const pcdLoader = new PCDLoader();
let renderPcdJson = (row) => {
pcdLoading.value = true;
if( !scene ) {
scene = new THREE.Scene();
renderer = new THREE.WebGLRenderer();
camera = new THREE.PerspectiveCamera(cameraFov, canvas3dWidth.value / canvas3dHeight.value, 0.1, 10000);
renderer.setSize(canvas3dWidth.value, canvas3dHeight.value);
renderer.render(scene, camera);
controls = new OrbitControls(camera, renderer.domElement);
controls.minAzimuthAngle = 0;
controls.maxAzimuthAngle = 0;
controls.minPolarAngle = Math.PI / 2;
controls.maxPolarAngle = Math.PI;
controls.addEventListener('change', () => {
renderer.render(scene, camera);
});
canvas_3d.value.appendChild(renderer.domElement);
}
scene.clear();
let pcdPro = new Promise((resolve, reject) => {
pcdLoader.load(pcdUrl, (pcd) => {
resolve(pcd);
});
});
let jsonPro = new Promise((resolve, reject) => {
fetch(jsonUrl).then((res) => {
res.json().then((result) => {
resolve(result);
});
});
});
Promise.all([pcdPro, jsonPro]).then((result) => {
let pcd = result[0];
let json = result[1];
canvas_3d.value.appendChild(renderer.domElement);
const group = new THREE.Group();
group.add(pcd);
json.forEach((boxObj) => {
let box = boxObj.corners;
let material = new THREE.LineBasicMaterial({ color: '#FF0000' });
let points = [
new THREE.Vector3(box[0][0], box[0][1], box[0][2]),
new THREE.Vector3(box[1][0], box[1][1], box[1][2]),
new THREE.Vector3(box[2][0], box[2][1], box[2][2]),
new THREE.Vector3(box[3][0], box[3][1], box[3][2]),
];
let line = new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [
new THREE.Vector3(box[4][0], box[4][1], box[4][2]),
new THREE.Vector3(box[5][0], box[5][1], box[5][2]),
new THREE.Vector3(box[6][0], box[6][1], box[6][2]),
new THREE.Vector3(box[7][0], box[7][1], box[7][2]),
];
line = new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [new THREE.Vector3(box[0][0], box[0][1], box[0][2]), new THREE.Vector3(box[4][0], box[4][1], box[4][2])];
line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [new THREE.Vector3(box[1][0], box[1][1], box[1][2]), new THREE.Vector3(box[5][0], box[5][1], box[5][2])];
line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [new THREE.Vector3(box[2][0], box[2][1], box[2][2]), new THREE.Vector3(box[6][0], box[6][1], box[6][2])];
line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [new THREE.Vector3(box[3][0], box[3][1], box[3][2]), new THREE.Vector3(box[7][0], box[7][1], box[7][2])];
line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
});
scene.add(group);
// PCD点云的初始朝向为 X 轴正方向,相机的头顶方向为 Y 轴正方向,需要把点云转到 Y 轴正方向上
group.rotateOnAxis(new THREE.Vector3(0, 0, 1), -Math.PI / 2);
let bbox = new THREE.Box3().setFromObject(pcd);
// 纵向离原点的最大距离
let maxY = Math.max(Math.abs(bbox.max.y), Math.abs(bbox.min.y));
// 横向离原点的最大距离
let maxX = Math.max(Math.abs(bbox.max.x), Math.abs(bbox.min.x));
// 纵向能看到这个距离才能横向和纵向都看全
maxY = Math.max(maxY, maxX * (canvas3dWidth.value / canvas3dHeight.value));
// 相机在这个位置才能看全横向和纵向
let cameraZ = maxY / Math.tan(THREE.MathUtils.degToRad(cameraFov) / 2);
camera.position.set(0, 0, cameraZ + bbox.max.z);
controls.target.set(0, 0, 0);
controls.update();
renderer.render(scene, camera);
pcdLoading.value = false;
});
};
</script>
4、GLB 转 OBJ
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js';
import { SimplifyModifier } from 'three/examples/jsm/modifiers/SimplifyModifier.js';
new GLTFLoader().load(glb_url, (gltf) => {
// 尺寸修复,导出尺寸受 mesh.children[0].scale 影响
let bbox = new THREE.Box3().setFromObject(mesh);
let originScale = mesh.children[0].scale;
originScale = Math.max(originScale.x, originScale.y, originScale.z);
// 未知原因,导出尺寸 不一定受 mesh.children[0].scale 影响,通过合理尺寸区分
let scale = bbox.max.x - bbox.min.x > 0.2 ? 1 : 1 / originScale;
mesh.scale.set(scale, scale, scale);
// 缩放旋转调整 后 需要 scene.add 并 renderer.render ,才能对导出的obj生效
gltf.scene.rotateX(Math.PI / 2);
scene.add(mesh);
renderer.render(scene, camera);
// 减面
gltf.scene.traverse((child) => {
if (child.isMesh) {
try {
const count = Math.ceil(Math.sqrt(child.geometry.attributes.position.count));
const simplifiedGeometry = new SimplifyModifier().modify(child.geometry, count);
child.geometry.dispose();
child.geometry = simplifiedGeometry;
} catch(e){}
}
});
const exporter = new OBJExporter();
let obj = exporter.parse(mesh);
console.log(obj);
});
5、生成GLB缩略图
let ambientLight = new THREE.AmbientLight(0xffffff, 1); // 自然光
let directionalLight = new THREE.DirectionalLight(0xffffff, 1); // 平行光
new GLTFLoader().load(glb_url, (gltf) => {
// 需要用原模型,clone() 可能导致尺寸变化
let mesh = gltf.scene;
scene.add(ambientLight);
scene.add(directionalLight);
// scene.add(new THREE.AxesHelper(50)); // 坐标轴 辅助
// 假设原模型为一辆车,位姿为:平底平行并贴于XZ平面,车头朝向X轴正方向,车底中心位于坐标轴原点
mesh.rotateY(Math.PI * 1.25);
scene.add(mesh);
// bbox 不受 rotate 影响,但受 mesh.children[0].scale的影响
let bbox = new THREE.Box3().setFromObject(mesh);
let originScale = mesh.children[0].scale;
originScale = Math.max(originScale.x, originScale.y, originScale.z);
// 未知原因,bbox 不一定受 mesh.children[0].scale 影响,通过合理尺寸区分
let scale = bbox.max.x - bbox.min.x > 0.2 ? 1 : 1 / originScale;
camera.position.set(0, bbox.max.y * scale, bbox.max.x * scale * 1.25);
camera.lookAt(0, 0, 0);
let position = camera.position.clone();
directionalLight.position.set(position.x, position.y, position.z);
renderer.render(scene, camera);
let dataUrl = renderer.domElement.toDataURL('image/png');
});
PlayCanvas
官方例子:https://playcanvas.vercel.app/
疑似bug
1、相机的 position 和 lookAt 的 target 得有两个纬度不一样,camera.forward才能正常,否则就是默认的{0,0,-1}
加载 .obj .glb
window.pc = pc // 在 obj-model.js 中会用到
// 来源于 playcanvas/engine 项目 scripts/parsers/obj-model.js
app.assets.loadFromUrl('obj-model.js', 'script', () => {
// 注册 obj 解析器
const parser = new window.ObjModelParser(app.graphicsDevice)
app.loader.getHandler('model').addParser(parser , (url) => {
return pc.path.getExtension(url) === '.obj'
})
})
loadOBJ() {
let asset = new pc.Asset('obj', 'model', {
url: 'male02.obj'
})
app.assets.add(asset)
app.assets.load(asset)
asset.on('load', () => {
const entity = new pc.Entity()
entity.addComponent('model')
entity.model.model = asset.resource
scene.addChild(entity)
})
})
},
loadGLB() {
let asset = new pc.Asset('glb', 'container', { url: `cangfang_1.glb` })
const assetListLoader = new pc.AssetListLoader([asset], app.assets)
assetListLoader.load(() => {
const entity = asset.resource.instantiateRenderEntity({})
app.root.addChild(entity)
})
}
相机控制器
orbitControl() {
window.pc = pc
// 来自于 playcanvas/engine 项目的 scripts/camera/orbit-camera.js
const asset = new pc.Asset('script', 'script', { url: `orbit-camera.js` })
const assetListLoader = new pc.AssetListLoader([asset], app.assets)
assetListLoader.load(() => {
camera.addComponent('script')
camera.script.create('orbitCamera', {
attributes: {
inertiaFactor: 0.2,
distanceMax: 190,
frameOnStart: false
}
})
camera.script.create('orbitCameraInputMouse')
})
}




更多推荐









所有评论(0)