prophetk-3d-viewer
Version:
A Three.js r128 based GLB/GLTF model viewer for WeChat Mini Program and Web.
378 lines (305 loc) • 17.8 kB
Markdown
# 模型交互与相机控制
适用于 `prophetk-3d-viewer` 0.4.0,微信小程序和 Web 共用同一套交互逻辑。
## 场景配置
```js
const sceneConfig = {
environment: {
url: '',
showSkybox: false,
showDragGrid: true,
showBounds: true,
},
camera: {
controlMode: 'orbit',
position: [0, 1.5, 3],
lookAt: [0, 0, 0],
fov: 60,
},
models: [
{
id: 'accessory',
url: 'demoRes/bucket.glb',
isMovable: true,
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
};
await viewer.loadScene(sceneConfig);
```
`viewer` 可以是微信页面通过 `this.selectComponent('#viewer')` 得到的组件、
Web 的 `createWebModelViewer()` 返回值,或核心 `ModelRenderer` 实例。
| 配置项 | 默认值 | 含义 |
| --- | --- | --- |
| `models[].isMovable` | `false` | 仅显式设置为 `true` 的模型支持选中和拖动;一个 GLB 整体作为一个对象 |
| `models[].invisible` | `false` | 不渲染模型及后代,但保留点击、选择和拖动逻辑;仍需分别配置 `clickEvent`、`isMovable` |
| `models[].clickEvent` | 无 | 非空事件名启用点击,与能否拖动无关 |
| `models[].parent` | `null` | 父模型 ID;位置、旋转、缩放相对父模型,父级未就绪时暂不显示 |
| `camera.controlMode` | `'orbit'` | 空白或不可移动模型上的拖动:`orbit` 为绕点旋转,`pan` 为 XZ 平移 |
| `environment.showDragGrid` | `true` | 拖动模型时显示水平参考网格 |
| `environment.showBounds` | `true` | 显示当前选中模型的包围框 |
显示开关只控制辅助图形,不影响选择与移动。每次 `loadScene()` 使用新场景的配置;
省略这些字段时恢复表中的默认值。配置对象不会被交互逻辑自动改写。
## 不可见模型的交互
模型配置支持 `invisible: true`,适用于不可见的点击区域。`loadScene()` 和
`loadModel()` 均支持,小程序和 Web 行为一致:
```js
await viewer.loadModel({
id: 'hotspot',
url: 'hotspot.glb',
invisible: true,
isMovable: false,
clickEvent: 'openDetails',
});
```
此模型不绘制颜色、深度或阴影,仍按真实网格表面检测点击,事件格式不变。
`invisible` 不会自动开启点击或拖动:点击需要非空 `clickEvent`,选择和拖动需要
`isMovable: true`。包围框和拖动网格仍由 `showBounds`、`showDragGrid` 控制。
不可见模型仍参与拾取遮挡,前方不可见模型不会被穿透点击;普通的
`Object3D.visible = false` 仍会禁止交互。不可见父模型会让其后代也不渲染,后代仍按
各自配置响应交互;父模型尚未加载完成的子模型依然不显示、不参与交互。
省略 `invisible` 或设为 `false` 保持原有显示行为,模型资源仍需加载以用于表面拾取。
## 模型父子关系
`models[].parent` 可以指定另一个模型 ID,支持多级层级,配置顺序不限。
子模型的 `position`、`rotation`(角度制)和 `scale` 都是相对于父模型的 local TRS。
不配置 `parent` 或设置为 `null` 时,模型属于场景根节点。
```js
await viewer.loadScene({
models: [
// 子模型可以写在父模型前面。
{ id: 'accessory', parent: 'body', url: 'accessory.glb',
position: [0, 1, 0], rotation: [0, 90, 0], scale: [0.4, 0.4, 0.4],
isMovable: false, clickEvent: 'openDetails' },
{ id: 'body', url: 'body.glb', isMovable: true },
],
});
viewer.SetParent('accessory', null); // 回到场景根节点
viewer.setParent('accessory', 'body'); // 两个接口名称等价
```
`SetParent(modelId, parentId)` 同步生效,返回 `void`。换父级或解除父级时保持
local TRS 数值不变,世界姿态可能改变。未知子模型 ID、自引用、循环引用和无效父级
参数会报错;子模型已提交加载但尚未完成时也可以换父级。微信组件尚未初始化时调用
会报 `renderer not ready`,此时应通过加载配置指定 `parent`。
**父模型未就绪时,子模型不显示、不参与交互,但可以后台加载资源。**
`loadModel()` 在自身资源就绪后完成,`loadScene()` 在本批资源及场景加载任务完成后
完成;两者均不等待尚未提交的外部父模型,因而可以这样顺序加载:
```js
await viewer.loadModel({ id: 'child', parent: 'parent', url: 'child.glb', normalize: false });
// 此时 child 已加载,但不可见。
await viewer.loadModel({ id: 'parent', url: 'parent.glb', normalize: false });
// parent 及其祖先就绪后,child 自动挂接显示。
```
父模型失败时对应加载 Promise reject,子模型继续等待;可以用相同 ID 重试失败的父模型。
其他已存在或正在加载的模型 ID 不允许重复。场景中的重复 ID 和循环关系会在清空旧场景前
拒绝。无关模型正常加载,父级缺失不会让子模型临时出现在场景原点。
父子模型仍独立响应点击和拖动,使用各自的 `clickEvent`、`isMovable`;点击事件不向
父模型冒泡。拖动父模型时后代一起移动,拖动子模型时修改其 local position,保持世界
Y 不变。父级变换不可逆时停止更新拖动位置。包围框与贴花只作用于模型自己的网格。
贴花尺寸计算也排除独立子模型;模型文件自带的 `modelId` 元数据不影响网格归属。
换父级会取消受影响的手势,拖动取消事件的 `reason` 为 `parentchange`。
删除父模型会级联删除全部后代,包括等待或正在加载的模型;即使父模型尚不存在,
`clearModel('parent')` 也能清理等待它的后代。返回数量为移除的模型记录数。
被删除、清空、重载或销毁取消的加载 Promise 会 reject,迟到的资源会释放而不会显示。
`clearModel('value')` 优先按模型 ID(包括尚未加载的父模型 ID)匹配,再尝试按 URL 匹配。
要严格按 URL 删除所有同源模型,使用 `clearModel({ url: 'asset.glb' })`;即使其他模型
恰好使用同名 ID,也不会误删。`clearModel({ id: 'body' })` 显式按 ID 删除。
所有形式均会级联删除匹配模型的后代。
`getModelTransforms()` 导出子模型时包含 `parent` 及 local TRS;根模型保持原有结构。
等待父级但已加载的子模型也会导出。默认演示中的 `box3` 是 `box1` 的不可拖动子模型,
可点击,并可通过页面按钮挂接或解除父级。
## 模型选择和移动
| 操作 | 结果 |
| --- | --- |
| 点击可移动模型 | 单选该模型,再点击另一个可移动模型可切换选择 |
| 按住可移动模型并移动 | 同一次手势完成选中和拖动,不需要长按等待 |
| 点击空白或不可移动模型 | 取消选择 |
| 拖动空白或不可移动模型 | 操作相机,保留已有选择 |
| 双指张开/收拢 | 拉近/拉远相机 |
| 操作画布按钮或滑块 | 优先响应控件 |
拖动超过 8 个 CSS 像素才开始移动,避免手指抖动。模型保持世界坐标 Y 不变,
只在 XZ 平面平移,旋转与缩放不变;保留原始抓取偏移,不会跳到手指中心。
手势开始后用途固定,移出模型轮廓仍继续拖动,从空白开始的操作也不会中途抓住模型。
拾取使用包围盒初筛和模型表面检测。手机触摸额外采样半径 12 CSS 像素的区域,
方便选中小配饰;其他模型仍参与遮挡判断,隐藏模型不参与拾取。
拖动中加入第二根手指,会保留当前位置并结束移动,再进入缩放。
双指变回单指后需全部松开,再开始新手势。系统取消触摸、窗口或页面失焦、
画布尺寸改变会取消本次拖动并恢复起始位置。
当拾取射线与移动平面夹角小于 10°,或无法得到前方有效交点时,停止更新位置,
保留最后有效位置,并提示稍微俯视后再拖动;不会自动改变相机角度。
### 辅助网格与 Bounds
水平网格覆盖相机可见的整个平面,高度固定在开始拖动时的模型底部。
参考线固定在世界坐标,不跟随模型移动;间距按视野尺度确定,远处密集线条淡出。
网格使用两个三角形绘制,支持 WebGL 1 无标准导数扩展时的兼容渲染。
- **网格不被模型遮挡**,用于展示完整的移动参考平面。
- **Bounds 保留深度遮挡**,位于模型后方的线段会被写入深度的几何体挡住。
- 两类辅助图形不写入深度,也不参与模型拾取;画布 UI 显示在它们上方。
- 松手后网格隐藏,选中框保留;取消选择、删除模型或重载场景会清理对应辅助图形。
```js
viewer.getSelectedModelId(); // 当前选中的模型 ID,未选中时返回 null
viewer.clearSelection(); // 取消选择;若正在拖动,同时撤销这一次移动
```
## 相机模式与缩放
```js
viewer.setCameraControlMode('pan');
viewer.setCameraControlMode('orbit');
const mode = viewer.getCameraControlMode();
```
`orbit` 围绕当前观察点旋转。`pan` 同时沿世界 XZ 平面平移相机和观察点,
保持方向、高度和观察距离不变。操作类似拖地图:向右拖动画面,相机向左移动;
上下拖动沿相机的水平方向调整远近位置。
模式切换不重置相机位置;切回旋转后绕平移后的观察点旋转。
若正在拖动相机,切换模式后需先松手;模型拖动、UI 和双指缩放不受模式切换影响。
不支持的模式值会抛出 `RangeError`。
微信组件允许在渲染器初始化前调用切换接口。调用 `loadScene()` 后、场景读取期间
再次设置的模式优先于该次读取到的配置;后续主动加载另一个场景则采用新场景的模式。
Web 或核心渲染器加载远程场景时,建议 `await loadScene()` 后再切换模式。
### Ctrl+滚轮
Web 和微信开发者工具的 Android 模拟器均支持:鼠标放在画布内,
Ctrl+滚轮向上拉近,向下拉远。普通滚轮也支持相同操作。
两个相机模式下均有效,与双指缩放共用相机距离限制:1–30 个世界单位。
缩放调整相机到观察点的距离,不缩放模型、不改变观察点与视角方向。
平移模式的固定高度约束只针对单指平移,缩放仍沿观察方向改变距离。
正在拖动、操作控件或使用触摸手势时,滚轮暂不生效,以防操作冲突。
Web 在画布内阻止浏览器页面缩放和滚动;微信组件通过 `catch:wheel` 转发
`event.detail`,因此不依赖模拟器是否提供 `ctrlKey` 字段。
需要自行对接输入时,核心提供:
```js
renderer.onWheel({ deltaY: -100, deltaMode: 0, ctrlKey: true });
// deltaY:负值拉近、正值拉远。
// deltaMode:0 像素(默认),1 行,2 页。
// 返回 boolean,表示本次是否实际改变了相机距离。
```
## 获取所有模型的 Transform JSON
```js
const json = viewer.getModelTransforms();
const transforms = JSON.parse(json);
```
返回值是 **JSON 字符串**,内容为数组。例如(以下为了便于阅读进行了排版):
```json
[
{
"id": "accessory",
"position": [1, 0, 2],
"rotation": [0, 90, 0],
"scale": [1, 1, 1]
}
]
```
- 导出当前已经加载成功的全部模型,包含不可移动模型及等待父级的子模型。
- 子模型包含 `parent` 字段;场景根模型省略该字段。
- `position` 为相对父节点的位置,`rotation` 为 Euler XYZ **角度制**,`scale` 为三轴缩放。
- 旋转由当前四元数换算,数值可能与最初输入不同,但代表相同姿态。
- 不包含相机、灯光、辅助网格、Bounds、UI 或单个 GLB 内的子网格。
- 空场景、尚未初始化的微信组件返回字符串 `"[]"`。
- 拖动期间读取的是实时位置;如需保存已确认的位置,在 `dragend` 中读取。
- 方法只返回快照,不修改配置、不自动写入存储,也不自动上传。
这是 Transform 快照,不是完整场景 JSON。应使用稳定且唯一的模型 `id`,
将快照合并到原配置,保留 `url`、`isMovable` 等字段:
```js
const byId = new Map(JSON.parse(viewer.getModelTransforms()).map(item => [item.id, item]));
const savedScene = {
...sceneConfig,
models: sceneConfig.models.map(item => {
const transform = byId.get(item.id);
if (!transform) return item;
// 根模型快照省略 parent,需要显式清除原配置中的父级。
return { ...item, ...transform, parent: transform.parent === undefined ? null : transform.parent };
}),
};
// 之后可以 await viewer.loadScene(savedScene)。
```
通过 `loadModel()` 追加的模型也会被导出,应用需另行保存其模型来源配置。
TypeScript 中解析后的元素类型为包导出的 `ModelTransform`。
## 模型事件
### 配置点击事件
模型配置中的 `clickEvent` 是自定义事件名,与 `isMovable` 无关。
不可拖动模型也可以点击;省略或留空时不发送点击事件。
`loadScene()` 的 JSON 配置和 `loadModel()` 的模型参数均支持此字段:
```js
const model = {
id: 'box1',
url: 'demoRes/bucket.glb',
isMovable: false,
clickEvent: 'openDetails',
};
await viewer.loadModel(model);
```
微信小程序通过现有事件绑定调用页面中的自定义函数:
```xml
<model-viewer id="viewer" bind:modelevent="onViewerModelEvent" />
```
```js
Page({
onViewerModelEvent(e) {
const detail = e.detail;
if (detail.type === 'click' && detail.event === 'openDetails') {
this.openDetails(detail.modelId);
}
},
openDetails(modelId) {
wx.showToast({ title: '点击模型:' + modelId, icon: 'none' });
},
});
```
Web 使用构造参数回调(也可监听画布的 `modelevent` DOM 事件):
```js
const { createWebModelViewer } = require('prophetk-3d-viewer/web');
const viewer = createWebModelViewer({
container: document.querySelector('#viewer'),
onModelEvent(detail) {
if (detail.type === 'click' && detail.event === 'openDetails') {
openDetails(detail.modelId);
}
},
});
function openDetails(modelId) {
console.log('点击模型:', modelId);
}
```
按下、松开命中同一模型且手势未超过 8 CSS 像素移动阈值时,松手发送一次
`{ type: 'click', modelId, event }`;重复点击已选中模型仍触发。
点击以整个模型为单位,沿用表面拾取与触摸容差,不穿透前方模型。
滑动、双指操作、取消手势、隐藏或移除模型不会触发点击,画布 UI 优先响应。
不可拖动模型上的滑动仍操作相机。事件名由页面显式分发,不会自动执行同名函数。
### 事件列表
微信组件使用 `bind:modelevent`;Web 与核心构造参数使用 `onModelEvent` 回调,
Web 画布也会发送 `modelevent` DOM 事件。
| `type` | 时机与数据 |
| --- | --- |
| `click` | 配置了 `clickEvent` 的模型被点击;包含 `modelId` 和 `event`(配置的事件名),不要求模型可拖动 |
| `selectionchange` | 选择发生变化;包含 `modelId`(可为 `null`)、`previousModelId`、`reason` |
| `dragstart` | 超过移动阈值且平面计算有效,开始实际拖动 |
| `drag` | 位置变化,最多每渲染帧通知一次 |
| `dragend` | 正常松手或转入双指操作,包含最终位置 |
| `dragcancel` | 本次拖动已撤销,位置恢复到起点 |
| `dragblocked` | 当前角度无法稳定移动;包含 `reason: 'low-angle'` 和提示 `message` |
移动相关事件均包含 `modelId`、`startPosition`、`position`;坐标与模型配置相同,
均为父级局部坐标。`dragend.reason` 为 `release` 或 `multitouch`。
取消原因包括 `touchcancel`、`blur`、`resize`、`selectioncleared`、`modelremoved`、
`scenechange`、`dispose`、`parentchange`。除 `drag` 外的事件即时发送;未开始实际移动就受阻时,
只发送 `dragblocked`,不会补发 `dragstart` 或 `dragend`。
```xml
<model-viewer id="viewer" bind:modelevent="onModelEvent" />
```
```js
Page({
onModelEvent(e) {
if (e.detail.type === 'dragend') {
const json = this.selectComponent('#viewer').getModelTransforms();
wx.setStorageSync('model-transforms', json);
}
},
});
```
## 本地资源、升级与边界
微信端支持 `demoRes/bucket.glb` 或 `./demoRes/bucket.glb`,相对于小程序根目录,
直接通过文件系统读取,不进行下载与远程缓存。Web 的相对地址按浏览器页面 URL 解析。
远程地址保留现有下载与缓存行为,微信端仍需配置合法域名。
仓库默认场景使用 `demoRes/plane.glb` 和 `demoRes/bucket.glb`。这些演示资源不随 npm
发布;接入项目应使用自己的模型和场景配置,或自行复制对应文件。
升级后在微信开发者工具中重新“构建 npm”并编译项目,确保新增的核心模块一并加载。
本版本支持静态 GLB 整体交互,不包含子节点编辑、碰撞、吸附、移动范围限制、
升降、旋转/缩放编辑或撤销历史。选择模型不改变贴花目标,投影贴花维持世界空间行为。
运行 `npm run check` 可检查包入口、点击和拖动、模型层级、异步加载取消、级联清理、
按 URL 删除、元数据兼容、贴花尺寸隔离、Transform 导出以及两端输入适配。