前言
在做 web
可视化项目的过程中,如果涉及到镜头编辑的话免不了来回改,那这次就来实现一个嫌贵高性能镜头编辑器,能方便地编辑镜头轨迹并导出数据。
资源
这个编辑器用到了官方的案例:
调试场景用到了下面官方案例里的泳池场景:
开头录屏使用的场景用到了 SketchUp 作者 amogusstrikesback2 的场景:
涉及技术
- RenderTarget
- CatmullRomCurve3
- generator Animation
- Bounding Volume
- Transform Control
性能展示
下面展示了一千万面左右的场景 San-Miguel 的顺畅编辑:
开始
初始化
先把模型加载出来:
js
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import Stats from 'three/addons/libs/stats.module.js';
let camera, scene, renderer, controls;
let stats;
init();
render();
function init() {
const bgColor = 0x111111;
renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(bgColor, 1);
document.body.appendChild(renderer.domElement);
scene = new THREE.Scene();
scene.add(new THREE.AmbientLight(0xf0f0f0, 3));
camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 1, 10000);
camera.position.set(0, 50, 0);
camera.lookAt(0, 0, 0);
scene.add(camera);
controls = new OrbitControls(camera, renderer.domElement);
stats = new Stats();
document.body.appendChild(stats.dom);
new GLTFLoader().load('http://localhost:2000/Architecture/chicken_gun_deserttown2.glb',
function (gltf) {
let model = gltf.scene;
model.frustumCulled = false;
scene.add(model);
},
function (xhr) {
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
},
function (error) {
console.log(error);
})
window.addEventListener('resize', () => {
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
});
}
function render() {
requestAnimationFrame(render);
stats.update();
renderer.render(scene, camera);
}
看下效果:
很明显的问题,模型很难找到😂,而且每次换模型还要去调相机位置,立马解决它。 办法是通过计算模型包围盒和中心点,然后把调到模型左下角。
代码:
js
// 模型加载回调里
const box = new THREE.Box3();
// 获取包围盒
box.setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(controls.target);
camera.position.x = center.x - size.x / 2;
camera.position.y = 2 * size.y;
camera.position.z = center.z - size.z / 2;
controls.update();
效果:
看起来不错,又试了几个场景,位置大差不差,有的时候给到的场景并非基于世界坐标中心点而是基于经纬度变换或者其他场合的时候可以用到这个办法来做相机定位。由于这个场景相对较大,接下来把它换成泳池场景,并且把帧率降低到 30
用来调试。如果你还不知道如何控制帧率的话请看我的另一篇文章 three 帧率控制 。
同时把场景的包围盒信息存储下来方便后续操作:
js
dimension.min = box.min;
dimension.max = box.max;
dimension.center = center;
dimension.size = size;
美化
现在的场景黑乎乎的有些看不清,我们添加灯光,把 clearColor
设置为白色(0xf0f0f0
)。 由于有前面的 dimension
,定位光源位置就变得方便了:
js
// illuminate the scene for better view
const light = new THREE.SpotLight(0xffffff, 4.5);
light.position.set(center.x, 50 * center.y, center.z);
light.lookAt(center.x, center.y, center.z);
light.angle = Math.PI * 0.2;
light.decay = 0;
light.castShadow = true;
light.shadow.camera.near = 200;
light.shadow.camera.far = 2000;
light.shadow.bias = - 0.000222;
light.shadow.mapSize.width = 1024;
light.shadow.mapSize.height = 1024;
scene.add(light);
添加曲线和控制块
接下来就把曲线编辑器的代码添加进来。曲线使用 CatmullRom3
对象, 它有三种 curveType
, 我们创建 3 个 Mesh
把它们都添加进来,克隆同一个 BufferGeometry
, 每次 点位 变化再动态地改变 buffer
。把三种曲线都添加进去,然后把创建的 曲线Mesh 挂载到曲线对象里。一开始随机生成几个点来调试。
创建曲线
js
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(ARC_SEGMENTS * 3), 3));
let curve = new THREE.CatmullRomCurve3(points);
curve.curveType = 'catmullrom';
curve.mesh = new THREE.Line(geometry.clone(), new THREE.LineBasicMaterial({
color: 0xff0000,
opacity: 0.35
}));
curve.mesh.castShadow = true;
splines.uniform = curve;
curve = new THREE.CatmullRomCurve3(points);
curve.curveType = 'centripetal';
curve.mesh = new THREE.Line(geometry.clone(), new THREE.LineBasicMaterial({
color: 0x00ff00,
opacity: 0.35
}));
curve.mesh.castShadow = true;
splines.centripetal = curve;
curve = new THREE.CatmullRomCurve3(points);
curve.curveType = 'chordal';
curve.mesh = new THREE.Line(geometry.clone(), new THREE.LineBasicMaterial({
color: 0x0000ff,
opacity: 0.35
}));
curve.mesh.castShadow = true;
splines.chordal = curve;
for (const k in splines) {
const spline = splines[k];
scene.add(spline.mesh);
}
// TO-DELETE: 先随机加几个点
const { center, min, max, size } = dimension;
for (let i = 0; i < 3; ++i) {
points.push(new THREE.Vector3(
Math.random() * size.x + min.x,
Math.random() * size.y + center.y,
Math.random() * size.z + min.z
));
}
// 创建 Mesh
updateSplineMesh();
addControlMesh();
更新曲线 updateSplineMesh
js
function updateSplineMesh() {
const point = new THREE.Vector3();
for (const k in splines) {
const spline = splines[k];
const splineMesh = spline.mesh;
const position = splineMesh.geometry.attributes.position;
for (let i = 0; i < ARC_SEGMENTS; i++) {
const t = i / (ARC_SEGMENTS - 1);
spline.getPoint(t, point);
position.setXYZ(i, point.x, point.y, point.z);
}
// update buffer in next frame
position.needsUpdate = true;
}
}
另外每个 点位 都需要有控制器,使用随机颜色的控制块 来表示,与上面类似也是共用同一个 geometry
,动态用 Mesh
的 position
来控制显示的位置。 同时,由于三条曲线是一直存在的,不需要动态生成,而添加点位需要生成新的控制块 Mesh
, 单独把他们添加到一个Group
里来管理,通过与点位做比较判断是否创建,同样可以把创建逻辑和更新逻辑写到同一个函数里。
创建控制块变量
js
const controlGroup = new THREE.Group();
const cubeScale = .2;
const controlGeometry = new THREE.BoxGeometry(cubeScale, cubeScale, cubeScale);
const controlMaterial = new THREE.MeshLambertMaterial({ color: Math.random() * 0xffffff });
添加控制块函数 addControlMesh
js
function addControlMesh() {
const controlCount = controlGroup.children.length;
const pointCount = points.length;
if (controlCount == pointCount) return;
let startIndex = controlCount - 1 < 0 ? 0 : controlCount - 1;
for (let i = startIndex; i < pointCount; ++i) {
const cube = new THREE.Mesh(controlGeometry, controlMaterial);
const point = points[i];
cube.position.setX(point.x);
cube.position.setY(point.y);
cube.position.setZ(point.z);
cube.castShadow = true;
cube.receiveShadow = true;
controlGroup.add(cube);
}
}
看下效果:
曲线编辑功能
接下来把 gui
添加进来控制曲线点位的数量,同时添加 导出功能 把初始点位固定下来。还要 添加控制器 来实时调整点位。
添加控制器
控制器需要被拾取,我们使用常规的 raycast
来拾取,当然我们还可以使用基于 GPU
的拾取,许多桌面软件比如 Blender 就是这么做的,我还没搞懂怎么在 three.js 里适配,后面再说~。话说回来,raycast
需要在场景里遍历物体求交,出于性能考虑我们把碰撞范围限定到 cubeGroup
。另外来理一下操作逻辑,分为几种情况:
- 鼠标移动时:
- 如果检测到控制块,则
attach
,改变控制块同样的逻辑,因为控制器同一时刻只能操作一个物体
- 如果检测到控制块,则
- 鼠标按下时:
- 什么都不用做
- 鼠标抬起:
- 如果检测不到控制块,则
detach
- 如果检测不到控制块,则
看下效果:
控制块更新
位置更新
通过控制块移动获取新位置更新曲线,我看参考例子的时候发现它做的比较 magic,在创建完 控制块 后把位置数组对应位置换成 控制块 位置的引用,然后再通过 THREE.CatmullRomCurve3
构造器传入位置数组,这样 spline
对象就持有了位置数组的引用,更新只需要调用 getPoint
截取曲线上的点来更新 曲线Mesh ,我特意翻开源码看了下发现 CatmullRomCurve3
调用 getPoint
来获取分割位置的点,每次都会用 const points = this.points;
来使用位置数组,然后再调用曲线算法生成分割位置的具体数值,对于默认的调用来说,getPoint
函数的表现类似 静态方法, 这也是能这样做的原因。
非常好用,抄了:
js
function init() {
...
for (let i = 0; i < splinePointsLength; i++) {
addSplineObject(positions[i]);
}
positions.length = 0;
for (let i = 0; i < splinePointsLength; i++) {
positions.push(splineHelperObjects[i].position);
}
...
}
当然在控制块移动的时候还需要禁用 orbitControls
的事件:
js
transformControl.addEventListener('dragging-changed', event => {
controls.enabled = !event.value;
});
看下效果:
添加控制面板
创建控制面板来实现更多的控制,把每块功能进行单独控制。先添加 3 个核心功能。
js
const params = {
addPoint: addPoint,
removePoint: removePoint,
exportSpline: exportSpline,
}
添加控制点位 AddPoint
添加控制点位的时候有几个行为:
- 生成点位并添加到
points
- 增加新的控制块 :
addControlMesh
- 更新曲线:
updateSplineMesh
看代码:
js
// generate new point and add to points
function addPoint() {
const { size, center, min, max } = dimension;
const randomPoint = new THREE.Vector3(
Math.random() * size.x + min.x,
Math.random() * size.y + center.y,
Math.random() * size.z + min.z
);
addControlMesh(randomPoint, (cube, nextPointIndex) => {
// set positions reference so that points don't need to manually overwrite
points.push(cube.position);
});
updateSplineMesh();
}
移除控制点位 removePoint
移除控制点需要考虑到几件事:
- 设置最少的点(3个),少于等于这个数弹窗提示
- 如果当前控制块处于操作状态则
detach
- 销毁相应的数据
看代码:
js
// remove point from points
function removePoint() {
if (points.length <= 3) {
alert("Can't not remove from points less than 3.");
return;
}
const cubes = controlGroup.children;
points.pop();
const cube = cubes.pop();
// a proper way to determine if is the same object
if (transformControl.object && cube.uuid === transformControl.object.uuid) {
transformControl.detach();
}
cube.dispose && cube.dispose();
updateSplineMesh();
}
导出点位 exportSpline
直接在页面和控制台输出控制点位的信息,或者也可以把曲线序列按分割数输出,这样数据可以用 JSON 存起来,长镜头序列可以直接用。
代码:
js
function exportSpline() {
const strplace = [];
const objects = controlGroup.children;
const length = objects.length;
for (let i = 0; i < length; i++) {
const p = objects[i].position;
strplace.push(`new THREE.Vector3(${p.x}, ${p.y}, ${p.z})`);
}
console.log(strplace.join(',\n'));
const code = '[' + (strplace.join(',\n\t')) + ']';
prompt('copy and paste code', code);
}
取消选中
这里增加一下取消选中状态,通过单击右键来控制,这里其实是做了很多测试过后才确定的,为了方便操作和性能考量。transformControl
有两个子组件分别是 (操作控件)gizmoPlane
和 (指示平面) transformPlane
。我们需要和前者判断,读者可以自己测试下就知道了。
js
// right click
window.addEventListener('contextmenu', event => {
raycaster.setFromCamera(pointer, camera);
const gizmo = transformControl.children[0];
const intersects = raycaster.intersectObjects([...controlGroup.children, gizmo], false);
if (intersects.length == 0) {
transformControl.detach();
}
});
到这里,曲线编辑功能已经完成了,看下效果:
渲染
预览窗口
思路
上述功能完成后我们需要一个小窗口来预览相机沿曲线轨迹运动的画面,最直接的做法就是再起一个渲染器单独渲染到新的画布,这里我们为了不增加 WebGL
上下文的数量,直接在同一个画布里使用裁剪和视口变换来做:
js
...
// full screen
renderer.setScissor(0, 0, width, height);
renderer.setViewport(0, 0, width, height);
renderer.setScissorTest(true);
renderer.setClearColor(0x000000, 1);
renderer.render(scene, camera);
// top left
renderer.setScissor(0, height - height / 4, width / 4, height / 4);
renderer.setViewport(0, height - height / 4, width / 4, height / 4);
renderer.setScissorTest(true);
renderer.setClearColor(0x202020, 1);
renderer.render(scene, camera);
...
对于没有使用过这两个 API
的读者可以上官网看下。原理也很简单,setScissor
在画布上裁剪出一片空间,setViewPort
来把 视图矩阵 更新到相应位置,对于多视口功能记得要用renderer.setScissorTest(true);
只对裁剪区域生效。
看下效果:
封装
为了更好地归纳 viewPort
的功能方便后续把 渲染函数 抽出来,把各个视口的参数封装到对象里,为了能从 gui
里实现控制,需要动态获取数值,因此封装到函数里。由于渲染位置是叠加的,所以渲染顺序应该是先渲染主画面,再渲染小画面。
获取视口渲染参数:
js
// viewports
function getViewports() {
const { clearColor, scale, top, left } = params;
// left and top is relative to the leftTop corner while webgl is from leftBottom
return [
{
name: "main",
width: windwoWidth,
height: windowHeight,
left: 0,
top: 0,
clearColor: BG_COLOR
},
{
name: "preview",
width: scale * windwoWidth,
height: scale * windowHeight,
left: left,
top: windowHeight * (1 - scale) - top,
clearColor: clearColor
}
];
}
解耦渲染函数
对于 编辑态 来说,尽管我们已经使用了较低的 帧率 来刷新,但是对于编辑器来说没必要使用 RAF
实时刷新状态,只需要在进行对应操作的时候去调用渲染函数即可。比方说只需要在进行鼠标移动的时候再去更新渲染状态。
封装渲染函数:
js
// render webgl
function render() {
stats.update();
const views = getViewports();
for (let view of views) {
const { clearColor, left, top, width, height } = view;
renderer.setScissor(left, top, width, height);
renderer.setViewport(left, top, width, height);
renderer.setScissorTest(true);
renderer.setClearColor(clearColor, 1);
renderer.render(scene, camera);
}
}
这时已经取消 requestAnimationFrame
的使用了,分析下什么时候需要触发画面的更新:
- 任意窗口事件
- 鼠标滚轮缩放
- 鼠标指针移动
- 鼠标右键取消选中
- 窗口
resize
- 所有的
gui
选项操作
窗口事件已经都确定了,先把 render()
函数加进去:
js
window.addEventListener('contextmenu', () => {
...
render();
});
window.addEventListener('resize', () => {
...
render();
});
window.addEventListener('mousewheel', () => {
...
render();
});
window.addEventListener('mousemove', () => {
...
render();
});
交互上和原来没什么两样,可以继续往下把动画的功能完成再添加到 gui
选项操作。
动画预览
添加曲线控制参数
在开发动画预览功能之前先来处理下曲线分段的问题。当轨迹变长,曲线 分段数量 不足的情况下会逐渐失去 曲度,比如这样:
把切线 分段数量 也添加到 gui
里去控制,同时把 tension
也添加到控制面板,它也会影响曲线。由于一开始 曲线Mesh 是根据这个分段来生成相应大小的 buffer
, 这里需要更新下 updateSplineMesh
函数去用新的数值生成 buffer
:
js
// update splines meshes already exist
function updateSplineMesh() {
const point = new THREE.Vector3();
const newSegments = params.arcSegments;
const updateBuffer = (arcSegments != newSegments);
for (const k in splines) {
const spline = splines[k];
const splineMesh = spline.mesh;
const geometry = splineMesh.geometry;
let position = geometry.attributes.position;
if (updateBuffer) {
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(newSegments * 3), 3));
// a must do step: new buffer is replaced, change BufferAttribute reference
position = geometry.attributes.position;
}
for (let i = 0; i < newSegments; i++) {
const t = i / (newSegments - 1);
spline.getPoint(t, point);
position.setXYZ(i, point.x, point.y, point.z);
}
// update buffer in next frame
position.needsUpdate = true;
}
}
注意这里标注的必须做的一步,我们对 BufferAttribute
的 buffer
进行了覆写,此时的 position
内部指向了 亡值,虽然不会报错,但是如果不改变引用,那么将不会有效果,这里我调试了好久才发现😒
预览相机
编辑好的轨迹需要有回放功能,方便进行预览。新建一个相机,朝向 轨迹切线 ,并把相机参数暴露到 gui
里方便调整。轨迹的 切线 用曲线上的下一个点来替代。 先导出几个点来固定住初始状态,新建个相机来替换小视口的视角,同时使用 CameraHelper
来可视化预览相机的 视锥体。
添加后的效果:
在没有进行动画的时候,设置 cameraHelper
跟随第一个控制块的位置移动,这样交互会更棒:
js
transformControl.addEventListener('objectChange', event => {
updateSplineMesh();
if (!isAnimating) {
setPreviewCamera();
}
});
生成器动画
对于轨迹动画来说,我们使用 生成器 来输出是个不错的注意,它可以天然地保留执行的状态,方便暂停。启动动画的时候使用 RAF
来触发 动画生成器 输出,动画结束或提前结束则终止生成器,并终止 RAF
, 这样在播放动画的时候就完全依赖操作来更新画面。在这种方式下,之前帧率控制的意义就不太大了,下面我们会通过最基础的生成器函数开始,然后再通过位运算技巧控制运动速率,最后再使用缓动函数计算进度变化。
播放(play):
js
// play animation
function play() {
if (!animationTask) {
animationTask = animation();
}
if (!isAnimating) {
startAnimation();
}
isAnimating = true;
function startAnimation() {
stats.update();
animationId = requestAnimationFrame(startAnimation);
if (params.pause) return;
let value = animationTask && animationTask.next();
// animation end reset to the origin
if (value && value.done) stop();
render();
}
}
由于现在有了 play
, stop
功能,需要把 重置 预览相机行为进行封装。重置状态分成两种:
- 恢复初始位置,比如使用
stop
强行终止动画,设为状态 0 - 动画过程中需要获取曲线上的点和下一个点的情况,设为状态 1
代码如下:
js
/**
* state: 0 means reset, 1 means use index to update
* index: current index in spline
*/
function setPreviewCamera(state = 0, index) {
switch (state) {
case 0:
previewCamera.position.copy(points[0]);
previewCamera.lookAt(points[1]);
break;
case 1:
const count = params.arcSegments;
const curveType = params.curveType;
const spline = splines[curveType].getPoints(count);
if (index < count - 1) {
previewCamera.position.copy(spline[index]);
previewCamera.lookAt(spline[index + 1]);
previewCamera.updateProjectionMatrix();
previewCameraHelper.update();
}
break;
}
const { showCameraHelper, fov, near, far, aspect } = params;
previewCamera.aspect = aspect;
previewCamera.fov = fov;
previewCamera.near = near;
previewCamera.far = far;
previewCamera.updateProjectionMatrix();
previewCameraHelper.visible = params.showCameraHelper;
previewCameraHelper.update();
}
动画运动函数:
js
function* animation() {
const count = params.arcSegments;
let pointIndex = 0;
while (pointIndex < count - 1) {
setPreviewCamera(1, pointIndex);
yield;
}
}
控制运动速率
直接使用上面的做法画面会运动得很快,可以添加一个 步长参数 来控制。这里有个小技巧使用位与运算来向下取整,小数位的二进制运算后会全部截取为 0 ,而整数的部分保留:
js
function* animation() {
const count = params.arcSegments;
let index = 0;
let pointIndex = index;
while (index < count - 1) {
// Interger judgement
let interger = index & 0x7FFFFFFF;
if (interger > 0) pointIndex = interger;
setPreviewCamera(1, pointIndex);
// Control the stride
index += params.stride;
yield;
}
}
使用缓动函数
由于是轨迹动画,我们需要使用缓动函数计算轨迹点位索引,并判断边界和取整运算,代码如下:
js
function* animation() {
const { arcSegments, curveType, stride, easing, easingType } = params;
let dt = 0;
let easingFun = TWEEN.Easing[easing];
if (easing == 'generatePow') {
easingFun = easingFun();
}
while (dt <= 1) {
const length = clipSplinePoints.length;
let startIndex = easingFun[easingType](dt) * arcSegments & 0x0FFFFFFF;
// boundary
if (startIndex >= length - 1) {
startIndex = length - 2;
}
let endIndex = startIndex + 1;
const startPoint = clipSplinePoints[startIndex];
const endPoint = clipSplinePoints[endIndex];
previewCamera.position.copy(startPoint);
previewCamera.lookAt(endPoint);
previewCamera.updateProjectionMatrix();
previewCameraHelper.update();
dt += stride;
yield;
}
}
看下效果:
添加动画分段控制
由于动画轨迹可能很长,所以添加轨迹分段控制,可以从某一处开始,做法也很简单,根据进度截取曲线点位数组,同时在其他相应的事件里调用这个函数,然后把预览相机更新到相应位置:
js
// clip spline points by clipProgress
function clipProgress() {
const { arcSegments, curveType, progress } = params;
if (progress == 0) {
return splines[curveType].getPoints(arcSegments);
}
let spline = splines[curveType].getPoints(arcSegments, progress, 1);
const clipOffset = ~~(progress * arcSegments);
spline = spline.splice(clipOffset, arcSegments - 1);
return spline;
}
let clipSplinePoints = clipProgress();
看下效果:
结语
到这里就结束了,可以看到交互细节非常多,这还只是功能相对单一的情况,这上面的操作逻辑和代码优化也是我花了时间调出来的。其实仔细想想还有不少地方可以加入新功能,比如每个 控制块 加上位置标注;写入到浏览器数据库方便状态保存,做到回撤和重开状态恢复 等等;
Web编辑器
是一个非常有前景的长尾市场,近年来各大公司做了不少这类产品,比如 Google Docs
,自动桌公司的 CAD
如今也能在浏览器里使用,BIM 行业的 SketchUp
,还有知名的 Figma
,但同时做这个领域难度也是巨大的想必各位也懂为什么。扯远了,话说回来对我们而言可以去把业务上的东西沉淀下来,封装成方便的功能,减少 javascript
代码的运行次数,节能减排,为绿水青山做一份贡献 😁