一、总体内容结构总结
本次开发的核心是构建一个基于Vue 3 + TypeScript + Babylon.js的3D对象驱动行为管理系统,重点实现了DriveLerpControllerEditor类及其配套的UI界面。整个系统采用行为(Behavior)模式、命令模式(Command Pattern)和响应式编程架构,为3D场景中的对象提供了统一的插值动画控制能力。
1.1 系统架构分层
整个项目采用清晰的分层架构:
数据层(Babylon.js Behavior系统)
-
DriveLerpControllerEditor作为核心控制器,继承自DriveLerpBehaviorEditor -
通过
DriveBehaviorEditorManager管理单个节点上的多个驱动行为 -
使用
DriveLerpProcessInfo连接控制器与具体行为,管理时间轴映射 -
采用树形结构
DriveLerpControllerTreeNode组织父子节点关系
命令层(撤销/重做支持)
-
所有状态变更通过
CmdProperty和Command基类实现 -
支持复合命令
CompositeCommand批量操作 -
每个驱动行为(位置、旋转、缩放、可见性等)都有对应的命令封装
UI层(Vue 3组件)
-
属性面板系统:
ObjectPropertiesPanel→ObjectBaseProperties/ObjectMatProperties/DriveBehaviorList -
控制器对话框:
DriveLerpControllerDialog作为主容器 -
树形视图:
DC_TreeList+DC_TreeNodeItem展示层级关系 -
轨道编辑器:
DC_EditingTrack+StartDurationTrack实现可视化时间轴拖拽
通信层(事件总线)
-
使用
Observable模式替代传统Vue watch,实现跨组件通信 -
EventBus提供全局事件发射器 -
provide/inject机制实现依赖注入
1.2 核心功能模块
插值行为管理系统支持6种驱动行为:
-
DriveLerpPositionEditor:位置插值移动 -
DriveLerpRotationEditor:旋转插值(欧拉角) -
DriveLerpScalingEditor:缩放插值 -
DriveLerpVisibilityEditor:可见性渐变(仅Mesh) -
DriveAxisRotateEditor:轴向持续旋转 -
DriveLerpControllerEditor:统一控制器,协调多个行为
控制器核心能力:
-
统一时间轴:将所有子行为的进度映射到全局0-100%范围
-
播放控制:支持正向/反向播放、暂停、停止、循环
-
速度调节:1x/2x/4x/8x多档速度
-
可视化编辑:树形列表 + 轨道拖拽 + 时间标记
-
实时预览:拖动进度条时实时更新场景状态
1.3 关键数据流
3D对象 → DriveBehaviorEditorManager → DriveLerpBehaviorEditor
↓
DriveLerpControllerEditor → DriveLerpProcessInfo (时间映射)
↓
UI组件树 (provide/inject + Observable)
二、关键技巧点总结
技巧1:provide/inject的进阶用法------提供全局状态与函数
在大型组件树中,传统的props传递会导致"prop drilling"问题。本项目大量使用provide/inject不仅传递数据,更关键的是提供全局函数和响应式状态,实现真正的跨层通信。
应用实例1:在DriveLerpControllerDialog.vue中提供选中状态管理
TypeScript
<script setup lang="ts">
// 提供全局的选中 ID,所有子组件可以注入并使用
const selectedId = ref<string | null>(null)
provide('selectedId', selectedId)
const handleGlobalClick = () => {
selectedId.value = null // 点击空白处清除选中
}
</script>
子组件如DriveLerpProcessInfoNodeItem.vue中注入使用:
TypeScript
<script setup lang="ts">
// 注入全局的 selectedId,用于同步所有组件的选中状态
const selectedId = inject<Ref<string | null>>('selectedId', ref(null));
const handleClick = (event: MouseEvent) => {
event.stopPropagation();
if (props.info?.id && selectedId) {
selectedId.value = props.info.id; // 更新全局选中状态
}
};
</script>
应用实例2:在DC_EditingTrack.vue中提供注册函数
TypeScript
<script setup lang="ts">
// 提供注册函数给子组件
const registerTrackWidth = (id: string, width: number) => {
trackWidthMap.value.set(id, width);
};
// 将注册函数和常量通过 provide 暴露给子组件
provide('registerTrackWidth', registerTrackWidth);
provide('unregisterTrackWidth', unregisterTrackWidth);
provide('requestAutoScroll', requestAutoScroll);
provide('PIXEL_TO_SECOND', PIXEL_TO_SECOND);
provide('SECOND_TO_PIXEL', SECOND_TO_PIXEL);
provide('MIN_LEFT_OFFSET', MIN_LEFT_OFFSET);
</script>
子组件StartDurationTrack.vue中注入调用:
TypeScript
<script setup lang="ts">
// 注入父组件提供的注册函数
const registerTrackWidth = inject<(id: string, width: number) => void>('registerTrackWidth');
const unregisterTrackWidth = inject<(id: string) => void>('unregisterTrackWidth');
onMounted(() => {
// 注册当前组件的 trackWidth
if (registerTrackWidth && props.info?.id) {
registerTrackWidth(props.info.id, trackWidth.value);
}
});
onUnmounted(() => {
// 取消注册 trackWidth
if (unregisterTrackWidth && props.info?.id) {
unregisterTrackWidth(props.info.id);
}
});
</script>
优势:父组件无需知道子组件结构,子组件也无需知道数据来自何处,实现真正的解耦。
技巧2:事件驱动优先于watch------解决响应"迟钝"问题
在3D编辑器场景中,状态变化频繁且要求实时性。传统Vue watch在某些情况下(如深层嵌套对象、大量数据更新)会有性能延迟。本项目全面采用Observable事件模式,由Babylon.js的观察者模式驱动UI更新。
典型场景:行为列表动态更新
在DriveBehaviorList.vue中,不watch props.behavManager.driveBehaviors,而是监听Observable:
TypeScript
<script setup lang="ts">
let behaviorChangedObserver: Observer<void> | null = null;
onMounted(() => {
// 只需监听一个统一的变化事件
behaviorChangedObserver = props.behavManager.onDriveBehaviorsChangedObservable.add(() => {
updateBehavior(); // 响应式更新本地状态
});
});
onUnmounted(() => {
if (behaviorChangedObserver) {
props.behavManager.onDriveBehaviorsChangedObservable.remove(behaviorChangedObserver);
}
});
</script>
对应的DriveBehaviorEditorManager.ts中:
TypeScript
public readonly onDriveBehaviorsChangedObservable = new Observable<void>();
private _setupBehaviorListener(behavior: DriveBehaviorEditor): void {
behavior.onSetEnabledObservable.add(() => {
this.onDriveBehaviorsChangedObservable.notifyObservers(); // 主动通知
this.onSetDriveBehaviorEnabledObservable.notifyObservers(behavior);
});
}
对比优势:
-
即时性:状态变化立即触发,无延迟
-
精确性:只通知需要的组件,避免无关rerender
-
解耦:行为管理器无需知道谁在使用它
另一个例子:在ObjectMatProperties.vue中修复材质状态同步
TypeScript
// 监听变化(关键修复)
originalMatKeeper.value.onMaterialChangedObservable?.add(() => {
isUsedOriginalMaterial.value = originalMatKeeper.value!.isUsedOriginalMaterial;
});
使用Observable而非watch,确保材质状态变化时UI立即响应。
技巧3:拦截Checkbox点击事件------@click.stop的妙用
TDesign的t-checkbox组件在点击时会触发change事件,同时也会冒泡click事件。在某些场景下(如树形节点点击选中),需要阻止这种冒泡,避免父级节点误响应。
应用场景:DriveLerpProcessInfoNodeItem.vue中的复选框
TypeScript
<template>
<div
class="process-info-item-container"
@click="handleClick" <!-- 父级点击事件 -->
>
<t-checkbox
class="process-info-item-checkbox"
v-model="localCtrlEnabled"
@change="handleCtrlEnabledChange"
@click.stop <!-- 关键:阻止click事件冒泡到父级 -->
/>
<!-- ... -->
</div>
</template>
<script setup lang="ts">
const handleClick = (event: MouseEvent) => {
// 点击容器时设置选中状态
event.stopPropagation();
if (props.info?.id && selectedId) {
selectedId.value = props.info.id;
}
};
const handleCtrlEnabledChange = (enabled: boolean) => {
// 复选框状态变化时调用命令
if (!props.info || isSyncing) return;
try {
isSyncing = true;
if (typeof props.info.cmdSetCtrlEnabled === 'function') {
props.info.cmdSetCtrlEnabled(enabled);
}
} finally {
isSyncing = false;
}
};
</script>
原理分析:
-
点击复选框时,会同时触发
t-checkbox的change事件和click事件 -
click.stop阻止click事件向上冒泡到父级div -
父级div的
handleClick只响应容器本身的点击,用于选中该行 -
复选框的
handleCtrlEnabledChange独立处理启用/禁用逻辑
类似应用 :在DC_TrackItem.vue等组件中也广泛使用此技巧,确保交互精确可控。
技巧4:v-bind在CSS中的灵活运用------动态样式计算
Vue 3的v-bind在<style>中支持直接绑定响应式变量,避免了大量内联样式,使模板更简洁。本项目多处使用此特性实现动态布局。
应用实例1:DriveLerpControllerDialog.vue中动态宽高
TypeScript
<template>
<div class="custom-dialog-container" :style="dialogStyle">
<!-- ... -->
</div>
</template>
<script setup lang="ts">
const dialogStyle = computed(() => ({
width: `${curDialogWidth.value}px`,
height: `${dialogHeight}px`,
left: `${dialogPosition.value.x}px`,
top: `${dialogPosition.value.y}px`
}))
</script>
<style scoped>
.dc-editing-input-area {
width: v-bind('timeInputAreaWidth + "px"'); /* 动态绑定180px */
}
.dc-editing-track-area {
width: v-bind('timeTrackAreaWidth + "px"'); /* 动态绑定1138px */
}
</style>
应用实例2:StartDurationTrack.vue中使用注入的常量
TypeScript
<style scoped>
.handle-left {
left: calc(-1 * v-bind(MIN_LEFT_OFFSET) * 1px); /* 计算为-3px */
}
.handle-right {
left: 0;
transform: translateX(calc(-1 * v-bind(MIN_LEFT_OFFSET) * 1px));
}
</style>
优势:
-
响应式:变量变化自动更新样式,无需手动操作DOM
-
可读性:将样式与逻辑分离,模板更清晰
-
性能:Vue自动优化,避免不必要的重排重绘
高级用法 :在DC_EditingTrack.vue中结合计算属性:
TypeScript
<style scoped>
.track-content {
width: v-bind('trackContentWidth + "px"'); /* 动态计算最大宽度 */
}
</style>
技巧5:鼠标拖拽的完整实现模式
拖拽是编辑器核心交互。本项目抽象出了一套标准的拖拽三件套:startDrag → onDrag → stopDrag,并处理边界约束和性能优化。
完整实现:DriveLerpControllerDialog.vue的对话框拖拽
TypeScript
<script setup lang="ts">
const startDrag = (e: MouseEvent) => {
if (!dialogRef.value) return
isDragging.value = true
dragStartPos.value = { x: e.clientX, y: e.clientY }
dialogStartPos.value = { ...dialogPosition.value }
// 在document上监听,确保鼠标移出窗口也能捕获
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
e.preventDefault()
e.stopPropagation()
}
const onDrag = (e: MouseEvent) => {
if (!isDragging.value) return
const deltaX = e.clientX - dragStartPos.value.x
const deltaY = e.clientY - dragStartPos.value.y
const newX = dialogStartPos.value.x + deltaX
const newY = dialogStartPos.value.y + deltaY
clampPos(newX, newY) // 边界约束
e.preventDefault()
e.stopPropagation()
}
const clampPos = (x: number, y: number) => {
const minX = 180 - curDialogWidth.value
const maxX = window.innerWidth - 180
const maxY = window.innerHeight - 88
// 确保对话框不超出视口
dialogPosition.value = {
x: Math.max(minX, Math.min(x, maxX)),
y: Math.max(0, Math.min(y, maxY))
}
}
const stopDrag = (e?: MouseEvent) => {
isDragging.value = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
if (e) {
e.preventDefault()
e.stopPropagation()
}
}
</script>
轨道拖拽实现:StartDurationTrack.vue的三段式拖拽
TypeScript
<script setup lang="ts">
// 拖拽主体(移动start)
const handleDurationBodyMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault(); e.stopPropagation();
isDraggingBody.value = true;
dragStartX.value = e.clientX;
dragStartStart.value = props.info.start;
dragStartDuration.value = props.info.duration;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
// 拖拽左侧(调整start和duration)
const handleLeftHandleMouseDown = (e: MouseEvent) => {
isDraggingLeft.value = true;
// ... 类似逻辑
}
// 拖拽右侧(调整duration)
const handleRightHandleMouseDown = (e: MouseEvent) => {
isDraggingRight.value = true;
// ... 类似逻辑
}
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - dragStartX.value;
const deltaTime = deltaX * PIXEL_TO_SECOND; // 像素转时间
if (isDraggingBody.value) {
let newStart = dragStartStart.value + deltaTime;
newStart = Math.max(0, newStart); // 约束最小值
props.info.setStart(newStart);
// 自动滚动,确保元素可见
if (requestAutoScroll) {
requestAutoScroll(startWidth.value, durationWidth.value, 'body');
}
}
// ... 左右拖拽逻辑
}
</script>
关键要点:
-
在
document上监听mousemove/mouseup,避免鼠标离开元素后失效 -
使用
e.button !== 0过滤非左键点击 -
拖拽时添加
dragging类名,实现视觉反馈 -
边界约束和自动滚动提升用户体验
技巧6:CSS filter: brightness() 实现hover效果
传统hover效果使用背景色变化,但在深色主题中效果不明显。本项目使用brightness()滤镜实现更细腻的明暗变化,无需定义多个颜色变量。
应用实例:StartDurationTrack.vue的轨道条
TypeScript
<style scoped>
.duration-bar {
/* 内部浮雕效果 */
box-shadow:
inset 0 1px 3px rgba(255, 255, 255, 0.25),
inset 0 -2px 2px rgba(0, 0, 0, 0.5);
}
.duration-bar:hover {
filter: brightness(1.4) !important; /* 悬停时提亮40% */
}
.duration-bar.dragging {
filter: brightness(1.5) !important; /* 拖拽时更亮 */
cursor: grabbing;
}
.handle:hover {
filter: brightness(1.2) !important; /* 拖拽手柄提亮 */
}
.handle.dragging {
filter: brightness(1.25) !important;
}
</style>
应用实例:DC_TopBar.vue的工具按钮
TypeScript
<style scoped>
.tool-btn:hover {
background-color: var(--td-brand-color-11);
/* 添加白色边框效果 */
border: 1px solid rgba(255, 255, 255, 0.2);
}
.tool-btn:hover :deep(svg) {
filter: brightness(1.2); /* 图标提亮 */
}
</style>
优势:
-
单一颜色源:只需定义基础色,hover效果自动计算
-
视觉效果统一:所有元素使用相同的提亮比例,风格一致
-
性能:CSS滤镜由GPU加速,比JS计算更高效
技巧7:CSS calc() 实现动态计算
在无法直接使用v-bind或需要复杂计算时,calc()结合CSS变量实现动态样式。本项目在拖拽手柄定位中大量使用。
应用实例:StartDurationTrack.vue的左右手柄定位
TypeScript
<template>
<div class="duration-bar-container" :style="{ left: startWidth + 'px' }">
<!-- 左侧拖拽竖条 -->
<div class="handle handle-left"
:class="{ 'dragging': isDraggingLeft }"
@mousedown="handleLeftHandleMouseDown">
</div>
<!-- 持续时间条主体 -->
<div class="duration-bar"
:style="{ width: durationWidth + 'px' }">
</div>
<!-- 右侧拖拽竖条 -->
<div class="handle handle-right"
:style="{ left: durationWidth + 'px' }"
@mousedown="handleRightHandleMouseDown">
</div>
</div>
</template>
<style scoped>
.handle-left {
left: calc(-1 * v-bind(MIN_LEFT_OFFSET) * 1px); /* 计算为-3px,确保手柄可见 */
}
.handle-right {
left: 0;
transform: translateX(calc(-1 * v-bind(MIN_LEFT_OFFSET) * 1px)); /* 向右偏移3px */
}
.start-duration-track {
height: 28px;
}
.duration-bar-container {
position: absolute;
top: 0;
height: 100%;
}
</style>
计算逻辑:
-
MIN_LEFT_OFFSET = 3(像素) -
左手柄
left: -3px,使手柄从轨道左侧露出 -
右手柄使用
translateX(-3px),避免改变布局流
应用实例:DriveLerpControllerDialog.vue的响应式布局
TypeScript
<style scoped>
.dc-editing-input-area {
width: v-bind('timeInputAreaWidth + "px"'); /* 180px */
}
.dc-editing-track-area {
width: v-bind('timeTrackAreaWidth + "px"'); /* 1138px */
}
.main-content-area {
height: calc(100% - 36px); /* 减去头部高度 */
}
</style>
技巧8:计算属性实现复杂动态样式
对于需要多个变量组合计算的样式,使用computed属性集中处理,避免模板臃肿。
应用实例:DriveLerpControllerDialog.vue的完整对话框样式
TypeScript
<script setup lang="ts">
const dialogBaseWidth = 1680
const timeInputAreaWidth = 180
const timeTrackAreaWidth = 1138
const dialogHeight = 800
const curDialogWidth = ref(dialogBaseWidth)
const dialogStyle = computed(() => ({
width: `${curDialogWidth.value}px`,
height: `${dialogHeight}px`,
left: `${dialogPosition.value.x}px`,
top: `${dialogPosition.value.y}px`
}))
const toggleInputArea = () => {
const curCenterH = dialogPosition.value.x + curDialogWidth.value * 0.5
showInputArea.value = !showInputArea.value
updateDialogWidth()
const newX = curCenterH - curDialogWidth.value * 0.5
clampPos(newX, dialogPosition.value.y) // 保持中心点不变
}
const updateDialogWidth = () => {
let width = dialogBaseWidth
if (!showInputArea.value) {
width -= timeInputAreaWidth // 隐藏时减去180px
}
if(!showDragBarArea.value) {
width -= timeTrackAreaWidth // 隐藏时减去1138px
}
curDialogWidth.value = width // 响应式更新
}
</script>
<template>
<div
ref="dialogRef"
class="custom-dialog-container"
:style="dialogStyle"
@mousedown="startDrag"
>
<!-- 动态内容区域 -->
<div class="dc-editing-input-area" v-if="showInputArea">
<DC_EditingInput :rootTreeNode="props.controller.rootTreeNode" />
</div>
<div class="dc-editing-track-area" v-if="showDragBarArea">
<DC_EditingTrack :controller="props.controller" />
</div>
</div>
</template>
优势:
-
单一数据源 :所有尺寸计算集中在
updateDialogWidth -
响应式联动 :宽度变化自动触发
dialogStyle重新计算 -
用户体验:切换区域时保持对话框中心点不变,避免视觉跳跃
技巧9:CSS容器查询实现响应式布局
传统响应式依赖视口宽度,但在组件化开发中,需要根据组件自身宽度 调整布局。DC_TopBar.vue使用CSS容器查询,在窄面板中隐藏耗时信息。
实现:DC_TopBar.vue
TypeScript
<template>
<div class="top-function-show-area">
<div class="center-section">
<!-- 播放控制按钮 -->
<div class="top-buttons-container">
<!-- ... -->
</div>
<!-- 速度选择 -->
<t-select class="multi-speed-select" v-model="multiSpeed" />
</div>
<!-- 时间信息 -->
<div class="process-info">
当前时间: {{ currentProcess.toFixed(2) }} 秒 / 总体时长: {{ totalProcess.toFixed(2) }} 秒
</div>
</div>
</template>
<style scoped>
.top-function-show-area {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
container-type: inline-size; /* 启用容器查询 */
}
.process-info {
position: absolute;
right: 22px;
color: #999 !important;
margin-left: auto;
user-select: none;
}
/* 当容器宽度小于800px时隐藏process-info */
@container (max-width: 799px) {
.process-info {
display: none; /* 窄容器下隐藏详细信息 */
}
}
</style>
工作原理:
-
container-type: inline-size声明该元素为查询容器 -
@container (max-width: 799px)基于容器宽度而非视口宽度应用样式 -
当顶部栏被压缩(如侧边栏展开)时,自动隐藏耗时信息,保留核心控制按钮
适用场景:
-
组件在不同父容器中有不同表现
-
局部布局调整无需全局媒体查询
-
更细粒度的响应式设计
三、开发心得与最佳实践
1. 架构设计原则
-
关注点分离:数据层(Babylon Behavior)、逻辑层(Manager)、视图层(Vue)清晰分离
-
命令模式:所有状态变更通过Command封装,天然支持撤销/重做
-
事件驱动:使用Observable替代watch,性能更优、耦合更低
2. 性能优化技巧
-
组件懒加载 :
Teleport配合v-if延迟渲染对话框 -
按需更新 :通过
requestAutoScroll只在需要时滚动容器 -
避免重复计算 :使用
trackWidthMap缓存子组件宽度,减少重排
3. 可维护性建议
-
类型安全 :全程使用TypeScript,定义清晰的接口(如
DriveType、PlayState) -
命名规范 :
cnName属性统一中文显示名,便于UI国际化 -
代码复用 :抽取
useDriveLerpColor等composable,避免重复逻辑
4. 交互体验细节
-
视觉反馈 :拖拽时
brightness变化、添加dragging类名 -
防误触 :
e.button !== 0过滤非左键、e.stopPropagation()阻止冒泡 -
智能滚动 :
requestAutoScroll确保拖拽元素始终可见
以上技巧和架构设计不仅适用于3D编辑器,也可推广到任何复杂的交互式前端应用开发中。