终于有时间对之前一段时间内积累的内容做一个整理和输出了,先大概介绍下业务流程背景。
最近在做的是一个虚拟仿真的项目,我们单看软件部分。虚拟仿真系统是由unity实现的操作平台,用户可以在该平台中制作加工模型,制作完成后点击上传模型,会调用rpc streaming 接口将obj模型上传到服务器,这部分逻辑代码可以参考 Go实现服务端小文件和大文件的上传(包含http和rpc streaming两种方式+前端源码),上传成功之后,服务端会返回给unity客户端这个模型的存储地址。
勘误
这里需要对上一篇上传大文件的文章做一个勘误。在之前文章中的 grpc 方法我使用的是客户端流式上传接口:
service UploadService {
rpc UploadLarge (stream Chunk) returns (UploadResp); //大文件上传
}
由于业务需要服务端在收到全部分片的文件后,会返回给客户端存储地址,如果只是使用客户端流式上传,这里的服务端返回的地址就无法回传了,所以需要改成双向流,以确保客户端和服务端都可以向对方发送数据流。
service UploadService {
rpc UploadLarge (stream Chunk) returns (stream UploadResp); //大文件上传}
对应的逻辑更改如下:
func (U *UploadServer) UploadLarge(stream pbs.UploadService_UploadLargeServer) error {
var byteArr []byte
var fileName, filePath, nowDate string
var totalSize, receivedSize int64 = 0, 0
sendResp := func(path string) error {
return stream.Send(&pbs.UploadResp{Path: path})
}
for {
chunk, err := stream.Recv()
if err == io.EOF {
// 在 stream.End() 之后,确保不再使用该 stream 对象
return nil
}
if err != nil {
log.Printf("failed to recv: %v", err)
return err
}
// 如果是第一个块,初始化相关变量
if chunk.ChunkIndex == 0 {
nowDate = time.Now().Format("20060102")
filePath = conf.Conf.UploadPath + conf.Conf.UploadAssetUrl + nowDate + "/"
err = utils.IsFolder(filePath)
if err != nil {
return err
}
fileName = primitive.NewObjectID().Hex() + path.Ext(chunk.Name)
totalSize = chunk.TotalSize
byteArr = make([]byte, totalSize)
}
receivedSize += int64(len(chunk.File))
// 添加数据块到byteArr
copy(byteArr[receivedSize:], chunk.File)
// 写入文件
err = ioutil.WriteFile(filePath+fileName, byteArr, 0666)
if err != nil {
log.Printf("write to file fail: %v", err)
return err
}
fileUrl := conf.Conf.UploadAssetUrl + nowDate + "/" + fileName
log.Println("模型上传成功, 模型路径为:"+fileUrl, "fileName=", fileName)
// if err := stream.SendAndClose(&pbs.UploadResp{Path: fileUrl}); err != nil {
// return err
// }
// 发送响应
if err := sendResp(fileUrl); err != nil {
return err
}
}
}
OK,我们现在已经在客户端成功收到了能够正常解析模型地址的url,当用户模型作品制作加工完成,点击提交后触发生成报告接口,这时客户端会将模型路径和加工过程中其他业务参数全部传回给服务器。服务器收到后先是进行参数校验,通过校验的数据持久化到数据库。在前端访问时生成该用户的报告页面。页面中将展示用户作品模型,以及根据已设置好的标准对提交上来的其他业务数据进行打分。
打分部分忽略,我们来看前端模型展示部分。假设我现在要把这个奎爷的3D模型展示到我的网站中,应该采用什么技术栈,又怎么在前端ts中使用呢?
后续演示中我将使用奎爷不加贴图的素色obj模型进行演示。
模型+贴图出处:https://www.cgmodel.com/model/282927.html 仅供webGL代码演示
WebGL
WebGL(Web图形库)是一个JavaScript API,可在任何兼容的Web浏览器中渲染高性能的交互式3D和2D图形,而无需使用插件,该API 可以在HTML5 <canvas>元素中使用。
Three.js 和 Babylon.js 引擎都是对于 WebGL 的封装,可以根据自身业务需求选择。
Babylon.js
以下将以 Babylon.js 引擎为例展开讨论。
首先在HTML的<head>中增加 script 引入Babylon.js:
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
然后到ts中为模型创建画布:
const canvas = document.getElementById("renderCanvas") as HTMLCanvasElement;
创建引擎,Engine 是Babylon.js的渲染器类,封装了WebGL的API:
const engine = new BABYLON.Engine(canvas, true);
创建场景:
const scene = new BABYLON.Scene(engine);
然后我需要从服务器中拿到模型url路径,然后将路径分割为模型地址和模型名称以便加载使用:
const parts = this.assetPath.split("/");
const modelName = parts[parts.length - 1];
const modelPath = environment.domainName+"/" + this.assetPath.replace(modelName, "");
在初始化加载模型前,我还增加了个进度条,展示模型加载进度:
const progressBar = document.getElementById('progress');
const pb = document.getElementById('progress-bar')
OK,接下来的代码都是放在初始化模型initAsset()中,异步加载模型,同步进度条进度:
const initAsset = async () => {
// 异步加载初始模型并保存meshes
const result = await BABYLON.SceneLoader.ImportMeshAsync("", modelPath, modelName, scene, (event) => {
if (event.lengthComputable) {
const progress = (event.loaded / event.total) * 100;
progressBar.style.width = `${progress}%`;
}
});
progressBar.style.display = 'none';
pb.style.display = 'none';
canvas.style.display = 'block';
我们需要一个BABYLON.Mesh数组用于遍历模型中的每个mesh面,确保mesh 具有统一的位置和旋转,之后再将mesh进行合并:
const meshes = result.meshes as BABYLON.Mesh[];
// 存放最终合并 mesh 后的模型
const meshesToMerge = [];
// 遍历每个 mesh
meshes.forEach((mesh) => {
mesh.scaling = new BABYLON.Vector3(scale, scale, scale);
mesh.isPickable = true;
mesh.material = stainlessSteelMaterial;
// 确保 mesh 具有统一的位置和旋转
mesh.position.copyFromFloats(0, 0, 0);
mesh.rotationQuaternion = BABYLON.Quaternion.Identity();
// 放入mesh进行合并
meshesToMerge.push(mesh);
});
// 合并模型
const mergedMesh = BABYLON.Mesh.MergeMeshes(meshesToMerge, true, true);
meshes.forEach((mesh) => {
mesh.dispose();
});
为了防止相机碰撞或者穿透模型,我先声明变量记录下模型中点坐标以及相机所能达到的距离极限。这里的scale我设置为了10,可以根据需要调整。
// 通过模型边界框定位中点、模型大小及相机距离
const boundingInfo = mergedMesh.getBoundingInfo();
const modelCenter = boundingInfo.boundingBox.center;
const modelSize = boundingInfo.boundingBox.extendSize;
const maxDimension = Math.max(modelSize.x, modelSize.y, modelSize.z);
const cameraDistance = maxDimension * scale;
创建相机和灯光:
// 创建弧形旋转相机
const arcRotateCamera = new BABYLON.ArcRotateCamera("camera", -Math.PI / 2, Math.PI / 2.5, cameraDistance, modelCenter, scene);
// 开启框架行为监控,防止相机穿透模型
arcRotateCamera.useFramingBehavior = true;
arcRotateCamera.setTarget(mergedMesh)
// 使用半球光
const light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0.7, 0.9, 0.3), scene);
light.intensity = 1.3;
light.range = 50;
监听鼠标的按下松开、移动及滚轮等事件,并增加碰撞检测逻辑:
// 监听鼠标按下和松开事件
let isPointerDown = false;
canvas.addEventListener("pointerdown", () => {
isPointerDown = true;
});
canvas.addEventListener("pointerup", () => {
isPointerDown = false;
});
// 监听鼠标移动事件
canvas.addEventListener("pointermove", (event) => {
if (isPointerDown) {
// 根据鼠标移动的偏移量调整相机的旋转角度
arcRotateCamera.alpha -= event.movementX / 100;
arcRotateCamera.beta -= event.movementY / 100;
// 碰撞检测
const ray = arcRotateCamera.getForwardRay();
const pickInfo = scene.pickWithRay(ray);
if (pickInfo.hit && pickInfo.pickedMesh) {
const distance = pickInfo.distance;
const collisionDirection = ray.direction.scale(-distance);
const cameraPositionBeforeCollision = modelCenter.add(collisionDirection);
// 判断相机位置是否在模型内部(针对空心模型)
const rayFromCamera = new BABYLON.Ray(cameraPositionBeforeCollision, collisionDirection);
const collisionInfoFromCamera = scene.pickWithRay(rayFromCamera);
if (!collisionInfoFromCamera) {
arcRotateCamera.position = cameraPositionBeforeCollision;
}
}
}
});
let initialWheelPosition = null;
// 监听鼠标滚轮事件
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
if (initialWheelPosition === null) {
initialWheelPosition = event.deltaY;
}
const delta = event.deltaY - initialWheelPosition;
const distanceFactor = Math.max(maxDimension * scale / 10, 1); // 根据模型大小调整距离因子
arcRotateCamera.radius -= delta * 0.01 * distanceFactor;
// 动态计算最小相机距离
const minCameraDistance = maxDimension * scale * 0.2; // 设置最小相机距离系数
const dynamicMinCameraDistance = Math.max(minCameraDistance, maxDimension * scale * 0.1); // 根据模型大小调整最小相机距离
if (arcRotateCamera.radius < dynamicMinCameraDistance) {
arcRotateCamera.radius = dynamicMinCameraDistance;
}
});
// 鼠标移开恢复位置
canvas.addEventListener("pointerleave", () => {
initialWheelPosition = null;
});
至此,初始化模型initAsset()结束,接下来就是渲染场景和监听窗口的大小改变:
// 渲染场景
initAsset().then(() => {
engine.runRenderLoop(() => {
if (scene) {
scene.render();
}
});
});
// 监听窗口大小改变(使场景始终填满整个窗口)
window.addEventListener("resize", () => {
engine.resize();
});
好的,ts中的逻辑代码就大功告成了,接下来要添加到HTML中展示模型和进度条:
<ng-container *ngIf="assetType == 1">
<button class="fullsModelBtn" (click)="fullscreenButton()">
<img src="../../../assets/img/v1.png" alt="全屏图标">
</button>
<div id="progress-bar">
<div id="progress"></div>
</div>
<!-- 展示3D模型 -->
<canvas id="renderCanvas" style="width: 98%;height: 83%;text-align: center;"></canvas>
</ng-container>
细心的你可能发现了,我在网页中还增加了一个全屏展示模型的按钮,这个对应的方法直接使用 canvas.requestFullscreen() 即可:
fullscreenButton() {
setTimeout(()=>{
const canvas = document.getElementById("renderCanvas") as HTMLCanvasElement;
if (canvas.requestFullscreen) {
canvas.requestFullscreen();
}
}, 100)
}
运行项目,呈现在网页上的效果如下:
点击模型右上角按钮可以将画布全屏,可在全屏下360度放大缩小浏览模型细节,而且清晰不失真:
请原谅我这个不怎么好看的配色……(捂脸)
因为我这里客户端只是传来obj模型,实际业务中的模型不会像奎爷这么复杂,也并不需要贴图。如果你感兴趣可以到BabylonJS官方的Sandbox中尝试下按面贴图。
对于模型材质,Babylon也提供了标准材质以供修改,比如这样:
// 创建不锈钢材质
const stainlessSteelMaterial = new BABYLON.StandardMaterial("stainlessSteelMaterial", scene);
stainlessSteelMaterial.diffuseColor = new BABYLON.Color3(0.8, 0.8, 0.8); // 设置漫反射颜色
stainlessSteelMaterial.specularColor = new BABYLON.Color3(0.9, 0.9, 0.9); // 设置高光反射颜色
stainlessSteelMaterial.specularPower = 85; // 设置高光反射强度
// 创建黄铜材质
const brassMaterial = new BABYLON.StandardMaterial("brassMaterial", scene);
brassMaterial.diffuseColor = new BABYLON.Color3(0.8, 0.6, 0.0); // 设置漫反射颜色
brassMaterial.specularColor = new BABYLON.Color3(0.9, 0.7, 0.0); // 设置高光反射颜色
brassMaterial.specularPower = 64; // 设置高光反射强度
模型展示的代码我放到了 ngAfterViewInit() 中,在Angular的生命周期中,ngAfterViewInit() 当 Angular 初始化完组件视图及其子视图或包含该指令的视图之后调用。为什么放到这呢?因为在 ngOnInit() 初始化数据时我请求了后端获取报告的接口,并将返回的数据初始化到页面。业务需要我根据返回数据中 assetType 字段来判定前端展示效果,是展示模型还是普通图片。