From 02086d5ca2d106d6ffabc676e5bc1ee5b879b0f6 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Wed, 26 Aug 2026 17:46:56 +0800 Subject: [PATCH 1/9] feat(scene): support reflection probe baking --- docs/dev/scene/reflection-probe-bake.md | 568 ++++++++++++++++++ .../__snapshots__/dts-snapshot.test.ts.snap | 18 + src/api/scene/reflection-probe-schema.ts | 21 + src/api/scene/reflection-probe.ts | 30 + src/api/scene/scene.ts | 3 + src/core/scene/common/index.ts | 1 + src/core/scene/common/reflection-probe.ts | 28 + src/core/scene/main-process/index.ts | 4 + .../proxy/reflection-probe-proxy.ts | 12 + .../main-process/reflection-probe-renderer.ts | 70 +++ src/core/scene/main-process/rpc.ts | 2 + .../scene/scene-process/engine-bootstrap.ts | 23 + src/core/scene/scene-process/service/index.ts | 1 + .../scene/scene-process/service/interfaces.ts | 4 + .../scene-process/service/reflection-probe.ts | 501 +++++++++++++++ src/server/socket.ts | 3 + tests/reflection-probe-bake-api.test.ts | 93 +++ workflow/build-scene-bundle.js | 2 +- 18 files changed, 1383 insertions(+), 1 deletion(-) create mode 100644 docs/dev/scene/reflection-probe-bake.md create mode 100644 src/api/scene/reflection-probe-schema.ts create mode 100644 src/api/scene/reflection-probe.ts create mode 100644 src/core/scene/common/reflection-probe.ts create mode 100644 src/core/scene/main-process/proxy/reflection-probe-proxy.ts create mode 100644 src/core/scene/main-process/reflection-probe-renderer.ts create mode 100644 src/core/scene/scene-process/service/reflection-probe.ts create mode 100644 tests/reflection-probe-bake-api.test.ts diff --git a/docs/dev/scene/reflection-probe-bake.md b/docs/dev/scene/reflection-probe-bake.md new file mode 100644 index 000000000..9e995245d --- /dev/null +++ b/docs/dev/scene/reflection-probe-bake.md @@ -0,0 +1,568 @@ +# Reflection Probe Bake CLI/MCP 设计方案 + +## 背景与目标 + +当前 CLI 已可通过 `scene-create-node-by-type` 创建反射探针节点:传入 +`nodeType: "Light-Reflection-Probe"` 后,scene-process 会创建真正的 +`cc.ReflectionProbe` 组件。 + +反射探针烘焙不是简单设置组件属性,完整流程包含: + +1. 引擎将探针的 6 个方向渲染到 RenderTexture。 +2. 将 6 张 RenderTexture 读回 CPU 并保存为临时 PNG。 +3. 调用 cmft,将 6 张图片转换为 RGBM latlong PNG。 +4. 写入 TextureCube 导入配置并刷新资产数据库。 +5. 等待 `erp-texture-cube` importer 生成 `TextureCube` 子资源。 +6. 加载 `TextureCube` 并设置到 `ReflectionProbe.cubemap`。 +7. 更新 `ReflectionProbeManager`,标记场景变更并保存场景。 + +目标是在 cocos-cli 中提供一个完整的 MCP 接口: + +```text +scene-bake-reflection-probe +``` + +调用方不需要了解 RenderTexture、cmft、RGBM、资源导入器或引擎刷新细节。 + +## 可实现性结论 + +可以实现,可行性较高。 + +CLI 已具备主要基础设施: + +- scene-process 运行在引擎环境中,可以访问 `cc.ReflectionProbe`、RenderTexture、 + `cc.director` 和 `ReflectionProbeManager`。 +- main-process 与 scene-process 之间已有 RPC service/proxy 机制。 +- `preview/buffer.ts` 和 Creator 的反射探针扩展都提供了 GPU texture readback 参考。 +- `sharp` 已经是项目依赖,可以将 RGBA buffer 保存为 PNG。 +- CLI 已包含 cmft 下载、定位和执行逻辑;发布配置也包含 cmft。 +- CLI 已实现 `erp-texture-cube` importer,能够从 RGBM latlong PNG 生成 + `TextureCube` 和可选的卷积 mipmap。 +- `assetManager.refreshAsset`、资源变更通知和 scene-process 资源重载链路已经存在。 + +Creator 的实际实现位于: + +```text +D:/work/cocos-editor/app/modules/editor-extensions/extensions/reflection-probe/ + source/scene/index.ts + source/utils/graphics.ts +``` + +CLI 应移植这条实际链路,而不是依赖引擎类型声明中的 +`EditorExtends.Asset.saveDataToImage` 或 `EditorExtends.Asset.bakeReflectionProbe`。 +Creator 当前实现也没有使用这两个声明接口。 + +## Creator 实际流程 + +```text +ReflectionProbe.probe.captureCubemap() + -> 等待帧结束 + -> 读取 bakedCubeTextures[0..5] + -> 按图形后端需要翻转图片 + -> 保存 6 张临时 PNG + -> cmft 生成 reflectionProbe_.png(RGBM latlong) + -> 写入/更新 PNG 的 .meta + -> asset-db refresh + -> erp-texture-cube importer 生成 /textureCube 子资源 + -> 加载 TextureCube + -> ReflectionProbe.cubemap = asset + -> ReflectionProbeManager 更新 + -> 场景 dirty/保存 +``` + +需要保持的兼容规则: + +- 反射探针固定按 HDR 数据处理,cmft 使用 `--rgbm`。 +- `fastBake` 决定 TextureCube 的 mip bake 模式: + - `true`:`mipBakeMode = 1`,自动 mipmap。 + - `false`:`mipBakeMode = 2`,生成卷积贴图。 +- 默认资源名为 `reflectionProbe_.png`。 +- 默认放在当前场景同名目录下,保证重复烘焙覆盖和卷积缓存管理行为一致。 + +## 总体架构 + +```text +外部 AI / MCP Client + -> MCP tool: scene-bake-reflection-probe + -> src/api/scene/reflection-probe.ts + -> Scene.ReflectionProbe.bake(options) + -> src/core/scene/main-process/proxy/reflection-probe-proxy.ts + -> Rpc.request('ReflectionProbe', 'bake', [options]) + -> src/core/scene/scene-process/service/reflection-probe.ts + -> cc.ReflectionProbe / cmft / assetManager / ReflectionProbeManager +``` + +职责划分: + +- API 层负责 schema、参数校验、MCP tool 暴露和统一错误包装。 +- main-process proxy 只负责 RPC 转发。 +- scene-process service 负责定位组件、驱动渲染、读回像素和绑定资源。 +- 资产路径查询、刷新和导入状态查询通过反向 RPC 调用 main-process + `assetManager`。 +- cmft 优先复用现有工具定位和 `quickSpawn` 封装,避免重复实现平台判断。 + +## 对外 MCP 接口 + +新增 tool:`scene-bake-reflection-probe`。 + +### 输入 + +```ts +interface IReflectionProbeBakeOptions { + /** 当前场景中的节点路径。 */ + nodePath: string; + + /** + * 快速烘焙使用自动 mipmap;关闭后生成卷积贴图。 + * 默认读取组件的 fastBake 属性,也允许调用方覆盖。 + */ + fastBake?: boolean; + + /** 烘焙成功后是否保存当前场景,默认 true。 */ + saveScene?: boolean; + + /** 整个烘焙和资源导入流程的超时时间,建议默认 120 秒。 */ + timeoutMs?: number; +} +``` + +第一版不建议开放任意 `outputDbURL` 和 `assetName`。Creator 使用稳定的场景目录与 +`probeId` 命名规则,关联了重复烘焙覆盖、卷积缓存清理和旧资源复用。后续如需开放 +输出位置,必须同时定义 `.meta`、卷积目录、重名和重烘焙行为。 + +### 输出 + +```ts +interface IReflectionProbeBakeResult { + nodePath: string; + componentUuid: string; + probeId: number; + cubemapUuid: string; + cubemapUrl: string; + fastBake: boolean; +} +``` + +调用示例: + +```json +{ + "options": { + "nodePath": "Reflection Probe", + "fastBake": true, + "saveScene": true, + "timeoutMs": 120000 + } +} +``` + +## 文件改动 + +### Shared 类型 + +新增 `src/core/scene/common/reflection-probe.ts`: + +- `IReflectionProbeBakeOptions` +- `IReflectionProbeBakeResult` +- `IReflectionProbeService` +- `IPublicReflectionProbeService` + +修改 `src/core/scene/common/index.ts` 导出类型。 + +### Scene-process service + +新增: + +```text +src/core/scene/scene-process/service/reflection-probe.ts +``` + +通过 `@register('ReflectionProbe')` 注册,并修改: + +- `src/core/scene/scene-process/service/interfaces.ts` +- `src/core/scene/scene-process/service/index.ts` + +在 `IPublicServiceManager` 和 `IServiceManager` 中加入 `ReflectionProbe`。 + +### Main-process proxy + +新增: + +```text +src/core/scene/main-process/proxy/reflection-probe-proxy.ts +``` + +修改 `src/core/scene/main-process/index.ts`,暴露: + +```ts +Scene.ReflectionProbe.bake(options) +``` + +### MCP API + +新增: + +```text +src/api/scene/reflection-probe-schema.ts +src/api/scene/reflection-probe.ts +``` + +修改 `src/api/scene/scene.ts`,挂载 `ReflectionProbeApi`。 + +### 可复用 helper + +建议补充或抽取: + +```ts +readRenderTexturePixels(texture): Uint8Array +saveProbeFace(buffer, width, height, path): Promise +resolveCmftExecutable(): string +runReflectionProbeCmft(options): Promise +waitForTextureCubeImport(url, timeoutMs): Promise +loadTextureCube(uuid): Promise +``` + +cmft 定位逻辑应从现有 `image-mics.ts`、`erp-texture-cube.ts` 中抽取,避免出现第三套 +平台路径判断。 + +## Scene-process 内部流程 + +### 1. 校验目标组件 + +通过现有 `NodeMgr.getNodeByPath` 定位节点,然后获取 `cc.ReflectionProbe`。 + +需要处理: + +- 当前没有打开场景,或者当前编辑对象不支持烘焙。 +- `nodePath` 不存在。 +- 节点没有 `cc.ReflectionProbe`。 +- 组件未启用或节点未激活。 +- `probeType` 不是 `renderer.scene.ProbeType.CUBE`。 +- 同一探针或 service 正在烘焙。 + +第一版建议同一时刻只允许一个反射探针烘焙,避免 cmft、临时文件和资产刷新冲突。 + +### 2. 捕获 6 个方向 + +```ts +component.probe.captureCubemap(); +``` + +Creator 当前等待一次 `Director.EVENT_END_FRAME`。CLI 建议在每次 repaint 后检查 +`isFinishedRendering()`,并受统一 timeout 控制: + +```ts +while (!component.probe.isFinishedRendering()) { + Service.Engine.repaintInEditMode(); + await waitForEndFrame(); + assertBeforeDeadline(); +} +``` + +这样可以避免无 UI/offscreen 环境下一帧尚未完成六面渲染。 + +### 3. 读取 RenderTexture + +从 `component.probe.bakedCubeTextures[0..5]` 获取六张纹理,顺序必须保持为: + +```text +0: +X +1: -X +2: +Y +3: -Y +4: +Z +5: -Z +``` + +优先复用 Creator 的 `gfxDevice.copyTextureToBuffers` 方案,不直接假设 +`RenderTexture.readPixels()` 在所有后端行为一致。 + +图片方向与 Creator 保持一致:当 +`director.root.device.capabilities.clipSpaceMinZ === -1` 时进行 Y 翻转。Metal 的 +BGRA/RGBA 转换需要结合实际 readback 结果验证,不能直接照搬 preview window 的判断。 + +### 4. 保存临时 face PNG + +使用 `sharp` 保存六张 RGBA PNG。临时文件建议包含 probeId: + +```text +.reflection-probe--px.png +.reflection-probe--nx.png +.reflection-probe--py.png +.reflection-probe--ny.png +.reflection-probe--pz.png +.reflection-probe--nz.png +``` + +cmft 成功后删除临时文件。失败时可保留并在错误日志中给出目录,便于排查;最终策略应 +保持一致并有测试覆盖。 + +### 5. cmft 生成 RGBM latlong PNG + +输出: + +```text +db://assets//reflectionProbe_.png +``` + +核心参数与 Creator 一致: + +```text +--rgbm +--bypassoutputtype +--output0params png,rgbm,latlong +--inputFacePosX +--inputFaceNegX +--inputFacePosY +--inputFaceNegY +--inputFacePosZ +--inputFaceNegZ +--output0 +``` + +调用前检查工具存在。子进程非零退出、被终止或没有产生目标文件都视为失败;超时时需要 +终止 cmft 子进程。 + +### 6. 写入 TextureCube 导入配置 + +目标 PNG 的 meta 需要包含: + +```json +{ + "ver": "0.0.0", + "importer": "*", + "userData": { + "type": "texture cube", + "isRGBE": true + }, + "subMetas": { + "b47c0": { + "userData": { + "mipBakeMode": 1 + } + } + } +} +``` + +`mipBakeMode` 根据 `fastBake` 设置为 1 或 2。已有 meta 时只更新必要字段,不覆盖 +asset-db 维护的 UUID 和其他元数据。 + +每次重烘焙(包括切换 `fastBake` 模式)都应清理: + +```text +reflectionProbe__convolution/mipmap_0.png ... mipmap_5.png +``` + +否则 importer 可能复用上一次的卷积缓存。 + +### 7. 刷新资产并等待导入完成 + +通过反向 RPC 调用: + +```ts +assetManager.refreshAsset(targetUrlOrPath) +``` + +`refreshAsset` 返回不能直接视为 TextureCube 已经可用,需要等待并确认: + +1. 主资源 `reflectionProbe_.png` 可以查询。 +2. `/textureCube` 子资源已经生成。 +3. 相关子资源导入完成。 +4. `assetManager.loadAny(textureCubeUuid)` 成功返回 `TextureCube`。 + +可以结合资产变更通知和轮询查询实现,但必须: + +- 先注册监听,再触发 refresh,避免丢失快速事件。 +- 每次收到事件后重新查询目标 URL,不依赖固定事件数量。 +- 成功、失败和超时时都移除监听。 +- 整个等待受 `timeoutMs` 限制。 + +这是实现中最需要重点验证的异步边界。 + +### 8. 绑定 TextureCube 并刷新探针 + +```ts +component.cubemap = textureCube; +ReflectionProbeManager.probeManager.updateBakedCubemap(component.probe); +ReflectionProbeManager.probeManager.updatePreviewSphere(component.probe); +Service.Engine.repaintInEditMode(); +``` + +`ReflectionProbe.cubemap` setter 已负责部分 probe/model 更新。以 Creator 当前使用的两个 +显式刷新方法为基线;如果 CLI 实测发现模型数据未刷新,再补 `updateProbeData()` 或 +`updateProbeOfModels()`。 + +### 9. Dirty、Undo 与保存 + +Creator 使用 `SceneFacadeManager.beginRecording/endRecording` 包裹 cubemap 赋值,以触发 +场景 dirty 和 undo 记录。 + +CLI 应复用现有 Operation/Undo 机制记录组件变化。如果当前机制不能覆盖运行时资源赋值, +至少显式标记场景 dirty,确保 `Scene.Editor.save({})` 将 cubemap UUID 序列化到场景。 + +`saveScene !== false` 时保存场景。保存失败应使整个接口返回失败,否则资源虽已生成,但 +场景引用没有可靠落盘。 + +## 状态、并发与超时 + +第一版建议在 service 内实现互斥锁: + +- 同一时间只运行一个 bake。 +- 重复请求同一探针时返回 `already baking`。 +- 其他探针请求直接返回 busy;暂不实现 Creator UI 的队列和取消操作。 + +统一 deadline 覆盖: + +- 等待引擎渲染。 +- PNG 输出。 +- cmft 执行。 +- asset refresh/import。 +- TextureCube 加载。 +- 场景保存。 + +默认 10 秒不足以覆盖非 fast bake 的卷积过程,建议默认 120 秒,并允许调用方调整。 + +## 错误处理 + +MCP tool 返回统一 `CommonResultType`。错误信息应包含失败阶段和目标探针,例如: + +```json +{ + "code": 1, + "reason": "Reflection probe bake failed during asset import: db://assets/Main/reflectionProbe_0.png/textureCube was not available before timeout" +} +``` + +至少覆盖: + +- 未打开场景。 +- 节点不存在或没有 ReflectionProbe。 +- probe 不是 cube 类型,或者组件不可用。 +- 已有烘焙任务。 +- RenderTexture 渲染超时或数量不是 6。 +- texture readback 失败。 +- sharp 写 PNG 失败。 +- cmft 缺失、退出失败或输出文件缺失。 +- meta 读写失败。 +- asset refresh/import 失败或超时。 +- TextureCube 加载或类型校验失败。 +- cubemap 绑定、dirty 标记或场景保存失败。 + +如果最终绑定失败,生成的资产可以保留以便重试;临时六面 PNG 按统一策略处理。 + +## 平台支持 + +- Windows:已有 `cmftRelease64.exe`,作为第一阶段完整支持和验证平台。 +- macOS:下载及发布配置已有 cmft 路径,但需要真实机器验证可执行权限和输出一致性。 +- Linux:当前工具下载配置和 Creator 参考实现没有形成完整的受支持链路,第一版应标记为 + 未支持或实验性,不能宣称完整跨平台支持。 + +## 验证方案 + +### 类型和单元测试 + +```bash +npx tsc -b --pretty false +npx jest --runInBand +``` + +单元测试应覆盖: + +- API schema 默认值和非法参数。 +- face 顺序、cmft 参数和 fastBake/mipBakeMode 映射。 +- meta 新建与保留 UUID 的更新行为。 +- cmft 非零退出、超时和目标文件缺失。 +- asset import 事件先后顺序、超时和监听清理。 +- service 并发锁释放。 + +### Windows 真实链路测试 + +1. 启动 MCP server 并打开一个 3D 场景。 +2. 创建 `Light-Reflection-Probe` 节点。 +3. 调用: + + ```json + { + "options": { + "nodePath": "Reflection Probe", + "fastBake": true, + "saveScene": true + } + } + ``` + +4. 确认生成: + + ```text + assets//reflectionProbe_.png + assets//reflectionProbe_.png.meta + ``` + +5. 查询组件,确认 cubemap 指向 `/textureCube` 子资源。 +6. 保存并重开场景,确认引用仍然存在。 +7. 检查场景视图、预览球和受影响模型。 +8. 使用 `fastBake: false` 重烘焙,确认卷积目录重新生成。 +9. 修改场景环境后再次烘焙,确认没有复用旧卷积结果。 + +## 分阶段实现建议 + +### 第一阶段:捕获与临时图片 + +- 新增 common、service、proxy、API 和 MCP tool。 +- 完成节点/组件校验、捕获、等待渲染、readback 和六张 PNG 输出。 +- 验证 face 顺序、图片方向和无 UI 场景下的渲染完成条件。 + +### 第二阶段:cmft 与 TextureCube 导入 + +- 抽取并复用 cmft 工具定位。 +- 生成 RGBM latlong PNG。 +- 新建/更新 meta,处理 fastBake 和卷积缓存。 +- refresh asset,并可靠等待 `/textureCube` 子资源完成导入。 + +### 第三阶段:绑定、保存与错误恢复 + +- 加载 TextureCube 并绑定组件。 +- 更新 ReflectionProbeManager。 +- 接入 dirty/undo,保存场景。 +- 完成互斥、timeout、临时文件清理和失败恢复。 + +### 第四阶段:跨平台和高级能力 + +- macOS 验证。 +- 评估 Linux cmft 分发。 +- 根据需要增加批量烘焙、取消、进度事件和自定义输出目录。 + +## 工作量预估 + +- 主链路、API、类型和 schema:约 2 个开发日。 +- 资产导入等待、超时、重复烘焙与错误恢复:约 1~2 个开发日。 +- Windows 真实场景联调和自动化测试:约 1~2 个开发日。 +- macOS/Linux 验证另计。 + +Windows 第一版预计约 4~6 个开发日。 + +## 结论 + +该能力适合在 CLI 中实现为单一的 scene MCP tool: + +```text +scene-bake-reflection-probe({ options: { nodePath, fastBake?, saveScene?, timeoutMs? } }) +``` + +实现应复用 Creator 已验证的资产结构: + +```text +引擎捕获 6 RT + -> PNG + -> cmft RGBM latlong PNG + -> texture cube meta + -> erp-texture-cube importer + -> TextureCube 子资源 + -> 绑定 ReflectionProbe + -> manager 刷新 + -> dirty/保存 +``` + +最关键的工程问题不是 cmft 本身,而是可靠等待 TextureCube 子资源完成导入,并在成功、 +失败和超时情况下正确清理监听、锁与临时文件。 diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 4015e1c86..0e7cb9bcb 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -6500,6 +6500,23 @@ export declare interface IRedoService { redo(options?: IUndoOperationOptions): Promise; canRedo(options?: IUndoOperationOptions): boolean; } +export declare interface IReflectionProbeBakeOptions { + nodePath: string; + fastBake?: boolean; + saveScene?: boolean; + timeoutMs?: number; +} +export declare interface IReflectionProbeBakeResult { + nodePath: string; + componentUuid: string; + probeId: number; + cubemapUuid: string; + cubemapUrl: string; + fastBake: boolean; +} +export declare interface IReflectionProbeService extends IServiceEvents { + bake(options: IReflectionProbeBakeOptions): Promise; +} export declare interface IReloadOptions { urlOrUUID?: string; preserveUndoHistory?: boolean; @@ -6644,6 +6661,7 @@ export declare interface IServiceManager { SceneView: ISceneViewService; Preview: IPreviewService; UI: IUIService; + ReflectionProbe: IReflectionProbeService; } export declare interface ISetParentParams { paths: string[]; diff --git a/src/api/scene/reflection-probe-schema.ts b/src/api/scene/reflection-probe-schema.ts new file mode 100644 index 000000000..247c86300 --- /dev/null +++ b/src/api/scene/reflection-probe-schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const SchemaReflectionProbeBakeOptions = z.object({ + nodePath: z.string().trim().min(1).describe('Path of the node containing cc.ReflectionProbe'), + fastBake: z.boolean().optional().describe('Use automatic mipmaps instead of baked convolution mipmaps'), + saveScene: z.boolean().optional().default(true).describe('Save the current scene after binding the cubemap'), + timeoutMs: z.number().int().positive().max(600_000).optional().default(120_000) + .describe('Timeout for capture, cmft, asset import, binding, and scene save'), +}).describe('Reflection probe bake options'); + +export const SchemaReflectionProbeBakeResult = z.object({ + nodePath: z.string(), + componentUuid: z.string(), + probeId: z.number().int(), + cubemapUuid: z.string(), + cubemapUrl: z.string(), + fastBake: z.boolean(), +}).describe('Reflection probe bake result'); + +export type TReflectionProbeBakeOptions = z.infer; +export type TReflectionProbeBakeResult = z.infer; diff --git a/src/api/scene/reflection-probe.ts b/src/api/scene/reflection-probe.ts new file mode 100644 index 000000000..da225d078 --- /dev/null +++ b/src/api/scene/reflection-probe.ts @@ -0,0 +1,30 @@ +import { description, param, result, title, tool } from '../decorator/decorator'; +import { COMMON_STATUS, CommonResultType } from '../base/schema-base'; +import { Scene } from '../../core/scene'; +import { + SchemaReflectionProbeBakeOptions, + SchemaReflectionProbeBakeResult, + TReflectionProbeBakeOptions, + TReflectionProbeBakeResult, +} from './reflection-probe-schema'; + +export class ReflectionProbeApi { + @tool('scene-bake-reflection-probe') + @title('Bake reflection probe') + @description('Capture and bake a cube reflection probe, import its TextureCube, bind it to the component, and optionally save the scene.') + @result(SchemaReflectionProbeBakeResult) + async bake( + @param(SchemaReflectionProbeBakeOptions) options: TReflectionProbeBakeOptions, + ): Promise> { + try { + const data = await Scene.ReflectionProbe.bake(options); + return { code: COMMON_STATUS.SUCCESS, data }; + } catch (error) { + console.error(error); + return { + code: COMMON_STATUS.FAIL, + reason: error instanceof Error ? error.message : String(error), + }; + } + } +} diff --git a/src/api/scene/scene.ts b/src/api/scene/scene.ts index 121556351..bd6382415 100644 --- a/src/api/scene/scene.ts +++ b/src/api/scene/scene.ts @@ -22,17 +22,20 @@ import { Scene, TSceneTemplateType } from '../../core/scene'; import { ComponentApi } from './component'; import { NodeApi } from './node'; import { PrefabApi } from './prefab'; +import { ReflectionProbeApi } from './reflection-probe'; import { options } from '../../core/builder/platforms/android/i18n/en'; export class SceneApi { public component: ComponentApi; public node: NodeApi; public prefab: PrefabApi; + public reflectionProbe: ReflectionProbeApi; constructor() { this.component = new ComponentApi(); this.node = new NodeApi(); this.prefab = new PrefabApi(); + this.reflectionProbe = new ReflectionProbeApi(); } @tool('scene-query-current') diff --git a/src/core/scene/common/index.ts b/src/core/scene/common/index.ts index c37aabece..42a8067b7 100644 --- a/src/core/scene/common/index.ts +++ b/src/core/scene/common/index.ts @@ -16,4 +16,5 @@ export * from './gizmo'; export * from './scene-view'; export * from './preview'; export * from './ui'; +export * from './reflection-probe'; export * from './message'; diff --git a/src/core/scene/common/reflection-probe.ts b/src/core/scene/common/reflection-probe.ts new file mode 100644 index 000000000..403068c89 --- /dev/null +++ b/src/core/scene/common/reflection-probe.ts @@ -0,0 +1,28 @@ +import type { IServiceEvents } from '../scene-process/service/core'; + +export interface IReflectionProbeBakeOptions { + nodePath: string; + fastBake?: boolean; + saveScene?: boolean; + timeoutMs?: number; +} + +export interface IReflectionProbeBakeResult { + nodePath: string; + componentUuid: string; + probeId: number; + cubemapUuid: string; + cubemapUrl: string; + fastBake: boolean; +} + +export interface IReflectionProbeEvents { + 'reflection-probe:bake-start': [nodePath: string]; + 'reflection-probe:bake-end': [nodePath: string, error?: string]; +} + +export interface IReflectionProbeService extends IServiceEvents { + bake(options: IReflectionProbeBakeOptions): Promise; +} + +export type IPublicReflectionProbeService = Omit; diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index c6d14b7dd..0595e5482 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -6,6 +6,8 @@ import { ComponentProxy } from './proxy/component-proxy'; import { AssetProxy } from './proxy/asset-proxy'; import { EngineProxy } from './proxy/engine-proxy'; import { PrefabProxy } from './proxy/prefab-proxy'; +import { ReflectionProbeProxy } from './proxy/reflection-probe-proxy'; +import { reflectionProbeRenderer } from './reflection-probe-renderer'; import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; @@ -17,6 +19,7 @@ export interface IMainModule { 'programming': typeof scriptManager; 'sceneConfigInstance': typeof sceneConfigInstance; 'i18n': typeof i18n; + 'reflectionProbeRenderer': typeof reflectionProbeRenderer; } export const Scene = { @@ -29,6 +32,7 @@ export const Scene = { Node: NodeProxy, // 组件相关的接口 Component: ComponentProxy, + ReflectionProbe: ReflectionProbeProxy, // 场景进程 worker: sceneWorker, }; diff --git a/src/core/scene/main-process/proxy/reflection-probe-proxy.ts b/src/core/scene/main-process/proxy/reflection-probe-proxy.ts new file mode 100644 index 000000000..b2667a772 --- /dev/null +++ b/src/core/scene/main-process/proxy/reflection-probe-proxy.ts @@ -0,0 +1,12 @@ +import type { + IPublicReflectionProbeService, + IReflectionProbeBakeOptions, + IReflectionProbeBakeResult, +} from '../../common'; +import { Rpc } from '../rpc'; + +export const ReflectionProbeProxy: IPublicReflectionProbeService = { + bake(options: IReflectionProbeBakeOptions): Promise { + return Rpc.getInstance().request('ReflectionProbe', 'bake', [options]); + }, +}; diff --git a/src/core/scene/main-process/reflection-probe-renderer.ts b/src/core/scene/main-process/reflection-probe-renderer.ts new file mode 100644 index 000000000..c98dd254e --- /dev/null +++ b/src/core/scene/main-process/reflection-probe-renderer.ts @@ -0,0 +1,70 @@ +import type { RemoteSocket } from 'socket.io'; +import type { DefaultEventsMap } from 'socket.io/dist/typed-events'; +import { socketService } from '../../../server/socket'; + +export interface IReflectionProbeCaptureResult { + resolution: number; + faces: string[]; +} + +interface ICaptureResponse { + result?: IReflectionProbeCaptureResult; + error?: string; +} + +interface ICaptureRequest { + sceneUrl: string; + nodePath: string; + timeoutMs: number; +} + +type Socket = RemoteSocket; + +function requestSocket(socket: Socket, request: ICaptureRequest): Promise { + return new Promise((resolve, reject) => { + socket.timeout(request.timeoutMs).emit( + 'scene:capture-reflection-probe', + request, + (error: Error | null, response?: ICaptureResponse) => { + if (error) { + reject(error); + } else if (response?.result) { + resolve(response.result); + } else { + reject(new Error(response?.error || 'WebGL scene renderer returned no reflection-probe data.')); + } + }, + ); + }); +} + +export const reflectionProbeRenderer = { + async capture(sceneUrl: string, nodePath: string, timeoutMs: number): Promise { + const io = socketService.io; + if (!io) { + throw new Error('The WebGL scene renderer is unavailable because the HTTP server is not running.'); + } + const sockets = await io.fetchSockets(); + if (sockets.length === 0) { + throw new Error('Reflection Probe Bake requires a WebGL scene renderer. Open /scene-editor/ in a browser and retry.'); + } + + const errors: string[] = []; + return await new Promise((resolve, reject) => { + let pending = sockets.length; + for (const socket of sockets) { + requestSocket(socket, { sceneUrl, nodePath, timeoutMs }).then(resolve).catch((error) => { + errors.push(error instanceof Error ? error.message : String(error)); + pending--; + if (pending === 0) { + reject(new Error( + 'No connected WebGL scene renderer completed the reflection-probe capture. ' + + 'Open /scene-editor/ and wait for it to finish loading, then retry.' + + (errors.length ? ` (${errors.join('; ')})` : ''), + )); + } + }); + } + }); + }, +}; diff --git a/src/core/scene/main-process/rpc.ts b/src/core/scene/main-process/rpc.ts index a76bd0c0f..71c83bff6 100644 --- a/src/core/scene/main-process/rpc.ts +++ b/src/core/scene/main-process/rpc.ts @@ -4,6 +4,7 @@ import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; +import { reflectionProbeRenderer } from './reflection-probe-renderer'; import type { IPublicServiceManager } from '../scene-process'; @@ -35,6 +36,7 @@ export class RpcProxy { programming: scriptManager, sceneConfigInstance: sceneConfigInstance, i18n: i18n, + reflectionProbeRenderer, }); console.log(`[Node] Scene Process RPC ready ${prc ? '(Attached)' : '(Detached - Web Mode)'}`); } diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 3454526ea..b746bada5 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -508,6 +508,29 @@ async function setupBrowserInvokeChannel(serverURL: string) { invoke(msg.module, msg.method, msg.args); } }); + socket.on('scene:capture-reflection-probe', async ( + msg: { sceneUrl?: string; nodePath?: string; timeoutMs?: number }, + reply: (response: { result?: unknown; error?: string }) => void, + ) => { + try { + if (!msg?.sceneUrl || !msg?.nodePath) { + throw new Error('Invalid reflection-probe capture request.'); + } + const current = await DecoratorService.Editor.queryCurrent(); + const currentAssetUrl = (current as any)?.__identifier__?.assetUrl + ?? (current as any)?.assetUrl; + if (currentAssetUrl !== msg.sceneUrl) { + await DecoratorService.Editor.open({ urlOrUUID: msg.sceneUrl }); + } + const result = await (DecoratorService.ReflectionProbe as any).capturePixels( + msg.nodePath, + msg.timeoutMs, + ); + reply({ result }); + } catch (error) { + reply({ error: error instanceof Error ? error.message : String(error) }); + } + }); // 连接建立时同步一次设计分辨率(首次进入 / 断线重连时补齐错过的变更) socket.on('connect', () => invoke('Engine', 'syncDesignResolution', [])); } catch (e) { diff --git a/src/core/scene/scene-process/service/index.ts b/src/core/scene/scene-process/service/index.ts index 5aa4fe94a..96a5d1ea6 100644 --- a/src/core/scene/scene-process/service/index.ts +++ b/src/core/scene/scene-process/service/index.ts @@ -18,4 +18,5 @@ export * from './scene-view'; export * from './particle'; export * from './preview'; export * from './ui'; +export * from './reflection-probe'; export * from './core/global-events'; diff --git a/src/core/scene/scene-process/service/interfaces.ts b/src/core/scene/scene-process/service/interfaces.ts index 30ddcf09f..4a2fd8a19 100644 --- a/src/core/scene/scene-process/service/interfaces.ts +++ b/src/core/scene/scene-process/service/interfaces.ts @@ -32,6 +32,8 @@ import { IPublicUIService, IUIService, IAnimationService, + IReflectionProbeService, + IPublicReflectionProbeService, } from '../../common'; /** @@ -54,6 +56,7 @@ export interface IPublicServiceManager { SceneView: IPublicSceneViewService, Preview: IPublicPreviewService, UI: IPublicUIService, + ReflectionProbe: IPublicReflectionProbeService, } export interface IServiceManager { @@ -74,4 +77,5 @@ export interface IServiceManager { SceneView: ISceneViewService, Preview: IPreviewService, UI: IUIService, + ReflectionProbe: IReflectionProbeService, } diff --git a/src/core/scene/scene-process/service/reflection-probe.ts b/src/core/scene/scene-process/service/reflection-probe.ts new file mode 100644 index 000000000..9d40b6a26 --- /dev/null +++ b/src/core/scene/scene-process/service/reflection-probe.ts @@ -0,0 +1,501 @@ +'use strict'; + +import { ChildProcess, spawn } from 'child_process'; +import { + assert, + assetManager, + director, + Director, + gfx, + ReflectionProbe, + renderer, + TextureCube, +} from 'cc'; +import { ReflectionProbeManager } from 'cc/editor/reflection-probe'; +import { + ensureDir, + existsSync, + outputJson, + pathExists, + readJson, + remove, +} from 'fs-extra'; +import { join } from 'path'; +import type { + IReflectionProbeBakeOptions, + IReflectionProbeBakeResult, + IReflectionProbeEvents, + IReflectionProbeService, +} from '../../common'; +import { NodeEventType } from '../../common'; +import { BaseService, register, Service } from './core'; +import { ServiceEvents } from './core/global-events'; +import { Rpc } from '../rpc'; + +const DEFAULT_TIMEOUT_MS = 120_000; +const POLL_INTERVAL_MS = 200; +const FACE_NAMES = ['px', 'nx', 'py', 'ny', 'pz', 'nz'] as const; + +interface IAssetInfo { + uuid: string; + url: string; + [key: string]: unknown; +} + +interface ICapturedFaces { + resolution: number; + faces: string[]; +} + +@register('ReflectionProbe') +export class ReflectionProbeService extends BaseService implements IReflectionProbeService { + private _baking = false; + private _cmftProcess: ChildProcess | null = null; + + public async bake(options: IReflectionProbeBakeOptions): Promise { + if (this._baking) { + throw new Error('A reflection probe bake is already in progress.'); + } + if (!options?.nodePath?.trim()) { + throw new Error('Reflection probe nodePath is required.'); + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('Reflection probe timeoutMs must be greater than zero.'); + } + + const deadline = Date.now() + timeoutMs; + const nodePath = options.nodePath.trim(); + this._baking = true; + this.broadcast('reflection-probe:bake-start', nodePath); + + try { + const node = this._getNodeByExactPath(nodePath); + if (!node) { + throw new Error(`Reflection probe node was not found: ${nodePath}`); + } + + const component = node.getComponent(ReflectionProbe); + if (!component) { + throw new Error(`Node does not contain cc.ReflectionProbe: ${nodePath}`); + } + if (!component.enabled || !node.activeInHierarchy) { + throw new Error(`Reflection probe is disabled or inactive: ${nodePath}`); + } + if (component.probeType !== renderer.scene.ProbeType.CUBE) { + throw new Error(`Only cube reflection probes can be baked: ${nodePath}`); + } + + const probe = component.probe; + const probeId = probe.getProbeId(); + const resolution = Number((component as any)._resolution); + if (!Number.isInteger(resolution) || resolution <= 0) { + throw new Error(`Reflection probe has an invalid resolution: ${resolution}`); + } + const sceneName = node.scene?.name; + if (!sceneName) { + throw new Error('No scene is currently open.'); + } + const fastBake = options.fastBake ?? component.fastBake; + + const current = await Service.Editor.queryCurrent(); + const currentAssetUrl = ((current as any)?.__identifier__?.assetUrl + ?? (current as any)?.assetUrl) as string | undefined; + if (!currentAssetUrl) { + throw new Error('The currently opened scene has no asset URL.'); + } + + const captured = gfx.deviceManager.gfxDevice.gfxAPI === gfx.API.UNKNOWN + ? await Rpc.getInstance().request('reflectionProbeRenderer', 'capture', [ + currentAssetUrl, + nodePath, + Math.max(1, deadline - Date.now()), + ]) + : await this.capturePixels(nodePath, Math.max(1, deadline - Date.now())); + if (captured.resolution !== resolution || captured.faces.length !== 6) { + throw new Error('The WebGL scene renderer returned invalid reflection-probe faces.'); + } + + const assetRoot = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string | null; + if (!assetRoot) { + throw new Error('The db://assets directory is unavailable.'); + } + + const sceneDir = join(assetRoot, sceneName); + await ensureDir(sceneDir); + const facePaths = await this._writeFaces(captured.faces, sceneDir, probeId, resolution, deadline); + const outputBase = join(sceneDir, `reflectionProbe_${probeId}`); + const outputPath = `${outputBase}.png`; + const outputUrl = `db://assets/${sceneName}/reflectionProbe_${probeId}.png`; + const textureCubeUrl = `${outputUrl}/textureCube`; + + try { + await this._runCmft(facePaths, outputBase, deadline); + await this._prepareMeta(outputPath, fastBake); + // Match Creator: every manual rebake invalidates importer-generated + // convolution images, including when switching bake modes. + await this._clearConvolutionCache(outputBase); + this._assertBeforeDeadline(deadline, 'asset import'); + await Rpc.getInstance().request('assetManager', 'refreshAsset', [outputUrl]); + const cubeInfo = await this._waitForTextureCube(textureCubeUrl, deadline); + const textureCube = await this._loadTextureCube(cubeInfo.uuid, deadline); + + const commandId = Service.Undo.beginRecording([component.uuid], { + label: 'Bake reflection probe', + scope: { + nodePath, + propPath: `_components.${node.components.indexOf(component)}._cubemap`, + editorType: 'scene', + }, + }); + try { + component.cubemap = textureCube; + await Service.Undo.endRecording(commandId); + } catch (error) { + Service.Undo.cancelRecording(commandId); + throw error; + } + ReflectionProbeManager.probeManager.updateBakedCubemap(component.probe); + ReflectionProbeManager.probeManager.updatePreviewSphere(component.probe); + const componentIndex = node.components.indexOf(component); + ServiceEvents.emit('node:change', node, { + type: NodeEventType.SET_PROPERTY, + propPath: `_components.${componentIndex}._cubemap`, + }); + ServiceEvents.broadcast('node:change', nodePath); + await Service.Engine.repaintInEditMode(); + + if (options.saveScene !== false) { + this._assertBeforeDeadline(deadline, 'scene save'); + await Service.Editor.save({}); + } + + this.broadcast('reflection-probe:bake-end', nodePath); + return { + nodePath, + componentUuid: component.uuid, + probeId, + cubemapUuid: cubeInfo.uuid, + cubemapUrl: cubeInfo.url, + fastBake, + }; + } finally { + await Promise.all(facePaths.map(async (path) => remove(path).catch(() => undefined))); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.broadcast('reflection-probe:bake-end', nodePath, message); + throw error; + } finally { + if (this._cmftProcess) { + this._cmftProcess.kill(); + this._cmftProcess = null; + } + this._baking = false; + } + } + + /** + * Runs inside the browser scene client when the Node scene process uses EmptyDevice. + * Faces are base64 encoded so they can cross socket.io and process IPC unchanged. + */ + public async capturePixels(nodePath: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + if (gfx.deviceManager.gfxDevice.gfxAPI === gfx.API.UNKNOWN) { + throw new Error('Reflection-probe pixels cannot be captured with the headless EmptyDevice.'); + } + const node = this._getNodeByExactPath(nodePath); + if (!node) { + throw new Error(`Reflection probe node was not found in the WebGL scene renderer: ${nodePath}`); + } + const component = node.getComponent(ReflectionProbe); + if (!component) { + throw new Error(`Node does not contain cc.ReflectionProbe in the WebGL scene renderer: ${nodePath}`); + } + const resolution = Number((component as any)._resolution); + if (!Number.isInteger(resolution) || resolution <= 0) { + throw new Error(`Reflection probe has an invalid resolution: ${resolution}`); + } + + const deadline = Date.now() + timeoutMs; + component.probe.captureCubemap(); + await this._waitForCapture(component.probe, deadline); + const flip = director.root!.device.capabilities.clipSpaceMinZ === -1; + return { + resolution, + faces: component.probe.bakedCubeTextures.map((texture: unknown) => { + const pixels = this._readPixels(texture); + const data = flip ? this._flipImage(pixels, resolution, resolution) : pixels; + return this._encodeBase64(data); + }), + }; + } + + private async _waitForCapture(probe: any, deadline: number): Promise { + do { + this._assertBeforeDeadline(deadline, 'cubemap capture'); + await Service.Engine.repaintInEditMode(); + await this._waitForEndFrame(deadline); + } while (typeof probe.isFinishedRendering === 'function' && !probe.isFinishedRendering()); + + if (!Array.isArray(probe.bakedCubeTextures) || probe.bakedCubeTextures.length !== 6) { + throw new Error('Reflection probe capture did not produce six render textures.'); + } + } + + private _getNodeByExactPath(path: string): any | null { + if (path === '/') { + return director.getScene(); + } + const segments = path.split('/').map((segment) => segment.trim()).filter(Boolean); + let current: any = director.getScene(); + for (const segment of segments) { + current = current?.children?.find((child: any) => child.name === segment) ?? null; + if (!current) { + return null; + } + } + return current; + } + + private _waitForEndFrame(deadline: number): Promise { + return new Promise((resolve, reject) => { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + reject(new Error('Reflection probe bake timed out during cubemap capture.')); + return; + } + const timer = setTimeout(() => { + director.off(Director.EVENT_END_FRAME, onFrame); + reject(new Error('Reflection probe bake timed out during cubemap capture.')); + }, remaining); + const onFrame = () => { + clearTimeout(timer); + resolve(); + }; + director.once(Director.EVENT_END_FRAME, onFrame); + }); + } + + private async _writeFaces( + faces: string[], + sceneDir: string, + probeId: number, + resolution: number, + deadline: number, + ): Promise { + const result: string[] = []; + try { + // Keep sharp out of the browser service initialization path. Its + // libvips bootstrap is Node-only and crashes the WebGL scene client. + const sharp = (await import('sharp')).default; + const decodedFaces = faces.map((face, index) => { + const data = Buffer.from(face, 'base64'); + if (data.length !== resolution * resolution * 4) { + throw new Error(`Reflection probe face ${FACE_NAMES[index]} has an invalid byte length.`); + } + return data; + }); + const hasAnyColor = decodedFaces.some((data) => { + for (let offset = 0; offset < data.length; offset += 4) { + if (data[offset] !== 0 || data[offset + 1] !== 0 || data[offset + 2] !== 0) { + return true; + } + } + return false; + }); + if (!hasAnyColor) { + throw new Error('All reflection probe faces are empty; refusing to overwrite the existing bake.'); + } + for (let i = 0; i < FACE_NAMES.length; i++) { + this._assertBeforeDeadline(deadline, 'render texture readback'); + const data = decodedFaces[i]; + const facePath = join(sceneDir, `.reflection-probe-${probeId}-${FACE_NAMES[i]}.png`); + result.push(facePath); + await sharp(data, { + raw: { width: resolution, height: resolution, channels: 4 }, + }).png().toFile(facePath); + } + return result; + } catch (error) { + await Promise.all(result.map(async (path) => remove(path).catch(() => undefined))); + throw error; + } + } + + private _readPixels(texture: any): Uint8Array { + const gfxTexture = texture?.getGFXTexture?.(); + if (!gfxTexture) { + throw new Error('Failed to access a reflection probe render texture.'); + } + const width = texture.width; + const height = texture.height; + const buffer = new Uint8Array(width * height * 4); + const region = new gfx.BufferTextureCopy(); + region.texExtent.width = width; + region.texExtent.height = height; + gfx.deviceManager.gfxDevice.copyTextureToBuffers(gfxTexture, [buffer], [region]); + return buffer; + } + + private _flipImage(data: Uint8Array, width: number, height: number): Uint8Array { + const result = new Uint8Array(data.length); + const rowBytes = width * 4; + for (let y = 0; y < height; y++) { + result.set(data.subarray(y * rowBytes, (y + 1) * rowBytes), (height - y - 1) * rowBytes); + } + return result; + } + + private _encodeBase64(data: Uint8Array): string { + let binary = ''; + const chunkSize = 0x8000; + for (let offset = 0; offset < data.length; offset += chunkSize) { + binary += String.fromCharCode(...data.subarray(offset, offset + chunkSize)); + } + return btoa(binary); + } + + private async _runCmft(facePaths: string[], outputBase: string, deadline: number): Promise { + const executable = this._resolveCmftExecutable(); + const args = [ + '--rgbm', + '--bypassoutputtype', + '--output0params', 'png,rgbm,latlong', + '--inputFacePosX', facePaths[0], + '--inputFaceNegX', facePaths[1], + '--inputFacePosY', facePaths[2], + '--inputFaceNegY', facePaths[3], + '--inputFacePosZ', facePaths[4], + '--inputFaceNegZ', facePaths[5], + '--output0', outputBase, + ]; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error('Reflection probe bake timed out before cmft started.'); + } + + await new Promise((resolve, reject) => { + const child = this._cmftProcess = spawn(executable, args, { windowsHide: true }); + let stderr = ''; + child.stderr?.on('data', (data) => { stderr += String(data); }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error('Reflection probe bake timed out while running cmft.')); + }, remaining); + child.once('error', (error) => { + clearTimeout(timer); + reject(new Error(`Failed to start cmft: ${error.message}`)); + }); + child.once('close', (code) => { + clearTimeout(timer); + this._cmftProcess = null; + if (code !== 0) { + reject(new Error(`cmft exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`)); + } else { + resolve(); + } + }); + }); + + if (!await pathExists(`${outputBase}.png`)) { + throw new Error(`cmft did not create the expected output: ${outputBase}.png`); + } + } + + private _resolveCmftExecutable(): string { + const suffix = process.platform === 'win32' ? '.exe' : ''; + // This service is also bundled for the browser WebGL renderer. Resolve + // the Node-only static path lazily so browser module initialization does + // not import GlobalPaths (which relies on __dirname). + const staticDir = join(__dirname, '../../../../../static'); + const candidates = [ + join(staticDir, `tools/cmft/cmftRelease64${suffix}`), + join(staticDir, `tools/cmft/cmft${suffix}`), + ]; + const executable = candidates.find(existsSync); + if (!executable) { + throw new Error(`cmft executable was not found (checked ${candidates.join(', ')}).`); + } + return executable; + } + + private async _prepareMeta(outputPath: string, fastBake: boolean): Promise { + const metaPath = `${outputPath}.meta`; + let meta: any = {}; + if (await pathExists(metaPath)) { + meta = await readJson(metaPath); + } + meta.ver ??= '0.0.0'; + meta.importer ??= '*'; + meta.userData ??= {}; + meta.userData.type = 'texture cube'; + meta.userData.isRGBE = true; + meta.subMetas ??= {}; + meta.subMetas.b47c0 ??= {}; + meta.subMetas.b47c0.userData ??= {}; + meta.subMetas.b47c0.userData.mipBakeMode = fastBake ? 1 : 2; + await outputJson(metaPath, meta, { spaces: 2 }); + } + + private async _clearConvolutionCache(outputBase: string): Promise { + const cacheDir = `${outputBase}_convolution`; + for (let i = 0; i < 6; i++) { + await remove(join(cacheDir, `mipmap_${i}.png`)).catch(() => undefined); + } + } + + private async _waitForTextureCube(url: string, deadline: number): Promise { + let lastError: unknown; + while (Date.now() < deadline) { + try { + const info = await Rpc.getInstance().request('assetManager', 'queryAssetInfo', [url]) as IAssetInfo | null; + if (info?.uuid) { + return info; + } + } catch (error) { + lastError = error; + } + await this._delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now()))); + } + const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : ''; + throw new Error(`TextureCube subasset was not imported before timeout: ${url}.${detail}`); + } + + private async _loadTextureCube(uuid: string, deadline: number): Promise { + while (Date.now() < deadline) { + const remaining = deadline - Date.now(); + const asset = await new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + settled = true; + resolve('timeout'); + }, remaining); + assetManager.loadAny(uuid, (error: Error | null, value: TextureCube) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(error ? null : value); + }); + }); + if (asset === 'timeout') { + break; + } + if (asset instanceof TextureCube) { + return asset; + } + await this._delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now()))); + } + throw new Error(`TextureCube could not be loaded before timeout: ${uuid}`); + } + + private _assertBeforeDeadline(deadline: number, stage: string): void { + assert(Date.now() < deadline, `Reflection probe bake timed out during ${stage}.`); + } + + private _delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/server/socket.ts b/src/server/socket.ts index ca38fb715..21d0555ea 100644 --- a/src/server/socket.ts +++ b/src/server/socket.ts @@ -16,6 +16,9 @@ export class SocketService { // 与 HTTP 路由的 CORS(server.ts 的 app.use(cors),Access-Control-Allow-Origin: *)保持一致。 this.io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] }, + // Reflection-probe capture returns six raw RGBA faces from the WebGL + // scene client. A 1024px probe is ~32 MiB before base64 encoding. + maxHttpBufferSize: 128 * 1024 * 1024, }); this.io.on('connection', (socket: any) => { console.log(`socket ${socket.id} connected`); diff --git a/tests/reflection-probe-bake-api.test.ts b/tests/reflection-probe-bake-api.test.ts new file mode 100644 index 000000000..f52709d57 --- /dev/null +++ b/tests/reflection-probe-bake-api.test.ts @@ -0,0 +1,93 @@ +import 'reflect-metadata'; +import { COMMON_STATUS } from '../src/api/base/schema-base'; +import { + SchemaReflectionProbeBakeOptions, + SchemaReflectionProbeBakeResult, +} from '../src/api/scene/reflection-probe-schema'; + +const mockBake = jest.fn(); + +jest.mock('../src/api/decorator/decorator.js', () => ({ + description: () => jest.fn(), + param: () => jest.fn(), + result: () => jest.fn(), + title: () => jest.fn(), + tool: () => jest.fn(), +}), { virtual: true }); + +jest.mock('../src/core/scene', () => ({ + Scene: { + ReflectionProbe: { + bake: (...args: unknown[]) => mockBake(...args), + }, + }, +})); + +import { ReflectionProbeApi } from '../src/api/scene/reflection-probe'; + +describe('reflection probe bake API', () => { + beforeEach(() => mockBake.mockReset()); + + it('applies safe defaults and rejects invalid input', () => { + expect(SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe' })).toEqual({ + nodePath: 'Probe', + saveScene: true, + timeoutMs: 120_000, + }); + expect(() => SchemaReflectionProbeBakeOptions.parse({ nodePath: ' ' })).toThrow(); + expect(() => SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe', timeoutMs: 0 })).toThrow(); + expect(() => SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe', timeoutMs: 600_001 })).toThrow(); + }); + + it('accepts the public result shape', () => { + expect(SchemaReflectionProbeBakeResult.parse({ + nodePath: 'Probe', + componentUuid: 'component-uuid', + probeId: 3, + cubemapUuid: 'cubemap-uuid', + cubemapUrl: 'db://assets/Main/reflectionProbe_3.png/textureCube', + fastBake: true, + }).probeId).toBe(3); + }); + + it('forwards options and wraps success', async () => { + const data = { + nodePath: 'Probe', + componentUuid: 'component-uuid', + probeId: 1, + cubemapUuid: 'cube-uuid', + cubemapUrl: 'db://assets/Main/reflectionProbe_1.png/textureCube', + fastBake: true, + }; + mockBake.mockResolvedValue(data); + + const result = await new ReflectionProbeApi().bake({ + nodePath: 'Probe', + fastBake: true, + saveScene: true, + timeoutMs: 120_000, + }); + + expect(mockBake).toHaveBeenCalledWith({ + nodePath: 'Probe', + fastBake: true, + saveScene: true, + timeoutMs: 120_000, + }); + expect(result).toEqual({ code: COMMON_STATUS.SUCCESS, data }); + }); + + it('wraps service failures', async () => { + mockBake.mockRejectedValue(new Error('cmft failed')); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const result = await new ReflectionProbeApi().bake({ + nodePath: 'Probe', + saveScene: true, + timeoutMs: 120_000, + }); + + expect(result).toEqual({ code: COMMON_STATUS.FAIL, reason: 'cmft failed' }); + errorSpy.mockRestore(); + }); +}); diff --git a/workflow/build-scene-bundle.js b/workflow/build-scene-bundle.js index 265918263..fc814d177 100644 --- a/workflow/build-scene-bundle.js +++ b/workflow/build-scene-bundle.js @@ -40,7 +40,7 @@ async function buildSceneBundle() { 'fs', 'node:fs', 'fs-extra', 'graceful-fs', 'lodash', 'package.json', '@cocos/asset-db', 'constants', 'stream', 'assert', 'crypto', 'child_process', 'vm', 'buffer', 'tty', 'zlib', 'http', 'https', 'net', 'tls', 'dns', 'readline', 'punycode', - 'cc/mods-mgr', 'inherits', 'sys', 'url', 'process', 'proper-lockfile' + 'cc/mods-mgr', 'inherits', 'sys', 'url', 'process', 'proper-lockfile', 'sharp' ]; if (stubs.includes(id)) { return '\0smart-' + id; From ba54d063b40d83e9b8aad8272eda5e5106c23069 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Wed, 26 Aug 2026 17:57:41 +0800 Subject: [PATCH 2/9] update doc --- docs/dev/scene/reflection-probe-bake.md | 589 ++---------------------- 1 file changed, 47 insertions(+), 542 deletions(-) diff --git a/docs/dev/scene/reflection-probe-bake.md b/docs/dev/scene/reflection-probe-bake.md index 9e995245d..c1228420d 100644 --- a/docs/dev/scene/reflection-probe-bake.md +++ b/docs/dev/scene/reflection-probe-bake.md @@ -1,568 +1,73 @@ -# Reflection Probe Bake CLI/MCP 设计方案 +# Reflection Probe Bake -## 背景与目标 +CLI 通过 MCP 工具 `scene-bake-reflection-probe` 烘焙立方体反射探针。它会捕获六面纹理、调用 cmft 生成 RGBM latlong PNG、导入 TextureCube、绑定组件,并按需保存场景。 -当前 CLI 已可通过 `scene-create-node-by-type` 创建反射探针节点:传入 -`nodeType: "Light-Reflection-Probe"` 后,scene-process 会创建真正的 -`cc.ReflectionProbe` 组件。 +## 使用条件 -反射探针烘焙不是简单设置组件属性,完整流程包含: +- CLI HTTP/MCP 服务已启动。 +- 浏览器已打开 `/scene-editor/`,目标场景显示为 `Loaded`。 +- `nodePath` 指向包含 `cc.ReflectionProbe` 的节点。 -1. 引擎将探针的 6 个方向渲染到 RenderTexture。 -2. 将 6 张 RenderTexture 读回 CPU 并保存为临时 PNG。 -3. 调用 cmft,将 6 张图片转换为 RGBM latlong PNG。 -4. 写入 TextureCube 导入配置并刷新资产数据库。 -5. 等待 `erp-texture-cube` importer 生成 `TextureCube` 子资源。 -6. 加载 `TextureCube` 并设置到 `ReflectionProbe.cubemap`。 -7. 更新 `ReflectionProbeManager`,标记场景变更并保存场景。 +Node 场景进程使用 EmptyDevice,不能进行有效的 GPU 捕获。因此烘焙会将捕获请求转发给浏览器中的 WebGL 场景渲染器。没有可用渲染器时会明确失败,不会回退生成黑图;六面像素全部为空时也会停止并保留已有资源。 -目标是在 cocos-cli 中提供一个完整的 MCP 接口: - -```text -scene-bake-reflection-probe -``` - -调用方不需要了解 RenderTexture、cmft、RGBM、资源导入器或引擎刷新细节。 - -## 可实现性结论 - -可以实现,可行性较高。 - -CLI 已具备主要基础设施: - -- scene-process 运行在引擎环境中,可以访问 `cc.ReflectionProbe`、RenderTexture、 - `cc.director` 和 `ReflectionProbeManager`。 -- main-process 与 scene-process 之间已有 RPC service/proxy 机制。 -- `preview/buffer.ts` 和 Creator 的反射探针扩展都提供了 GPU texture readback 参考。 -- `sharp` 已经是项目依赖,可以将 RGBA buffer 保存为 PNG。 -- CLI 已包含 cmft 下载、定位和执行逻辑;发布配置也包含 cmft。 -- CLI 已实现 `erp-texture-cube` importer,能够从 RGBM latlong PNG 生成 - `TextureCube` 和可选的卷积 mipmap。 -- `assetManager.refreshAsset`、资源变更通知和 scene-process 资源重载链路已经存在。 - -Creator 的实际实现位于: - -```text -D:/work/cocos-editor/app/modules/editor-extensions/extensions/reflection-probe/ - source/scene/index.ts - source/utils/graphics.ts -``` - -CLI 应移植这条实际链路,而不是依赖引擎类型声明中的 -`EditorExtends.Asset.saveDataToImage` 或 `EditorExtends.Asset.bakeReflectionProbe`。 -Creator 当前实现也没有使用这两个声明接口。 - -## Creator 实际流程 - -```text -ReflectionProbe.probe.captureCubemap() - -> 等待帧结束 - -> 读取 bakedCubeTextures[0..5] - -> 按图形后端需要翻转图片 - -> 保存 6 张临时 PNG - -> cmft 生成 reflectionProbe_.png(RGBM latlong) - -> 写入/更新 PNG 的 .meta - -> asset-db refresh - -> erp-texture-cube importer 生成 /textureCube 子资源 - -> 加载 TextureCube - -> ReflectionProbe.cubemap = asset - -> ReflectionProbeManager 更新 - -> 场景 dirty/保存 -``` - -需要保持的兼容规则: - -- 反射探针固定按 HDR 数据处理,cmft 使用 `--rgbm`。 -- `fastBake` 决定 TextureCube 的 mip bake 模式: - - `true`:`mipBakeMode = 1`,自动 mipmap。 - - `false`:`mipBakeMode = 2`,生成卷积贴图。 -- 默认资源名为 `reflectionProbe_.png`。 -- 默认放在当前场景同名目录下,保证重复烘焙覆盖和卷积缓存管理行为一致。 - -## 总体架构 - -```text -外部 AI / MCP Client - -> MCP tool: scene-bake-reflection-probe - -> src/api/scene/reflection-probe.ts - -> Scene.ReflectionProbe.bake(options) - -> src/core/scene/main-process/proxy/reflection-probe-proxy.ts - -> Rpc.request('ReflectionProbe', 'bake', [options]) - -> src/core/scene/scene-process/service/reflection-probe.ts - -> cc.ReflectionProbe / cmft / assetManager / ReflectionProbeManager -``` - -职责划分: - -- API 层负责 schema、参数校验、MCP tool 暴露和统一错误包装。 -- main-process proxy 只负责 RPC 转发。 -- scene-process service 负责定位组件、驱动渲染、读回像素和绑定资源。 -- 资产路径查询、刷新和导入状态查询通过反向 RPC 调用 main-process - `assetManager`。 -- cmft 优先复用现有工具定位和 `quickSpawn` 封装,避免重复实现平台判断。 - -## 对外 MCP 接口 - -新增 tool:`scene-bake-reflection-probe`。 - -### 输入 - -```ts -interface IReflectionProbeBakeOptions { - /** 当前场景中的节点路径。 */ - nodePath: string; - - /** - * 快速烘焙使用自动 mipmap;关闭后生成卷积贴图。 - * 默认读取组件的 fastBake 属性,也允许调用方覆盖。 - */ - fastBake?: boolean; - - /** 烘焙成功后是否保存当前场景,默认 true。 */ - saveScene?: boolean; - - /** 整个烘焙和资源导入流程的超时时间,建议默认 120 秒。 */ - timeoutMs?: number; -} -``` - -第一版不建议开放任意 `outputDbURL` 和 `assetName`。Creator 使用稳定的场景目录与 -`probeId` 命名规则,关联了重复烘焙覆盖、卷积缓存清理和旧资源复用。后续如需开放 -输出位置,必须同时定义 `.meta`、卷积目录、重名和重烘焙行为。 - -### 输出 - -```ts -interface IReflectionProbeBakeResult { - nodePath: string; - componentUuid: string; - probeId: number; - cubemapUuid: string; - cubemapUrl: string; - fastBake: boolean; -} -``` - -调用示例: +## 调用 ```json { - "options": { - "nodePath": "Reflection Probe", - "fastBake": true, - "saveScene": true, - "timeoutMs": 120000 - } + "nodePath": "Reflection Probe", + "fastBake": false, + "saveScene": true, + "timeoutMs": 120000 } ``` -## 文件改动 - -### Shared 类型 - -新增 `src/core/scene/common/reflection-probe.ts`: - -- `IReflectionProbeBakeOptions` -- `IReflectionProbeBakeResult` -- `IReflectionProbeService` -- `IPublicReflectionProbeService` - -修改 `src/core/scene/common/index.ts` 导出类型。 - -### Scene-process service - -新增: - -```text -src/core/scene/scene-process/service/reflection-probe.ts -``` - -通过 `@register('ReflectionProbe')` 注册,并修改: - -- `src/core/scene/scene-process/service/interfaces.ts` -- `src/core/scene/scene-process/service/index.ts` - -在 `IPublicServiceManager` 和 `IServiceManager` 中加入 `ReflectionProbe`。 - -### Main-process proxy - -新增: - -```text -src/core/scene/main-process/proxy/reflection-probe-proxy.ts -``` - -修改 `src/core/scene/main-process/index.ts`,暴露: +参数: -```ts -Scene.ReflectionProbe.bake(options) -``` +- `nodePath`:探针节点在当前场景中的路径,必填。 +- `fastBake`:使用自动 mipmap;省略时采用组件当前配置。 +- `saveScene`:绑定后保存场景,默认 `true`。 +- `timeoutMs`:完整流程超时,默认 120 秒,最大 600 秒。 -### MCP API +成功结果包含探针节点、组件 UUID、probe ID,以及生成的 TextureCube UUID 和 URL。 -新增: +## 处理链路 ```text -src/api/scene/reflection-probe-schema.ts -src/api/scene/reflection-probe.ts -``` - -修改 `src/api/scene/scene.ts`,挂载 `ReflectionProbeApi`。 - -### 可复用 helper - -建议补充或抽取: - -```ts -readRenderTexturePixels(texture): Uint8Array -saveProbeFace(buffer, width, height, path): Promise -resolveCmftExecutable(): string -runReflectionProbeCmft(options): Promise -waitForTextureCubeImport(url, timeoutMs): Promise -loadTextureCube(uuid): Promise -``` - -cmft 定位逻辑应从现有 `image-mics.ts`、`erp-texture-cube.ts` 中抽取,避免出现第三套 -平台路径判断。 - -## Scene-process 内部流程 - -### 1. 校验目标组件 - -通过现有 `NodeMgr.getNodeByPath` 定位节点,然后获取 `cc.ReflectionProbe`。 - -需要处理: - -- 当前没有打开场景,或者当前编辑对象不支持烘焙。 -- `nodePath` 不存在。 -- 节点没有 `cc.ReflectionProbe`。 -- 组件未启用或节点未激活。 -- `probeType` 不是 `renderer.scene.ProbeType.CUBE`。 -- 同一探针或 service 正在烘焙。 - -第一版建议同一时刻只允许一个反射探针烘焙,避免 cmft、临时文件和资产刷新冲突。 - -### 2. 捕获 6 个方向 - -```ts -component.probe.captureCubemap(); -``` - -Creator 当前等待一次 `Director.EVENT_END_FRAME`。CLI 建议在每次 repaint 后检查 -`isFinishedRendering()`,并受统一 timeout 控制: - -```ts -while (!component.probe.isFinishedRendering()) { - Service.Engine.repaintInEditMode(); - await waitForEndFrame(); - assertBeforeDeadline(); -} +MCP scene-bake-reflection-probe + -> scene process: 校验场景与探针 + -> main process: 请求已连接的 WebGL renderer + -> browser /scene-editor/: 捕获六面 RGBA + -> scene process: 写入临时 PNG + -> cmft: 生成 reflectionProbe_.png + -> asset-db: 导入 /textureCube 子资源 + -> ReflectionProbe.cubemap: 绑定、刷新预览球、保存场景 ``` -这样可以避免无 UI/offscreen 环境下一帧尚未完成六面渲染。 - -### 3. 读取 RenderTexture - -从 `component.probe.bakedCubeTextures[0..5]` 获取六张纹理,顺序必须保持为: - -```text -0: +X -1: -X -2: +Y -3: -Y -4: +Z -5: -Z -``` +主要实现: -优先复用 Creator 的 `gfxDevice.copyTextureToBuffers` 方案,不直接假设 -`RenderTexture.readPixels()` 在所有后端行为一致。 +- API:`src/api/scene/reflection-probe.ts` +- WebGL 请求桥:`src/core/scene/main-process/reflection-probe-renderer.ts` +- 浏览器监听:`src/core/scene/scene-process/engine-bootstrap.ts` +- 捕获、转换、导入和绑定:`src/core/scene/scene-process/service/reflection-probe.ts` -图片方向与 Creator 保持一致:当 -`director.root.device.capabilities.clipSpaceMinZ === -1` 时进行 Y 翻转。Metal 的 -BGRA/RGBA 转换需要结合实际 readback 结果验证,不能直接照搬 preview window 的判断。 +## 输出与兼容行为 -### 4. 保存临时 face PNG +- 输出位置:`assets//reflectionProbe_.png` +- TextureCube 子资源:`db://assets//reflectionProbe_.png/textureCube` +- cmft 参数保持 Creator 的 RGBM latlong 行为。 +- `fastBake=true` 写入 `mipBakeMode=1`;否则写入 `mipBakeMode=2`。 +- 重复烘焙复用资源身份,并清理旧卷积缓存后重新导入。 +- 绑定操作进入 Undo,成功后刷新探针管理器与预览球。 -使用 `sharp` 保存六张 RGBA PNG。临时文件建议包含 probeId: +## 验证 -```text -.reflection-probe--px.png -.reflection-probe--nx.png -.reflection-probe--py.png -.reflection-probe--ny.png -.reflection-probe--pz.png -.reflection-probe--nz.png +```powershell +npm.cmd run compile +npm.cmd test -- --runInBand tests/reflection-probe-bake-api.test.ts ``` -cmft 成功后删除临时文件。失败时可保留并在错误日志中给出目录,便于排查;最终策略应 -保持一致并有测试覆盖。 - -### 5. cmft 生成 RGBM latlong PNG - -输出: - -```text -db://assets//reflectionProbe_.png -``` - -核心参数与 Creator 一致: - -```text ---rgbm ---bypassoutputtype ---output0params png,rgbm,latlong ---inputFacePosX ---inputFaceNegX ---inputFacePosY ---inputFaceNegY ---inputFacePosZ ---inputFaceNegZ ---output0 -``` - -调用前检查工具存在。子进程非零退出、被终止或没有产生目标文件都视为失败;超时时需要 -终止 cmft 子进程。 - -### 6. 写入 TextureCube 导入配置 - -目标 PNG 的 meta 需要包含: - -```json -{ - "ver": "0.0.0", - "importer": "*", - "userData": { - "type": "texture cube", - "isRGBE": true - }, - "subMetas": { - "b47c0": { - "userData": { - "mipBakeMode": 1 - } - } - } -} -``` - -`mipBakeMode` 根据 `fastBake` 设置为 1 或 2。已有 meta 时只更新必要字段,不覆盖 -asset-db 维护的 UUID 和其他元数据。 - -每次重烘焙(包括切换 `fastBake` 模式)都应清理: - -```text -reflectionProbe__convolution/mipmap_0.png ... mipmap_5.png -``` - -否则 importer 可能复用上一次的卷积缓存。 - -### 7. 刷新资产并等待导入完成 - -通过反向 RPC 调用: - -```ts -assetManager.refreshAsset(targetUrlOrPath) -``` - -`refreshAsset` 返回不能直接视为 TextureCube 已经可用,需要等待并确认: - -1. 主资源 `reflectionProbe_.png` 可以查询。 -2. `/textureCube` 子资源已经生成。 -3. 相关子资源导入完成。 -4. `assetManager.loadAny(textureCubeUuid)` 成功返回 `TextureCube`。 - -可以结合资产变更通知和轮询查询实现,但必须: - -- 先注册监听,再触发 refresh,避免丢失快速事件。 -- 每次收到事件后重新查询目标 URL,不依赖固定事件数量。 -- 成功、失败和超时时都移除监听。 -- 整个等待受 `timeoutMs` 限制。 - -这是实现中最需要重点验证的异步边界。 - -### 8. 绑定 TextureCube 并刷新探针 - -```ts -component.cubemap = textureCube; -ReflectionProbeManager.probeManager.updateBakedCubemap(component.probe); -ReflectionProbeManager.probeManager.updatePreviewSphere(component.probe); -Service.Engine.repaintInEditMode(); -``` - -`ReflectionProbe.cubemap` setter 已负责部分 probe/model 更新。以 Creator 当前使用的两个 -显式刷新方法为基线;如果 CLI 实测发现模型数据未刷新,再补 `updateProbeData()` 或 -`updateProbeOfModels()`。 - -### 9. Dirty、Undo 与保存 - -Creator 使用 `SceneFacadeManager.beginRecording/endRecording` 包裹 cubemap 赋值,以触发 -场景 dirty 和 undo 记录。 - -CLI 应复用现有 Operation/Undo 机制记录组件变化。如果当前机制不能覆盖运行时资源赋值, -至少显式标记场景 dirty,确保 `Scene.Editor.save({})` 将 cubemap UUID 序列化到场景。 - -`saveScene !== false` 时保存场景。保存失败应使整个接口返回失败,否则资源虽已生成,但 -场景引用没有可靠落盘。 - -## 状态、并发与超时 - -第一版建议在 service 内实现互斥锁: - -- 同一时间只运行一个 bake。 -- 重复请求同一探针时返回 `already baking`。 -- 其他探针请求直接返回 busy;暂不实现 Creator UI 的队列和取消操作。 - -统一 deadline 覆盖: - -- 等待引擎渲染。 -- PNG 输出。 -- cmft 执行。 -- asset refresh/import。 -- TextureCube 加载。 -- 场景保存。 - -默认 10 秒不足以覆盖非 fast bake 的卷积过程,建议默认 120 秒,并允许调用方调整。 - -## 错误处理 - -MCP tool 返回统一 `CommonResultType`。错误信息应包含失败阶段和目标探针,例如: - -```json -{ - "code": 1, - "reason": "Reflection probe bake failed during asset import: db://assets/Main/reflectionProbe_0.png/textureCube was not available before timeout" -} -``` - -至少覆盖: - -- 未打开场景。 -- 节点不存在或没有 ReflectionProbe。 -- probe 不是 cube 类型,或者组件不可用。 -- 已有烘焙任务。 -- RenderTexture 渲染超时或数量不是 6。 -- texture readback 失败。 -- sharp 写 PNG 失败。 -- cmft 缺失、退出失败或输出文件缺失。 -- meta 读写失败。 -- asset refresh/import 失败或超时。 -- TextureCube 加载或类型校验失败。 -- cubemap 绑定、dirty 标记或场景保存失败。 - -如果最终绑定失败,生成的资产可以保留以便重试;临时六面 PNG 按统一策略处理。 - -## 平台支持 - -- Windows:已有 `cmftRelease64.exe`,作为第一阶段完整支持和验证平台。 -- macOS:下载及发布配置已有 cmft 路径,但需要真实机器验证可执行权限和输出一致性。 -- Linux:当前工具下载配置和 Creator 参考实现没有形成完整的受支持链路,第一版应标记为 - 未支持或实验性,不能宣称完整跨平台支持。 - -## 验证方案 - -### 类型和单元测试 - -```bash -npx tsc -b --pretty false -npx jest --runInBand -``` - -单元测试应覆盖: - -- API schema 默认值和非法参数。 -- face 顺序、cmft 参数和 fastBake/mipBakeMode 映射。 -- meta 新建与保留 UUID 的更新行为。 -- cmft 非零退出、超时和目标文件缺失。 -- asset import 事件先后顺序、超时和监听清理。 -- service 并发锁释放。 - -### Windows 真实链路测试 - -1. 启动 MCP server 并打开一个 3D 场景。 -2. 创建 `Light-Reflection-Probe` 节点。 -3. 调用: - - ```json - { - "options": { - "nodePath": "Reflection Probe", - "fastBake": true, - "saveScene": true - } - } - ``` - -4. 确认生成: - - ```text - assets//reflectionProbe_.png - assets//reflectionProbe_.png.meta - ``` - -5. 查询组件,确认 cubemap 指向 `/textureCube` 子资源。 -6. 保存并重开场景,确认引用仍然存在。 -7. 检查场景视图、预览球和受影响模型。 -8. 使用 `fastBake: false` 重烘焙,确认卷积目录重新生成。 -9. 修改场景环境后再次烘焙,确认没有复用旧卷积结果。 - -## 分阶段实现建议 - -### 第一阶段:捕获与临时图片 - -- 新增 common、service、proxy、API 和 MCP tool。 -- 完成节点/组件校验、捕获、等待渲染、readback 和六张 PNG 输出。 -- 验证 face 顺序、图片方向和无 UI 场景下的渲染完成条件。 - -### 第二阶段:cmft 与 TextureCube 导入 - -- 抽取并复用 cmft 工具定位。 -- 生成 RGBM latlong PNG。 -- 新建/更新 meta,处理 fastBake 和卷积缓存。 -- refresh asset,并可靠等待 `/textureCube` 子资源完成导入。 - -### 第三阶段:绑定、保存与错误恢复 - -- 加载 TextureCube 并绑定组件。 -- 更新 ReflectionProbeManager。 -- 接入 dirty/undo,保存场景。 -- 完成互斥、timeout、临时文件清理和失败恢复。 - -### 第四阶段:跨平台和高级能力 - -- macOS 验证。 -- 评估 Linux cmft 分发。 -- 根据需要增加批量烘焙、取消、进度事件和自定义输出目录。 - -## 工作量预估 - -- 主链路、API、类型和 schema:约 2 个开发日。 -- 资产导入等待、超时、重复烘焙与错误恢复:约 1~2 个开发日。 -- Windows 真实场景联调和自动化测试:约 1~2 个开发日。 -- macOS/Linux 验证另计。 - -Windows 第一版预计约 4~6 个开发日。 - -## 结论 - -该能力适合在 CLI 中实现为单一的 scene MCP tool: - -```text -scene-bake-reflection-probe({ options: { nodePath, fastBake?, saveScene?, timeoutMs? } }) -``` - -实现应复用 Creator 已验证的资产结构: - -```text -引擎捕获 6 RT - -> PNG - -> cmft RGBM latlong PNG - -> texture cube meta - -> erp-texture-cube importer - -> TextureCube 子资源 - -> 绑定 ReflectionProbe - -> manager 刷新 - -> dirty/保存 -``` +端到端验证还应确认: -最关键的工程问题不是 cmft 本身,而是可靠等待 TextureCube 子资源完成导入,并在成功、 -失败和超时情况下正确清理监听、锁与临时文件。 +1. `/scene-editor/` 能看到天空盒和测试模型。 +2. MCP 调用返回 `code: 200` 和 TextureCube URL。 +3. Creator 重新打开场景后,探针预览球仍显示烘焙结果。 From dac91f7372d476a9b228414b0a1161fd6b39e73e Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Wed, 26 Aug 2026 18:14:46 +0800 Subject: [PATCH 3/9] fix(scene): harden reflection probe baking --- .../main-process/reflection-probe-renderer.ts | 31 ++-- .../scene/scene-process/engine-bootstrap.ts | 17 +- .../scene-process/service/reflection-probe.ts | 160 ++++++++++++------ src/server/socket.ts | 12 ++ tests/reflection-probe-renderer.test.ts | 50 ++++++ 5 files changed, 197 insertions(+), 73 deletions(-) create mode 100644 tests/reflection-probe-renderer.test.ts diff --git a/src/core/scene/main-process/reflection-probe-renderer.ts b/src/core/scene/main-process/reflection-probe-renderer.ts index c98dd254e..d1bbc3a77 100644 --- a/src/core/scene/main-process/reflection-probe-renderer.ts +++ b/src/core/scene/main-process/reflection-probe-renderer.ts @@ -1,6 +1,6 @@ import type { RemoteSocket } from 'socket.io'; import type { DefaultEventsMap } from 'socket.io/dist/typed-events'; -import { socketService } from '../../../server/socket'; +import { SCENE_RENDERER_ROOM, socketService } from '../../../server/socket'; export interface IReflectionProbeCaptureResult { resolution: number; @@ -44,27 +44,20 @@ export const reflectionProbeRenderer = { if (!io) { throw new Error('The WebGL scene renderer is unavailable because the HTTP server is not running.'); } - const sockets = await io.fetchSockets(); + const sockets = await io.in(SCENE_RENDERER_ROOM).fetchSockets(); if (sockets.length === 0) { throw new Error('Reflection Probe Bake requires a WebGL scene renderer. Open /scene-editor/ in a browser and retry.'); } - const errors: string[] = []; - return await new Promise((resolve, reject) => { - let pending = sockets.length; - for (const socket of sockets) { - requestSocket(socket, { sceneUrl, nodePath, timeoutMs }).then(resolve).catch((error) => { - errors.push(error instanceof Error ? error.message : String(error)); - pending--; - if (pending === 0) { - reject(new Error( - 'No connected WebGL scene renderer completed the reflection-probe capture. ' - + 'Open /scene-editor/ and wait for it to finish loading, then retry.' - + (errors.length ? ` (${errors.join('; ')})` : ''), - )); - } - }); - } - }); + const socket = sockets.find((candidate) => candidate.data.sceneUrl === sceneUrl) ?? sockets[0]; + try { + return await requestSocket(socket, { sceneUrl, nodePath, timeoutMs }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + 'The selected WebGL scene renderer could not complete the reflection-probe capture. ' + + `Open /scene-editor/ and wait for it to finish loading, then retry. (${detail})`, + ); + } }, }; diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index b746bada5..3a16d01e9 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -493,6 +493,10 @@ async function setupBrowserInvokeChannel(serverURL: string) { return; } const socket = io(serverURL); + const querySceneUrl = async (): Promise => { + const current = await DecoratorService.Editor.queryCurrent(); + return (current as any)?.__identifier__?.assetUrl ?? (current as any)?.assetUrl ?? ''; + }; const invoke = (module: string, method: string, args?: any[]) => { try { const svc = (DecoratorService as any)[module]; @@ -522,6 +526,7 @@ async function setupBrowserInvokeChannel(serverURL: string) { if (currentAssetUrl !== msg.sceneUrl) { await DecoratorService.Editor.open({ urlOrUUID: msg.sceneUrl }); } + socket.emit('scene-renderer:scene', { sceneUrl: msg.sceneUrl }); const result = await (DecoratorService.ReflectionProbe as any).capturePixels( msg.nodePath, msg.timeoutMs, @@ -532,7 +537,17 @@ async function setupBrowserInvokeChannel(serverURL: string) { } }); // 连接建立时同步一次设计分辨率(首次进入 / 断线重连时补齐错过的变更) - socket.on('connect', () => invoke('Engine', 'syncDesignResolution', [])); + socket.on('connect', async () => { + let sceneUrl = ''; + try { + sceneUrl = await querySceneUrl(); + } catch { + // A renderer without an open scene is still available and can + // open the requested scene when a bake starts. + } + socket.emit('scene-renderer:register', { sceneUrl }); + invoke('Engine', 'syncDesignResolution', []); + }); } catch (e) { console.warn('[engine-bootstrap] setup browser-invoke channel failed:', e); } diff --git a/src/core/scene/scene-process/service/reflection-probe.ts b/src/core/scene/scene-process/service/reflection-probe.ts index 9d40b6a26..89a1044da 100644 --- a/src/core/scene/scene-process/service/reflection-probe.ts +++ b/src/core/scene/scene-process/service/reflection-probe.ts @@ -18,6 +18,7 @@ import { outputJson, pathExists, readJson, + move, remove, } from 'fs-extra'; import { join } from 'path'; @@ -47,6 +48,11 @@ interface ICapturedFaces { faces: string[]; } +interface IOutputTransaction { + commit(): Promise; + rollback(): Promise; +} + @register('ReflectionProbe') export class ReflectionProbeService extends BaseService implements IReflectionProbeService { private _baking = false; @@ -129,59 +135,65 @@ export class ReflectionProbeService extends BaseService const outputPath = `${outputBase}.png`; const outputUrl = `db://assets/${sceneName}/reflectionProbe_${probeId}.png`; const textureCubeUrl = `${outputUrl}/textureCube`; + const stagedBase = join(sceneDir, `.reflection-probe-${probeId}-${Date.now()}`); + const stagedOutputPath = `${stagedBase}.png`; try { - await this._runCmft(facePaths, outputBase, deadline); - await this._prepareMeta(outputPath, fastBake); - // Match Creator: every manual rebake invalidates importer-generated - // convolution images, including when switching bake modes. - await this._clearConvolutionCache(outputBase); - this._assertBeforeDeadline(deadline, 'asset import'); - await Rpc.getInstance().request('assetManager', 'refreshAsset', [outputUrl]); - const cubeInfo = await this._waitForTextureCube(textureCubeUrl, deadline); - const textureCube = await this._loadTextureCube(cubeInfo.uuid, deadline); - - const commandId = Service.Undo.beginRecording([component.uuid], { - label: 'Bake reflection probe', - scope: { - nodePath, - propPath: `_components.${node.components.indexOf(component)}._cubemap`, - editorType: 'scene', - }, - }); + await this._runCmft(facePaths, stagedBase, deadline); + await this._prepareMeta(stagedOutputPath, fastBake, outputPath); + const outputTransaction = await this._replaceOutput(stagedOutputPath, outputPath); try { - component.cubemap = textureCube; - await Service.Undo.endRecording(commandId); + this._assertBeforeDeadline(deadline, 'asset import'); + await Rpc.getInstance().request('assetManager', 'refreshAsset', [outputUrl]); + const cubeInfo = await this._waitForTextureCube(textureCubeUrl, deadline); + const textureCube = await this._loadTextureCube(cubeInfo.uuid, deadline); + const previousCubemap = component.cubemap; + const commandId = Service.Undo.beginRecording([component.uuid], { + label: 'Bake reflection probe', + scope: { + nodePath, + propPath: `_components.${node.components.indexOf(component)}._cubemap`, + editorType: 'scene', + }, + }); + try { + component.cubemap = textureCube; + this._notifyCubemapChanged(node, component, nodePath); + await Service.Engine.repaintInEditMode(); + + if (options.saveScene !== false) { + this._assertBeforeDeadline(deadline, 'scene save'); + await Service.Editor.save({}); + } + await Service.Undo.endRecording(commandId); + } catch (error) { + Service.Undo.cancelRecording(commandId); + component.cubemap = previousCubemap; + this._notifyCubemapChanged(node, component, nodePath); + await Service.Engine.repaintInEditMode(); + throw error; + } + await outputTransaction.commit(); + this.broadcast('reflection-probe:bake-end', nodePath); + return { + nodePath, + componentUuid: component.uuid, + probeId, + cubemapUuid: cubeInfo.uuid, + cubemapUrl: cubeInfo.url, + fastBake, + }; } catch (error) { - Service.Undo.cancelRecording(commandId); + await outputTransaction.rollback(); + await Rpc.getInstance().request('assetManager', 'refreshAsset', [outputUrl]).catch(() => undefined); throw error; } - ReflectionProbeManager.probeManager.updateBakedCubemap(component.probe); - ReflectionProbeManager.probeManager.updatePreviewSphere(component.probe); - const componentIndex = node.components.indexOf(component); - ServiceEvents.emit('node:change', node, { - type: NodeEventType.SET_PROPERTY, - propPath: `_components.${componentIndex}._cubemap`, - }); - ServiceEvents.broadcast('node:change', nodePath); - await Service.Engine.repaintInEditMode(); - - if (options.saveScene !== false) { - this._assertBeforeDeadline(deadline, 'scene save'); - await Service.Editor.save({}); - } - - this.broadcast('reflection-probe:bake-end', nodePath); - return { - nodePath, - componentUuid: component.uuid, - probeId, - cubemapUuid: cubeInfo.uuid, - cubemapUrl: cubeInfo.url, - fastBake, - }; } finally { - await Promise.all(facePaths.map(async (path) => remove(path).catch(() => undefined))); + await Promise.all([ + ...facePaths, + stagedOutputPath, + `${stagedOutputPath}.meta`, + ].map(async (path) => remove(path).catch(() => undefined))); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -420,11 +432,12 @@ export class ReflectionProbeService extends BaseService return executable; } - private async _prepareMeta(outputPath: string, fastBake: boolean): Promise { + private async _prepareMeta(outputPath: string, fastBake: boolean, previousOutputPath?: string): Promise { const metaPath = `${outputPath}.meta`; let meta: any = {}; - if (await pathExists(metaPath)) { - meta = await readJson(metaPath); + const previousMetaPath = previousOutputPath ? `${previousOutputPath}.meta` : metaPath; + if (await pathExists(previousMetaPath)) { + meta = await readJson(previousMetaPath); } meta.ver ??= '0.0.0'; meta.importer ??= '*'; @@ -438,11 +451,52 @@ export class ReflectionProbeService extends BaseService await outputJson(metaPath, meta, { spaces: 2 }); } - private async _clearConvolutionCache(outputBase: string): Promise { - const cacheDir = `${outputBase}_convolution`; - for (let i = 0; i < 6; i++) { - await remove(join(cacheDir, `mipmap_${i}.png`)).catch(() => undefined); + private async _replaceOutput(stagedOutputPath: string, outputPath: string): Promise { + const suffix = `.bake-backup-${process.pid}-${Date.now()}`; + const outputBase = outputPath.slice(0, -4); + const targets = [outputPath, `${outputPath}.meta`, `${outputBase}_convolution`]; + const backups = targets.map((target) => `${target}${suffix}`); + const movedBackups: Array<{ target: string; backup: string }> = []; + + const restore = async () => { + await Promise.all(targets.map(async (target) => remove(target).catch(() => undefined))); + for (const { target, backup } of movedBackups) { + if (await pathExists(backup)) { + await move(backup, target, { overwrite: true }); + } + } + }; + + try { + for (let i = 0; i < targets.length; i++) { + if (await pathExists(targets[i])) { + await move(targets[i], backups[i], { overwrite: false }); + movedBackups.push({ target: targets[i], backup: backups[i] }); + } + } + await move(stagedOutputPath, outputPath, { overwrite: false }); + await move(`${stagedOutputPath}.meta`, `${outputPath}.meta`, { overwrite: false }); + } catch (error) { + await restore(); + throw error; } + + return { + commit: async () => { + await Promise.all(backups.map(async (backup) => remove(backup).catch(() => undefined))); + }, + rollback: restore, + }; + } + + private _notifyCubemapChanged(node: any, component: ReflectionProbe, nodePath: string): void { + ReflectionProbeManager.probeManager.updateBakedCubemap(component.probe); + ReflectionProbeManager.probeManager.updatePreviewSphere(component.probe); + ServiceEvents.emit('node:change', node, { + type: NodeEventType.SET_PROPERTY, + propPath: `_components.${node.components.indexOf(component)}._cubemap`, + }); + ServiceEvents.broadcast('node:change', nodePath); } private async _waitForTextureCube(url: string, deadline: number): Promise { diff --git a/src/server/socket.ts b/src/server/socket.ts index 21d0555ea..1c1c5e306 100644 --- a/src/server/socket.ts +++ b/src/server/socket.ts @@ -3,6 +3,8 @@ import type { Server as HTTPSServer } from 'https'; import { middlewareService } from './middleware'; import { Server } from 'socket.io'; +export const SCENE_RENDERER_ROOM = 'scene-renderer'; + export class SocketService { public io: Server | undefined; @@ -22,6 +24,16 @@ export class SocketService { }); this.io.on('connection', (socket: any) => { console.log(`socket ${socket.id} connected`); + socket.on('scene-renderer:register', (data?: { sceneUrl?: string }) => { + socket.join(SCENE_RENDERER_ROOM); + socket.data.sceneRenderer = true; + socket.data.sceneUrl = data?.sceneUrl || ''; + }); + socket.on('scene-renderer:scene', (data?: { sceneUrl?: string }) => { + if (socket.data.sceneRenderer) { + socket.data.sceneUrl = data?.sceneUrl || ''; + } + }); middlewareService.middlewareSocket.forEach((middleware) => { middleware.connection(socket); }); diff --git a/tests/reflection-probe-renderer.test.ts b/tests/reflection-probe-renderer.test.ts new file mode 100644 index 000000000..0b87c8a88 --- /dev/null +++ b/tests/reflection-probe-renderer.test.ts @@ -0,0 +1,50 @@ +const mockFetchSockets = jest.fn(); +const mockIn = jest.fn(() => ({ fetchSockets: mockFetchSockets })); + +jest.mock('../src/server/socket', () => ({ + SCENE_RENDERER_ROOM: 'scene-renderer', + socketService: { io: { in: mockIn } }, +})); + +import { reflectionProbeRenderer } from '../src/core/scene/main-process/reflection-probe-renderer'; + +function rendererSocket(sceneUrl: string, resolution = 64) { + const socket = { + data: { sceneUrl }, + timeout: jest.fn(), + emit: jest.fn((_event, _request, reply) => { + reply(null, { result: { resolution, faces: Array(6).fill('pixels') } }); + }), + }; + socket.timeout.mockReturnValue(socket); + return socket; +} + +describe('reflection probe WebGL renderer selection', () => { + beforeEach(() => jest.clearAllMocks()); + + it('uses the renderer room and selects only the client with the requested scene', async () => { + const other = rendererSocket('db://assets/Other.scene'); + const matching = rendererSocket('db://assets/Target.scene'); + mockFetchSockets.mockResolvedValue([other, matching]); + + await expect(reflectionProbeRenderer.capture( + 'db://assets/Target.scene', + 'Probe', + 1000, + )).resolves.toMatchObject({ resolution: 64 }); + + expect(mockIn).toHaveBeenCalledWith('scene-renderer'); + expect(matching.emit).toHaveBeenCalledTimes(1); + expect(other.emit).not.toHaveBeenCalled(); + }); + + it('fails without a registered WebGL renderer', async () => { + mockFetchSockets.mockResolvedValue([]); + await expect(reflectionProbeRenderer.capture( + 'db://assets/Target.scene', + 'Probe', + 1000, + )).rejects.toThrow('requires a WebGL scene renderer'); + }); +}); From a57b17cb2549b689afd691d6d9808ac1db8aeb85 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Thu, 27 Aug 2026 11:06:51 +0800 Subject: [PATCH 4/9] fix(scene): stabilize reflection probe rebakes --- .../scene-process/service/reflection-probe.ts | 102 ++++++++++++++---- 1 file changed, 84 insertions(+), 18 deletions(-) diff --git a/src/core/scene/scene-process/service/reflection-probe.ts b/src/core/scene/scene-process/service/reflection-probe.ts index 89a1044da..e777b8125 100644 --- a/src/core/scene/scene-process/service/reflection-probe.ts +++ b/src/core/scene/scene-process/service/reflection-probe.ts @@ -13,15 +13,17 @@ import { } from 'cc'; import { ReflectionProbeManager } from 'cc/editor/reflection-probe'; import { + copy, ensureDir, existsSync, outputJson, pathExists, readJson, + readdir, move, remove, } from 'fs-extra'; -import { join } from 'path'; +import { basename, dirname, join } from 'path'; import type { IReflectionProbeBakeOptions, IReflectionProbeBakeResult, @@ -129,6 +131,7 @@ export class ReflectionProbeService extends BaseService } const sceneDir = join(assetRoot, sceneName); + const backupRoot = join(assetRoot, '..', 'temp', 'reflection-probe-bake'); await ensureDir(sceneDir); const facePaths = await this._writeFaces(captured.faces, sceneDir, probeId, resolution, deadline); const outputBase = join(sceneDir, `reflectionProbe_${probeId}`); @@ -141,10 +144,18 @@ export class ReflectionProbeService extends BaseService try { await this._runCmft(facePaths, stagedBase, deadline); await this._prepareMeta(stagedOutputPath, fastBake, outputPath); - const outputTransaction = await this._replaceOutput(stagedOutputPath, outputPath); + const outputTransaction = await this._replaceOutput( + stagedOutputPath, + outputPath, + backupRoot, + fastBake, + ); try { this._assertBeforeDeadline(deadline, 'asset import'); await Rpc.getInstance().request('assetManager', 'refreshAsset', [outputUrl]); + if (!fastBake) { + await this._ensureConvolution(outputBase, outputUrl, deadline); + } const cubeInfo = await this._waitForTextureCube(textureCubeUrl, deadline); const textureCube = await this._loadTextureCube(cubeInfo.uuid, deadline); const previousCubemap = component.cubemap; @@ -158,7 +169,7 @@ export class ReflectionProbeService extends BaseService }); try { component.cubemap = textureCube; - this._notifyCubemapChanged(node, component, nodePath); + this._notifyCubemapChanged(node, component); await Service.Engine.repaintInEditMode(); if (options.saveScene !== false) { @@ -169,7 +180,7 @@ export class ReflectionProbeService extends BaseService } catch (error) { Service.Undo.cancelRecording(commandId); component.cubemap = previousCubemap; - this._notifyCubemapChanged(node, component, nodePath); + this._notifyCubemapChanged(node, component); await Service.Engine.repaintInEditMode(); throw error; } @@ -246,8 +257,12 @@ export class ReflectionProbeService extends BaseService private async _waitForCapture(probe: any, deadline: number): Promise { do { this._assertBeforeDeadline(deadline, 'cubemap capture'); + // Subscribe before requesting a repaint. The browser editor renders + // on demand, so subscribing afterwards can miss the only frame and + // leave the bake waiting for an unrelated future repaint. + const endFrame = this._waitForEndFrame(deadline); await Service.Engine.repaintInEditMode(); - await this._waitForEndFrame(deadline); + await endFrame; } while (typeof probe.isFinishedRendering === 'function' && !probe.isFinishedRendering()); if (!Array.isArray(probe.bakedCubeTextures) || probe.bakedCubeTextures.length !== 6) { @@ -441,41 +456,60 @@ export class ReflectionProbeService extends BaseService } meta.ver ??= '0.0.0'; meta.importer ??= '*'; + meta.imported = false; meta.userData ??= {}; meta.userData.type = 'texture cube'; meta.userData.isRGBE = true; meta.subMetas ??= {}; meta.subMetas.b47c0 ??= {}; + meta.subMetas.b47c0.imported = false; meta.subMetas.b47c0.userData ??= {}; meta.subMetas.b47c0.userData.mipBakeMode = fastBake ? 1 : 2; await outputJson(metaPath, meta, { spaces: 2 }); } - private async _replaceOutput(stagedOutputPath: string, outputPath: string): Promise { - const suffix = `.bake-backup-${process.pid}-${Date.now()}`; + private async _replaceOutput( + stagedOutputPath: string, + outputPath: string, + backupRoot: string, + fastBake: boolean, + ): Promise { + await this._cleanupLegacyBackupMetas(outputPath); + const backupDir = join(backupRoot, `${process.pid}-${Date.now()}`); + await ensureDir(backupDir); const outputBase = outputPath.slice(0, -4); const targets = [outputPath, `${outputPath}.meta`, `${outputBase}_convolution`]; - const backups = targets.map((target) => `${target}${suffix}`); - const movedBackups: Array<{ target: string; backup: string }> = []; + const backups = targets.map((_target, index) => join(backupDir, String(index))); + const savedBackups: Array<{ target: string; backup: string }> = []; const restore = async () => { await Promise.all(targets.map(async (target) => remove(target).catch(() => undefined))); - for (const { target, backup } of movedBackups) { + for (const { target, backup } of savedBackups) { if (await pathExists(backup)) { - await move(backup, target, { overwrite: true }); + await copy(backup, target, { overwrite: true }); } } + await remove(backupDir).catch(() => undefined); }; try { for (let i = 0; i < targets.length; i++) { if (await pathExists(targets[i])) { - await move(targets[i], backups[i], { overwrite: false }); - movedBackups.push({ target: targets[i], backup: backups[i] }); + await copy(targets[i], backups[i], { overwrite: false }); + savedBackups.push({ target: targets[i], backup: backups[i] }); } } - await move(stagedOutputPath, outputPath, { overwrite: false }); - await move(`${stagedOutputPath}.meta`, `${outputPath}.meta`, { overwrite: false }); + if (fastBake) { + await remove(`${outputBase}_convolution`).catch(() => undefined); + } else { + // Preserve AssetDB-generated meta files and their UUIDs while + // invalidating only the six stale convolution images. + await Promise.all(FACE_NAMES.map(async (_face, index) => ( + remove(join(`${outputBase}_convolution`, `mipmap_${index}.png`)).catch(() => undefined) + ))); + } + await move(stagedOutputPath, outputPath, { overwrite: true }); + await move(`${stagedOutputPath}.meta`, `${outputPath}.meta`, { overwrite: true }); } catch (error) { await restore(); throw error; @@ -483,20 +517,52 @@ export class ReflectionProbeService extends BaseService return { commit: async () => { - await Promise.all(backups.map(async (backup) => remove(backup).catch(() => undefined))); + await remove(backupDir).catch(() => undefined); }, rollback: restore, }; } - private _notifyCubemapChanged(node: any, component: ReflectionProbe, nodePath: string): void { + private async _cleanupLegacyBackupMetas(outputPath: string): Promise { + const prefix = `${basename(outputPath)}.bake-backup-`; + const entries = await readdir(dirname(outputPath)).catch(() => []); + await Promise.all(entries + .filter((entry) => entry.startsWith(prefix) && entry.endsWith('.meta')) + .map(async (entry) => remove(join(dirname(outputPath), entry)).catch(() => undefined))); + } + + private _notifyCubemapChanged(node: any, component: ReflectionProbe): void { ReflectionProbeManager.probeManager.updateBakedCubemap(component.probe); ReflectionProbeManager.probeManager.updatePreviewSphere(component.probe); ServiceEvents.emit('node:change', node, { type: NodeEventType.SET_PROPERTY, propPath: `_components.${node.components.indexOf(component)}._cubemap`, }); - ServiceEvents.broadcast('node:change', nodePath); + } + + private async _ensureConvolution(outputBase: string, outputUrl: string, deadline: number): Promise { + const convolutionDir = `${outputBase}_convolution`; + if (!await this._hasCompleteConvolution(convolutionDir)) { + // A brand-new PNG is imported in two stages: the image importer + // first creates the TextureCube subasset, then erp-texture-cube can + // run its convolution importer on the following refresh. + this._assertBeforeDeadline(deadline, 'texture cube convolution'); + await this._prepareMeta(`${outputBase}.png`, false); + await Rpc.getInstance().request('assetManager', 'refreshAsset', [outputUrl]); + } + while (Date.now() < deadline) { + if (await this._hasCompleteConvolution(convolutionDir)) { + return; + } + await this._delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now()))); + } + throw new Error(`TextureCube convolution mipmaps were not generated before timeout: ${outputUrl}`); + } + + private async _hasCompleteConvolution(convolutionDir: string): Promise { + return (await Promise.all(FACE_NAMES.map((_face, index) => ( + pathExists(join(convolutionDir, `mipmap_${index}.png`)) + )))).every(Boolean); } private async _waitForTextureCube(url: string, deadline: number): Promise { From 33eaf9ba165ab69fd44ec72bec362901ff7913d1 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Thu, 27 Aug 2026 11:44:02 +0800 Subject: [PATCH 5/9] refactor(scene): use reflection probe bake mode --- docs/dev/scene/reflection-probe-bake.md | 3 +-- .../__tests__/__snapshots__/dts-snapshot.test.ts.snap | 1 - src/api/scene/reflection-probe-schema.ts | 1 - src/core/scene/common/reflection-probe.ts | 1 - src/core/scene/scene-process/service/reflection-probe.ts | 2 +- tests/reflection-probe-bake-api.test.ts | 7 +++++-- 6 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/dev/scene/reflection-probe-bake.md b/docs/dev/scene/reflection-probe-bake.md index c1228420d..c0302fdc1 100644 --- a/docs/dev/scene/reflection-probe-bake.md +++ b/docs/dev/scene/reflection-probe-bake.md @@ -15,7 +15,6 @@ Node 场景进程使用 EmptyDevice,不能进行有效的 GPU 捕获。因此 ```json { "nodePath": "Reflection Probe", - "fastBake": false, "saveScene": true, "timeoutMs": 120000 } @@ -24,7 +23,7 @@ Node 场景进程使用 EmptyDevice,不能进行有效的 GPU 捕获。因此 参数: - `nodePath`:探针节点在当前场景中的路径,必填。 -- `fastBake`:使用自动 mipmap;省略时采用组件当前配置。 +- `fastBake` 直接读取场景中 ReflectionProbe 组件的当前配置。 - `saveScene`:绑定后保存场景,默认 `true`。 - `timeoutMs`:完整流程超时,默认 120 秒,最大 600 秒。 diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 0e7cb9bcb..2979f39c0 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -6502,7 +6502,6 @@ export declare interface IRedoService { } export declare interface IReflectionProbeBakeOptions { nodePath: string; - fastBake?: boolean; saveScene?: boolean; timeoutMs?: number; } diff --git a/src/api/scene/reflection-probe-schema.ts b/src/api/scene/reflection-probe-schema.ts index 247c86300..94edfc68b 100644 --- a/src/api/scene/reflection-probe-schema.ts +++ b/src/api/scene/reflection-probe-schema.ts @@ -2,7 +2,6 @@ import { z } from 'zod'; export const SchemaReflectionProbeBakeOptions = z.object({ nodePath: z.string().trim().min(1).describe('Path of the node containing cc.ReflectionProbe'), - fastBake: z.boolean().optional().describe('Use automatic mipmaps instead of baked convolution mipmaps'), saveScene: z.boolean().optional().default(true).describe('Save the current scene after binding the cubemap'), timeoutMs: z.number().int().positive().max(600_000).optional().default(120_000) .describe('Timeout for capture, cmft, asset import, binding, and scene save'), diff --git a/src/core/scene/common/reflection-probe.ts b/src/core/scene/common/reflection-probe.ts index 403068c89..47422bbc4 100644 --- a/src/core/scene/common/reflection-probe.ts +++ b/src/core/scene/common/reflection-probe.ts @@ -2,7 +2,6 @@ import type { IServiceEvents } from '../scene-process/service/core'; export interface IReflectionProbeBakeOptions { nodePath: string; - fastBake?: boolean; saveScene?: boolean; timeoutMs?: number; } diff --git a/src/core/scene/scene-process/service/reflection-probe.ts b/src/core/scene/scene-process/service/reflection-probe.ts index e777b8125..6d89f30ef 100644 --- a/src/core/scene/scene-process/service/reflection-probe.ts +++ b/src/core/scene/scene-process/service/reflection-probe.ts @@ -105,7 +105,7 @@ export class ReflectionProbeService extends BaseService if (!sceneName) { throw new Error('No scene is currently open.'); } - const fastBake = options.fastBake ?? component.fastBake; + const fastBake = component.fastBake; const current = await Service.Editor.queryCurrent(); const currentAssetUrl = ((current as any)?.__identifier__?.assetUrl diff --git a/tests/reflection-probe-bake-api.test.ts b/tests/reflection-probe-bake-api.test.ts index f52709d57..2ca166421 100644 --- a/tests/reflection-probe-bake-api.test.ts +++ b/tests/reflection-probe-bake-api.test.ts @@ -34,6 +34,11 @@ describe('reflection probe bake API', () => { saveScene: true, timeoutMs: 120_000, }); + expect(SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe', fastBake: true })).toEqual({ + nodePath: 'Probe', + saveScene: true, + timeoutMs: 120_000, + }); expect(() => SchemaReflectionProbeBakeOptions.parse({ nodePath: ' ' })).toThrow(); expect(() => SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe', timeoutMs: 0 })).toThrow(); expect(() => SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe', timeoutMs: 600_001 })).toThrow(); @@ -63,14 +68,12 @@ describe('reflection probe bake API', () => { const result = await new ReflectionProbeApi().bake({ nodePath: 'Probe', - fastBake: true, saveScene: true, timeoutMs: 120_000, }); expect(mockBake).toHaveBeenCalledWith({ nodePath: 'Probe', - fastBake: true, saveScene: true, timeoutMs: 120_000, }); From 92429528065a65734622262805f21d889a067d71 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Fri, 28 Aug 2026 09:57:09 +0800 Subject: [PATCH 6/9] refactor(server): use default socket buffer limit --- src/server/socket.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/server/socket.ts b/src/server/socket.ts index 1c1c5e306..f18bfdc7a 100644 --- a/src/server/socket.ts +++ b/src/server/socket.ts @@ -18,9 +18,6 @@ export class SocketService { // 与 HTTP 路由的 CORS(server.ts 的 app.use(cors),Access-Control-Allow-Origin: *)保持一致。 this.io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] }, - // Reflection-probe capture returns six raw RGBA faces from the WebGL - // scene client. A 1024px probe is ~32 MiB before base64 encoding. - maxHttpBufferSize: 128 * 1024 * 1024, }); this.io.on('connection', (socket: any) => { console.log(`socket ${socket.id} connected`); From 018f242a78da8b74145a99a78fcd88ed34677928 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Fri, 28 Aug 2026 10:56:22 +0800 Subject: [PATCH 7/9] fix(server): allow reflection probe capture payloads --- src/server/socket.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/server/socket.ts b/src/server/socket.ts index f18bfdc7a..746f4f24a 100644 --- a/src/server/socket.ts +++ b/src/server/socket.ts @@ -18,6 +18,9 @@ export class SocketService { // 与 HTTP 路由的 CORS(server.ts 的 app.use(cors),Access-Control-Allow-Origin: *)保持一致。 this.io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] }, + // Reflection-probe capture returns six raw RGBA faces from the WebGL + // scene client. A 1024px probe is ~24 MiB raw and ~32 MiB as base64. + maxHttpBufferSize: 128 * 1024 * 1024, }); this.io.on('connection', (socket: any) => { console.log(`socket ${socket.id} connected`); From bdadde52132ddc35da17e7e2788a010360ccf0a1 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Fri, 28 Aug 2026 11:10:50 +0800 Subject: [PATCH 8/9] docs(scene): clarify reflection probe bake usage --- docs/dev/scene/reflection-probe-bake.md | 27 ++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/dev/scene/reflection-probe-bake.md b/docs/dev/scene/reflection-probe-bake.md index c0302fdc1..95744daf6 100644 --- a/docs/dev/scene/reflection-probe-bake.md +++ b/docs/dev/scene/reflection-probe-bake.md @@ -6,17 +6,24 @@ CLI 通过 MCP 工具 `scene-bake-reflection-probe` 烘焙立方体反射探针 - CLI HTTP/MCP 服务已启动。 - 浏览器已打开 `/scene-editor/`,目标场景显示为 `Loaded`。 +- 烘焙期间场景编辑器需要保持可见且可渲染;浏览器后台标签页或最小化窗口可能被节流并导致捕获超时。 - `nodePath` 指向包含 `cc.ReflectionProbe` 的节点。 Node 场景进程使用 EmptyDevice,不能进行有效的 GPU 捕获。因此烘焙会将捕获请求转发给浏览器中的 WebGL 场景渲染器。没有可用渲染器时会明确失败,不会回退生成黑图;六面像素全部为空时也会停止并保留已有资源。 -## 调用 +## MCP 调用 + +工具名:`scene-bake-reflection-probe` + +MCP Inspector 切换到 JSON 输入时,调用参数如下: ```json { - "nodePath": "Reflection Probe", - "saveScene": true, - "timeoutMs": 120000 + "options": { + "nodePath": "Reflection Probe", + "saveScene": true, + "timeoutMs": 120000 + } } ``` @@ -29,6 +36,14 @@ Node 场景进程使用 EmptyDevice,不能进行有效的 GPU 捕获。因此 成功结果包含探针节点、组件 UUID、probe ID,以及生成的 TextureCube UUID 和 URL。 +调用前应先通过 `scene-open` 打开场景,并在 `/scene-editor/` 中加载同一个场景。`nodePath` 是相对于场景根节点的节点路径,不是资源 URL 或 UUID。 + +## Pink 场景 Webview + +Pink 场景 Webview 中的场景服务运行在本地 WebGL 环境。完整烘焙仍应通过 MCP 工具调用:Sharp、cmft、文件写入和 Asset DB 导入依赖 Node 环境,不能只在 Webview 中完成。 + +MCP 工具在 Node 主进程执行,并经 Node IPC 进入 scene-process。由于 Node scene-process 使用 EmptyDevice,MCP 路径会额外请求已加载同一场景的 Pink Webview,通过其本地 `window.cli.Scene.ReflectionProbe.capturePixels()` 完成六面捕获;该方法是内部渲染桥,不是公开的完整烘焙入口。Asset DB、配置和文件系统等 Node 能力继续通过 RPC 调用。 + ## 处理链路 ```text @@ -53,8 +68,10 @@ MCP scene-bake-reflection-probe - 输出位置:`assets//reflectionProbe_.png` - TextureCube 子资源:`db://assets//reflectionProbe_.png/textureCube` +- 捕获分辨率、clear flag、背景色、visibility、probe size 和 `fastBake` 均读取 `ReflectionProbe` 组件当前配置;MCP 参数不会覆盖这些值。 - cmft 参数保持 Creator 的 RGBM latlong 行为。 - `fastBake=true` 写入 `mipBakeMode=1`;否则写入 `mipBakeMode=2`。 +- 六面 RGBA 会通过同一条 Socket.IO 消息从 WebGL 场景渲染器返回;1024 分辨率约为 24 MiB 原始数据、32 MiB Base64 数据,因此服务端保留 128 MiB 的单消息上限。 - 重复烘焙复用资源身份,并清理旧卷积缓存后重新导入。 - 绑定操作进入 Undo,成功后刷新探针管理器与预览球。 @@ -62,7 +79,7 @@ MCP scene-bake-reflection-probe ```powershell npm.cmd run compile -npm.cmd test -- --runInBand tests/reflection-probe-bake-api.test.ts +npm.cmd test -- --runInBand tests/reflection-probe-bake-api.test.ts tests/reflection-probe-renderer.test.ts ``` 端到端验证还应确认: From aa7c3ad7c3d93cd1ea9905d18d4e1155847403e9 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Fri, 28 Aug 2026 11:23:09 +0800 Subject: [PATCH 9/9] refactor(scene): isolate renderer registration on connect --- .../scene/scene-process/engine-bootstrap.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 3a16d01e9..5e32980db 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -537,16 +537,17 @@ async function setupBrowserInvokeChannel(serverURL: string) { } }); // 连接建立时同步一次设计分辨率(首次进入 / 断线重连时补齐错过的变更) - socket.on('connect', async () => { - let sceneUrl = ''; - try { - sceneUrl = await querySceneUrl(); - } catch { - // A renderer without an open scene is still available and can - // open the requested scene when a bake starts. - } - socket.emit('scene-renderer:register', { sceneUrl }); - invoke('Engine', 'syncDesignResolution', []); + socket.on('connect', () => invoke('Engine', 'syncDesignResolution', [])); + socket.on('connect', () => { + // Join the renderer room immediately. Scene discovery must not + // delay the pre-existing design-resolution synchronization. + socket.emit('scene-renderer:register', { sceneUrl: '' }); + void querySceneUrl().then((sceneUrl) => { + socket.emit('scene-renderer:scene', { sceneUrl }); + }).catch(() => { + // A renderer without an open scene can open the requested + // scene when a bake starts. + }); }); } catch (e) { console.warn('[engine-bootstrap] setup browser-invoke channel failed:', e);