最前
大家好,我是qingshun,最近心血来潮实现了一款简易版glTF编辑器,在功能实现的过程中也踩了一些坑与复习了threejs和Vue3的知识,收获颇多。
正好太久没更新了,今天就来将收获进行分享。本文将和大家一同学习如何从0到1 实现一款glTF编辑器,难度不大功能不多,但内容还算饱满,相信大家看完以后一定会有收获,内容有点长但值得你慢慢看完。
预览地址:http://www.hiwindy.cn/gltf-editor
源码地址:https://github.com/coderZqs/gltf-editor.git
项目介绍
项目是由 Vue3 + threeJS + pinia 进行开发。整个项目只有如下一个页面
左边菜单为模型的可编辑模块树形导航栏,点击切换编辑的物体部位
中间为效果展示画布,展示坐标格辅助对象与模型
右边为参数调试区域,拥有属性、变换、材质三块区域
本文将以单独模块实现 -> 各个模块交互的顺序讲解
单独模块实现
左侧树形导航栏
我们先来梳理一下功能逻辑:
从gltf模型中获取isMesh为true的物体,然后以树形结构展示出来
模型的数据结构本身就是一个树形结构,比如一个人物模型来说:最顶部对象为头、身体、下身。然后头拥有鼻子、眼睛等器官,身体拥有胸部、肚子等器官,然后每个器官又拥有自己的子器官。这样就形成了一个树形结构。
既然是树形结构那递归算法肯定会出现的,我们要思考一下递归条件:
- 首先不是每个结构都是需要编辑的,我们需要编辑的是(isMesh = true)网格模型。
- 终止条件是子对象数组是否为空
- 如果子对象数组不存在(isMesh)网格模型,则拿到上一轮的子对象数组作为下一轮循环的对象数组
- 如果子对象数组存在(isMesh)网格模型,则拿本轮的子对象数组作为下一轮的对象数组。
由此4个条件,我们很轻易就得到了以下递归函数
js
/**
* 获得tree
* @param model
* @param ls
*/
const getTree = (model, ls) => {
if (model.children && model.children.length) {
if (model.children.some(v => v.isMesh)) {
model.children.forEach(e => {
if (e.isMesh) {
ls.push({ uuid: e.uuid, title: e.name, key: e.uuid, children: [] });
}
getTree(e, ls[ls.length - 1]?.children);
});
} else {
model.children.forEach(e => {
getTree(e, ls);
});
}
}
};
由此递归函数我们就能拿到模型中可编辑的物体。然后使用antdv中树形组件就实现左侧树形导航栏。
中间效果预览区域
首先我们需要初始化相机、光照、渲染器、场景, 初始化OrbitControl控制器、画布响应式等
这一块就不水代码了,具体可以看我项目中的封装好的 方法文件
由于需要各模块通信都会用到3D 对象(camera、Scene、renderer等),pinia工具就出现了,我们创建一个three文件用于集中化管理 threejs中的对象与方法。
js
export default defineStore("three", () => {
let camera: THREE.PerspectiveCamera = T.initCamera();
let renderer: THREE.WebGLRenderer = new THREE.WebGLRenderer();
let scene: THREE.Scene = new THREE.Scene();
let model: Ref<THREE.Object3D> = ref();
/**
* 设置屏幕大小
*/
const setScreenSize = dom => {
let { width, height } = dom.getBoundingClientRect();
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
return { width, height };
};
/**
* 初始化场景
*/
const initScreen = dom => {
if (renderer && camera && scene) {
let { width, height } = setScreenSize(dom);
let controls = T.addOrbitControls(camera, renderer.domElement);
T.appendCanvasToElement(dom, renderer.domElement);
// controls.enableDamping = true;
window.addEventListener("resize", () => {
setScreenSize(dom);
});
let animate = () => {
requestAnimationFrame(animate);
renderer.setClearColor(0x272822)
renderer!.render(scene!, camera!);
controls.update();
};
animate();
}
};
return {
camera,
renderer,
scene,
model,
initScreen,
};
}
我们这里的步骤是:当用户上传了gltf模型并加载完成后,加载场景并将模型加入到公共对象中,并生成左侧导航栏需要的树形结构。
js
/**
* 文件上传事件
*/
const handleChange = async (info) => {
const status = info.file.status;
if (status === "uploading") {
state.isLoading = true;
}
if (status === "done") {
let code = info.file.response.code;
if (code === 200) {
let fileUrl = '/' + info.file.response.data.image;
let e = (await T.loadGLTF(fileUrl)) as any;
console.log(e)
state.isLoading = false;
state.gltfUploaded = true;
// 发送给父元素,用于控制左右两边菜单栏显隐。
emits("uploadSuccess", state.gltfUploaded);
let model = e.scene.children[0];
console.log(model)
Scene.model = model;
Index.current = model;
model.position.set(0, 0, 0);
Scene.scene!.add(model);
// 需开启transparent属性,才能修改透明度
model.traverse((v) => {
if (v.isMesh) {
v.material.transparent = true;
v.material.opacity = 1;
}
})
Index.tree = { uuid: model.uuid, key: model.uuid, title: model.name, children: [] };
Index.getTree(model, Index.tree!.children);
nextTick(() => {
Scene.initScreen(canvasWrapper.value);
});
}
}
};
这样完成了场景的加载。
右边参数调试区域
从上图我们可以看到有大量相同的控件,如数字输入、贴图文件上传。很轻易的就能想到将他封装起来。
为了用户的操作交互体验,编辑器中的数值输入组件通常拥有鼠标按住上下滑动修改值的功能,如下图
实现起来也是非常简单,监听鼠标点击和移动事件得出鼠标上下滑动偏移量就实现了,代码如下
js
let props = defineProps({ modelValue: { type: Number, default: 0 }, step: { type: Number }, min: { type: Number, default: 0 }, max: { type: Number, default: 1 } });
let emits = defineEmits(["update:modelValue"])
let value = computed({
set: (val) => {
emits("update:modelValue", Number(val));
},
get: () => {
return props.modelValue.toFixed(1);
}
})
/**
* 通过max min值进行value修改
*/
const formatValueByLimit = (value) => {
if (value < props.min) {
value = props.min
}
if (value > props.max) {
value = props.max;
}
emits("update:modelValue", value);
}
const mousedown = (e) => {
let sourceValue = JSON.parse(JSON.stringify(props.modelValue));
let sourcePoiY = e.pageY;
window.onmousemove = (moveEvent) => {
let rY = sourcePoiY - moveEvent.pageY;
let value = Number((sourceValue + rY * (props.step || 0.1)).toFixed(2));
formatValueByLimit(value);
}
window.onmouseup = () => {
window.onmousemove = null;
}
}
const blur = () => {
formatValueByLimit(props.modelValue);
}
上传组件的功能也很简单,双向绑定展示的图片,子组件内部处理上传的后修改图片的逻辑,就不贴代码了。
至此整个左、中、右布局框架就已经搭建起来了。
各模块交互
梳理逻辑
- 用户上传模型(model)
- 用户选中模块(current)
- 用户调整参数(right.State)
- 模型通过参数去修改模型自身属性(updateModelByState事件)
对,要做的就这么简单。
第一步在上面做中间区域的时候就已经完成了,保存在了pinia中。 第二步中的选中模块应该在左侧导航栏中进行操作
我们通过遍历模型判断uuid的方式去查找。
js
const onNodeClick = (selectKey, { node }) => {
let uuid = node.dataRef.uuid;
Scene.model.traverse((v) => {
if (v.uuid === uuid) {
Index.current = v;
}
})
}
高亮效果
我们还需要完成一个选中高亮的效果,如下
这个后期效果在官方示例 webgl_postprocessing_outline 已经有了详细的说明。
js
const setOutLineEffect = (width, height) => {
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
outlinePass.value = new OutlinePass(
new THREE.Vector2(width, height),
scene,
camera
);
let params = {
edgeStrength: 10, // 边缘强度
edgeGlow: 1, // 边缘发光
edgeThickness: 4, // 边缘厚度
pulsePeriod: 5, // 脉冲周期
rotate: false,
usePatternTexture: false
};
outlinePass.value.edgeStrength = Number(params.edgeStrength);
outlinePass.value.edgeGlow = Number(params.edgeGlow);
outlinePass.value.edgeThickness = Number(params.edgeThickness);
outlinePass.value.pulsePeriod = Number(params.pulsePeriod);
outlinePass.value.visibleEdgeColor.set("#ffffff");
outlinePass.value.hiddenEdgeColor.set("#ffffff");
composer.addPass(toRaw(outlinePass.value));
};
先定义一个后期组件将渲染器renderer作为参数传入,然后使用 OutlinePass 对象去实现边缘高亮效果。 Scene.outlinePass.selectedObjects = [v];
将需要高亮的物体传入数组里面就可以了。
此时我们实现了第二步
第三第四步在我们的参数调整导航栏中进行,其实就是模型与参数间的相互影响
我们定义两个函数,通过状态修改模型 与通过模型修改状态
js
/**
* 通过模型取到状态
* @param model
*/
const setStateByModel = (model: THREE.Mesh) => {
colorPickValue.value = "#" + (model.material?.color.getHexString() || '000000');
state.value.type = model.isObject3D ? "Object" : "Group";
state.value.name = model.name;
state.value.vertexCount = model?.geometry?.attributes?.position?.count || 0;
state.value.triangleCount = Math.floor(state.value.vertexCount / 3 || 0);
state.value.position = model.position.clone();
state.value.scale = model.scale.clone();
// 旋转弧度转角度。
let rotation = model.rotation.clone();
const degree2radians = (d) => d * 180 / Math.PI;
state.value.rotation = { x: degree2radians(rotation.x), y: degree2radians(rotation.y), z: degree2radians(rotation.z) };
if (model.material) {
Object.keys(materialKey).forEach(async (key) => {
if (materialKey[key] == 'Map') {
let mapImage = model.material[key]?.image;
if (mapImage) {
state.value[key] = await transImage(mapImage);
} else {
state.value[key] = "";
}
} else {
state.value[key] = model.material[key];
}
})
}
};
我们可以看到,大部分的属性都是可以直接获取的。
但贴图这边好像做了特殊处理
state.value[key] = await transImage(mapImage);
是因为贴图是imageBitmap位图图像,文档中也写到:ImageBitmap
提供了一种异步且高资源利用率的方式来为 WebGL 的渲染准备基础结构。
一开始我是想着有没有什么方法让threejs贴图不使用位图格式,答案是有的。
js
delete window.createimagebitmap;
直接删除创建位图的方法,这样就能得到url的格式。
由于安全策略并不能展示,所以只能硬着头皮去学习imageBitmap了。
imageBitmap 可以用canvas transferFromImageBitmap 转移所有权,然后又可以通过toBlob转成blob,再通过createObjectURL 转成url地址
js
/**
* bmp转成image
* @param bmp
*/
let transImage = async (bmp) => {
const canvas = document.createElement('canvas');
canvas.width = bmp.width;
canvas.height = bmp.height;
const ctx = canvas.getContext('bitmaprenderer');
let originalImageBitmap = await createImageBitmap(bmp);
ctx!.transferFromImageBitmap(originalImageBitmap);
const blob = await new Promise((res) => canvas.toBlob(res));
canvas.remove();
return URL.createObjectURL(blob);
}
这样我们就能得到可链接的texture地址,此时通过通过模型修改状态的阶段完成。
接下来就是通过状态修改模型了。
js
/**
* 通过配置设置模型
*/
const setModelByConfig = () => {
Scene.model.traverse((v) => {
if (v.uuid === Index.current.uuid) {
let { x, y, z } = state.value.position;
let { x: sx, y: sy, z: sz } = state.value.scale;
let { x: rx, y: ry, z: rz } = state.value.rotation;
v.position.set(x, y, z);
v.rotation.set(rx * Math.PI / 180, ry * Math.PI / 180, rz * Math.PI / 180);
v.scale.set(sx, sy, sz);
if (v.material) {
Object.keys(materialKey).forEach((key) => {
if (materialKey[key] !== 'Map') {
v.material[key] = state.value[key];
}
})
}
}
});
};
逻辑如出一辙,非常简单。
结尾
至此glTF 编辑器的主要逻辑都讲完了。
很简单但也算饱满,相信各位同学看完或多或少有一些收获,这正是我们都想看到的,如果同学们觉得不错的话麻烦点赞收藏关注一下吧