绘图工具

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;
        // 横向前后100let 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,设置线宽无效

three.js
JavaScript 3D Library.

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')
      })
    }

推荐内容
阅读全文
AI总结
GitHub 加速计划 / th / three.js
8
1
下载
JavaScript 3D Library.
最近提交(Master分支:1 个月前 )
dcabbf73 5 小时前
a3fe811a * Examples: Fix shadows in physics instancing demos. * Clean up. 23 小时前
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐