Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@
- [x] **A1 · 契约 + Babylon headless + conformance**([#37](https://github.com/longyi-xw/lowcode-3d/pull/37)):契约加引擎中立 `describeNode(): RuntimeNodeInfo`(读引擎对象)+ `dispose()`;`BabylonAdapter`(`@babylonjs/core` `NullEngine`,headless)实现 `syncNode` 核心 kind(group/mesh/light/camera);conformance 套件让 ThreeAdapter + BabylonAdapter 跑同一批断言全绿 = 契约对第二引擎成立。纯 headless,不接实时视口。
- [x] **A2 · behaviors 跨引擎运行时**([#38](https://github.com/longyi-xw/lowcode-3d/pull/38)):`installBehaviors`/`tickBehaviors`/`uninstallBehaviors` 纳入 `IRuntimeAdapter`;Babylon behavior 框架(registry + auto-rotate + bob,操作 Babylon 节点,共享引擎中立 `BehaviorDefinition`);conformance 验运行时对等——**bob 跨引擎精确对等**(位置突变同公式)、auto-rotate ran/累积/卸载、enabled/unknown 隔离。纯 headless。hover-highlight(事件型)+ behavior codegen 留后续。
- [ ] **A3 · glTF + 导出**:prefab_instance/glTF 加载 + `babylon` 导出 target(含 behavior codegen 跨引擎)。
- [ ] **B · 实时视口切引擎**:抽 `IRenderHost`(渲染循环/相机控制/gizmo/拾取/outline),ThreeViewport 改为面向它,Babylon 渲染宿主落地,用户可把实时编辑器切到 Babylon。spec §8 已备边界清单。
- [x] **B1 · render host:实时切引擎(看+转相机)**:引擎中立 `IRenderHost` 契约(B1 子集:mount/渲染循环/相机/resize/dispose)+ `BabylonRenderHost`(真 `Engine` + `ArcRotateCamera`,`BabylonAdapter` 构造可注入引擎、默认仍 NullEngine 保 conformance)+ `BabylonViewport`(`diffSceneNodes` 增量同步,warn-skip 错误隔离)+ 视口工具栏 Three/Babylon 开关(`useUIStore.viewportEngine` 会话态,切换强制退 play)。Babylon 场景 `useRightHandedSystem` 对齐 three/glTF 坐标系(无镜像)。Babylon 模式 view-only:play/gizmo/拾取/拖放/F focus 经 `isEngineEditingCapable` 统一禁用。**ThreeViewport 零改动**(「并行+定接口」,B2/B3 按 A1 spec §8 清单收敛)。
- [ ] **B2 · 拾取 + 选中描边跨引擎**:`IRenderHost` 扩 pick/选中高亮,ThreeViewport 开始收敛(迁 `scene-diff`)。
- [ ] **B3 · gizmo + snap 跨引擎**。
- [ ] **B4 · 视口能力剩余**:socket 标记 / 资源拖放落点 / F focus / play 行为预览跨引擎;Babylon 视口底色 sRGB 对齐(Three OutputPass 提亮 vs Babylon 直写 raw,见 B1 smoke 记录);Babylon 光强/材质观感对齐。
- **Depends on**: v0.5 行为系统全部完成(v0.5 Stage C)

### v1.x — Planned
Expand Down
1,307 changes: 1,307 additions & 0 deletions docs/superpowers/plans/2026-06-10-v1.0b1-render-host.md

Large diffs are not rendered by default.

172 changes: 172 additions & 0 deletions docs/superpowers/specs/2026-06-10-v1.0b1-render-host-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# v1.0 sub-stage B1 — Render Host:实时视口切引擎(看 + 转相机)设计

日期:2026-06-10
分支:`feat/v1.0b-render-host`
前置:v1.0 A1(多适配器契约 + Babylon headless + conformance,PR #37)、A2(Babylon behaviors 跨引擎运行时,PR #38)

---

## 1. 背景与目标

lowcode-3d 是多框架低代码 3D 平台,渲染引擎可切换是战略目标。A1/A2 已让 `BabylonAdapter` 在 headless(`NullEngine`)下通过与 `ThreeAdapter` 相同的 conformance 套件;但用户在编辑器里仍然只能看到 Three.js 渲染。

**B1 目标**:编辑器视口可以在 Three.js 与 Babylon.js 之间实时切换。Babylon 模式为最小可用——**只能"看 + 转相机"**:场景实时同步渲染 + 轨道相机交互,无视口内编辑交互。

### B 阶段整体拆分(已与用户确认)

- **B1(本期)**:`IRenderHost` 接口 + Babylon 真 `Engine` + `ArcRotateCamera` + 引擎切换开关。
- **B2**:拾取 + 选中描边跨引擎。
- **B3**:gizmo + snap 跨引擎。
- **B4**:socket 标记 / 资源拖放 / focus / play 行为预览等剩余视口能力。

### 落地策略(已与用户确认):「并行 + 定接口」

定义 `IRenderHost` + `BabylonRenderHost` + 新 `BabylonViewport` 组件 + 顶层 `Viewport` 按引擎切换。**`ThreeViewport.tsx`(795 行单 mount effect)本期不动**——不破坏可用的 Three 编辑器;B2/B3 按 A1 spec §8 的边界清单逐步把 Three 侧收敛到 `IRenderHost` 后面。已否决的备选「现在就抽 ThreeRenderHost」:动 fragile 大文件、回归风险陡增、且在只有一个 host 消费方时抽象容易抽错。

---

## 2. 范围

### In

- `IRenderHost` 接口(B1 子集:mount / 渲染循环 / 相机 / resize / dispose)。
- `BabylonAdapter` 构造支持注入引擎(默认仍 `NullEngine`)。
- `BabylonRenderHost`(真 `Engine` + `ArcRotateCamera`)。
- `BabylonViewport` 组件(场景种入 + 增量同步 + resize + 清理)。
- 引擎中立的场景 diff 纯函数 `scene-diff.ts`。
- 顶层 `Viewport` 切换组件 + 视口工具栏 Three/Babylon 开关(UI store 会话态)。
- Babylon 模式下不支持的交互入口禁用(见 §7)。

### Out(延后)

- 拾取 / 选中描边(B2)、gizmo / snap(B3)、socket 标记 / 拖放 / focus / play(B4)。
- ThreeViewport 任何改动(B2/B3 收敛)。
- `generateBehaviorCode` / Babylon 导出(A3)。
- camera 朝向、ambient→Hemispheric 等已记录的跨引擎 gap(roadmap Backlog)。

---

## 3. `IRenderHost` 接口

新文件 `src/runtime/render-host.ts`,引擎中立:

```ts
import type { RuntimeTarget } from "@/core/scene/types";

export interface IRenderHost {
/** Which engine this host renders with. */
readonly engine: RuntimeTarget["kind"];
/** Create the real engine + editor camera + camera controls on the canvas. */
mount(canvas: HTMLCanvasElement): void;
/** Start / stop the render loop. */
start(): void;
stop(): void;
/** Resize the drawing buffer + camera aspect. */
resize(width: number, height: number): void;
/** Tear down controls, engine, GPU resources. */
dispose(): void;
}
```

- 只覆盖 B1 能力。B2 扩 `pick` / 选中高亮,B3 扩 gizmo——届时按 A1 spec §8 清单收敛 ThreeViewport。
- **Three 侧本期不实现该接口**;B1 唯一实现是 `BabylonRenderHost`。接口形状以 §8 清单 + ThreeViewport 现状反推,避免单实现臆造抽象。
- 调用顺序约定:`mount` → `start` →(任意次 `resize` / `stop` / `start`)→ `dispose`。`dispose` 后实例不可复用。

## 4. `BabylonAdapter` 构造注入引擎

```ts
constructor(options?: { engine?: AbstractEngine })
```

- 缺省 `new NullEngine()`——conformance / headless 测试零改动(硬约束)。
- `AbstractEngine` 是 `Engine` 与 `NullEngine` 的公共基类(`@babylonjs/core` 9.11 已导出,已核实)。
- 所有权约定:为保持 `dispose()` 语义简单,**adapter 始终 dispose 自己持有的 engine(无论注入与否)**。`BabylonRenderHost.dispose` 顺序:停 loop → detach 相机 → `adapter.dispose()`(内含 `engine.dispose()`),host 不再二次 dispose engine。

## 5. `BabylonRenderHost` + `BabylonViewport` + `scene-diff`

### 5.1 `src/runtime/babylon/render-host.ts`

- `mount(canvas)`:`new Engine(canvas, true)` → `new BabylonAdapter({ engine })` → `new ArcRotateCamera(...)` 设为 `scene.activeCamera` 并 `attachControl(canvas)`(轨道交互,对位 Three 侧 OrbitControls)。
- 相机初始参数对齐 ThreeViewport 编辑器相机的观感(位置 / 目标 / fov 在 plan 阶段从 ThreeViewport 读取换算,目标:切换引擎时构图大致一致,不要求逐像素)。
- `start()` = `engine.runRenderLoop(() => scene.render())`;`stop()` = `engine.stopRenderLoop()`。
- `resize(w, h)` = `engine.resize()`(Babylon 从 canvas 尺寸自取,参数用于契约对称)。
- 测试 seam:构造可注入 engine 工厂 `new BabylonRenderHost({ createEngine?: (canvas) => AbstractEngine })`,测试传 `NullEngine`(jsdom 无 WebGL)。
- host 暴露 `adapter`(`BabylonAdapter`)给 viewport 做 `syncNode`。

### 5.2 `src/ui/viewport/scene-diff.ts`(引擎中立纯函数)

ThreeViewport 的 `diffAndApply` 耦合 gizmo / outlinePass,不复用。抽纯函数:

```ts
diffSceneNodes(old: Record<string, SceneNode>, next: Record<string, SceneNode>):
{ added: SceneNode[]; removed: SceneNode[]; updated: SceneNode[] }
```

- 判定沿用 ThreeViewport 现行节点同一性语义(id 存在性 + 节点对象引用/内容变化;plan 阶段对照 `diffAndApply` 现实现,保证两个视口对同一 store 变更得出一致结论)。
- ThreeViewport 本期不迁移到该函数;B2 收敛时迁移。

### 5.3 `src/ui/viewport/BabylonViewport.tsx`

镜像 ThreeViewport 的 mount-effect 约定:

- mount effect **dep 只有 `project?.metadata.id`**(沿用 PR #7 视口同步约定:编辑节点绝不重建 canvas,相机状态存活)。
- 流程:建 canvas(`display:block; width/height:100%`,沿用布局约定)→ `host.mount(canvas)` → 种场景(按 hierarchy 顺序遍历 nodes,`adapter.syncNode(node, "add")`)→ `host.start()` → 订阅 `useSceneStore`,用 `diffSceneNodes` 增量 `syncNode("add"/"update"/"remove")` → `ResizeObserver` 调 `host.resize` → cleanup:退订 → `host.stop()` → `host.dispose()`。
- 节点种入失败(如 `prefab_instance` 在 Babylon 报 unknown、自定义 kind 抛错):`console.warn` + 跳过该节点继续(镜像 behaviors 的错误隔离哲学——一个节点坏不拉黑整个视口)。已知 gap 不在 B1 修。

## 6. 顶层切换

- `useUIStore` 加会话态(**用户已选方案 A**,不持久化、不进项目数据):

```ts
viewportEngine: "three.js" | "babylon.js"; // default "three.js"
setViewportEngine(engine): void;
```

- 新 `src/ui/viewport/Viewport.tsx`:按 `viewportEngine` 渲染 `<ThreeViewport/>` 或 `<BabylonViewport/>`,**带 `key={engine}`** 强制卸载重挂——两个视口各自的 mount effect 自洁,互不知晓。`EditorView` 改挂 `<Viewport/>`(原 `<ThreeViewport/>` 处)。
- 开关 UI:视口工具栏(gizmo pill 旁)一个 Three/Babylon 分段 toggle。i18n 走现有 editor namespace(UI 字符串英文,沿用项目惯例)。
- 切换即丢当前视口相机姿态(卸载重挂),B1 接受;跨引擎相机姿态保持延后。

## 7. Babylon 模式功能边界(B1 = 看 + 转相机)

**可用**:

- 场景实时同步:hierarchy / 属性面板 / AI 命令对节点的修改照常走 store → `diffSceneNodes` → `syncNode`,Babylon 视口即时可见。
- 轨道相机(ArcRotate)、视口 resize、引擎来回切换。

**禁用(disabled 态 + 不响应,不隐藏)**:

- gizmo 模式 pill(B3)。
- play 按钮(B4 接 behaviors 预览;tooltip 说明 Babylon 模式暂不支持)。
- 视口点选 / 拾取(B2)——hierarchy 选中仍可用,但视口无描边反馈。
- 资源库拖放入视口(依赖 `raycastGroundPoint`,B4)。
- socket 标记 / F focus(focus 是 viewport-owned,Babylon 视口 B1 不实现,快捷键在 Babylon 模式 no-op)。

实现方式:上述 UI 读 `viewportEngine` 判 disabled,集中一个 helper(如 `isEngineEditingCapable(engine)`)避免散落布尔判断,B2/B3/B4 逐项放开。

## 8. 测试策略

- `scene-diff.test.ts`:added / removed / updated / 无变化 / 混合变更。
- `babylon/adapter.test.ts` 追加:缺省构造仍 headless(NullEngine);注入 engine 被采用且 `dispose()` 释放之。
- `babylon/render-host.test.ts`:注入 `NullEngine` 工厂走 mount → start → resize → stop → dispose 生命周期不抛、dispose 后引擎已释放。
- `BabylonViewport.test.tsx`:注入 NullEngine seam 下 mount / 卸载不抛、store 变更触发 `syncNode`(spy)。
- conformance 套件零改动全绿(NullEngine 缺省保证)。
- **视觉验收(必跑,vitest 绿 ≠ 功能正确)**:`pnpm tauri dev` — 切到 Babylon 看到同一场景;转相机;面板改 transform/材质色即时同步;切回 Three 编辑功能无回归;play/gizmo/拖放在 Babylon 模式呈禁用态。

## 9. 成功标准

1. 视口工具栏可在 Three/Babylon 间来回切换,Babylon 模式正确渲染当前场景并可轨道转相机。
2. Babylon 模式下面板编辑实时反映到视口。
3. Three 模式所有既有功能零回归(ThreeViewport 未动)。
4. conformance / 既有测试全绿 + 新增单测绿;`pnpm lint && pnpm typecheck && pnpm test` 全绿。
5. 视觉 smoke(§8)通过。

## 9.1 实现偏差注记

- ⚠ **偏差(2026-06-11,真机 smoke 反馈修复)**:「ThreeViewport 本期不动」被一处 3 行的根因修复打破——`ThreeViewport` 挂载时的初始 `syncSelection` 与 async `seedScene` 存在竞态(挂载时 adapter 还没有对象,初始同步落空;重选同一节点不触发 store 订阅)。pre-B1 潜伏 bug:以前 ThreeViewport 只在选中为 null 时重挂,B1 引擎切换是第一条「带活选中重挂」的路径。修复 = `seedScene` 完成后重放 `syncSelection` + `rebuildSocketMarkers`(带 `unmounted` 守卫)。

## 10. 后续路径

- **B2**:`IRenderHost` 扩拾取 + 选中高亮;ThreeViewport 开始向 host 收敛(迁 `scene-diff`)。
- **B3**:gizmo + snap 跨引擎。
- **B4**:socket 标记 / 拖放 / focus / play 行为预览。
- Backlog 不变:Babylon camera 朝向 gap、ambient→Hemispheric position gap、prefab_instance kind gap(A3)、有状态 behavior 的 `dispose` 遍历。
10 changes: 8 additions & 2 deletions src/i18n/locales/en-US/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@
},
"viewport": {
"empty": "Empty scene",
"empty_hint": "Drop a .glb file here or pick a template from the menu."
"empty_hint": "Drop a .glb file here or pick a template from the menu.",
"engine": {
"title": "Render engine",
"three": "Three",
"babylon": "Babylon"
}
},
"behaviors": {
"tab_title": "Behaviors",
Expand All @@ -46,7 +51,8 @@
},
"play": {
"play": "Play",
"pause": "Pause"
"pause": "Pause",
"engine_unavailable": "Play is not available in the Babylon preview yet"
},
"close_project": "Close project",
"help": {
Expand Down
10 changes: 8 additions & 2 deletions src/i18n/locales/zh-CN/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@
},
"viewport": {
"empty": "空场景",
"empty_hint": "拖入 .glb 文件,或从菜单选一个模板。"
"empty_hint": "拖入 .glb 文件,或从菜单选一个模板。",
"engine": {
"title": "渲染引擎",
"three": "Three",
"babylon": "Babylon"
}
},
"behaviors": {
"tab_title": "行为",
Expand All @@ -46,7 +51,8 @@
},
"play": {
"play": "播放",
"pause": "暂停"
"pause": "暂停",
"engine_unavailable": "Babylon 预览暂不支持播放"
},
"close_project": "关闭项目",
"help": {
Expand Down
23 changes: 23 additions & 0 deletions src/runtime/babylon/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { NullEngine } from "@babylonjs/core";

import type { SceneNode } from "@/core/scene/types";

Expand Down Expand Up @@ -200,3 +201,25 @@ describe("BabylonAdapter behaviors", () => {
a.dispose();
});
});

describe("engine injection (v1.0 B1)", () => {
it("uses the injected engine and disposes it with the adapter", () => {
const engine = new NullEngine();
const adapter = new BabylonAdapter({ engine });
expect(adapter.scene.getEngine()).toBe(engine);
adapter.dispose();
expect(engine.isDisposed).toBe(true);
});

it("defaults to a NullEngine when nothing is injected", () => {
const adapter = new BabylonAdapter();
expect(adapter.scene.getEngine()).toBeInstanceOf(NullEngine);
adapter.dispose();
});

it("scene uses the right-handed system (matches three.js / glTF transforms)", () => {
const adapter = new BabylonAdapter();
expect(adapter.scene.useRightHandedSystem).toBe(true);
adapter.dispose();
});
});
17 changes: 15 additions & 2 deletions src/runtime/babylon/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
TransformNode,
UniversalCamera,
Vector3,
type AbstractEngine,
type Node as BabylonNode,
} from "@babylonjs/core";

Expand Down Expand Up @@ -61,8 +62,10 @@ function notImplemented(method: string): never {
*/
export class BabylonAdapter implements IRuntimeAdapter {
readonly target = BABYLON_TARGET;
private readonly engine = new NullEngine();
private readonly scene = new Scene(this.engine);
/** Engine-specific escape hatch (mirrors ThreeAdapter.scene) — used by
* BabylonRenderHost to mount the editor camera. Not on IRuntimeAdapter. */
readonly scene: Scene;
private readonly engine: AbstractEngine;
private readonly objects = new Map<string, BabylonNode>();
private readonly behaviorRegistry: BabylonBehaviorRegistry =
createBabylonBehaviorRegistry();
Expand All @@ -74,6 +77,16 @@ export class BabylonAdapter implements IRuntimeAdapter {
>
>();

constructor(options?: { engine?: AbstractEngine }) {
this.engine = options?.engine ?? new NullEngine();
this.scene = new Scene(this.engine);
// Project transforms are right-handed (three.js was the first engine and
// glTF is RH); Babylon defaults to left-handed, which would mirror the
// rendered scene on z. Raw stored values are unaffected, so headless
// conformance/describeNode behavior does not change.
this.scene.useRightHandedSystem = true;
}

syncNode(node: SceneNode, op: SyncOp): void {
if (op === "remove") {
const existing = this.objects.get(node.id);
Expand Down
50 changes: 50 additions & 0 deletions src/runtime/babylon/render-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ArcRotateCamera, NullEngine } from "@babylonjs/core";
import { describe, expect, it } from "vitest";

import { BabylonRenderHost } from "./render-host";

function makeHost() {
const engine = new NullEngine();
const host = new BabylonRenderHost({ createEngine: () => engine });
return { host, engine };
}

describe("BabylonRenderHost", () => {
it("identifies as the babylon.js engine", () => {
expect(makeHost().host.engine).toBe("babylon.js");
});

it("adapter throws before mount", () => {
expect(() => makeHost().host.adapter).toThrow(/mount/);
});

it("mount creates the adapter and an ArcRotate editor camera at [4,3,4]", () => {
const { host } = makeHost();
host.mount(document.createElement("canvas"));
const scene = host.adapter.scene;
expect(scene.activeCamera).toBeInstanceOf(ArcRotateCamera);
const cam = scene.activeCamera as ArcRotateCamera;
expect(cam.position.x).toBeCloseTo(4);
expect(cam.position.y).toBeCloseTo(3);
expect(cam.position.z).toBeCloseTo(4);
host.dispose();
});

it("full lifecycle mount → start → resize → stop → dispose does not throw", () => {
const { host, engine } = makeHost();
host.mount(document.createElement("canvas"));
host.start();
host.resize(800, 600);
host.stop();
host.dispose();
expect(engine.isDisposed).toBe(true);
});

it("dispose releases the adapter (engine ownership lives there)", () => {
const { host, engine } = makeHost();
host.mount(document.createElement("canvas"));
host.dispose();
expect(engine.isDisposed).toBe(true);
expect(() => host.adapter).toThrow();
});
});
Loading
Loading