bricks/bricks/scene3d.js
yumoqing 406ed6f17a feat(scene3d): Scene3D 接入脚本引擎 + 按需注入 three.js + 自写环绕相机
- engine.js/inherit.js 以 UMD 双分支引入(node 测试照跑 + 浏览器拼进 bricks.js)
- Scene3D 新增本地逻辑引擎(独立世界):描述符 properties/events 喂 SceneRuntime,
  点击→tap 注入、每帧 step 推进、transform/visible 回填 three.js
- three.min.js 按需注入(非 3D 页面不背 608KB)
- 自写环绕相机:单指旋转/双指缩放/双指平移(three.js r148+ 移除 UMD OrbitControls)
- 新增 API:set_entity_transform / set_paused / reset_runtime
- 已验证:门铃示例(定时+条件+点击+moveTo+属性变化)、相机旋转全链路
2026-08-27 15:41:51 +08:00

468 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

var bricks = window.bricks || {};
/*
* scene3d.js — bricks3d 核心 widgetScene3D
*
* 三层职责:
* 1. 渲染:用 three.js 把后端下发的场景描述符(实体/变换/颜色/相机)渲染出来
* 2. 输入上报:点击实体 → dispatch('entity_tapped', {entity_id, entity_name}) → 走 binds 回传后端
* 3. 本地逻辑(独立世界):描述符若带 properties/events则用 bricks.ScriptEngine.SceneRuntime
* 在浏览器里本地跑脚本引擎,每帧 step() 推进,把 transform/visible 状态回填 three.js
*
* 架构(独立世界 / 共享世界):
* - 独立世界:数据全下前端,本地跑引擎(本文件第 3 层职责)
* - 共享世界:后端权威世界副本,前端只渲染 + 上报输入,后端推送状态更新(本文件第 1/2 层职责)
*
* options全部 get_ 前缀,避开 opts_set_style 的 option→实例拷贝冲突
* scene : 内联场景描述符对象
* scene_url : 后端数据端点(优先于 scene返回场景描述符
* get_bgcolor : 背景色(默认 0x141420
* get_grid : 是否显示地面网格(默认 true
* get_run_local : 是否本地跑脚本引擎(独立世界=true共享世界/纯渲染=false默认 false
* get_three_url : three.js 的 URL默认 /bricks/3parties/three.min.js按需注入用
*
* 场景描述符格式(渲染子集 + 逻辑子集统一):
* {
* "camera": {"position": [x,y,z], "fov": 50},
* "entities": [
* {"id": "door", "name": "门", "type": "box",
* "transform": {"pos": [x,y,z], "rot": [deg,deg,deg], "scale": [x,y,z]},
* "appearance": {"color": "#3b82f6"},
* "properties": {"hasVisitor": {"type":"boolean","value":true}}, // 逻辑子集(可选)
* "events": [ ... ] // 逻辑子集(可选)
* }
* ],
* "events": [ ... ] // 场景级事件(可选)
* }
*
* dispatch 的事件:
* entity_tapped {entity_id, entity_name}
* scene_ready {entity_count}
* scene_error {message}
*
* three.js 依赖:/bricks/3parties/three.min.jsr149 UMD 全局版),构造时按需注入。
* 活动对象scene/camera/renderer/mesh只存实例私有字段绝不进 opts/描述符
* (防 JSON.stringify 循环引用 —— bricks 已知坑)。
*/
// 全局 three.js 加载 Promise多实例共享避免重复注入 script
bricks._s3_three_promise = bricks._s3_three_promise || null;
bricks.Scene3D = class extends bricks.VBox {
constructor(opts) {
opts.width = opts.width || '100%';
opts.height = opts.height || '100%';
super(opts);
this.dom_element.style.display = 'flex';
this.dom_element.style.flexDirection = 'column';
this.dom_element.style.overflow = 'hidden';
this.dom_element.style.touchAction = 'none'; // 移动端手势:禁用浏览器默认缩放/滚动
// three.js 活动对象 —— 私有字段,不进 opts
this._s3_scene = null;
this._s3_camera = null;
this._s3_renderer = null;
this._s3_meshes = {}; // entity_id → THREE.Mesh
this._s3_raf = 0;
this._s3_raycaster = null;
this._s3_pointer = null;
this._s3_ready = false;
// 引擎(独立世界本地逻辑)
this._s3_runtime = null; // SceneRuntime 实例
this._s3_last_t = 0; // 上一帧时间戳step 用)
this._s3_paused = false;
// 相机控制状态
this._s3_ctrl = { rotX: 0, rotY: 0, dist: 12, targetX: 0, targetY: 1, targetZ: 0, lastPX: 0, lastPY: 0, pinchDist: 0, mode: 'rotate' };
this._s3_cam_pos = [8, 6, 10]; // 当前相机位置(环绕球坐标计算)
schedule_once(this.s3_build.bind(this), 0.1);
}
// ── three.js 按需注入 ──
s3_load_three() {
if (typeof THREE !== 'undefined') return Promise.resolve(THREE);
if (bricks._s3_three_promise) return bricks._s3_three_promise;
var url = this.opts.get_three_url || '/bricks/3parties/three.min.js';
bricks._s3_three_promise = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = url;
s.onload = function () { if (typeof THREE !== 'undefined') resolve(THREE); else reject(new Error('THREE 未定义')); };
s.onerror = function () { bricks._s3_three_promise = null; reject(new Error('three.js 加载失败: ' + url)); };
document.head.appendChild(s);
});
return bricks._s3_three_promise;
}
async s3_build() {
try { await this.s3_load_three(); }
catch (e) { this.s3_show_error(e.message); this.dispatch('scene_error', { message: String(e) }); return; }
var desc = null;
if (this.opts.scene_url) {
try {
var jc = new bricks.HttpJson();
desc = await jc.get(this.opts.scene_url);
} catch (e) {
this.s3_show_error('场景数据加载失败: ' + this.opts.scene_url);
this.dispatch('scene_error', { message: String(e) });
return;
}
} else {
desc = this.opts.scene || null;
}
if (!desc) {
this.s3_show_error('未提供场景(需要 scene 或 scene_url');
return;
}
try {
this.s3_init_gl(desc);
this.s3_build_entities(desc.entities || []);
this.s3_bind_input(desc);
this.s3_start_runtime(desc);
this.s3_loop();
this._s3_ready = true;
this.dispatch('scene_ready', { entity_count: (desc.entities || []).length });
} catch (e) {
console.error('Scene3D build error', e);
this.s3_show_error('场景构建失败: ' + e.message);
this.dispatch('scene_error', { message: String(e) });
}
}
s3_init_gl(desc) {
var el = this.dom_element;
var w = el.clientWidth || 640;
var h = el.clientHeight || 400;
var bg = this.opts.get_bgcolor || 0x141420;
var scene = new THREE.Scene();
scene.background = new THREE.Color(bg);
var cam = desc.camera || {};
var camera = new THREE.PerspectiveCamera(cam.fov || 50, w / h, 0.1, 1000);
this._s3_camera = camera; // 先赋值s3_apply_camera 依赖它
var p = cam.position || [8, 6, 10];
this._s3_cam_pos = p.slice();
// 初始化环绕球参数(由初始相机位置反推)
var dx = p[0], dy = p[1] - 1, dz = p[2]; // 目标点默认 [0,1,0]
var dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
this._s3_ctrl.dist = dist || 12;
this._s3_ctrl.rotY = Math.atan2(dx, dz); // 方位角(绕 Y
this._s3_ctrl.rotX = Math.asin(Math.max(-1, Math.min(1, dy / (dist || 1)))); // 俯仰角
this.s3_apply_camera();
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
el.appendChild(renderer.domElement);
renderer.domElement.style.display = 'block';
// 光照
var amb = new THREE.AmbientLight(0xffffff, 0.55);
var dir = new THREE.DirectionalLight(0xffffff, 0.9);
dir.position.set(5, 10, 7);
scene.add(amb);
scene.add(dir);
// 地面网格(编辑态辅助)
if (this.opts.get_grid !== false) {
var grid = new THREE.GridHelper(20, 20, 0x444466, 0x2a2a40);
scene.add(grid);
}
this._s3_scene = scene;
this._s3_camera = camera;
this._s3_renderer = renderer;
this._s3_raycaster = new THREE.Raycaster();
this._s3_pointer = new THREE.Vector2();
this.bind('resize', this.s3_on_resize.bind(this));
}
// ── 环绕相机:单指旋转 / 双指缩放 / 双指平移 ──
s3_apply_camera() {
var c = this._s3_ctrl;
var cy = Math.cos(c.rotX), sy = Math.sin(c.rotX);
var cx = Math.cos(c.rotY), sx = Math.sin(c.rotY);
var x = c.targetX + c.dist * cy * sx;
var y = c.targetY + c.dist * sy;
var z = c.targetZ + c.dist * cy * cx;
this._s3_camera.position.set(x, y, z);
this._s3_camera.lookAt(c.targetX, c.targetY, c.targetZ);
this._s3_cam_pos = [x, y, z];
}
s3_bind_input(desc) {
var self = this;
var cv = this._s3_renderer.domElement;
this._s3_pointers = {}; // pointerId → {x, y}
var downX = 0, downY = 0; // 单指按下位置(点击判定用)
var downCount = 0;
cv.addEventListener('pointerdown', function (ev) {
cv.setPointerCapture && cv.setPointerCapture(ev.pointerId);
self._s3_pointers[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
var n = Object.keys(self._s3_pointers).length;
if (n === 1) {
downX = ev.clientX; downY = ev.clientY; downCount = 1;
self._s3_ctrl.pinchDist = 0;
self._s3_ctrl.pinchMid = null;
} else if (n === 2) {
// 进入双指:记录初始距离与中点
var ids = Object.keys(self._s3_pointers);
var p1 = self._s3_pointers[ids[0]], p2 = self._s3_pointers[ids[1]];
self._s3_ctrl.pinchDist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
self._s3_ctrl.pinchMid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
}
});
cv.addEventListener('pointermove', function (ev) {
if (!(ev.pointerId in self._s3_pointers)) return;
self._s3_pointers[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
var ids = Object.keys(self._s3_pointers);
if (ids.length === 2) {
var p1 = self._s3_pointers[ids[0]], p2 = self._s3_pointers[ids[1]];
var d = Math.hypot(p1.x - p2.x, p1.y - p2.y);
if (self._s3_ctrl.pinchDist > 0) {
var ratio = self._s3_ctrl.pinchDist / d;
self._s3_ctrl.dist = Math.max(1.5, Math.min(80, self._s3_ctrl.dist * ratio));
}
self._s3_ctrl.pinchDist = d;
var mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
if (self._s3_ctrl.pinchMid) {
self._s3_pan(mx - self._s3_ctrl.pinchMid.x, my - self._s3_ctrl.pinchMid.y);
}
self._s3_ctrl.pinchMid = { x: mx, y: my };
} else if (ids.length === 1) {
var dx = ev.clientX - self._s3_ctrl.lastPX;
var dy = ev.clientY - self._s3_ctrl.lastPY;
self._s3_ctrl.rotY -= dx * 0.005;
self._s3_ctrl.rotX += dy * 0.005;
self._s3_ctrl.rotX = Math.max(-1.4, Math.min(1.4, self._s3_ctrl.rotX));
self.s3_apply_camera();
}
self._s3_ctrl.lastPX = ev.clientX;
self._s3_ctrl.lastPY = ev.clientY;
});
cv.addEventListener('pointerup', function (ev) {
delete self._s3_pointers[ev.pointerId];
self._s3_ctrl.lastPX = ev.clientX;
self._s3_ctrl.lastPY = ev.clientY;
self._s3_ctrl.pinchDist = 0;
self._s3_ctrl.pinchMid = null;
downCount = 0;
});
cv.addEventListener('pointercancel', function (ev) {
delete self._s3_pointers[ev.pointerId];
self._s3_ctrl.pinchDist = 0;
self._s3_ctrl.pinchMid = null;
});
// 点击拾取(与拖拽区分:位移小于阈值才算点击)
cv.addEventListener('click', function (ev) {
if (Math.abs(ev.clientX - downX) > 6 || Math.abs(ev.clientY - downY) > 6) return; // 拖拽不算点击
if (!self._s3_ready) return;
var rect = cv.getBoundingClientRect();
self._s3_pointer.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
self._s3_pointer.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
self._s3_raycaster.setFromCamera(self._s3_pointer, self._s3_camera);
var hits = self._s3_raycaster.intersectObjects(Object.values(self._s3_meshes), false);
if (hits.length > 0) {
var ud = hits[0].object.userData;
if (self._s3_runtime) self._s3_runtime.tap(ud.entity_id); // 独立世界:注入引擎
self.dispatch('entity_tapped', { entity_id: ud.entity_id, entity_name: ud.entity_name });
}
});
}
s3_pan(dxpx, dypx) {
var c = this._s3_ctrl;
var scale = c.dist * 0.0012; // 平移速度随距离
var sy = Math.sin(c.rotX), cy = Math.cos(c.rotX);
var sx = Math.sin(c.rotY), cx = Math.cos(c.rotY);
// 相机右向量(水平,绕 Y
var rx = cx, rz = -sx;
// 相机上向量(简化)
var upx = -sy * sx, upy = cy, upz = -sy * cx;
c.targetX += (-rx * dxpx - upx * dypx) * scale;
c.targetY += (-upy * dypx) * scale;
c.targetZ += (-rz * dxpx - upz * dypx) * scale;
this.s3_apply_camera();
}
s3_build_entities(list) {
for (var i = 0; i < list.length; i++) {
var e = list[i];
var mesh = this.s3_make_mesh(e);
if (!mesh) continue;
mesh.userData.entity_id = e.id;
mesh.userData.entity_name = e.name || e.id;
this._s3_scene.add(mesh);
this._s3_meshes[e.id] = mesh;
}
}
s3_make_mesh(e) {
var t = e.transform || {};
var pos = t.pos || [0, 0, 0];
var rot = t.rot || [0, 0, 0];
var scl = t.scale || [1, 1, 1];
var color = (e.appearance && e.appearance.color) || '#8899aa';
var geo = null;
switch (e.type) {
case 'box':
geo = new THREE.BoxGeometry(1, 1, 1);
break;
case 'sphere':
geo = new THREE.SphereGeometry(0.5, 24, 16);
break;
case 'cylinder':
geo = new THREE.CylinderGeometry(0.5, 0.5, 1, 24);
break;
case 'model': // glTF 占位初版用盒子代替GLTFLoader 为后续
geo = new THREE.BoxGeometry(1, 1, 1);
break;
default:
console.warn('Scene3D: 未知实体类型', e.type, e.id);
return null;
}
var mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(color) });
var mesh = new THREE.Mesh(geo, mat);
mesh.position.set(pos[0], pos[1], pos[2]);
mesh.rotation.set(
THREE.MathUtils.degToRad(rot[0]),
THREE.MathUtils.degToRad(rot[1]),
THREE.MathUtils.degToRad(rot[2])
);
mesh.scale.set(scl[0], scl[1], scl[2]);
return mesh;
}
// ── 本地逻辑引擎(独立世界)──
s3_start_runtime(desc) {
if (!this.opts.get_run_local) return;
if (!bricks.ScriptEngine || !bricks.ScriptEngine.SceneRuntime) {
this.s3_show_error('脚本引擎未加载bricks.ScriptEngine 缺失)');
return;
}
var hasLogic = (desc.events && desc.events.length) ||
(desc.entities || []).some(function (e) { return (e.events && e.events.length) || (e.properties && Object.keys(e.properties).length); });
if (!hasLogic) return; // 纯渲染,不启动引擎
try {
this._s3_runtime = new bricks.ScriptEngine.SceneRuntime(desc, { frameDT: 0.05, budget: 100000 });
this._s3_last_t = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
} catch (e) {
console.error('Scene3D 引擎启动失败', e);
this.dispatch('scene_error', { message: '引擎启动失败: ' + e.message });
}
}
s3_sync_from_runtime() {
if (!this._s3_runtime) return;
var ents = this._s3_runtime.entities;
for (var id in this._s3_meshes) {
var mesh = this._s3_meshes[id];
var re = ents[id];
if (!re) continue;
var tr = re.transform;
mesh.position.set(tr.pos[0], tr.pos[1], tr.pos[2]);
mesh.rotation.set(
THREE.MathUtils.degToRad(tr.rot[0]),
THREE.MathUtils.degToRad(tr.rot[1]),
THREE.MathUtils.degToRad(tr.rot[2])
);
mesh.scale.set(tr.scale[0], tr.scale[1], tr.scale[2]);
mesh.visible = re.visible;
}
}
s3_loop() {
var self = this;
function tick() {
self._s3_raf = requestAnimationFrame(tick);
if (self._s3_runtime && !self._s3_paused) {
var now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
var dt = (now - self._s3_last_t) / 1000;
self._s3_last_t = now;
if (dt > 0 && dt < 0.5) self._s3_runtime.step(dt);
self.s3_sync_from_runtime();
}
if (self._s3_renderer && self._s3_scene && self._s3_camera) {
self._s3_renderer.render(self._s3_scene, self._s3_camera);
}
}
tick();
}
s3_on_resize() {
var el = this.dom_element;
var w = el.clientWidth, h = el.clientHeight;
if (!this._s3_renderer || w < 10 || h < 10) return;
this._s3_camera.aspect = w / h;
this._s3_camera.updateProjectionMatrix();
this._s3_renderer.setSize(w, h);
}
// 公共 API设置实体颜色后端状态更新或前端联动均可调用
set_entity_color(entity_id, color) {
var m = this._s3_meshes[entity_id];
if (m && m.material) m.material.color.set(color);
}
// 公共 API设置实体显隐
set_entity_visible(entity_id, visible) {
var m = this._s3_meshes[entity_id];
if (m) m.visible = !!visible;
}
// 公共 API设置实体变换共享世界后端推送状态用
set_entity_transform(entity_id, transform) {
var m = this._s3_meshes[entity_id];
if (!m || !transform) return;
var pos = transform.pos, rot = transform.rot, scl = transform.scale;
if (pos) m.position.set(pos[0], pos[1], pos[2]);
if (rot) m.rotation.set(THREE.MathUtils.degToRad(rot[0]), THREE.MathUtils.degToRad(rot[1]), THREE.MathUtils.degToRad(rot[2]));
if (scl) m.scale.set(scl[0], scl[1], scl[2]);
}
// 公共 API暂停/继续本地引擎
set_paused(paused) { this._s3_paused = !!paused; }
// 公共 API重置本地引擎重新加载描述符逻辑
reset_runtime(desc) {
if (this._s3_runtime && desc) {
this._s3_runtime = new bricks.ScriptEngine.SceneRuntime(desc, { frameDT: 0.05, budget: 100000 });
this._s3_last_t = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
}
}
s3_show_error(msg) {
var t = new bricks.Text({ text: '⚠ ' + msg, color: '#f87171', padding: '12px' });
this.add_widget(t);
}
// 释放 WebGL 资源clear_widgets 只移 DOM不释放 GL 上下文)
s3_dispose() {
if (this._s3_raf) cancelAnimationFrame(this._s3_raf);
this._s3_raf = 0;
var ids = Object.keys(this._s3_meshes);
for (var i = 0; i < ids.length; i++) {
var m = this._s3_meshes[ids[i]];
if (m.geometry) m.geometry.dispose();
if (m.material) m.material.dispose();
}
this._s3_meshes = {};
this._s3_runtime = null;
if (this._s3_renderer) {
this._s3_renderer.dispose();
if (this._s3_renderer.domElement && this._s3_renderer.domElement.parentNode) {
this._s3_renderer.domElement.parentNode.removeChild(this._s3_renderer.domElement);
}
this._s3_renderer = null;
}
this._s3_scene = null;
this._s3_camera = null;
this._s3_ready = false;
}
};
bricks.Factory.register('Scene3D', bricks.Scene3D);